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>
This commit is contained in:
parent
bbe7c1b71c
commit
3dfb0b2831
15
backend/.env
15
backend/.env
@ -67,3 +67,18 @@ S3_PATH_STYLE=true
|
|||||||
# MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages
|
# MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages
|
||||||
MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
|
MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
|
||||||
###< symfony/messenger ###
|
###< 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 ###
|
||||||
|
|||||||
@ -5,6 +5,7 @@ namespace App\Controller;
|
|||||||
use App\Entity\Employee;
|
use App\Entity\Employee;
|
||||||
use App\Repository\EmployeeRepository;
|
use App\Repository\EmployeeRepository;
|
||||||
use App\Service\VCardBuilder;
|
use App\Service\VCardBuilder;
|
||||||
|
use App\Service\WalletService;
|
||||||
use Endroid\QrCode\Builder\Builder;
|
use Endroid\QrCode\Builder\Builder;
|
||||||
use Endroid\QrCode\Encoding\Encoding;
|
use Endroid\QrCode\Encoding\Encoding;
|
||||||
use Endroid\QrCode\ErrorCorrectionLevel;
|
use Endroid\QrCode\ErrorCorrectionLevel;
|
||||||
@ -25,7 +26,7 @@ final class PublicProfileController extends AbstractController
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Route('/p/{companySlug}/{slug}', name: 'public_profile', methods: ['GET'])]
|
#[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);
|
$employee = $this->resolve($companySlug, $slug);
|
||||||
|
|
||||||
@ -33,6 +34,8 @@ final class PublicProfileController extends AbstractController
|
|||||||
'e' => $employee,
|
'e' => $employee,
|
||||||
'profileUrl' => $this->profileUrl($employee),
|
'profileUrl' => $this->profileUrl($employee),
|
||||||
'shareUrl' => $this->shareUrl($employee),
|
'shareUrl' => $this->shareUrl($employee),
|
||||||
|
'walletEnabled' => null !== $employee->getShortCode()
|
||||||
|
&& ($wallet->isAppleConfigured() || $wallet->isGoogleConfigured()),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
99
backend/src/Controller/WalletController.php
Normal file
99
backend/src/Controller/WalletController.php
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Repository\EmployeeRepository;
|
||||||
|
use App\Service\WalletService;
|
||||||
|
use Endroid\QrCode\Builder\Builder;
|
||||||
|
use Endroid\QrCode\Encoding\Encoding;
|
||||||
|
use Endroid\QrCode\ErrorCorrectionLevel;
|
||||||
|
use Endroid\QrCode\Writer\PngWriter;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Öffentliche Wallet-Endpunkte: QR/Landing zum Hinzufügen der Visitenkarte
|
||||||
|
* in Apple/Google Wallet. Adressierung über den stabilen Kurz-Code (shortCode).
|
||||||
|
*/
|
||||||
|
final class WalletController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly EmployeeRepository $employees,
|
||||||
|
private readonly WalletService $wallet,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/w/{code}', name: 'wallet_landing', methods: ['GET'])]
|
||||||
|
public function landing(string $code, Request $request): Response
|
||||||
|
{
|
||||||
|
$employee = $this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
299
backend/src/Service/WalletService.php
Normal file
299
backend/src/Service/WalletService.php
Normal file
@ -0,0 +1,299 @@
|
|||||||
|
<?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), '+/', '-_'), '=');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -144,6 +144,16 @@
|
|||||||
<img src="{{ path('public_profile_qr', {companySlug: e.company.slug, slug: e.slug}) }}" alt="QR-Code zum Profil">
|
<img src="{{ path('public_profile_qr', {companySlug: e.company.slug, slug: e.slug}) }}" alt="QR-Code zum Profil">
|
||||||
<p>QR-Code scannen, um dieses Profil zu öffnen</p>
|
<p>QR-Code scannen, um dieses Profil zu öffnen</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if walletEnabled %}
|
||||||
|
<div class="vc__section qr">
|
||||||
|
<div class="vc__label">Zur Wallet hinzufügen</div>
|
||||||
|
<a href="{{ path('wallet_landing', {code: e.shortCode}) }}">
|
||||||
|
<img src="{{ path('wallet_qr', {code: e.shortCode}) }}" alt="QR-Code: Karte zu Apple/Google Wallet hinzufügen">
|
||||||
|
</a>
|
||||||
|
<p>Scannen, um die Karte in Apple / Google Wallet zu speichern</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="foot">
|
<div class="foot">
|
||||||
|
|||||||
57
backend/templates/public/wallet.html.twig
Normal file
57
backend/templates/public/wallet.html.twig
Normal file
@ -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 %}
|
||||||
|
<style>
|
||||||
|
.wrap { max-width: 420px; margin: 0 auto; padding: 2rem 1.2rem; }
|
||||||
|
.wcard { background: #fff; border-radius: var(--radius); box-shadow: var(--shadow-sm); padding: 2rem 1.6rem; text-align: center; }
|
||||||
|
.wcard .badge-name { display: inline-block; padding: .3rem .9rem; border-radius: 999px; background: {{ primary }}; color: #fff; font-weight: 700; font-size: .92rem; }
|
||||||
|
.wcard h1 { font-size: 1.25rem; margin: 1rem 0 .2rem; }
|
||||||
|
.wcard .muted { color: var(--muted); font-size: .9rem; margin: 0 0 1.4rem; }
|
||||||
|
.wbtns { display: flex; flex-direction: column; gap: .7rem; align-items: center; }
|
||||||
|
.wbtn { display: inline-flex; align-items: center; justify-content: center; gap: .6rem; width: 100%; max-width: 280px;
|
||||||
|
padding: .85rem 1.2rem; border-radius: 12px; font-weight: 700; font-size: .98rem; text-decoration: none; }
|
||||||
|
.wbtn--apple { background: #000; color: #fff; }
|
||||||
|
.wbtn--google { background: #fff; color: #3c4043; border: 1px solid #dadce0; }
|
||||||
|
.wbtn:hover { opacity: .92; text-decoration: none; }
|
||||||
|
.wbtn .ic { font-size: 1.1rem; }
|
||||||
|
.whint { color: var(--muted); font-size: .82rem; margin-top: 1.4rem; }
|
||||||
|
.wback { display: inline-block; margin-top: 1.2rem; color: var(--muted); font-size: .85rem; }
|
||||||
|
.order { order: 2; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="wcard">
|
||||||
|
<span class="badge-name">{{ e.company.name }}</span>
|
||||||
|
<h1>{{ fullName }}</h1>
|
||||||
|
<p class="muted">{% if e.position %}{{ e.position }} · {% endif %}Digitale Visitenkarte zur Wallet hinzufügen</p>
|
||||||
|
|
||||||
|
{% if appleEnabled or googleEnabled %}
|
||||||
|
<div class="wbtns">
|
||||||
|
{% if appleEnabled %}
|
||||||
|
<a class="wbtn wbtn--apple {{ preferApple ? '' : 'order' }}" href="{{ path('wallet_apple', {code: code}) }}">
|
||||||
|
<span class="ic"></span> Zu Apple Wallet
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if googleEnabled %}
|
||||||
|
<a class="wbtn wbtn--google {{ preferApple ? 'order' : '' }}" href="{{ path('wallet_google', {code: code}) }}">
|
||||||
|
<span class="ic">🅖</span> Zu Google Wallet
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<p class="whint">Öffne diesen Link auf deinem Smartphone, um die Karte zu speichern.</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="whint">Wallet-Pässe sind für diesen Anbieter noch nicht aktiviert.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<a class="wback" href="{{ path('public_profile', {companySlug: e.company.slug, slug: e.slug}) }}">← zum Profil</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@ -91,3 +91,25 @@ per CI oder Service-Discovery.)
|
|||||||
Caddy stellt bereits Zertifikate für verifizierte Domains aus.
|
Caddy stellt bereits Zertifikate für verifizierte Domains aus.
|
||||||
- **Seed**: optional einmalig `app:seed` auf `app-1` für Demo-Daten.
|
- **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).
|
- **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).
|
||||||
|
|||||||
@ -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)
|
## 13. Produktkatalog (mehrere Produkttypen)
|
||||||
|
|
||||||
Die Plattform verkauft nicht nur Visitenkarten, sondern mehrere **Produkttypen**.
|
Die Plattform verkauft nicht nur Visitenkarten, sondern mehrere **Produkttypen**.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user