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), '+/', '-_'), '='); } }