diff --git a/backend/.env b/backend/.env index baa125f..06c87ee 100644 --- a/backend/.env +++ b/backend/.env @@ -67,3 +67,18 @@ S3_PATH_STYLE=true # MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0 ###< symfony/messenger ### + +###> Wallet-Pässe (Apple/Google) ### +# Apple Wallet (PassKit) – leer = deaktiviert. PEM-Dateien außerhalb des Webroots ablegen. +APPLE_WALLET_PASS_TYPE_ID= +APPLE_WALLET_TEAM_ID= +APPLE_WALLET_ORG_NAME=vcard4reseller +APPLE_WALLET_CERT_PATH= +APPLE_WALLET_KEY_PATH= +APPLE_WALLET_KEY_PASSWORD= +APPLE_WALLET_WWDR_PATH= +# Google Wallet – leer = deaktiviert. +GOOGLE_WALLET_ISSUER_ID= +GOOGLE_WALLET_SERVICE_ACCOUNT= +GOOGLE_WALLET_CLASS_SUFFIX=vcard_generic +###< Wallet-Pässe ### diff --git a/backend/src/Controller/PublicProfileController.php b/backend/src/Controller/PublicProfileController.php index 89dde30..3b000bc 100644 --- a/backend/src/Controller/PublicProfileController.php +++ b/backend/src/Controller/PublicProfileController.php @@ -5,6 +5,7 @@ namespace App\Controller; use App\Entity\Employee; use App\Repository\EmployeeRepository; use App\Service\VCardBuilder; +use App\Service\WalletService; use Endroid\QrCode\Builder\Builder; use Endroid\QrCode\Encoding\Encoding; use Endroid\QrCode\ErrorCorrectionLevel; @@ -25,7 +26,7 @@ final class PublicProfileController extends AbstractController } #[Route('/p/{companySlug}/{slug}', name: 'public_profile', methods: ['GET'])] - public function show(string $companySlug, string $slug): Response + public function show(string $companySlug, string $slug, WalletService $wallet): Response { $employee = $this->resolve($companySlug, $slug); @@ -33,6 +34,8 @@ final class PublicProfileController extends AbstractController 'e' => $employee, 'profileUrl' => $this->profileUrl($employee), 'shareUrl' => $this->shareUrl($employee), + 'walletEnabled' => null !== $employee->getShortCode() + && ($wallet->isAppleConfigured() || $wallet->isGoogleConfigured()), ]); } diff --git a/backend/src/Controller/WalletController.php b/backend/src/Controller/WalletController.php new file mode 100644 index 0000000..a351296 --- /dev/null +++ b/backend/src/Controller/WalletController.php @@ -0,0 +1,99 @@ +resolve($code); + $ua = (string) $request->headers->get('User-Agent', ''); + $isApple = (bool) preg_match('/iPhone|iPad|iPod|Macintosh/i', $ua); + + return $this->render('public/wallet.html.twig', [ + 'e' => $employee, + 'code' => $code, + 'appleEnabled' => $this->wallet->isAppleConfigured(), + 'googleEnabled' => $this->wallet->isGoogleConfigured(), + 'preferApple' => $isApple, + ]); + } + + #[Route('/w/{code}/qr.png', name: 'wallet_qr', methods: ['GET'])] + public function qr(string $code): Response + { + $this->resolve($code); + $url = $this->generateUrl('wallet_landing', ['code' => $code], UrlGeneratorInterface::ABSOLUTE_URL); + + $result = (new Builder( + writer: new PngWriter(), + data: $url, + encoding: new Encoding('UTF-8'), + errorCorrectionLevel: ErrorCorrectionLevel::Medium, + size: 320, + margin: 12, + ))->build(); + + return new Response($result->getString(), 200, ['Content-Type' => $result->getMimeType()]); + } + + #[Route('/w/{code}/apple.pkpass', name: 'wallet_apple', methods: ['GET'])] + public function apple(string $code): Response + { + $employee = $this->resolve($code); + if (!$this->wallet->isAppleConfigured()) { + throw $this->createNotFoundException('Apple Wallet ist nicht konfiguriert.'); + } + + return new Response($this->wallet->applePkpass($employee), 200, [ + 'Content-Type' => 'application/vnd.apple.pkpass', + 'Content-Disposition' => sprintf('attachment; filename="%s.pkpass"', $employee->getSlug()), + ]); + } + + #[Route('/w/{code}/google', name: 'wallet_google', methods: ['GET'])] + public function google(string $code): Response + { + $employee = $this->resolve($code); + if (!$this->wallet->isGoogleConfigured()) { + throw $this->createNotFoundException('Google Wallet ist nicht konfiguriert.'); + } + + return new RedirectResponse($this->wallet->googleSaveUrl($employee), 302); + } + + private function resolve(string $code): Employee + { + $employee = $this->employees->findByShortCode($code); + if (null === $employee) { + throw $this->createNotFoundException('Unbekannter Code.'); + } + + return $employee; + } +} diff --git a/backend/src/Service/WalletService.php b/backend/src/Service/WalletService.php new file mode 100644 index 0000000..1d495b9 --- /dev/null +++ b/backend/src/Service/WalletService.php @@ -0,0 +1,299 @@ +applePassTypeId && '' !== $this->appleTeamId + && is_file($this->appleCertPath) && is_file($this->appleKeyPath) && is_file($this->appleWwdrPath); + } + + public function isGoogleConfigured(): bool + { + return '' !== $this->googleIssuerId && '' !== $this->googleServiceAccount && is_file($this->googleServiceAccount); + } + + // --- Google Wallet --- + + public function googleSaveUrl(Employee $e): string + { + $sa = json_decode((string) file_get_contents($this->googleServiceAccount), true); + if (!is_array($sa) || !isset($sa['client_email'], $sa['private_key'])) { + throw new \RuntimeException('Google-Service-Account ungültig.'); + } + $c = $this->card($e); + $classId = $this->googleIssuerId.'.'.$this->googleClassSuffix; + $objectId = $this->googleIssuerId.'.vcard_'.($e->getShortCode() ?? $e->getId()->toBase58()); + + $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']]], + ]; + if (null !== $c['logoUrl']) { + $object['logo'] = ['sourceUri' => ['uri' => $c['logoUrl']], 'contentDescription' => $this->locValue($c['company'])]; + } + + $claims = [ + 'iss' => $sa['client_email'], + 'aud' => 'google', + 'typ' => 'savetowallet', + 'iat' => time(), + '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); + $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']], + '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']]], + ], + ]; + + $files = ['pass.json' => (string) json_encode($pass, \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE)]; + $files += $this->passImages($c); + + $manifest = []; + foreach ($files as $name => $content) { + $manifest[$name] = sha1($content); + } + $files['manifest.json'] = (string) json_encode($manifest, \JSON_UNESCAPED_SLASHES); + $files['signature'] = $this->signManifest($files['manifest.json']); + + return $this->zip($files); + } + + /** Detached PKCS#7-Signatur der manifest.json im DER-Format. */ + private function signManifest(string $manifest): string + { + $manifestFile = (string) tempnam(sys_get_temp_dir(), 'man'); + $sigFile = (string) tempnam(sys_get_temp_dir(), 'sig'); + file_put_contents($manifestFile, $manifest); + + $ok = openssl_pkcs7_sign( + $manifestFile, + $sigFile, + 'file://'.$this->appleCertPath, + ['file://'.$this->appleKeyPath, $this->appleKeyPassword], + [], + \PKCS7_BINARY | \PKCS7_DETACHED, + $this->appleWwdrPath, + ); + $smime = $ok ? (string) file_get_contents($sigFile) : ''; + @unlink($manifestFile); + @unlink($sigFile); + if (!$ok) { + throw new \RuntimeException('Apple-Pass-Signatur fehlgeschlagen.'); + } + + 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)) { + $b64 = $m[1]; + } else { + $b64 = substr($smime, (int) strrpos($smime, "\n\n") + 2); + } + + return (string) base64_decode((string) preg_replace('/\s+/', '', $b64)); + } + + /** + * @param array $files + */ + private function zip(array $files): string + { + $tmp = (string) tempnam(sys_get_temp_dir(), 'pkpass'); + $zip = new \ZipArchive(); + $zip->open($tmp, \ZipArchive::OVERWRITE); + foreach ($files as $name => $content) { + $zip->addFromString($name, $content); + } + $zip->close(); + $bin = (string) file_get_contents($tmp); + @unlink($tmp); + + return $bin; + } + + /** + * @param array{name: string, primaryColor: string} $c + * + * @return array + */ + private function passImages(array $c): 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 { + $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); + ob_start(); + imagepng($img); + $png = (string) ob_get_clean(); + imagedestroy($img); + + return $png; + }; + + return [ + 'icon.png' => $make(29), + 'icon@2x.png' => $make(58), + 'logo.png' => $make(50), + 'logo@2x.png' => $make(100), + ]; + } + + // --- gemeinsame Kartendaten --- + + /** + * @return array{name: string, role: string, company: string, phone: string, email: string, url: string, primaryColor: string, logoUrl: ?string} + */ + private function card(Employee $e): array + { + $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, + ]; + } + + private function shareUrl(Employee $e): string + { + if (null !== $e->getShortCode()) { + return $this->urls->generate('short_link', ['code' => $e->getShortCode()], UrlGeneratorInterface::ABSOLUTE_URL); + } + + return $this->urls->generate('public_profile', [ + 'companySlug' => $e->getCompany()->getSlug(), + 'slug' => $e->getSlug(), + ], UrlGeneratorInterface::ABSOLUTE_URL); + } + + /** @return array{0: int, 1: int, 2: int} */ + private function rgbParts(string $hex): array + { + $hex = ltrim($hex, '#'); + + return [(int) hexdec(substr($hex, 0, 2)), (int) hexdec(substr($hex, 2, 2)), (int) hexdec(substr($hex, 4, 2))]; + } + + private function rgb(string $hex): string + { + [$r, $g, $b] = $this->rgbParts($hex); + + return "rgb($r, $g, $b)"; + } + + /** @return array{defaultValue: array{language: string, value: string}} */ + private function locValue(string $value): array + { + return ['defaultValue' => ['language' => 'de', 'value' => $value]]; + } + + private function b64url(string $data): string + { + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + } +} diff --git a/backend/templates/public/profile.html.twig b/backend/templates/public/profile.html.twig index dea80ea..a4fb6a4 100644 --- a/backend/templates/public/profile.html.twig +++ b/backend/templates/public/profile.html.twig @@ -144,6 +144,16 @@ QR-Code zum Profil

