Added security check report settings and templating

This commit is contained in:
2026-06-22 14:00:14 +02:00
parent 0b7c08ba6b
commit 7a9f8e770a
18 changed files with 1120 additions and 12 deletions

View File

@@ -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)
));
}
}

View File

@@ -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];
}
}

View File

@@ -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<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
*/
@@ -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;

View File

@@ -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
);
}
}

View File

@@ -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">&#10003; ' . $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">&#10003; ' . $this->esc(t('Completed')) . '</span>'
: '<span class="pending">' . $this->esc(t('Open')) . '</span>';
$completedAt = trim((string) ($step['completed_at'] ?? ''));
$when = $completedAt !== '' ? ' <span class="muted">&middot; ' . $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}} &middot; {{title}} &middot; {{generated_at}}</div>
</body>
</html>
HTML;
}
private function esc(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
}