forked from fa/breadcrumb-the-shire
Added security check report settings and templating
This commit is contained in:
@@ -36,15 +36,19 @@ lib/Module/Security/
|
|||||||
SecurityBcSettingsGateway.php — global BC connection settings (core settings table, encrypted)
|
SecurityBcSettingsGateway.php — global BC connection settings (core settings table, encrypted)
|
||||||
SecurityOAuthTokenService.php — OAuth2 token acquire + encrypted cache
|
SecurityOAuthTokenService.php — OAuth2 token acquire + encrypted cache
|
||||||
SecurityBcGateway.php — thin BC OData (ONLY customer-search + domains-for-customer)
|
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)
|
pages/security/ — actions (*.php) + views (*.phtml)
|
||||||
checks/report($id).php — streams the customer report PDF (gated on steps 1–5 complete)
|
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/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)
|
i18n/default_de.json + default_en.json — keep both in sync (identical key sets)
|
||||||
migrations/ — 003 SQL files (module:migrate)
|
migrations/ — 003 SQL files (module:migrate)
|
||||||
web/css/security.css | web/js/ — module styles + runtime components + page scripts
|
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`).
|
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}`
|
Pages: `security/checks` (list), `security/checks/create` (wizard), `security/checks/edit/{id}`
|
||||||
(workspace), `security/checks/report/{id}` (streams the customer PDF report),
|
(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/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
|
## JS runtime components
|
||||||
|
|
||||||
|
|||||||
@@ -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.",
|
"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.",
|
"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.",
|
"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 <img src=\"{{logo_src}}\">": "Logo-Bildquelle (Data-URI) – verwenden in <img src=\"{{logo_src}}\">",
|
||||||
|
"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.",
|
"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",
|
"%d of %d completed": "%d von %d erledigt",
|
||||||
"Order received": "Beauftragung",
|
"Order received": "Beauftragung",
|
||||||
|
|||||||
@@ -57,6 +57,31 @@
|
|||||||
"Delete this security check? This cannot be undone.": "Delete this security check? This cannot be undone.",
|
"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.",
|
"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.",
|
"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 <img src=\"{{logo_src}}\">": "Logo image source (data URI) — use in <img src=\"{{logo_src}}\">",
|
||||||
|
"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.",
|
"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",
|
"%d of %d completed": "%d of %d completed",
|
||||||
"Order received": "Order received",
|
"Order received": "Order received",
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ use MintyPHP\Module\Security\Service\SecurityCheckService;
|
|||||||
use MintyPHP\Module\Security\Service\SecurityCheckTemplateService;
|
use MintyPHP\Module\Security\Service\SecurityCheckTemplateService;
|
||||||
use MintyPHP\Module\Security\Service\SecurityMarkdownGateway;
|
use MintyPHP\Module\Security\Service\SecurityMarkdownGateway;
|
||||||
use MintyPHP\Module\Security\Service\SecurityOAuthTokenService;
|
use MintyPHP\Module\Security\Service\SecurityOAuthTokenService;
|
||||||
|
use MintyPHP\Module\Security\Service\SecurityReportLogoService;
|
||||||
use MintyPHP\Module\Security\Service\SecurityReportPdfService;
|
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\Access\PermissionService;
|
||||||
use MintyPHP\Service\Branding\BrandingLogoService;
|
use MintyPHP\Service\Branding\BrandingLogoService;
|
||||||
use MintyPHP\Service\Settings\SettingServicesFactory;
|
use MintyPHP\Service\Settings\SettingServicesFactory;
|
||||||
@@ -71,10 +74,22 @@ final class SecurityContainerRegistrar implements ContainerRegistrar
|
|||||||
$c->get(SecurityCheckProcess::class)
|
$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(
|
$container->set(SecurityReportPdfService::class, static fn (AppContainer $c): SecurityReportPdfService => new SecurityReportPdfService(
|
||||||
$c->get(SecurityCheckProcess::class),
|
$c->get(SecurityCheckProcess::class),
|
||||||
$c->get(BrandingLogoService::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)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\Module\Security\Service;
|
||||||
|
|
||||||
|
use MintyPHP\Service\Image\ImageUploadTrait;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores and resolves the logo shown on the customer security-check report PDF.
|
||||||
|
*
|
||||||
|
* A single global asset (the module has no per-tenant settings), kept on the
|
||||||
|
* filesystem under storage/security/report-logo (GR-SEC-006: uploads live under
|
||||||
|
* storage/ only, are MIME-validated, and SVGs are sanitised). Mirrors the core
|
||||||
|
* BrandingLogoService pattern via ImageUploadTrait.
|
||||||
|
*
|
||||||
|
* @api Consumed by the report-design page (upload/preview/delete) and
|
||||||
|
* SecurityReportPdfService (logo embedding).
|
||||||
|
*/
|
||||||
|
final class SecurityReportLogoService
|
||||||
|
{
|
||||||
|
use ImageUploadTrait;
|
||||||
|
|
||||||
|
private const MAX_SIZE = 5242880; // 5 MB
|
||||||
|
private const SIZES = [64, 128, 256];
|
||||||
|
private const DEFAULT_SIZE = 256;
|
||||||
|
|
||||||
|
public function storageBase(): string
|
||||||
|
{
|
||||||
|
return self::imageStorageBase();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reportLogoDir(): string
|
||||||
|
{
|
||||||
|
return $this->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<string, mixed> $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];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,10 @@ final class SecurityReportPdfService
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly SecurityCheckProcess $process,
|
private readonly SecurityCheckProcess $process,
|
||||||
private readonly BrandingLogoService $brandingLogoService,
|
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['logo_data_uri'] = $this->resolveLogoDataUri((string) ($options['tenant_uuid'] ?? ''));
|
||||||
$report['generated_at'] = date('Y-m-d H:i');
|
$report['generated_at'] = date('Y-m-d H:i');
|
||||||
|
|
||||||
$html = $this->renderTemplate($report);
|
$html = $this->renderReportHtml($report);
|
||||||
if ($html === '') {
|
if ($html === '') {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -109,6 +112,7 @@ final class SecurityReportPdfService
|
|||||||
'customer_no' => (string) ($check['debitor_no'] ?? ''),
|
'customer_no' => (string) ($check['debitor_no'] ?? ''),
|
||||||
'domain' => (string) ($check['domain_url'] ?? ($check['domain_no'] ?? '')),
|
'domain' => (string) ($check['domain_url'] ?? ($check['domain_no'] ?? '')),
|
||||||
'product' => (string) ($check['product_name'] ?? ($check['product_code'] ?? '')),
|
'product' => (string) ($check['product_name'] ?? ($check['product_code'] ?? '')),
|
||||||
|
'owner' => (string) ($check['owner_name'] ?? ''),
|
||||||
'status' => $status,
|
'status' => $status,
|
||||||
'status_label' => t(SecurityCheckProcess::statusLabel($status)),
|
'status_label' => t(SecurityCheckProcess::statusLabel($status)),
|
||||||
'summary' => [
|
'summary' => [
|
||||||
@@ -196,6 +200,26 @@ final class SecurityReportPdfService
|
|||||||
return $sections;
|
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<string, mixed> $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<string, mixed> $report
|
* @param array<string, mixed> $report
|
||||||
*/
|
*/
|
||||||
@@ -218,16 +242,25 @@ final class SecurityReportPdfService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a logo data URI: tenant light-logo → global branding logo →
|
* Resolve a logo data URI: configured report logo → tenant light-logo →
|
||||||
* static brand asset → transparent pixel. Mirrors the core PDF pattern; the
|
* global branding logo → static brand asset → transparent pixel. Mirrors the
|
||||||
* PDF has no theme context, so the light variant is always used.
|
* core PDF pattern; the PDF has no theme context, so the light variant is
|
||||||
|
* always used.
|
||||||
*/
|
*/
|
||||||
private function resolveLogoDataUri(string $tenantUuid): string
|
private function resolveLogoDataUri(string $tenantUuid): string
|
||||||
{
|
{
|
||||||
$path = '';
|
$path = '';
|
||||||
$mime = '';
|
$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);
|
$logoPath = $this->tenantLogoService->findLogoPath($tenantUuid, TenantLogoService::THEME_LIGHT, 128);
|
||||||
if ($logoPath && is_file($logoPath)) {
|
if ($logoPath && is_file($logoPath)) {
|
||||||
$path = $logoPath;
|
$path = $logoPath;
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\Module\Security\Service;
|
||||||
|
|
||||||
|
use MintyPHP\Service\Settings\SettingsMetadataGateway;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global presentation settings for the customer security-check report PDF.
|
||||||
|
*
|
||||||
|
* Stores the admin-authored HTML/CSS report template in the core `settings`
|
||||||
|
* table (GR-CORE-011). The template is not a secret, so it is stored in clear
|
||||||
|
* text (unlike the BC credentials in SecurityBcSettingsGateway). Setting keys
|
||||||
|
* are prefixed with 'security.' to avoid cross-module collisions.
|
||||||
|
*
|
||||||
|
* The uploaded report logo is a binary asset and lives on the filesystem —
|
||||||
|
* see SecurityReportLogoService — not here.
|
||||||
|
*
|
||||||
|
* @api Consumed by the Security report-design page and SecurityReportPdfService.
|
||||||
|
*/
|
||||||
|
class SecurityReportSettingsGateway
|
||||||
|
{
|
||||||
|
public const KEY_TEMPLATE_HTML = 'security.report_template_html';
|
||||||
|
|
||||||
|
/** Soft cap so the settings row stays sane; a real template is a few KB. */
|
||||||
|
public const MAX_TEMPLATE_LENGTH = 200000;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly SettingsMetadataGateway $settingsMetadataGateway
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stored custom template, or '' when none is configured (the PDF service
|
||||||
|
* then falls back to its built-in default layout).
|
||||||
|
*/
|
||||||
|
public function getTemplateHtml(): string
|
||||||
|
{
|
||||||
|
return trim((string) ($this->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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\Module\Security\Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the admin-authored HTML/CSS report template into a final HTML string
|
||||||
|
* by substituting {{placeholder}} variables with values from the report context.
|
||||||
|
*
|
||||||
|
* Pure transform (no I/O): the consuming SecurityReportPdfService builds the
|
||||||
|
* context, this fills the template, Dompdf rasterises the result. Scalar values
|
||||||
|
* are HTML-escaped so customer/BC data can never break out of the surrounding
|
||||||
|
* markup; the {{checklist}}, {{process}} and {{logo_src}} variables are built
|
||||||
|
* here as trusted HTML / a data URI. Substitution is a single simultaneous pass
|
||||||
|
* (strtr), so an injected value can never be re-expanded as another placeholder.
|
||||||
|
*
|
||||||
|
* The template text itself is authored by a settings-manager (gated by
|
||||||
|
* security.settings.manage) and rendered with Dompdf's remote fetching and PHP
|
||||||
|
* execution disabled — it shapes a downloaded PDF, not an app web page.
|
||||||
|
*
|
||||||
|
* @api Consumed by SecurityReportPdfService and the report-design page (variable
|
||||||
|
* reference + default scaffold).
|
||||||
|
*/
|
||||||
|
final class SecurityReportTemplateService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Fill a template with the report context.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $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<string, string>
|
||||||
|
*/
|
||||||
|
public function availableVariables(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'logo_src' => t('Logo image source (data URI) — use in <img src="{{logo_src}}">'),
|
||||||
|
'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<string, mixed> $context
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
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<int, mixed> $sections
|
||||||
|
*/
|
||||||
|
private function buildChecklistHtml(array $sections): string
|
||||||
|
{
|
||||||
|
if ($sections === []) {
|
||||||
|
return '<p class="muted">' . $this->esc(t('This product has no technical checklist items defined yet.')) . '</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = '';
|
||||||
|
foreach ($sections as $section) {
|
||||||
|
if (!is_array($section)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$label = trim((string) ($section['label'] ?? ''));
|
||||||
|
if ($label !== '') {
|
||||||
|
$rows .= '<tr class="section-row"><td colspan="2">' . $this->esc($label) . '</td></tr>';
|
||||||
|
}
|
||||||
|
foreach ((is_array($section['items'] ?? null) ? $section['items'] : []) as $item) {
|
||||||
|
if (!is_array($item)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$state = !empty($item['done'])
|
||||||
|
? '<span class="ok">✓ ' . $this->esc(t('Completed')) . '</span>'
|
||||||
|
: '<span class="pending">' . $this->esc(t('Not completed')) . '</span>';
|
||||||
|
$note = trim((string) ($item['note'] ?? ''));
|
||||||
|
$noteHtml = $note !== '' ? '<div class="note">' . $this->esc($note) . '</div>' : '';
|
||||||
|
$rows .= '<tr><td class="state">' . $state . '</td><td>'
|
||||||
|
. $this->esc((string) ($item['label'] ?? '')) . $noteHtml . '</td></tr>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '<table class="checklist">' . $rows . '</table>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, mixed> $steps
|
||||||
|
*/
|
||||||
|
private function buildProcessHtml(array $steps): string
|
||||||
|
{
|
||||||
|
$rows = '';
|
||||||
|
foreach ($steps as $step) {
|
||||||
|
if (!is_array($step)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$state = !empty($step['done'])
|
||||||
|
? '<span class="ok">✓ ' . $this->esc(t('Completed')) . '</span>'
|
||||||
|
: '<span class="pending">' . $this->esc(t('Open')) . '</span>';
|
||||||
|
$completedAt = trim((string) ($step['completed_at'] ?? ''));
|
||||||
|
$when = $completedAt !== '' ? ' <span class="muted">· ' . $this->esc($completedAt) . '</span>' : '';
|
||||||
|
$rows .= '<tr><td class="state">' . $state . '</td><td>'
|
||||||
|
. $this->esc((string) ($step['number'] ?? '')) . '. '
|
||||||
|
. $this->esc((string) ($step['label'] ?? '')) . $when . '</td></tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '<table class="checklist">' . $rows . '</table>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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'
|
||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { font-family: "DejaVu Sans", Arial, sans-serif; color: #1a1a1a; font-size: 12px; line-height: 1.45; margin: 0; }
|
||||||
|
h1 { font-size: 20px; margin: 0 0 2px; }
|
||||||
|
h2 { font-size: 14px; margin: 22px 0 8px; padding-bottom: 4px; border-bottom: 1px solid #e2e2e2; }
|
||||||
|
.muted { color: #6a6a6a; }
|
||||||
|
.header { border-bottom: 2px solid #1a1a1a; padding-bottom: 12px; margin-bottom: 16px; }
|
||||||
|
.header-logo { max-height: 48px; max-width: 200px; margin-bottom: 10px; }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
.meta td { padding: 3px 0; vertical-align: top; }
|
||||||
|
.meta td.label { color: #6a6a6a; width: 38%; }
|
||||||
|
.summary { margin: 6px 0 4px; font-size: 13px; }
|
||||||
|
.bar { height: 10px; background: #ececec; border-radius: 5px; overflow: hidden; margin-top: 6px; }
|
||||||
|
.bar-fill { height: 10px; background: #2e7d32; }
|
||||||
|
.checklist td { padding: 6px 8px; border-bottom: 1px solid #ededed; vertical-align: top; }
|
||||||
|
.checklist td.state { width: 110px; white-space: nowrap; }
|
||||||
|
.section-row td { background: #f5f5f5; font-weight: bold; padding: 6px 8px; }
|
||||||
|
.ok { color: #2e7d32; font-weight: bold; }
|
||||||
|
.pending { color: #b26a00; font-weight: bold; }
|
||||||
|
.note { color: #6a6a6a; font-size: 11px; margin-top: 2px; }
|
||||||
|
.footer { margin-top: 26px; padding-top: 8px; border-top: 1px solid #e2e2e2; color: #8a8a8a; font-size: 10px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<img src="{{logo_src}}" alt="" class="header-logo">
|
||||||
|
<h1>{{title}}</h1>
|
||||||
|
<div class="muted">{{generated_at}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="meta">
|
||||||
|
<tr><td class="label">Customer</td><td>{{customer_name}} ({{customer_no}})</td></tr>
|
||||||
|
<tr><td class="label">Domain</td><td>{{domain}}</td></tr>
|
||||||
|
<tr><td class="label">Software product</td><td>{{product}}</td></tr>
|
||||||
|
<tr><td class="label">Responsible</td><td>{{owner}}</td></tr>
|
||||||
|
<tr><td class="label">Status</td><td>{{status}}</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Result</h2>
|
||||||
|
<div class="summary">{{checks_done}} / {{checks_total}} ({{percent}}%)</div>
|
||||||
|
<div class="bar"><div class="bar-fill" style="width: {{percent}}%;"></div></div>
|
||||||
|
|
||||||
|
<h2>Technical checklist</h2>
|
||||||
|
{{checklist}}
|
||||||
|
|
||||||
|
<h2>Process</h2>
|
||||||
|
{{process}}
|
||||||
|
|
||||||
|
<div class="footer">{{app_name}} · {{title}} · {{generated_at}}</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
HTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function esc(string $value): string
|
||||||
|
{
|
||||||
|
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,8 @@ return [
|
|||||||
['path' => 'security/templates/edit/{id}', 'target' => 'security/templates/edit'],
|
['path' => 'security/templates/edit/{id}', 'target' => 'security/templates/edit'],
|
||||||
['path' => 'security/settings', 'target' => 'security/settings'],
|
['path' => 'security/settings', 'target' => 'security/settings'],
|
||||||
['path' => 'security/settings/test-connection-data', 'target' => 'security/settings/test-connection-data'],
|
['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' => [],
|
'public_paths' => [],
|
||||||
|
|
||||||
|
|||||||
84
modules/security/pages/security/report-design/index().php
Normal file
84
modules/security/pages/security/report-design/index().php
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use MintyPHP\Buffer;
|
||||||
|
use MintyPHP\Module\Security\SecurityAuthorizationPolicy;
|
||||||
|
use MintyPHP\Module\Security\Service\SecurityReportLogoService;
|
||||||
|
use MintyPHP\Module\Security\Service\SecurityReportSettingsGateway;
|
||||||
|
use MintyPHP\Module\Security\Service\SecurityReportTemplateService;
|
||||||
|
use MintyPHP\Router;
|
||||||
|
use MintyPHP\Session;
|
||||||
|
use MintyPHP\Support\Flash;
|
||||||
|
use MintyPHP\Support\Guard;
|
||||||
|
|
||||||
|
Guard::requireLogin();
|
||||||
|
Guard::requireAbilityOrForbidden(SecurityAuthorizationPolicy::ABILITY_SETTINGS_MANAGE);
|
||||||
|
|
||||||
|
$request = requestInput();
|
||||||
|
$reportSettings = app(SecurityReportSettingsGateway::class);
|
||||||
|
$logoService = app(SecurityReportLogoService::class);
|
||||||
|
$templateService = app(SecurityReportTemplateService::class);
|
||||||
|
|
||||||
|
if ($request->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')],
|
||||||
|
];
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
$templateHtml = (string) ($templateHtml ?? '');
|
||||||
|
$variables = is_array($variables ?? null) ? $variables : [];
|
||||||
|
$hasLogo = (bool) ($hasLogo ?? false);
|
||||||
|
$logoVersion = (string) ($logoVersion ?? '');
|
||||||
|
|
||||||
|
$logoCurrentSrc = '';
|
||||||
|
if ($hasLogo) {
|
||||||
|
$logoCurrentSrc = lurl('security/report-design/logo-file') . '?size=256'
|
||||||
|
. ($logoVersion !== '' ? '&v=' . rawurlencode($logoVersion) : '');
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<div class="app-details-container">
|
||||||
|
<section data-tab-panel data-tab-panel-wide>
|
||||||
|
<?php
|
||||||
|
$titlebar = [
|
||||||
|
'title' => 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');
|
||||||
|
?>
|
||||||
|
|
||||||
|
<p class="app-muted"><?php e(t('Configure how the customer security-check report PDF looks: upload a logo and edit the HTML/CSS template that is rendered to the PDF.')); ?></p>
|
||||||
|
|
||||||
|
<div class="app-details-card">
|
||||||
|
<h3><?php e(t('Report logo')); ?></h3>
|
||||||
|
<?php
|
||||||
|
$fileUpload = [
|
||||||
|
'name' => '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');
|
||||||
|
?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" id="security-report-design-form" enctype="multipart/form-data">
|
||||||
|
<div class="app-details-card">
|
||||||
|
<h3><?php e(t('Template (HTML & CSS)')); ?></h3>
|
||||||
|
<p class="app-muted"><?php e(t('Leave empty to use the built-in default template. Scripts and remote resources are not executed.')); ?></p>
|
||||||
|
<div class="security-template-editor">
|
||||||
|
<textarea name="template_html" rows="22" spellcheck="false" autocomplete="off" wrap="off"><?php e($templateHtml); ?></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($variables !== []): ?>
|
||||||
|
<div class="security-variable-reference">
|
||||||
|
<h4><?php e(t('Available variables')); ?></h4>
|
||||||
|
<p class="app-muted"><?php e(t('These placeholders are replaced when the report is generated:')); ?></p>
|
||||||
|
<dl class="security-variable-list">
|
||||||
|
<?php foreach ($variables as $token => $description): ?>
|
||||||
|
<dt><code><?php e('{{' . $token . '}}'); ?></code></dt>
|
||||||
|
<dd><?php e((string) $description); ?></dd>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php \MintyPHP\Session::getCsrfInput(); ?>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form method="post" id="security-report-logo-delete-form" hidden>
|
||||||
|
<?php \MintyPHP\Session::getCsrfInput(); ?>
|
||||||
|
<input type="hidden" name="action" value="delete_logo">
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use MintyPHP\Module\Security\SecurityAuthorizationPolicy;
|
||||||
|
use MintyPHP\Module\Security\Service\SecurityReportLogoService;
|
||||||
|
use MintyPHP\Support\Guard;
|
||||||
|
|
||||||
|
// Streams the report logo image directly — no view template for this route.
|
||||||
|
define('MINTY_ALLOW_OUTPUT', true);
|
||||||
|
|
||||||
|
Guard::requireLogin();
|
||||||
|
Guard::requireAbilityOrForbidden(SecurityAuthorizationPolicy::ABILITY_SETTINGS_MANAGE);
|
||||||
|
|
||||||
|
$logoService = app(SecurityReportLogoService::class);
|
||||||
|
|
||||||
|
$query = requestInput()->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);
|
||||||
@@ -9,6 +9,7 @@ $canManageSettings = !empty($securityNav['can_manage_settings']);
|
|||||||
$checksActive = navActive(['security/checks', 'security/checks/create', 'security/checks/edit'], true);
|
$checksActive = navActive(['security/checks', 'security/checks/create', 'security/checks/edit'], true);
|
||||||
$templatesActive = navActive(['security/templates', 'security/templates/create', 'security/templates/edit'], true);
|
$templatesActive = navActive(['security/templates', 'security/templates/create', 'security/templates/edit'], true);
|
||||||
$settingsActive = navActive('security/settings', true);
|
$settingsActive = navActive('security/settings', true);
|
||||||
|
$reportDesignActive = navActive('security/report-design', true);
|
||||||
|
|
||||||
$securityNavGroups = [
|
$securityNavGroups = [
|
||||||
[
|
[
|
||||||
@@ -41,6 +42,12 @@ $securityNavGroups = [
|
|||||||
'active' => $settingsActive,
|
'active' => $settingsActive,
|
||||||
'visible' => $canManageSettings,
|
'visible' => $canManageSettings,
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'label' => t('Report design'),
|
||||||
|
'path' => 'security/report-design',
|
||||||
|
'active' => $reportDesignActive,
|
||||||
|
'visible' => $canManageSettings,
|
||||||
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\Tests\Module\Security\Service;
|
||||||
|
|
||||||
|
use MintyPHP\Module\Security\Service\SecurityReportLogoService;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service-behaviour test. As with TenantLogoServiceTest, the successful upload
|
||||||
|
* path is asserted indirectly (MIME/size guards, no directory creation on
|
||||||
|
* rejection) because move_uploaded_file() needs a real HTTP-uploaded file and
|
||||||
|
* cannot run under phpunit. The move step is covered by manual smoke testing.
|
||||||
|
*/
|
||||||
|
class SecurityReportLogoServiceTest extends TestCase
|
||||||
|
{
|
||||||
|
private SecurityReportLogoService $service;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ namespace MintyPHP\Tests\Module\Security\Service;
|
|||||||
|
|
||||||
use MintyPHP\Module\Security\Service\SecurityCheckProcess;
|
use MintyPHP\Module\Security\Service\SecurityCheckProcess;
|
||||||
use MintyPHP\Module\Security\Service\SecurityReportPdfService;
|
use MintyPHP\Module\Security\Service\SecurityReportPdfService;
|
||||||
|
use MintyPHP\Module\Security\Service\SecurityReportSettingsGateway;
|
||||||
|
use MintyPHP\Module\Security\Service\SecurityReportTemplateService;
|
||||||
use MintyPHP\Service\Branding\BrandingLogoService;
|
use MintyPHP\Service\Branding\BrandingLogoService;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
@@ -48,6 +50,7 @@ class SecurityReportPdfServiceTest extends TestCase
|
|||||||
'domain_no' => 'DNS-1',
|
'domain_no' => 'DNS-1',
|
||||||
'product_name' => 'Intranet',
|
'product_name' => 'Intranet',
|
||||||
'product_code' => 'intranet',
|
'product_code' => 'intranet',
|
||||||
|
'owner_name' => 'Jane Doe',
|
||||||
'status' => SecurityCheckProcess::STATUS_IN_PROGRESS,
|
'status' => SecurityCheckProcess::STATUS_IN_PROGRESS,
|
||||||
'process_state' => $processState,
|
'process_state' => $processState,
|
||||||
'tech_schema_snapshot' => $snapshot,
|
'tech_schema_snapshot' => $snapshot,
|
||||||
@@ -64,6 +67,7 @@ class SecurityReportPdfServiceTest extends TestCase
|
|||||||
$this->assertSame('D-100', $context['customer_no']);
|
$this->assertSame('D-100', $context['customer_no']);
|
||||||
$this->assertSame('acme.de', $context['domain']);
|
$this->assertSame('acme.de', $context['domain']);
|
||||||
$this->assertSame('Intranet', $context['product']);
|
$this->assertSame('Intranet', $context['product']);
|
||||||
|
$this->assertSame('Jane Doe', $context['owner']);
|
||||||
$this->assertNotSame('', $context['title']);
|
$this->assertNotSame('', $context['title']);
|
||||||
|
|
||||||
// Summary mirrors the progress math (3 tech items, 2 done).
|
// Summary mirrors the progress math (3 tech items, 2 done).
|
||||||
@@ -126,4 +130,28 @@ class SecurityReportPdfServiceTest extends TestCase
|
|||||||
$this->assertNotSame('', $pdf);
|
$this->assertNotSame('', $pdf);
|
||||||
$this->assertStringStartsWith('%PDF-', $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(
|
||||||
|
'<!doctype html><html><body><h1>Custom {{customer_name}}</h1>{{checklist}}</body></html>'
|
||||||
|
);
|
||||||
|
|
||||||
|
$service = new SecurityReportPdfService(
|
||||||
|
new SecurityCheckProcess(),
|
||||||
|
$branding,
|
||||||
|
null,
|
||||||
|
$settings,
|
||||||
|
new SecurityReportTemplateService(),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
$pdf = $service->renderCustomerReportPdf($this->enrichedCheck(), ['app_name' => 'CoreCore']);
|
||||||
|
|
||||||
|
$this->assertStringStartsWith('%PDF-', $pdf);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\Tests\Module\Security\Service;
|
||||||
|
|
||||||
|
use MintyPHP\Module\Security\Service\SecurityReportSettingsGateway;
|
||||||
|
use MintyPHP\Service\Settings\SettingsMetadataGateway;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class SecurityReportSettingsGatewayTest extends TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $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 => " <h1>{{title}}</h1> ",
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame('<h1>{{title}}</h1>', $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('<p>x</p>'));
|
||||||
|
$this->assertSame('<p>x</p>', $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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\Tests\Module\Security\Service;
|
||||||
|
|
||||||
|
use MintyPHP\Module\Security\Service\SecurityReportTemplateService;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class SecurityReportTemplateServiceTest extends TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function context(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'title' => 'Security check report',
|
||||||
|
'customer_name' => '<b>Acme</b>',
|
||||||
|
'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(
|
||||||
|
'<h1>{{title}}</h1> who={{customer_name}} dom={{domain}} owner={{owner}} p={{percent}}% src={{logo_src}}',
|
||||||
|
$this->context()
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertStringContainsString('<h1>Security check report</h1>', $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('<b>Acme</b>', $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('<table class="checklist">', $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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -545,3 +545,58 @@
|
|||||||
padding: var(--app-spacing);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user