forked from fa/breadcrumb-the-shire
First version of the security module
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Module\Security\Service;
|
||||
|
||||
use Dompdf\Dompdf;
|
||||
use Dompdf\Options;
|
||||
use MintyPHP\Service\Branding\BrandingLogoService;
|
||||
use MintyPHP\Service\Tenant\TenantLogoService;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Renders the customer-facing PDF report for a completed security check.
|
||||
*
|
||||
* Follows the core Dompdf pattern (see UserAccessPdfService): build a flat,
|
||||
* render-ready context from the check, hydrate a self-contained PHTML template
|
||||
* to an HTML string, then rasterise it with Dompdf (remote + PHP execution
|
||||
* disabled). The context builder is pure (no I/O) so it is fully unit-testable;
|
||||
* only the render step touches the filesystem / Dompdf.
|
||||
*
|
||||
* @api Consumed by the security/checks/report download action.
|
||||
*/
|
||||
final class SecurityReportPdfService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SecurityCheckProcess $process,
|
||||
private readonly BrandingLogoService $brandingLogoService,
|
||||
private readonly ?TenantLogoService $tenantLogoService = null
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the customer report PDF. Returns raw PDF bytes, or '' if the
|
||||
* renderer is unavailable, the template is missing, or rendering fails.
|
||||
*
|
||||
* @param array<string, mixed> $check Enriched check row (SecurityCheckService::findById)
|
||||
* @param array{locale?: string, app_name?: string, tenant_uuid?: string} $options
|
||||
*/
|
||||
public function renderCustomerReportPdf(array $check, array $options = []): string
|
||||
{
|
||||
if (!class_exists(Dompdf::class) || !class_exists(Options::class)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$report = $this->buildReportContext($check);
|
||||
$report['app_name'] = trim((string) ($options['app_name'] ?? ''));
|
||||
$report['logo_data_uri'] = $this->resolveLogoDataUri((string) ($options['tenant_uuid'] ?? ''));
|
||||
$report['generated_at'] = date('Y-m-d H:i');
|
||||
|
||||
$html = $this->renderTemplate($report);
|
||||
if ($html === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
$options = new Options();
|
||||
// Keep rendering deterministic: no remote fetches, no embedded PHP.
|
||||
$options->setIsRemoteEnabled(false);
|
||||
$options->setIsPhpEnabled(false);
|
||||
$options->setDefaultFont('DejaVu Sans');
|
||||
|
||||
$dompdf = new Dompdf($options);
|
||||
$dompdf->loadHtml($html, 'UTF-8');
|
||||
$dompdf->setPaper('A4');
|
||||
$dompdf->render();
|
||||
$dompdf->addInfo('Title', (string) $report['title']);
|
||||
$dompdf->addInfo('Subject', (string) $report['title']);
|
||||
|
||||
return (string) $dompdf->output();
|
||||
} catch (Throwable) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A filename-safe slug describing the report (customer + domain), e.g.
|
||||
* "security-check-report-acme-acme-de". Always returns a non-empty base.
|
||||
*
|
||||
* @param array<string, mixed> $check
|
||||
*/
|
||||
public function buildReportFilename(array $check): string
|
||||
{
|
||||
$parts = array_filter([
|
||||
(string) ($check['debitor_name'] ?? $check['debitor_no'] ?? ''),
|
||||
(string) ($check['domain_url'] ?? $check['domain_no'] ?? ''),
|
||||
]);
|
||||
$slug = self::slugify(implode('-', $parts));
|
||||
|
||||
return 'security-check-report' . ($slug !== '' ? '-' . $slug : '') . '.pdf';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the flat, render-ready report context from an enriched check row.
|
||||
* Pure transform (no filesystem / network) — translation via t() only.
|
||||
*
|
||||
* @param array<string, mixed> $check
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function buildReportContext(array $check): array
|
||||
{
|
||||
$processState = is_array($check['process_state'] ?? null) ? $check['process_state'] : [];
|
||||
$techState = is_array($check['tech_state'] ?? null) ? $check['tech_state'] : [];
|
||||
$snapshot = is_array($check['tech_schema_snapshot'] ?? null) ? $check['tech_schema_snapshot'] : [];
|
||||
$progress = is_array($check['progress'] ?? null) ? $check['progress'] : [];
|
||||
$status = (string) ($check['status'] ?? SecurityCheckProcess::STATUS_OPEN);
|
||||
|
||||
return [
|
||||
'title' => t('Security check report'),
|
||||
'customer_name' => (string) ($check['debitor_name'] ?? ''),
|
||||
'customer_no' => (string) ($check['debitor_no'] ?? ''),
|
||||
'domain' => (string) ($check['domain_url'] ?? ($check['domain_no'] ?? '')),
|
||||
'product' => (string) ($check['product_name'] ?? ($check['product_code'] ?? '')),
|
||||
'status' => $status,
|
||||
'status_label' => t(SecurityCheckProcess::statusLabel($status)),
|
||||
'summary' => [
|
||||
'done' => (int) ($progress['done'] ?? 0),
|
||||
'total' => (int) ($progress['total'] ?? 0),
|
||||
'percent' => (int) ($progress['percent'] ?? 0),
|
||||
'tech_done' => (int) ($progress['tech_done'] ?? 0),
|
||||
'tech_total' => (int) ($progress['tech_total'] ?? 0),
|
||||
],
|
||||
'steps' => $this->buildSteps($processState),
|
||||
'tech_sections' => $this->buildTechSections($snapshot, $techState),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* High-level process milestones (label + completion date), excluding the
|
||||
* internal field values and per-step notes that are not customer-facing.
|
||||
*
|
||||
* @param array<string, mixed> $processState
|
||||
* @return list<array{number: int, label: string, done: bool, completed_at: string}>
|
||||
*/
|
||||
private function buildSteps(array $processState): array
|
||||
{
|
||||
$steps = [];
|
||||
$number = 0;
|
||||
foreach ($this->process->steps() as $step) {
|
||||
$number++;
|
||||
if ($step['kind'] === SecurityCheckProcess::KIND_TECH_CHECKLIST) {
|
||||
continue;
|
||||
}
|
||||
$key = $step['key'];
|
||||
$entry = is_array($processState[$key] ?? null) ? $processState[$key] : [];
|
||||
$steps[] = [
|
||||
'number' => $number,
|
||||
'label' => t($step['label']),
|
||||
'done' => !empty($entry['done']),
|
||||
'completed_at' => trim((string) ($entry['at'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
return $steps;
|
||||
}
|
||||
|
||||
/**
|
||||
* The frozen technical checklist grouped by section, each item carrying its
|
||||
* pass/fail state and any finding note. Items before the first section land
|
||||
* in an unlabelled leading group.
|
||||
*
|
||||
* @param array<string, mixed> $snapshot
|
||||
* @param array<string, mixed> $techState
|
||||
* @return list<array{label: string, items: list<array{label: string, done: bool, note: string}>}>
|
||||
*/
|
||||
private function buildTechSections(array $snapshot, array $techState): array
|
||||
{
|
||||
$items = is_array($snapshot['items'] ?? null) ? $snapshot['items'] : [];
|
||||
$sections = [];
|
||||
$current = ['label' => '', 'items' => []];
|
||||
|
||||
foreach ($items as $item) {
|
||||
if (!is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
if (($item['type'] ?? '') === 'section') {
|
||||
if ($current['items'] !== []) {
|
||||
$sections[] = $current;
|
||||
}
|
||||
$current = ['label' => (string) ($item['label'] ?? ''), 'items' => []];
|
||||
continue;
|
||||
}
|
||||
$key = trim((string) ($item['key'] ?? ''));
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
$entry = is_array($techState[$key] ?? null) ? $techState[$key] : [];
|
||||
$current['items'][] = [
|
||||
'label' => (string) ($item['label'] ?? ''),
|
||||
'done' => !empty($entry['done']),
|
||||
'note' => trim((string) ($entry['note'] ?? '')),
|
||||
];
|
||||
}
|
||||
if ($current['items'] !== []) {
|
||||
$sections[] = $current;
|
||||
}
|
||||
|
||||
return $sections;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $report
|
||||
*/
|
||||
private function renderTemplate(array $report): string
|
||||
{
|
||||
$templatePath = dirname(__DIR__, 4) . '/templates/pdf/customer-report.phtml';
|
||||
if (!is_file($templatePath)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Isolated scope: the template only sees $report, never this service's state.
|
||||
$render = static function (string $__path, array $report): string {
|
||||
ob_start();
|
||||
include $__path;
|
||||
|
||||
return (string) ob_get_clean();
|
||||
};
|
||||
|
||||
return $render($templatePath, $report);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
private function resolveLogoDataUri(string $tenantUuid): string
|
||||
{
|
||||
$path = '';
|
||||
$mime = '';
|
||||
|
||||
if ($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;
|
||||
$mime = $this->tenantLogoService->detectMime($logoPath);
|
||||
}
|
||||
}
|
||||
|
||||
if ($path === '' && $this->brandingLogoService->hasLogo()) {
|
||||
$logoPath = $this->brandingLogoService->findLogoPath(128);
|
||||
if ($logoPath && is_file($logoPath)) {
|
||||
$path = $logoPath;
|
||||
$mime = $this->brandingLogoService->detectMime($logoPath);
|
||||
}
|
||||
}
|
||||
|
||||
if ($path === '') {
|
||||
$fallback = dirname(__DIR__, 6) . '/web/brand/logo.svg';
|
||||
if (is_file($fallback)) {
|
||||
$path = $fallback;
|
||||
$mime = 'image/svg+xml';
|
||||
}
|
||||
}
|
||||
|
||||
if ($path === '' || !is_file($path)) {
|
||||
return self::transparentPixelDataUri();
|
||||
}
|
||||
|
||||
$content = @file_get_contents($path);
|
||||
if ($content === false || $content === '') {
|
||||
return self::transparentPixelDataUri();
|
||||
}
|
||||
|
||||
return 'data:' . ($mime !== '' ? $mime : 'image/png') . ';base64,' . base64_encode($content);
|
||||
}
|
||||
|
||||
private static function transparentPixelDataUri(): string
|
||||
{
|
||||
// Smallest safe placeholder so the <img> tag stays valid when no logo exists.
|
||||
return 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4////fwAJ+wP9KobjigAAAABJRU5ErkJggg==';
|
||||
}
|
||||
|
||||
private static function slugify(string $value): string
|
||||
{
|
||||
$value = strtolower(trim($value));
|
||||
if ($value === '') {
|
||||
return '';
|
||||
}
|
||||
$value = (string) preg_replace('/[^a-z0-9]+/', '-', $value);
|
||||
|
||||
return trim($value, '-');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user