diff --git a/modules/security/CLAUDE.md b/modules/security/CLAUDE.md index d815f1e..b4eb550 100644 --- a/modules/security/CLAUDE.md +++ b/modules/security/CLAUDE.md @@ -36,15 +36,19 @@ lib/Module/Security/ SecurityBcSettingsGateway.php — global BC connection settings (core settings table, encrypted) SecurityOAuthTokenService.php — OAuth2 token acquire + encrypted cache SecurityBcGateway.php — thin BC OData (ONLY customer-search + domains-for-customer) - SecurityReportPdfService.php — customer PDF report (Dompdf); pure context builder + render + SecurityReportPdfService.php — customer PDF report (Dompdf); picks custom template or built-in PHTML default + SecurityReportSettingsGateway.php— report HTML/CSS template (core settings table, clear text) + SecurityReportTemplateService.php— {{var}} substitution into the admin template + default scaffold (pure) + SecurityReportLogoService.php — report logo upload/serve (storage/security/report-logo, ImageUploadTrait) pages/security/ — actions (*.php) + views (*.phtml) checks/report($id).php — streams the customer report PDF (gated on steps 1–5 complete) + report-design/ — report look config: logo upload + HTML/CSS template editor; logo-file() serves the logo (settings.manage) templates/aside-security-panel.phtml— sidebar navigation -templates/pdf/customer-report.phtml — customer report PDF template (hydrated by SecurityReportPdfService) +templates/pdf/customer-report.phtml — built-in default report layout (used when no custom template is stored) i18n/default_de.json + default_en.json — keep both in sync (identical key sets) migrations/ — 003 SQL files (module:migrate) web/css/security.css | web/js/ — module styles + runtime components + page scripts -tests/Module/Security/Service/ — 7 test files (happy path + edge case) +tests/Module/Security/Service/ — 10 test files (happy path + edge case) ``` --- @@ -87,9 +91,13 @@ template edits never mutate in-flight checks. JSON state shapes: Main menu entry via the `aside.tab_panel` slot (icon `bi-shield-check`, permission `security.access`). Pages: `security/checks` (list), `security/checks/create` (wizard), `security/checks/edit/{id}` (workspace), `security/checks/report/{id}` (streams the customer PDF report), -`security/templates` (+create/edit/{id}), `security/settings`, plus data endpoints +`security/templates` (+create/edit/{id}), `security/settings`, `security/report-design` +(customer report look: logo + HTML/CSS template), plus data endpoints `security/checks-data`, `security/templates-data`, `security/customer-search-data`, -`security/customer-domains-data`, `security/settings/test-connection-data`. +`security/customer-domains-data`, `security/settings/test-connection-data`, +`security/report-design/logo-file` (serves the uploaded report logo). + +`report-design` reuses the `security.settings.manage` permission (no new permission key). ## JS runtime components diff --git a/modules/security/i18n/default_de.json b/modules/security/i18n/default_de.json index 20d0abe..47c691b 100644 --- a/modules/security/i18n/default_de.json +++ b/modules/security/i18n/default_de.json @@ -57,6 +57,31 @@ "Delete this security check? This cannot be undone.": "Diesen Security Check löschen? Dies kann nicht rückgängig gemacht werden.", "Archive this security check? It will become read-only.": "Diesen Security Check archivieren? Er wird schreibgeschützt.", "This security check is archived and read-only.": "Dieser Security Check ist archiviert und schreibgeschützt.", + "Report design": "Berichtsdesign", + "Customer report design": "Kundenbericht-Design", + "Configure how the customer security-check report PDF looks: upload a logo and edit the HTML/CSS template that is rendered to the PDF.": "Legen Sie das Aussehen des Kunden-Security-Check-Berichts (PDF) fest: Logo hochladen und die HTML/CSS-Vorlage bearbeiten, die ins PDF gerendert wird.", + "Report logo": "Berichtslogo", + "Report logo removed": "Berichtslogo entfernt", + "Current logo": "Aktuelles Logo", + "Delete this logo?": "Dieses Logo löschen?", + "Allowed file types: SVG, PNG, JPG, WEBP. Max 5 MB.": "Erlaubte Dateitypen: SVG, PNG, JPG, WEBP. Max. 5 MB.", + "Template (HTML & CSS)": "Vorlage (HTML & CSS)", + "Leave empty to use the built-in default template. Scripts and remote resources are not executed.": "Leer lassen, um die integrierte Standardvorlage zu verwenden. Skripte und externe Ressourcen werden nicht ausgeführt.", + "The template is too large.": "Die Vorlage ist zu groß.", + "Available variables": "Verfügbare Variablen", + "These placeholders are replaced when the report is generated:": "Diese Platzhalter werden beim Erstellen des Berichts ersetzt:", + "Logo image source (data URI) — use in ": "Logo-Bildquelle (Data-URI) – verwenden in ", + "Report title": "Berichtstitel", + "Customer name": "Kundenname", + "Customer number": "Kundennummer", + "Responsible person": "Verantwortliche Person", + "Completion percentage": "Fortschritt in Prozent", + "Completed technical checks": "Abgeschlossene technische Prüfungen", + "Total technical checks": "Technische Prüfungen gesamt", + "Generation date": "Erstellungsdatum", + "Application name": "Anwendungsname", + "Technical checklist table (HTML)": "Technische Checklisten-Tabelle (HTML)", + "Process steps table (HTML)": "Prozessschritte-Tabelle (HTML)", "This product has no technical checklist items defined yet.": "Für dieses Produkt sind noch keine technischen Checklisten-Punkte definiert.", "%d of %d completed": "%d von %d erledigt", "Order received": "Beauftragung", diff --git a/modules/security/i18n/default_en.json b/modules/security/i18n/default_en.json index 766548b..07c1844 100644 --- a/modules/security/i18n/default_en.json +++ b/modules/security/i18n/default_en.json @@ -57,6 +57,31 @@ "Delete this security check? This cannot be undone.": "Delete this security check? This cannot be undone.", "Archive this security check? It will become read-only.": "Archive this security check? It will become read-only.", "This security check is archived and read-only.": "This security check is archived and read-only.", + "Report design": "Report design", + "Customer report design": "Customer report design", + "Configure how the customer security-check report PDF looks: upload a logo and edit the HTML/CSS template that is rendered to the PDF.": "Configure how the customer security-check report PDF looks: upload a logo and edit the HTML/CSS template that is rendered to the PDF.", + "Report logo": "Report logo", + "Report logo removed": "Report logo removed", + "Current logo": "Current logo", + "Delete this logo?": "Delete this logo?", + "Allowed file types: SVG, PNG, JPG, WEBP. Max 5 MB.": "Allowed file types: SVG, PNG, JPG, WEBP. Max 5 MB.", + "Template (HTML & CSS)": "Template (HTML & CSS)", + "Leave empty to use the built-in default template. Scripts and remote resources are not executed.": "Leave empty to use the built-in default template. Scripts and remote resources are not executed.", + "The template is too large.": "The template is too large.", + "Available variables": "Available variables", + "These placeholders are replaced when the report is generated:": "These placeholders are replaced when the report is generated:", + "Logo image source (data URI) — use in ": "Logo image source (data URI) — use in ", + "Report title": "Report title", + "Customer name": "Customer name", + "Customer number": "Customer number", + "Responsible person": "Responsible person", + "Completion percentage": "Completion percentage", + "Completed technical checks": "Completed technical checks", + "Total technical checks": "Total technical checks", + "Generation date": "Generation date", + "Application name": "Application name", + "Technical checklist table (HTML)": "Technical checklist table (HTML)", + "Process steps table (HTML)": "Process steps table (HTML)", "This product has no technical checklist items defined yet.": "This product has no technical checklist items defined yet.", "%d of %d completed": "%d of %d completed", "Order received": "Order received", diff --git a/modules/security/lib/Module/Security/SecurityContainerRegistrar.php b/modules/security/lib/Module/Security/SecurityContainerRegistrar.php index b3434b8..ddc065c 100644 --- a/modules/security/lib/Module/Security/SecurityContainerRegistrar.php +++ b/modules/security/lib/Module/Security/SecurityContainerRegistrar.php @@ -15,7 +15,10 @@ use MintyPHP\Module\Security\Service\SecurityCheckService; use MintyPHP\Module\Security\Service\SecurityCheckTemplateService; use MintyPHP\Module\Security\Service\SecurityMarkdownGateway; use MintyPHP\Module\Security\Service\SecurityOAuthTokenService; +use MintyPHP\Module\Security\Service\SecurityReportLogoService; use MintyPHP\Module\Security\Service\SecurityReportPdfService; +use MintyPHP\Module\Security\Service\SecurityReportSettingsGateway; +use MintyPHP\Module\Security\Service\SecurityReportTemplateService; use MintyPHP\Service\Access\PermissionService; use MintyPHP\Service\Branding\BrandingLogoService; use MintyPHP\Service\Settings\SettingServicesFactory; @@ -71,10 +74,22 @@ final class SecurityContainerRegistrar implements ContainerRegistrar $c->get(SecurityCheckProcess::class) )); + // --- Customer report presentation (logo + HTML/CSS template) --- + $container->set(SecurityReportSettingsGateway::class, static fn (AppContainer $c): SecurityReportSettingsGateway => new SecurityReportSettingsGateway( + $c->get(SettingsMetadataGateway::class) + )); + + $container->set(SecurityReportTemplateService::class, static fn (): SecurityReportTemplateService => new SecurityReportTemplateService()); + + $container->set(SecurityReportLogoService::class, static fn (): SecurityReportLogoService => new SecurityReportLogoService()); + $container->set(SecurityReportPdfService::class, static fn (AppContainer $c): SecurityReportPdfService => new SecurityReportPdfService( $c->get(SecurityCheckProcess::class), $c->get(BrandingLogoService::class), - $c->get(TenantLogoService::class) + $c->get(TenantLogoService::class), + $c->get(SecurityReportSettingsGateway::class), + $c->get(SecurityReportTemplateService::class), + $c->get(SecurityReportLogoService::class) )); } } diff --git a/modules/security/lib/Module/Security/Service/SecurityReportLogoService.php b/modules/security/lib/Module/Security/Service/SecurityReportLogoService.php new file mode 100644 index 0000000..46d0a42 --- /dev/null +++ b/modules/security/lib/Module/Security/Service/SecurityReportLogoService.php @@ -0,0 +1,156 @@ +storageBase() . '/security/report-logo'; + } + + public function findLogoPath(?int $size = null): ?string + { + $dir = $this->reportLogoDir(); + if (!is_dir($dir)) { + return null; + } + if ($size) { + $variant = $this->findVariantPath($dir, $this->normalizeSize($size)); + if ($variant) { + return $variant; + } + } + $defaultVariant = $this->findVariantPath($dir, self::DEFAULT_SIZE); + if ($defaultVariant) { + return $defaultVariant; + } + $original = self::imageFindOriginalPath($dir); + + return $original ?: null; + } + + public function hasLogo(): bool + { + $path = $this->findLogoPath(); + + return $path ? is_file($path) : false; + } + + public function delete(): bool + { + $dir = $this->reportLogoDir(); + if (!is_dir($dir)) { + return true; + } + $matches = array_merge( + glob($dir . '/logo-*.*') ?: [], + glob($dir . '/logo.*') ?: [], + glob($dir . '/original.*') ?: [] + ); + foreach ($matches as $file) { + if (is_file($file)) { + @unlink($file); + } + } + + return true; + } + + /** + * @param array $file A single $_FILES entry (tmp_name, error, size) + * @return array{ok: bool, error?: string, path?: string, mime?: string} + */ + public function saveUpload(array $file): array + { + if (empty($file) || !isset($file['tmp_name'])) { + return ['ok' => false, 'error' => t('No file uploaded')]; + } + if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { + return ['ok' => false, 'error' => t('Upload failed')]; + } + if (($file['size'] ?? 0) > self::MAX_SIZE) { + return ['ok' => false, 'error' => t('File is too large')]; + } + + $tmpPath = (string) $file['tmp_name']; + $mime = $this->detectMime($tmpPath); + $isSvg = self::imageIsSvgUpload($mime, $tmpPath); + $ext = self::imageExtensionForMime($mime, $isSvg); + if (!$ext) { + return ['ok' => false, 'error' => t('Invalid image file')]; + } + if ($isSvg && !self::imageIsSafeSvg($tmpPath)) { + return ['ok' => false, 'error' => t('Invalid image file')]; + } + + $dir = $this->reportLogoDir(); + if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) { + return ['ok' => false, 'error' => t('Upload failed')]; + } + + $this->delete(); + $originalPath = $dir . '/original.' . $ext; + if (!move_uploaded_file($tmpPath, $originalPath)) { + return ['ok' => false, 'error' => t('Upload failed')]; + } + @chmod($originalPath, 0644); + + // SVGs are embedded as-is; raster images get downscaled variants when GD is available. + $variantExt = function_exists('imagewebp') ? 'webp' : 'jpg'; + if (!$isSvg && self::imageCanResize()) { + foreach (self::SIZES as $size) { + $target = $dir . '/logo-' . $size . '.' . $variantExt; + self::imageResizeAndFit($originalPath, $target, $size, $size, $variantExt); + } + } + + return ['ok' => true, 'path' => $originalPath, 'mime' => $mime]; + } + + public function detectMime(string $path): string + { + return self::imageDetectMime($path); + } + + private function normalizeSize(int $size): int + { + return in_array($size, self::SIZES, true) ? $size : self::DEFAULT_SIZE; + } + + // Multiple extensions may coexist (e.g. after a format change) — pick the freshest. + private function findVariantPath(string $dir, int $size): ?string + { + $matches = glob($dir . '/logo-' . $size . '.*'); + if (!$matches) { + return null; + } + usort($matches, static fn ($a, $b) => filemtime($b) <=> filemtime($a)); + + return $matches[0]; + } +} diff --git a/modules/security/lib/Module/Security/Service/SecurityReportPdfService.php b/modules/security/lib/Module/Security/Service/SecurityReportPdfService.php index d163457..f9fd943 100644 --- a/modules/security/lib/Module/Security/Service/SecurityReportPdfService.php +++ b/modules/security/lib/Module/Security/Service/SecurityReportPdfService.php @@ -24,7 +24,10 @@ final class SecurityReportPdfService public function __construct( private readonly SecurityCheckProcess $process, private readonly BrandingLogoService $brandingLogoService, - private readonly ?TenantLogoService $tenantLogoService = null + private readonly ?TenantLogoService $tenantLogoService = null, + private readonly ?SecurityReportSettingsGateway $reportSettings = null, + private readonly ?SecurityReportTemplateService $templateService = null, + private readonly ?SecurityReportLogoService $reportLogoService = null ) { } @@ -46,7 +49,7 @@ final class SecurityReportPdfService $report['logo_data_uri'] = $this->resolveLogoDataUri((string) ($options['tenant_uuid'] ?? '')); $report['generated_at'] = date('Y-m-d H:i'); - $html = $this->renderTemplate($report); + $html = $this->renderReportHtml($report); if ($html === '') { return ''; } @@ -109,6 +112,7 @@ final class SecurityReportPdfService 'customer_no' => (string) ($check['debitor_no'] ?? ''), 'domain' => (string) ($check['domain_url'] ?? ($check['domain_no'] ?? '')), 'product' => (string) ($check['product_name'] ?? ($check['product_code'] ?? '')), + 'owner' => (string) ($check['owner_name'] ?? ''), 'status' => $status, 'status_label' => t(SecurityCheckProcess::statusLabel($status)), 'summary' => [ @@ -196,6 +200,26 @@ final class SecurityReportPdfService return $sections; } + /** + * Produce the report HTML. When a custom template is configured (and the + * collaborating services are wired), it is filled with the report context; + * otherwise the built-in PHTML default layout is used. Keeping the default + * on the proven PHTML path makes the feature purely additive. + * + * @param array $report + */ + private function renderReportHtml(array $report): string + { + if ($this->reportSettings !== null && $this->templateService !== null) { + $template = $this->reportSettings->getTemplateHtml(); + if ($template !== '') { + return $this->templateService->render($template, $report); + } + } + + return $this->renderTemplate($report); + } + /** * @param array $report */ @@ -218,16 +242,25 @@ final class SecurityReportPdfService } /** - * Resolve a logo data URI: tenant light-logo → global branding logo → - * static brand asset → transparent pixel. Mirrors the core PDF pattern; the - * PDF has no theme context, so the light variant is always used. + * Resolve a logo data URI: configured report logo → tenant light-logo → + * global branding logo → static brand asset → transparent pixel. Mirrors the + * core PDF pattern; the PDF has no theme context, so the light variant is + * always used. */ private function resolveLogoDataUri(string $tenantUuid): string { $path = ''; $mime = ''; - if ($this->tenantLogoService !== null && $tenantUuid !== '' && $this->tenantLogoService->hasLogo($tenantUuid, TenantLogoService::THEME_LIGHT)) { + if ($this->reportLogoService !== null && $this->reportLogoService->hasLogo()) { + $logoPath = $this->reportLogoService->findLogoPath(256); + if ($logoPath && is_file($logoPath)) { + $path = $logoPath; + $mime = $this->reportLogoService->detectMime($logoPath); + } + } + + if ($path === '' && $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; diff --git a/modules/security/lib/Module/Security/Service/SecurityReportSettingsGateway.php b/modules/security/lib/Module/Security/Service/SecurityReportSettingsGateway.php new file mode 100644 index 0000000..6faa274 --- /dev/null +++ b/modules/security/lib/Module/Security/Service/SecurityReportSettingsGateway.php @@ -0,0 +1,64 @@ +settingsMetadataGateway->getValue(self::KEY_TEMPLATE_HTML) ?? '')); + } + + public function hasCustomTemplate(): bool + { + return $this->getTemplateHtml() !== ''; + } + + /** + * Persist the template. Passing null or an empty/blank string clears it, + * restoring the built-in default. Returns false if the value exceeds the + * soft length cap. + */ + public function setTemplateHtml(?string $html): bool + { + $html = trim((string) ($html ?? '')); + if ($html !== '' && strlen($html) > self::MAX_TEMPLATE_LENGTH) { + return false; + } + + return $this->settingsMetadataGateway->set( + self::KEY_TEMPLATE_HTML, + $html !== '' ? $html : null, + self::KEY_TEMPLATE_HTML + ); + } +} diff --git a/modules/security/lib/Module/Security/Service/SecurityReportTemplateService.php b/modules/security/lib/Module/Security/Service/SecurityReportTemplateService.php new file mode 100644 index 0000000..3427a9b --- /dev/null +++ b/modules/security/lib/Module/Security/Service/SecurityReportTemplateService.php @@ -0,0 +1,233 @@ + $context Output of SecurityReportPdfService::buildReportContext() + * plus app_name / logo_data_uri / generated_at. + */ + public function render(string $template, array $context): string + { + return strtr($template, $this->buildVariableMap($context)); + } + + /** + * Placeholder => already-translated description, for the UI variable reference + * and to document the contract in one place. Keys are the bare token names + * (without braces); render() wraps them in {{ }}. + * + * @return array + */ + public function availableVariables(): array + { + return [ + 'logo_src' => t('Logo image source (data URI) — use in '), + 'title' => t('Report title'), + 'customer_name' => t('Customer name'), + 'customer_no' => t('Customer number'), + 'domain' => t('Domain'), + 'product' => t('Software product'), + 'owner' => t('Responsible person'), + 'status' => t('Status'), + 'percent' => t('Completion percentage'), + 'checks_done' => t('Completed technical checks'), + 'checks_total' => t('Total technical checks'), + 'generated_at' => t('Generation date'), + 'app_name' => t('Application name'), + 'checklist' => t('Technical checklist table (HTML)'), + 'process' => t('Process steps table (HTML)'), + ]; + } + + /** + * Build the {{token}} => value map. Scalars are HTML-escaped; structural + * variables are trusted HTML built from the (already escaped) context. + * + * @param array $context + * @return array + */ + private function buildVariableMap(array $context): array + { + $summary = is_array($context['summary'] ?? null) ? $context['summary'] : []; + + $scalars = [ + 'logo_src' => (string) ($context['logo_data_uri'] ?? ''), + 'title' => (string) ($context['title'] ?? ''), + 'customer_name' => (string) ($context['customer_name'] ?? ''), + 'customer_no' => (string) ($context['customer_no'] ?? ''), + 'domain' => (string) ($context['domain'] ?? ''), + 'product' => (string) ($context['product'] ?? ''), + 'owner' => (string) ($context['owner'] ?? ''), + 'status' => (string) ($context['status_label'] ?? ''), + 'percent' => (string) (int) ($summary['percent'] ?? 0), + 'checks_done' => (string) (int) ($summary['tech_done'] ?? 0), + 'checks_total' => (string) (int) ($summary['tech_total'] ?? 0), + 'generated_at' => (string) ($context['generated_at'] ?? ''), + 'app_name' => (string) ($context['app_name'] ?? ''), + ]; + + $map = []; + foreach ($scalars as $key => $value) { + $map['{{' . $key . '}}'] = $this->esc($value); + } + + $map['{{checklist}}'] = $this->buildChecklistHtml( + is_array($context['tech_sections'] ?? null) ? $context['tech_sections'] : [] + ); + $map['{{process}}'] = $this->buildProcessHtml( + is_array($context['steps'] ?? null) ? $context['steps'] : [] + ); + + return $map; + } + + /** + * @param array $sections + */ + private function buildChecklistHtml(array $sections): string + { + if ($sections === []) { + return '

' . $this->esc(t('This product has no technical checklist items defined yet.')) . '

'; + } + + $rows = ''; + foreach ($sections as $section) { + if (!is_array($section)) { + continue; + } + $label = trim((string) ($section['label'] ?? '')); + if ($label !== '') { + $rows .= '' . $this->esc($label) . ''; + } + foreach ((is_array($section['items'] ?? null) ? $section['items'] : []) as $item) { + if (!is_array($item)) { + continue; + } + $state = !empty($item['done']) + ? '✓ ' . $this->esc(t('Completed')) . '' + : '' . $this->esc(t('Not completed')) . ''; + $note = trim((string) ($item['note'] ?? '')); + $noteHtml = $note !== '' ? '
' . $this->esc($note) . '
' : ''; + $rows .= '' . $state . '' + . $this->esc((string) ($item['label'] ?? '')) . $noteHtml . ''; + } + } + + return '' . $rows . '
'; + } + + /** + * @param array $steps + */ + private function buildProcessHtml(array $steps): string + { + $rows = ''; + foreach ($steps as $step) { + if (!is_array($step)) { + continue; + } + $state = !empty($step['done']) + ? '✓ ' . $this->esc(t('Completed')) . '' + : '' . $this->esc(t('Open')) . ''; + $completedAt = trim((string) ($step['completed_at'] ?? '')); + $when = $completedAt !== '' ? ' · ' . $this->esc($completedAt) . '' : ''; + $rows .= '' . $state . '' + . $this->esc((string) ($step['number'] ?? '')) . '. ' + . $this->esc((string) ($step['label'] ?? '')) . $when . ''; + } + + return '' . $rows . '
'; + } + + /** + * A ready-to-edit starter template offered in the editor. Faithful to the + * built-in default layout so admins have a working baseline to customise. + */ + public function defaultTemplateHtml(): string + { + return <<<'HTML' + + + + + + + +
+ +

{{title}}

+
{{generated_at}}
+
+ + + + + + + +
Customer{{customer_name}} ({{customer_no}})
Domain{{domain}}
Software product{{product}}
Responsible{{owner}}
Status{{status}}
+ +

Result

+
{{checks_done}} / {{checks_total}} ({{percent}}%)
+
+ +

Technical checklist

+ {{checklist}} + +

Process

+ {{process}} + + + + +HTML; + } + + private function esc(string $value): string + { + return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + } +} diff --git a/modules/security/module.php b/modules/security/module.php index 89de0f8..053278c 100644 --- a/modules/security/module.php +++ b/modules/security/module.php @@ -30,6 +30,8 @@ return [ ['path' => 'security/templates/edit/{id}', 'target' => 'security/templates/edit'], ['path' => 'security/settings', 'target' => 'security/settings'], ['path' => 'security/settings/test-connection-data', 'target' => 'security/settings/test-connection-data'], + ['path' => 'security/report-design', 'target' => 'security/report-design'], + ['path' => 'security/report-design/logo-file', 'target' => 'security/report-design/logo-file'], ], 'public_paths' => [], diff --git a/modules/security/pages/security/report-design/index().php b/modules/security/pages/security/report-design/index().php new file mode 100644 index 0000000..b3e970d --- /dev/null +++ b/modules/security/pages/security/report-design/index().php @@ -0,0 +1,84 @@ +isMethod('POST') && !Session::checkCsrfToken()) { + Flash::error(t('Form expired, please try again'), 'security/report-design', 'csrf_expired'); + Router::redirect('security/report-design'); + + return; +} + +if ($request->isMethod('POST')) { + $action = (string) $request->body('action', 'save'); + + if ($action === 'delete_logo') { + $logoService->delete(); + Flash::success(t('Report logo removed'), 'security/report-design', 'report_logo_removed'); + Router::redirect('security/report-design'); + + return; + } + + $errorBag = formErrors(); + + if (!$reportSettings->setTemplateHtml((string) $request->body('template_html', ''))) { + $errorBag->add('template_html', t('The template is too large.')); + } + + // Only touch the logo when a file was actually submitted. + $upload = $request->filesAll()['logo'] ?? []; + if (is_array($upload) && (int) ($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) { + $result = $logoService->saveUpload($upload); + if (!($result['ok'] ?? false)) { + $errorBag->addGlobal((string) ($result['error'] ?? t('Upload failed'))); + } + } + + if ($errorBag->hasAny()) { + flashFormErrors($errorBag, 'security/report-design', 'report_design_error'); + } else { + Flash::success(t('Settings saved'), 'security/report-design', 'report_design_saved'); + } + + Router::redirect('security/report-design'); + + return; +} + +$hasCustomTemplate = $reportSettings->hasCustomTemplate(); +// Prefill the editor with the built-in default scaffold when nothing is stored, +// so the admin has a working baseline to customise. +$templateHtml = $hasCustomTemplate ? $reportSettings->getTemplateHtml() : $templateService->defaultTemplateHtml(); +$variables = $templateService->availableVariables(); + +$hasLogo = $logoService->hasLogo(); +$logoVersion = ''; +if ($hasLogo) { + $logoPath = $logoService->findLogoPath(256); + $logoVersion = ($logoPath && is_file($logoPath)) ? (string) filemtime($logoPath) : ''; +} + +Buffer::set('title', t('Customer report design')); +Buffer::set('style_groups', json_encode(['security'])); +$breadcrumbs = [ + ['label' => t('Home'), 'path' => 'admin'], + ['label' => t('Security'), 'path' => 'security/checks'], + ['label' => t('Customer report design')], +]; diff --git a/modules/security/pages/security/report-design/index(default).phtml b/modules/security/pages/security/report-design/index(default).phtml new file mode 100644 index 0000000..0de7e49 --- /dev/null +++ b/modules/security/pages/security/report-design/index(default).phtml @@ -0,0 +1,75 @@ + +
+
+ t('Customer report design'), + 'actions' => [ + ['label' => t('Save'), 'type' => 'submit', 'form' => 'security-report-design-form', 'class' => 'primary', 'tone' => 'success'], + ], + ]; + require templatePath('partials/app-details-titlebar.phtml'); + ?> + +

+ +
+

+ 'logo', + 'accept' => 'image/svg+xml,image/png,image/jpeg,image/webp', + 'hint' => t('Allowed file types: SVG, PNG, JPG, WEBP. Max 5 MB.'), + 'currentSrc' => $logoCurrentSrc, + 'currentLabel' => t('Current logo'), + 'deleteConfirm' => t('Delete this logo?'), + 'formId' => 'security-report-design-form', + 'deleteFormId' => 'security-report-logo-delete-form', + ]; + require templatePath('partials/app-file-upload.phtml'); + ?> +
+ +
+
+

+

+
+ +
+ + +
+

+

+
+ $description): ?> +
+
+ +
+
+ +
+ + +
+ + +
+
diff --git a/modules/security/pages/security/report-design/logo-file().php b/modules/security/pages/security/report-design/logo-file().php new file mode 100644 index 0000000..bc42d9e --- /dev/null +++ b/modules/security/pages/security/report-design/logo-file().php @@ -0,0 +1,31 @@ +queryAll(); +$size = isset($query['size']) ? (int) $query['size'] : null; + +$path = $logoService->findLogoPath($size); +if (!$path || !is_file($path)) { + http_response_code(404); + + return; +} + +$mime = $logoService->detectMime($path); +header('Content-Type: ' . $mime); +header('X-Content-Type-Options: nosniff'); +header('Content-Security-Policy: sandbox'); +header('Cache-Control: private, max-age=300'); +header('Content-Length: ' . filesize($path)); +readfile($path); diff --git a/modules/security/templates/aside-security-panel.phtml b/modules/security/templates/aside-security-panel.phtml index 42d8c69..5777c31 100644 --- a/modules/security/templates/aside-security-panel.phtml +++ b/modules/security/templates/aside-security-panel.phtml @@ -9,6 +9,7 @@ $canManageSettings = !empty($securityNav['can_manage_settings']); $checksActive = navActive(['security/checks', 'security/checks/create', 'security/checks/edit'], true); $templatesActive = navActive(['security/templates', 'security/templates/create', 'security/templates/edit'], true); $settingsActive = navActive('security/settings', true); +$reportDesignActive = navActive('security/report-design', true); $securityNavGroups = [ [ @@ -41,6 +42,12 @@ $securityNavGroups = [ 'active' => $settingsActive, 'visible' => $canManageSettings, ], + [ + 'label' => t('Report design'), + 'path' => 'security/report-design', + 'active' => $reportDesignActive, + 'visible' => $canManageSettings, + ], ], ], ]; diff --git a/modules/security/tests/Module/Security/Service/SecurityReportLogoServiceTest.php b/modules/security/tests/Module/Security/Service/SecurityReportLogoServiceTest.php new file mode 100644 index 0000000..d087dca --- /dev/null +++ b/modules/security/tests/Module/Security/Service/SecurityReportLogoServiceTest.php @@ -0,0 +1,81 @@ +service = new SecurityReportLogoService(); + } + + public function testReportLogoDirIsScopedToSecurityReportLogo(): void + { + $this->assertStringEndsWith('/security/report-logo', $this->service->reportLogoDir()); + } + + public function testSaveUploadRejectsMissingFile(): void + { + $this->assertFalse($this->service->saveUpload([])['ok']); + } + + public function testSaveUploadRejectsUploadError(): void + { + $result = $this->service->saveUpload([ + 'tmp_name' => '/tmp/whatever', + 'error' => UPLOAD_ERR_NO_FILE, + 'size' => 100, + ]); + + $this->assertFalse($result['ok']); + } + + public function testSaveUploadRejectsOversizedFile(): void + { + $result = $this->service->saveUpload([ + 'tmp_name' => '/tmp/whatever', + 'error' => UPLOAD_ERR_OK, + 'size' => 6 * 1024 * 1024, + ]); + + $this->assertFalse($result['ok']); + } + + public function testSaveUploadRejectsNonImageContent(): void + { + $tmp = tempnam(sys_get_temp_dir(), 'sec-logo-test'); + $this->assertNotFalse($tmp); + file_put_contents($tmp, "just some plain text, not an image\n"); + + try { + $result = $this->service->saveUpload([ + 'tmp_name' => $tmp, + 'error' => UPLOAD_ERR_OK, + 'size' => filesize($tmp), + ]); + + $this->assertFalse($result['ok']); + // Rejection must happen before any directory/file is created. + $this->assertFalse(is_dir($this->service->reportLogoDir() . '/__never__')); + } finally { + @unlink($tmp); + } + } + + public function testDeleteIsIdempotentWhenNothingStored(): void + { + // delete() is safe to call even when the directory does not exist. + $this->assertTrue($this->service->delete()); + } +} diff --git a/modules/security/tests/Module/Security/Service/SecurityReportPdfServiceTest.php b/modules/security/tests/Module/Security/Service/SecurityReportPdfServiceTest.php index 7cacc7a..a308445 100644 --- a/modules/security/tests/Module/Security/Service/SecurityReportPdfServiceTest.php +++ b/modules/security/tests/Module/Security/Service/SecurityReportPdfServiceTest.php @@ -4,6 +4,8 @@ namespace MintyPHP\Tests\Module\Security\Service; use MintyPHP\Module\Security\Service\SecurityCheckProcess; use MintyPHP\Module\Security\Service\SecurityReportPdfService; +use MintyPHP\Module\Security\Service\SecurityReportSettingsGateway; +use MintyPHP\Module\Security\Service\SecurityReportTemplateService; use MintyPHP\Service\Branding\BrandingLogoService; use PHPUnit\Framework\TestCase; @@ -48,6 +50,7 @@ class SecurityReportPdfServiceTest extends TestCase 'domain_no' => 'DNS-1', 'product_name' => 'Intranet', 'product_code' => 'intranet', + 'owner_name' => 'Jane Doe', 'status' => SecurityCheckProcess::STATUS_IN_PROGRESS, 'process_state' => $processState, 'tech_schema_snapshot' => $snapshot, @@ -64,6 +67,7 @@ class SecurityReportPdfServiceTest extends TestCase $this->assertSame('D-100', $context['customer_no']); $this->assertSame('acme.de', $context['domain']); $this->assertSame('Intranet', $context['product']); + $this->assertSame('Jane Doe', $context['owner']); $this->assertNotSame('', $context['title']); // Summary mirrors the progress math (3 tech items, 2 done). @@ -126,4 +130,28 @@ class SecurityReportPdfServiceTest extends TestCase $this->assertNotSame('', $pdf); $this->assertStringStartsWith('%PDF-', $pdf); } + + public function testRenderCustomerReportPdfUsesConfiguredCustomTemplate(): void + { + $branding = $this->createMock(BrandingLogoService::class); + $branding->method('hasLogo')->willReturn(false); + + $settings = $this->createMock(SecurityReportSettingsGateway::class); + $settings->method('getTemplateHtml')->willReturn( + '

Custom {{customer_name}}

{{checklist}}' + ); + + $service = new SecurityReportPdfService( + new SecurityCheckProcess(), + $branding, + null, + $settings, + new SecurityReportTemplateService(), + null + ); + + $pdf = $service->renderCustomerReportPdf($this->enrichedCheck(), ['app_name' => 'CoreCore']); + + $this->assertStringStartsWith('%PDF-', $pdf); + } } diff --git a/modules/security/tests/Module/Security/Service/SecurityReportSettingsGatewayTest.php b/modules/security/tests/Module/Security/Service/SecurityReportSettingsGatewayTest.php new file mode 100644 index 0000000..5bdbb8c --- /dev/null +++ b/modules/security/tests/Module/Security/Service/SecurityReportSettingsGatewayTest.php @@ -0,0 +1,68 @@ + $values + */ + private function makeGateway(array $values = []): SecurityReportSettingsGateway + { + $metadata = $this->createMock(SettingsMetadataGateway::class); + $metadata->method('getValue')->willReturnCallback(static fn (string $key): ?string => $values[$key] ?? null); + $metadata->method('set')->willReturn(true); + + return new SecurityReportSettingsGateway($metadata); + } + + public function testTemplateDefaultsToEmpty(): void + { + $gateway = $this->makeGateway(); + + $this->assertSame('', $gateway->getTemplateHtml()); + $this->assertFalse($gateway->hasCustomTemplate()); + } + + public function testGetTemplateHtmlReturnsTrimmedStoredValue(): void + { + $gateway = $this->makeGateway([ + SecurityReportSettingsGateway::KEY_TEMPLATE_HTML => "

{{title}}

", + ]); + + $this->assertSame('

{{title}}

', $gateway->getTemplateHtml()); + $this->assertTrue($gateway->hasCustomTemplate()); + } + + public function testSetTemplateHtmlRejectsOversizedTemplate(): void + { + $gateway = $this->makeGateway(); + + $tooLong = str_repeat('a', SecurityReportSettingsGateway::MAX_TEMPLATE_LENGTH + 1); + $this->assertFalse($gateway->setTemplateHtml($tooLong)); + } + + public function testSetTemplateHtmlPersistsValueAndClearsOnEmpty(): void + { + $writes = []; + $metadata = $this->createMock(SettingsMetadataGateway::class); + $metadata->method('getValue')->willReturn(null); + $metadata->method('set')->willReturnCallback(static function (string $key, ?string $value, ?string $description = null) use (&$writes): bool { + $writes[$key] = $value; + + return true; + }); + $gateway = new SecurityReportSettingsGateway($metadata); + + $this->assertTrue($gateway->setTemplateHtml('

x

')); + $this->assertSame('

x

', $writes[SecurityReportSettingsGateway::KEY_TEMPLATE_HTML]); + + // Blank input clears the setting (restores the built-in default). + $this->assertTrue($gateway->setTemplateHtml(' ')); + $this->assertNull($writes[SecurityReportSettingsGateway::KEY_TEMPLATE_HTML]); + } +} diff --git a/modules/security/tests/Module/Security/Service/SecurityReportTemplateServiceTest.php b/modules/security/tests/Module/Security/Service/SecurityReportTemplateServiceTest.php new file mode 100644 index 0000000..fe46c60 --- /dev/null +++ b/modules/security/tests/Module/Security/Service/SecurityReportTemplateServiceTest.php @@ -0,0 +1,118 @@ + + */ + private function context(): array + { + return [ + 'title' => 'Security check report', + 'customer_name' => 'Acme', + 'customer_no' => 'D-100', + 'domain' => 'acme.de', + 'product' => 'Intranet', + 'owner' => 'Jane Doe', + 'status_label' => 'In progress', + 'generated_at' => '2026-06-22 10:00', + 'app_name' => 'CoreCore', + 'logo_data_uri' => 'data:image/png;base64,AAAA', + 'summary' => ['percent' => 72, 'tech_done' => 2, 'tech_total' => 3], + 'tech_sections' => [ + ['label' => 'Transport', 'items' => [ + ['label' => 'TLS enforced', 'done' => true, 'note' => 'TLS 1.3'], + ['label' => 'HSTS header', 'done' => false, 'note' => ''], + ]], + ], + 'steps' => [ + ['number' => 1, 'label' => 'Kickoff', 'done' => true, 'completed_at' => '2026-06-18'], + ], + ]; + } + + public function testRenderSubstitutesScalarsAndEscapesData(): void + { + $service = new SecurityReportTemplateService(); + + $out = $service->render( + '

{{title}}

who={{customer_name}} dom={{domain}} owner={{owner}} p={{percent}}% src={{logo_src}}', + $this->context() + ); + + $this->assertStringContainsString('

Security check report

', $out); + $this->assertStringContainsString('dom=acme.de', $out); + $this->assertStringContainsString('owner=Jane Doe', $out); + $this->assertStringContainsString('p=72%', $out); + $this->assertStringContainsString('src=data:image/png;base64,AAAA', $out); + // Customer data is HTML-escaped so it cannot break out of the template markup. + $this->assertStringContainsString('who=<b>Acme</b>', $out); + $this->assertStringNotContainsString('Acme', $out); + } + + public function testRenderBuildsChecklistAndProcessHtml(): void + { + $service = new SecurityReportTemplateService(); + + $out = $service->render('{{checklist}}||{{process}}', $this->context()); + + $this->assertStringContainsString('Transport', $out); + $this->assertStringContainsString('TLS enforced', $out); + $this->assertStringContainsString('TLS 1.3', $out); + $this->assertStringContainsString('Kickoff', $out); + $this->assertStringContainsString('', $out); + } + + public function testRenderIsSinglePassAndDoesNotReExpandInjectedTokens(): void + { + $service = new SecurityReportTemplateService(); + + $context = $this->context(); + $context['customer_name'] = '{{title}}'; + $context['title'] = 'INJECTED'; + + $out = $service->render('{{customer_name}}', $context); + + // The value substituted for {{customer_name}} must not be re-scanned, + // so the literal token survives and {{title}} is never expanded here. + $this->assertStringContainsString('{{title}}', $out); + $this->assertStringNotContainsString('INJECTED', $out); + } + + public function testRenderHandlesEmptyContextSafely(): void + { + $service = new SecurityReportTemplateService(); + + $out = $service->render('[{{title}}]({{checklist}})', []); + + $this->assertStringContainsString('[]', $out); + // No sections -> the "no items" notice instead of a table. + $this->assertStringContainsString('class="muted"', $out); + } + + public function testDefaultTemplateContainsCoreVariables(): void + { + $service = new SecurityReportTemplateService(); + + $html = $service->defaultTemplateHtml(); + + $this->assertNotSame('', $html); + $this->assertStringContainsString('{{logo_src}}', $html); + $this->assertStringContainsString('{{checklist}}', $html); + $this->assertStringContainsString('{{process}}', $html); + } + + public function testAvailableVariablesExposeStructuralTokens(): void + { + $variables = (new SecurityReportTemplateService())->availableVariables(); + + $this->assertArrayHasKey('logo_src', $variables); + $this->assertArrayHasKey('checklist', $variables); + $this->assertArrayHasKey('process', $variables); + } +} diff --git a/modules/security/web/css/security.css b/modules/security/web/css/security.css index 960d09d..8be02fd 100644 --- a/modules/security/web/css/security.css +++ b/modules/security/web/css/security.css @@ -545,3 +545,58 @@ padding: var(--app-spacing); } } + +/* --- Customer report design (logo + HTML/CSS template editor) --- */ +.security-template-editor textarea { + width: 100%; + margin: 0; + font-family: var(--font-mono, ui-monospace, "SFMono-Regular", "Menlo", "Consolas", monospace); + font-size: 0.8125rem; + line-height: 1.5; + tab-size: 2; + white-space: pre; + resize: vertical; +} + +.security-variable-reference { + margin-top: calc(var(--app-spacing) * 1.25); +} + +.security-variable-reference h4 { + margin: 0 0 0.25rem; + font-size: var(--text-sm, 0.875rem); + font-weight: var(--font-medium, 600); +} + +.security-variable-list { + display: grid; + grid-template-columns: minmax(12rem, max-content) 1fr; + gap: 0.25rem 1rem; + margin: 0.5rem 0 0; +} + +.security-variable-list dt { + margin: 0; +} + +.security-variable-list dt code { + font-size: 0.8125rem; + white-space: nowrap; +} + +.security-variable-list dd { + margin: 0; + color: var(--app-muted-color, #6a6a6a); + font-size: var(--text-sm, 0.875rem); +} + +@media (max-width: 576px) { + .security-variable-list { + grid-template-columns: 1fr; + gap: 0.1rem 0; + } + + .security-variable-list dd { + margin-bottom: 0.4rem; + } +}