forked from fa/breadcrumb-the-shire
234 lines
9.6 KiB
PHP
234 lines
9.6 KiB
PHP
|
|
<?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');
|
||
|
|
}
|
||
|
|
}
|