forked from fa/breadcrumb-the-shire
289 lines
9.8 KiB
PHP
289 lines
9.8 KiB
PHP
<?php
|
|
|
|
namespace MintyPHP\Service\User;
|
|
|
|
use Dompdf\Dompdf;
|
|
use Dompdf\Options;
|
|
use Endroid\QrCode\QrCode;
|
|
use Endroid\QrCode\Writer\PngWriter;
|
|
use MintyPHP\Service\Branding\BrandingLogoService;
|
|
use Throwable;
|
|
use ZipArchive;
|
|
|
|
class UserAccessPdfService
|
|
{
|
|
public function __construct(
|
|
private readonly UserAccessTemplateService $userAccessTemplateService,
|
|
private readonly BrandingLogoService $brandingLogoService
|
|
) {
|
|
}
|
|
|
|
// Returns PDF bytes for one user; empty string means rendering failed.
|
|
public function renderUserAccessPdf(array $user): string
|
|
{
|
|
if (!class_exists(Dompdf::class) || !class_exists(Options::class)) {
|
|
return '';
|
|
}
|
|
|
|
$context = $this->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';
|
|
}
|
|
$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 <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';
|
|
}
|
|
}
|