refactor: rename lib/ to core/ for clearer core-module separation
Rename the top-level lib/ directory to core/ so the project root immediately communicates which code is core platform and which lives in modules/. PHP namespaces (MintyPHP\*) are unchanged — only the Composer PSR-4 path mapping moves from lib/ to core/. Module-internal lib/ directories (modules/*/lib/) are untouched. Updated across the full stack: - composer.json PSR-4 mapping - bootstrap entry points (web/index.php, bin/*, tests/bootstrap.php) - tooling configs (phpstan.neon, phpunit.xml, php-cs-fixer) - 26 architecture contract tests - enforcement-policy, guard-catalog, quality-gates - all documentation (CLAUDE.md, README, 25 docs/, .agents/skills/) - bin/qa-extended.sh search paths - .claude/settings.local.json permission paths Workflow: .agents/runs/CORE-LIB-RENAME-001/ (Analyst → Planner → Executor → Code Review (4 findings fixed) → Security Review → Acceptance Test → Finalizer) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
288
core/Service/User/UserAccessPdfService.php
Normal file
288
core/Service/User/UserAccessPdfService.php
Normal file
@@ -0,0 +1,288 @@
|
||||
<?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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user