QR-Code scannen, um dieses Profil zu öffnen

+ + {% if walletEnabled %} +
+
Zur Wallet hinzufügen
+ + QR-Code: Karte zu Apple/Google Wallet hinzufügen + +

Scannen, um die Karte in Apple / Google Wallet zu speichern

+
+ {% endif %}
diff --git a/backend/templates/public/wallet.html.twig b/backend/templates/public/wallet.html.twig new file mode 100644 index 0000000..b47523a --- /dev/null +++ b/backend/templates/public/wallet.html.twig @@ -0,0 +1,57 @@ +{% extends 'base.html.twig' %} + +{% set fullName = (e.firstName ~ ' ' ~ e.lastName)|trim %} +{% set b = e.company.brandingConfig %} +{% set primary = (b.primaryColor is defined and b.primaryColor matches '/^#[0-9a-fA-F]{6}$/') ? b.primaryColor : '#f58220' %} + +{% block title %}{{ fullName }} – zur Wallet hinzufügen{% endblock %} + +{% block stylesheets %} + +{% endblock %} + +{% block body %} +
+
+ {{ e.company.name }} +

{{ fullName }}

+

{% if e.position %}{{ e.position }} · {% endif %}Digitale Visitenkarte zur Wallet hinzufügen

+ + {% if appleEnabled or googleEnabled %} +
+ {% if appleEnabled %} + + Zu Apple Wallet + + {% endif %} + {% if googleEnabled %} + + 🅖 Zu Google Wallet + + {% endif %} +
+

