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

296 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;
use Throwable;
use ZipArchive;
class UserAccessPdfService
{
2026-03-04 15:56:58 +01:00
public function __construct(
private readonly UserAccessTemplateService $userAccessTemplateService,
private readonly BrandingLogoService $brandingLogoService
) {
}
// 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);
2026-03-04 15:56:58 +01:00
$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.
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.
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;
}
2026-03-04 15:56:58 +01:00
private function resolveLogoDataUri(): string
{
$path = '';
$mime = '';
// Prefer tenant/app branding logo first.
2026-03-04 15:56:58 +01:00
if ($this->brandingLogoService->hasLogo()) {
$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';
}
}