vcard4reseller/backend/src/Service/WalletService.php
Thomas Peterson 3dfb0b2831 Wallet: QR auf Profilseite → Apple/Google Wallet-Pass
- WalletService (dependency-frei): Google signierter RS256-„Save"-JWT-Link;
  Apple .pkpass (pass.json + GD-Icons + manifest + PKCS#7 via openssl + zip).
  Konfigurationsgesteuert (env), ohne Zugangsdaten deaktiviert.
- WalletController: /w/{code} Landing (Geräteerkennung + Buttons),
  /w/{code}/qr.png, /apple.pkpass, /google (302). Adressierung via shortCode.
- Öffentliche Profilseite: QR-Bereich „Zur Wallet hinzufügen" (nur wenn
  Provider konfiguriert + shortCode vorhanden).
- .env Wallet-Block (leer=aus), KONZEPT §12 + deploy/README dokumentiert.

Verifiziert: not-configured → ausgeblendet/404; mit Test-Zertifikaten valides
signiertes .pkpass + Google-Save-JWT. Produktiv: echte Apple-/Google-Creds nötig.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 22:28:14 +02:00

300 lines
12 KiB
PHP

<?php
namespace App\Service;
use App\Entity\Employee;
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.
*/
final class WalletService
{
public function __construct(
private readonly UrlGeneratorInterface $urls,
#[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,
#[Autowire('%env(APPLE_WALLET_CERT_PATH)%')] private readonly string $appleCertPath,
#[Autowire('%env(APPLE_WALLET_KEY_PATH)%')] private readonly string $appleKeyPath,
#[Autowire('%env(APPLE_WALLET_KEY_PASSWORD)%')] private readonly string $appleKeyPassword,
#[Autowire('%env(APPLE_WALLET_WWDR_PATH)%')] private readonly string $appleWwdrPath,
#[Autowire('%env(GOOGLE_WALLET_ISSUER_ID)%')] private readonly string $googleIssuerId,
#[Autowire('%env(GOOGLE_WALLET_SERVICE_ACCOUNT)%')] private readonly string $googleServiceAccount,
#[Autowire('%env(GOOGLE_WALLET_CLASS_SUFFIX)%')] private readonly string $googleClassSuffix,
) {
}
public function isAppleConfigured(): bool
{
return '' !== $this->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<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) ---
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<string, string> $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<string, string>
*/
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), '+/', '-_'), '=');
}
}