userAccessTemplateService->buildContext($user); $locale = (string) ($context['locale'] ?? ''); $vars = is_array($context['vars'] ?? null) ? $context['vars'] : []; $vars['pdf_title'] = self::buildPdfTitle($locale); $vars['app_logo_url'] = $this->resolveLogoDataUri(); $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. 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; } $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. if (method_exists($options, 'setIsRemoteEnabled')) { $options->setIsRemoteEnabled(false); } else { $options->set('isRemoteEnabled', false); } if (method_exists($options, 'setIsPhpEnabled')) { $options->setIsPhpEnabled(false); } else { $options->set('isPhpEnabled', false); } if (method_exists($options, 'setDefaultFont')) { $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'; } if (!is_file($htmlHeaderPath)) { $htmlHeaderPath = $emailBase . '/partials/header.html'; } if (!is_file($htmlFooterPath)) { $htmlFooterPath = $emailBase . '/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; } private function resolveLogoDataUri(): string { $path = ''; $mime = ''; // Prefer tenant/app branding logo first. if ($this->brandingLogoService->hasLogo()) { $logoPath = $this->brandingLogoService->findLogoPath(128); if ($logoPath && is_file($logoPath)) { $path = $logoPath; $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 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'; } }