Files
breadcrumb-the-shire/core/Service/User/UserAccessPdfService.php

291 lines
10 KiB
PHP
Raw Normal View History

<?php
namespace MintyPHP\Service\User;
use Dompdf\Dompdf;
use Dompdf\Options;
use Endroid\QrCode\QrCode;
use Endroid\QrCode\Writer\PngWriter;
2026-03-04 15:56:58 +01:00
use MintyPHP\Service\Branding\BrandingLogoService;
feat(tenant): per-theme logos + file-upload + button UI polish Replace the single tenant avatar with a pair of theme-scoped brand logos. Render only the theme-matching <img> server-side and swap src on theme toggle via a JS hook — no reload, no double request, no CSS tricks. Tenant logos - TenantLogoService (ImageUploadTrait) with theme whitelist and per-theme storage storage/tenants/{uuid}/logo/{light|dark}/, SIZES 128/256/512 - Public serving endpoint auth/tenant-logo-file so login can show the logo pre-auth; matching authenticated admin preview endpoint - appTenantLogoUrl(?size, ?theme) with 4-step fallback cascade; PDF + mail always request 'light' - Admin tenant edit: avatar block replaced by "Tenant logos" details block inside the Master-data tab, two side-by-side slots via Pico .grid with the core app-file-upload partial - Policy rename ABILITY_ADMIN_TENANTS_AVATAR_VIEW -> LOGO_VIEW, action routes logo / logo-delete / logo-file with theme body/query param - API endpoint path kept (backward compat), internals on new service - CLI tenant:logo-migrate-avatars moves legacy avatar/ -> logo/light/ idempotently (--dry-run, --yes, --cleanup) - i18n "Tenant image" removed, 12 new keys synced across de/en File upload component - Full-width preview + filename/actions below (3D stack layout) - Fixed 16:9 aspect ratio with 1rem inner padding for consistent preview size across any logo aspect - Transparency checker pattern as background so black logos stay visible on dark mode and white logos on light mode - form="" + deleteFormId support so the partial works with barrier forms inside another form Buttons - width:100% dropped from button[type="submit"]; scoped back via .login-main for the auth-flow primary CTA - .outline base rule now tints background via color-mix of --app-color so secondary/primary/danger outlines all gain a subtle surface - .outline.secondary restyled Stripe-style in both themes: solid white chip with soft shadow in light, solid elevated dark chip with white text in dark; neutral border replaces role-colored border - .app-action-success/.app-action-danger outlines get color-mix bg + theme-aware outline-text tokens for stronger contrast - Filled .primary/.app-action-success/.app-action-danger get raised box-shadow (inset highlight + drop) — opt-in via class so chrome buttons stay flat - Dropped the legacy .secondary utility that was clobbering the custom-property cascade with a hardcoded muted color Theme swap - Logo img carries data-src-light + data-src-dark; theme-toggle JS swaps src when data-theme changes, keeping the topbar/login logo in sync without a page reload Quality gates: PHPUnit (2045), PHPStan L5, CS-Fixer, docs link/drift, codex skills sync — all green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 20:25:53 +02:00
use MintyPHP\Service\Tenant\TenantLogoService;
use Throwable;
use ZipArchive;
class UserAccessPdfService
{
2026-03-04 15:56:58 +01:00
public function __construct(
private readonly UserAccessTemplateService $userAccessTemplateService,
feat(tenant): per-theme logos + file-upload + button UI polish Replace the single tenant avatar with a pair of theme-scoped brand logos. Render only the theme-matching <img> server-side and swap src on theme toggle via a JS hook — no reload, no double request, no CSS tricks. Tenant logos - TenantLogoService (ImageUploadTrait) with theme whitelist and per-theme storage storage/tenants/{uuid}/logo/{light|dark}/, SIZES 128/256/512 - Public serving endpoint auth/tenant-logo-file so login can show the logo pre-auth; matching authenticated admin preview endpoint - appTenantLogoUrl(?size, ?theme) with 4-step fallback cascade; PDF + mail always request 'light' - Admin tenant edit: avatar block replaced by "Tenant logos" details block inside the Master-data tab, two side-by-side slots via Pico .grid with the core app-file-upload partial - Policy rename ABILITY_ADMIN_TENANTS_AVATAR_VIEW -> LOGO_VIEW, action routes logo / logo-delete / logo-file with theme body/query param - API endpoint path kept (backward compat), internals on new service - CLI tenant:logo-migrate-avatars moves legacy avatar/ -> logo/light/ idempotently (--dry-run, --yes, --cleanup) - i18n "Tenant image" removed, 12 new keys synced across de/en File upload component - Full-width preview + filename/actions below (3D stack layout) - Fixed 16:9 aspect ratio with 1rem inner padding for consistent preview size across any logo aspect - Transparency checker pattern as background so black logos stay visible on dark mode and white logos on light mode - form="" + deleteFormId support so the partial works with barrier forms inside another form Buttons - width:100% dropped from button[type="submit"]; scoped back via .login-main for the auth-flow primary CTA - .outline base rule now tints background via color-mix of --app-color so secondary/primary/danger outlines all gain a subtle surface - .outline.secondary restyled Stripe-style in both themes: solid white chip with soft shadow in light, solid elevated dark chip with white text in dark; neutral border replaces role-colored border - .app-action-success/.app-action-danger outlines get color-mix bg + theme-aware outline-text tokens for stronger contrast - Filled .primary/.app-action-success/.app-action-danger get raised box-shadow (inset highlight + drop) — opt-in via class so chrome buttons stay flat - Dropped the legacy .secondary utility that was clobbering the custom-property cascade with a hardcoded muted color Theme swap - Logo img carries data-src-light + data-src-dark; theme-toggle JS swaps src when data-theme changes, keeping the topbar/login logo in sync without a page reload Quality gates: PHPUnit (2045), PHPStan L5, CS-Fixer, docs link/drift, codex skills sync — all green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 20:25:53 +02:00
private readonly BrandingLogoService $brandingLogoService,
private readonly ?TenantLogoService $tenantLogoService = null
2026-03-04 15:56:58 +01:00
) {
}
// Returns PDF bytes for one user; empty string means rendering failed.
2026-03-04 15:56:58 +01:00
public function renderUserAccessPdf(array $user): string
{
if (!class_exists(Dompdf::class) || !class_exists(Options::class)) {
return '';
}
2026-03-04 15:56:58 +01:00
$context = $this->userAccessTemplateService->buildContext($user);
$locale = (string) ($context['locale'] ?? '');
$vars = is_array($context['vars'] ?? null) ? $context['vars'] : [];
$vars['pdf_title'] = self::buildPdfTitle($locale);
feat(tenant): per-theme logos + file-upload + button UI polish Replace the single tenant avatar with a pair of theme-scoped brand logos. Render only the theme-matching <img> server-side and swap src on theme toggle via a JS hook — no reload, no double request, no CSS tricks. Tenant logos - TenantLogoService (ImageUploadTrait) with theme whitelist and per-theme storage storage/tenants/{uuid}/logo/{light|dark}/, SIZES 128/256/512 - Public serving endpoint auth/tenant-logo-file so login can show the logo pre-auth; matching authenticated admin preview endpoint - appTenantLogoUrl(?size, ?theme) with 4-step fallback cascade; PDF + mail always request 'light' - Admin tenant edit: avatar block replaced by "Tenant logos" details block inside the Master-data tab, two side-by-side slots via Pico .grid with the core app-file-upload partial - Policy rename ABILITY_ADMIN_TENANTS_AVATAR_VIEW -> LOGO_VIEW, action routes logo / logo-delete / logo-file with theme body/query param - API endpoint path kept (backward compat), internals on new service - CLI tenant:logo-migrate-avatars moves legacy avatar/ -> logo/light/ idempotently (--dry-run, --yes, --cleanup) - i18n "Tenant image" removed, 12 new keys synced across de/en File upload component - Full-width preview + filename/actions below (3D stack layout) - Fixed 16:9 aspect ratio with 1rem inner padding for consistent preview size across any logo aspect - Transparency checker pattern as background so black logos stay visible on dark mode and white logos on light mode - form="" + deleteFormId support so the partial works with barrier forms inside another form Buttons - width:100% dropped from button[type="submit"]; scoped back via .login-main for the auth-flow primary CTA - .outline base rule now tints background via color-mix of --app-color so secondary/primary/danger outlines all gain a subtle surface - .outline.secondary restyled Stripe-style in both themes: solid white chip with soft shadow in light, solid elevated dark chip with white text in dark; neutral border replaces role-colored border - .app-action-success/.app-action-danger outlines get color-mix bg + theme-aware outline-text tokens for stronger contrast - Filled .primary/.app-action-success/.app-action-danger get raised box-shadow (inset highlight + drop) — opt-in via class so chrome buttons stay flat - Dropped the legacy .secondary utility that was clobbering the custom-property cascade with a hardcoded muted color Theme swap - Logo img carries data-src-light + data-src-dark; theme-toggle JS swaps src when data-theme changes, keeping the topbar/login logo in sync without a page reload Quality gates: PHPUnit (2045), PHPStan L5, CS-Fixer, docs link/drift, codex skills sync — all green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 20:25:53 +02:00
$tenantUuid = (string) ($user['tenant_uuid'] ?? $user['primary_tenant_uuid'] ?? '');
$vars['app_logo_url'] = $this->resolveLogoDataUri($tenantUuid);
$vars['login_qr_data_uri'] = self::buildLoginQrDataUri((string) ($vars['login_url'] ?? ''));
$vars['generated_at'] = gmdate('Y-m-d H:i:s') . ' UTC';
$html = self::renderTemplate('access_info', $vars, $locale);
if ($html === '') {
return '';
}
try {
$options = new Options();
self::configurePdfOptions($options);
$dompdf = new Dompdf($options);
$dompdf->loadHtml($html, 'UTF-8');
$dompdf->setPaper('A4');
$dompdf->render();
$dompdf->addInfo('Title', (string) $vars['pdf_title']);
$dompdf->addInfo('Subject', (string) $vars['pdf_title']);
return $dompdf->output();
} catch (Throwable $e) {
return '';
}
}
// Creates one PDF per user and packs them into a temp ZIP file.
// Caller is responsible for deleting the returned ZIP path after download.
2026-03-04 15:56:58 +01:00
public function renderUsersAccessZip(array $users): array
{
if (!class_exists(ZipArchive::class) || !$users) {
return ['path' => '', 'filename' => '', 'count' => 0];
}
$zipPath = tempnam(sys_get_temp_dir(), 'access_pdfs_');
if ($zipPath === false) {
return ['path' => '', 'filename' => '', 'count' => 0];
}
$zip = new ZipArchive();
if ($zip->open($zipPath, ZipArchive::OVERWRITE | ZipArchive::CREATE) !== true) {
@unlink($zipPath);
return ['path' => '', 'filename' => '', 'count' => 0];
}
$usedNames = [];
$count = 0;
foreach ($users as $user) {
if (!is_array($user)) {
continue;
}
2026-03-04 15:56:58 +01:00
$pdf = $this->renderUserAccessPdf($user);
if ($pdf === '') {
continue;
}
$filename = self::buildPdfEntryName($user, $usedNames, $count + 1);
if (!$zip->addFromString($filename, $pdf)) {
continue;
}
$count++;
}
$closed = $zip->close();
if (!$closed || $count === 0) {
@unlink($zipPath);
return ['path' => '', 'filename' => '', 'count' => 0];
}
return [
'path' => $zipPath,
'filename' => 'user-invitations-' . gmdate('Ymd-His') . '.zip',
'count' => $count,
];
}
private static function configurePdfOptions(Options $options): void
{
// Keep PDF rendering deterministic and avoid remote/network execution.
$options->setIsRemoteEnabled(false);
$options->setIsPhpEnabled(false);
$options->setDefaultFont('DejaVu Sans');
}
private static function renderTemplate(string $template, array $vars, string $locale): string
{
$base = dirname(__DIR__, 3) . '/templates';
$pdfBase = $base . '/pdfs';
$emailBase = $base . '/emails';
$htmlPath = $pdfBase . '/' . $locale . '/' . $template . '.html';
if (!is_file($htmlPath)) {
$htmlPath = $pdfBase . '/' . $template . '.html';
}
if (!is_file($htmlPath)) {
return '';
}
// Priority: locale-specific PDF partials -> locale email partials -> generic email partials.
$htmlHeaderPath = $pdfBase . '/' . $locale . '/partials/header.html';
$htmlFooterPath = $pdfBase . '/' . $locale . '/partials/footer.html';
if (!is_file($htmlHeaderPath)) {
$htmlHeaderPath = $emailBase . '/' . $locale . '/partials/header.html';
}
if (!is_file($htmlFooterPath)) {
$htmlFooterPath = $emailBase . '/' . $locale . '/partials/footer.html';
}
$html = (string) file_get_contents($htmlPath);
if (!isset($vars['email_header'])) {
$vars['email_header'] = is_file($htmlHeaderPath) ? (string) file_get_contents($htmlHeaderPath) : '';
}
if (!isset($vars['email_footer'])) {
$vars['email_footer'] = is_file($htmlFooterPath) ? (string) file_get_contents($htmlFooterPath) : '';
}
foreach ($vars as $key => $value) {
$html = str_replace('{{' . $key . '}}', (string) $value, $html);
}
// Second pass resolves placeholders that may appear inside injected header/footer snippets.
foreach ($vars as $key => $value) {
$html = str_replace('{{' . $key . '}}', (string) $value, $html);
}
return $html;
}
feat(tenant): per-theme logos + file-upload + button UI polish Replace the single tenant avatar with a pair of theme-scoped brand logos. Render only the theme-matching <img> server-side and swap src on theme toggle via a JS hook — no reload, no double request, no CSS tricks. Tenant logos - TenantLogoService (ImageUploadTrait) with theme whitelist and per-theme storage storage/tenants/{uuid}/logo/{light|dark}/, SIZES 128/256/512 - Public serving endpoint auth/tenant-logo-file so login can show the logo pre-auth; matching authenticated admin preview endpoint - appTenantLogoUrl(?size, ?theme) with 4-step fallback cascade; PDF + mail always request 'light' - Admin tenant edit: avatar block replaced by "Tenant logos" details block inside the Master-data tab, two side-by-side slots via Pico .grid with the core app-file-upload partial - Policy rename ABILITY_ADMIN_TENANTS_AVATAR_VIEW -> LOGO_VIEW, action routes logo / logo-delete / logo-file with theme body/query param - API endpoint path kept (backward compat), internals on new service - CLI tenant:logo-migrate-avatars moves legacy avatar/ -> logo/light/ idempotently (--dry-run, --yes, --cleanup) - i18n "Tenant image" removed, 12 new keys synced across de/en File upload component - Full-width preview + filename/actions below (3D stack layout) - Fixed 16:9 aspect ratio with 1rem inner padding for consistent preview size across any logo aspect - Transparency checker pattern as background so black logos stay visible on dark mode and white logos on light mode - form="" + deleteFormId support so the partial works with barrier forms inside another form Buttons - width:100% dropped from button[type="submit"]; scoped back via .login-main for the auth-flow primary CTA - .outline base rule now tints background via color-mix of --app-color so secondary/primary/danger outlines all gain a subtle surface - .outline.secondary restyled Stripe-style in both themes: solid white chip with soft shadow in light, solid elevated dark chip with white text in dark; neutral border replaces role-colored border - .app-action-success/.app-action-danger outlines get color-mix bg + theme-aware outline-text tokens for stronger contrast - Filled .primary/.app-action-success/.app-action-danger get raised box-shadow (inset highlight + drop) — opt-in via class so chrome buttons stay flat - Dropped the legacy .secondary utility that was clobbering the custom-property cascade with a hardcoded muted color Theme swap - Logo img carries data-src-light + data-src-dark; theme-toggle JS swaps src when data-theme changes, keeping the topbar/login logo in sync without a page reload Quality gates: PHPUnit (2045), PHPStan L5, CS-Fixer, docs link/drift, codex skills sync — all green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 20:25:53 +02:00
private function resolveLogoDataUri(string $tenantUuid = ''): string
{
$path = '';
$mime = '';
feat(tenant): per-theme logos + file-upload + button UI polish Replace the single tenant avatar with a pair of theme-scoped brand logos. Render only the theme-matching <img> server-side and swap src on theme toggle via a JS hook — no reload, no double request, no CSS tricks. Tenant logos - TenantLogoService (ImageUploadTrait) with theme whitelist and per-theme storage storage/tenants/{uuid}/logo/{light|dark}/, SIZES 128/256/512 - Public serving endpoint auth/tenant-logo-file so login can show the logo pre-auth; matching authenticated admin preview endpoint - appTenantLogoUrl(?size, ?theme) with 4-step fallback cascade; PDF + mail always request 'light' - Admin tenant edit: avatar block replaced by "Tenant logos" details block inside the Master-data tab, two side-by-side slots via Pico .grid with the core app-file-upload partial - Policy rename ABILITY_ADMIN_TENANTS_AVATAR_VIEW -> LOGO_VIEW, action routes logo / logo-delete / logo-file with theme body/query param - API endpoint path kept (backward compat), internals on new service - CLI tenant:logo-migrate-avatars moves legacy avatar/ -> logo/light/ idempotently (--dry-run, --yes, --cleanup) - i18n "Tenant image" removed, 12 new keys synced across de/en File upload component - Full-width preview + filename/actions below (3D stack layout) - Fixed 16:9 aspect ratio with 1rem inner padding for consistent preview size across any logo aspect - Transparency checker pattern as background so black logos stay visible on dark mode and white logos on light mode - form="" + deleteFormId support so the partial works with barrier forms inside another form Buttons - width:100% dropped from button[type="submit"]; scoped back via .login-main for the auth-flow primary CTA - .outline base rule now tints background via color-mix of --app-color so secondary/primary/danger outlines all gain a subtle surface - .outline.secondary restyled Stripe-style in both themes: solid white chip with soft shadow in light, solid elevated dark chip with white text in dark; neutral border replaces role-colored border - .app-action-success/.app-action-danger outlines get color-mix bg + theme-aware outline-text tokens for stronger contrast - Filled .primary/.app-action-success/.app-action-danger get raised box-shadow (inset highlight + drop) — opt-in via class so chrome buttons stay flat - Dropped the legacy .secondary utility that was clobbering the custom-property cascade with a hardcoded muted color Theme swap - Logo img carries data-src-light + data-src-dark; theme-toggle JS swaps src when data-theme changes, keeping the topbar/login logo in sync without a page reload Quality gates: PHPUnit (2045), PHPStan L5, CS-Fixer, docs link/drift, codex skills sync — all green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 20:25:53 +02:00
// Prefer tenant light-logo if available (PDF has no theme context; always-light).
if ($this->tenantLogoService !== null && $tenantUuid !== '' && $this->tenantLogoService->hasLogo($tenantUuid, TenantLogoService::THEME_LIGHT)) {
$logoPath = $this->tenantLogoService->findLogoPath($tenantUuid, TenantLogoService::THEME_LIGHT, 128);
if ($logoPath && is_file($logoPath)) {
$path = $logoPath;
$mime = $this->tenantLogoService->detectMime($logoPath);
}
}
// Fallback to global app-branding logo.
if ($path === '' && $this->brandingLogoService->hasLogo()) {
2026-03-04 15:56:58 +01:00
$logoPath = $this->brandingLogoService->findLogoPath(128);
if ($logoPath && is_file($logoPath)) {
$path = $logoPath;
2026-03-04 15:56:58 +01:00
$mime = $this->brandingLogoService->detectMime($logoPath);
}
}
// Fallback to static brand logo from web assets.
if ($path === '') {
$fallbackPath = dirname(__DIR__, 3) . '/web/brand/logo.svg';
if (is_file($fallbackPath)) {
$path = $fallbackPath;
$mime = 'image/svg+xml';
}
}
if ($path === '' || !is_file($path)) {
return self::transparentPixelDataUri();
}
$content = @file_get_contents($path);
if ($content === false || $content === '') {
return self::transparentPixelDataUri();
}
if ($mime === '') {
$mime = 'image/png';
}
return 'data:' . $mime . ';base64,' . base64_encode($content);
}
private static function buildLoginQrDataUri(string $loginUrl): string
{
$loginUrl = trim($loginUrl);
if ($loginUrl === '') {
return self::transparentPixelDataUri();
}
if (!class_exists(QrCode::class) || !class_exists(PngWriter::class)) {
return self::transparentPixelDataUri();
}
try {
$qrCode = new QrCode(data: $loginUrl, size: 180, margin: 8);
$writer = new PngWriter();
$result = $writer->write($qrCode);
$png = (string) $result->getString();
if ($png === '') {
return self::transparentPixelDataUri();
}
return 'data:image/png;base64,' . base64_encode($png);
} catch (Throwable $e) {
return self::transparentPixelDataUri();
}
}
private static function transparentPixelDataUri(): string
{
// Smallest safe image placeholder to keep <img> tags valid in PDF templates.
return 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4////fwAJ+wP9KobjigAAAABJRU5ErkJggg==';
}
private static function buildPdfEntryName(array $user, array &$usedNames, int $fallbackIndex): string
{
$parts = [
(string) ($user['first_name'] ?? ''),
(string) ($user['last_name'] ?? ''),
];
if (trim(implode('', $parts)) === '' && !empty($user['email'])) {
$parts[] = (string) preg_replace('/@.*/', '', (string) $user['email']);
}
$base = self::slugify(implode('-', array_filter(array_map('trim', $parts))));
if ($base === '') {
$base = 'user-' . $fallbackIndex;
}
$uuid = trim((string) ($user['uuid'] ?? ''));
$suffix = '';
if ($uuid !== '') {
$suffix = substr(preg_replace('/[^a-z0-9]/i', '', $uuid), 0, 8);
}
$name = $base;
if ($suffix !== '') {
$name .= '-' . strtolower($suffix);
}
$name .= '.pdf';
$candidate = $name;
$counter = 2;
// Ensure stable unique filenames inside the ZIP archive.
while (isset($usedNames[$candidate])) {
$candidate = substr($name, 0, -4) . '-' . $counter . '.pdf';
$counter++;
}
$usedNames[$candidate] = true;
return $candidate;
}
private static function slugify(string $value): string
{
$value = strtolower(trim($value));
if ($value === '') {
return '';
}
$value = preg_replace('/[^a-z0-9]+/', '-', $value);
$value = trim((string) $value, '-');
return $value;
}
private static function buildPdfTitle(string $locale): string
{
if (str_starts_with(strtolower($locale), 'de')) {
return 'Zugangsdaten PDF';
}
return 'Access Details PDF';
}
}