Öffne diesen Link auf deinem Smartphone, um die Karte zu speichern.

+ {% else %} +

Wallet-Pässe sind für diesen Anbieter noch nicht aktiviert.

+ {% endif %} + + ← zum Profil +
+
+{% endblock %} diff --git a/deploy/README.md b/deploy/README.md index f592bd8..b682e2a 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -91,3 +91,25 @@ per CI oder Service-Discovery.) Caddy stellt bereits Zertifikate für verifizierte Domains aus. - **Seed**: optional einmalig `app:seed` auf `app-1` für Demo-Daten. - **Updates**: neuen Stand ausrollen = auf den App-Nodes `git pull` + `docker compose ... up -d --build` (später per CI/Skript). + +## Wallet-Pässe (Apple / Google) — optional + +Auf der öffentlichen Profilseite erscheint ein QR „Zur Wallet hinzufügen" (Landing `/w/{code}`), +sobald die Zugangsdaten gesetzt sind. Ohne Konfiguration ist das Feature ausgeblendet. + +**Apple Wallet** (kostenpflichtiger Apple-Developer-Account): +1. Pass Type ID anlegen, Zertifikat erzeugen → als PEM exportieren (`cert.pem` + `key.pem`). +2. Apple **WWDR**-Zwischenzertifikat als PEM (`wwdr.pem`). +3. PEM-Dateien außerhalb des Webroots ablegen, Env setzen: + `APPLE_WALLET_PASS_TYPE_ID`, `APPLE_WALLET_TEAM_ID`, `APPLE_WALLET_CERT_PATH`, + `APPLE_WALLET_KEY_PATH`, `APPLE_WALLET_KEY_PASSWORD`, `APPLE_WALLET_WWDR_PATH`, `APPLE_WALLET_ORG_NAME`. + +**Google Wallet** (kostenlos): +1. In der Google Cloud Console die **Wallet API** + einen **Issuer** anlegen, Service-Account + mit Rolle „Wallet Object Issuer" erstellen, JSON-Key herunterladen. +2. Env setzen: `GOOGLE_WALLET_ISSUER_ID`, `GOOGLE_WALLET_SERVICE_ACCOUNT` (Pfad zur JSON), + optional `GOOGLE_WALLET_CLASS_SUFFIX`. + +Hinweis: Selbstsignierte Test-Zertifikate erzeugen ein technisch valides `.pkpass`/JWT, +werden aber von Apple/Google **nicht akzeptiert** — für die Produktion echte Zugangsdaten nötig. +Over-the-air-Sync (APNs/Objekt-Patch) ist noch nicht umgesetzt (nur Pass-Erstellung). diff --git a/docs/KONZEPT.md b/docs/KONZEPT.md index e4f0608..de36139 100644 --- a/docs/KONZEPT.md +++ b/docs/KONZEPT.md @@ -337,6 +337,26 @@ Apple Pass Type ID ist an *einen* Apple-Account gebunden. Optionen: **(a)** ein --- +### Umsetzung (implementiert) + +QR-Code auf der **öffentlichen Profilseite** („Zur Wallet hinzufügen") → Landing +`/w/{shortCode}` mit Geräteerkennung und Apple-/Google-Buttons. Beides +konfigurationsgesteuert (env); ohne Zugangsdaten ausgeblendet/deaktiviert. + +- `WalletService` (dependency-frei): Google = signierter RS256-„Save"-JWT-Link + (`pay.google.com/gp/v/save/{jwt}`, fat genericClasses+Objects); Apple = `.pkpass` + (pass.json + GD-Icons + manifest + **PKCS#7-Signatur** via `openssl_pkcs7_sign`, gezippt). +- `WalletController`: `GET /w/{code}` (Landing), `/w/{code}/qr.png`, + `/w/{code}/apple.pkpass`, `/w/{code}/google` (302). Adressierung über `shortCode`. +- **Konfig (env):** Apple = `APPLE_WALLET_PASS_TYPE_ID`, `_TEAM_ID`, `_CERT_PATH`, + `_KEY_PATH`, `_KEY_PASSWORD`, `_WWDR_PATH`, `_ORG_NAME`; Google = + `GOOGLE_WALLET_ISSUER_ID`, `GOOGLE_WALLET_SERVICE_ACCOUNT` (Pfad zur + service-account.json), `GOOGLE_WALLET_CLASS_SUFFIX`. +- **Offen (Sync/Push):** Apple PassKit-Web-Service (`register`/`unregister`/`latest`) + + APNs + `WalletDevice`; Google Objekt-`patch`. Bisher nur Pass-Erstellung. + +--- + ## 13. Produktkatalog (mehrere Produkttypen) Die Plattform verkauft nicht nur Visitenkarten, sondern mehrere **Produkttypen**.