forked from fa/breadcrumb-the-shire
224 lines
8.0 KiB
PHP
224 lines
8.0 KiB
PHP
<?php
|
|
|
|
namespace MintyPHP\Module\Security\Service;
|
|
|
|
use MintyPHP\Module\Security\Repository\SecurityCheckTemplateRepository;
|
|
|
|
/**
|
|
* Business logic for security check templates (the per-product checklist catalog).
|
|
*
|
|
* A template owns the dynamic technical checklist for one software product. The
|
|
* checklist schema shape is:
|
|
* { "version": int, "items": [ {type:"section", label}, {type:"check", key, label, hint?, markdown?} ] }
|
|
* Item keys are auto-slugified from labels and kept unique within a template.
|
|
*
|
|
* @api Consumed by Security template pages and the create wizard.
|
|
*/
|
|
class SecurityCheckTemplateService
|
|
{
|
|
public const MAX_ITEMS = 100;
|
|
|
|
private const ALLOWED_ITEM_TYPES = ['section', 'check'];
|
|
|
|
public function __construct(
|
|
private readonly SecurityCheckTemplateRepository $repository
|
|
) {
|
|
}
|
|
|
|
/**
|
|
* @api Called from the create wizard product picker
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
public function listActive(int $tenantId): array
|
|
{
|
|
return $this->repository->listActive($tenantId);
|
|
}
|
|
|
|
/**
|
|
* @api Called from templates-data endpoint
|
|
* @param array<string, mixed> $filters
|
|
* @return array{total: int, rows: list<array<string, mixed>>}
|
|
*/
|
|
public function listPaged(int $tenantId, array $filters): array
|
|
{
|
|
return $this->repository->listPaged($tenantId, $filters);
|
|
}
|
|
|
|
public function findById(int $tenantId, int $id): ?array
|
|
{
|
|
return $this->repository->findById($tenantId, $id);
|
|
}
|
|
|
|
public function findByCode(int $tenantId, string $code): ?array
|
|
{
|
|
return $this->repository->findByCode($tenantId, $code);
|
|
}
|
|
|
|
/**
|
|
* Decode a template's stored tech schema into {version, items}.
|
|
*
|
|
* @param array<string, mixed> $template
|
|
* @return array{version: int, items: list<array<string, mixed>>}
|
|
*/
|
|
public function decodeSchema(array $template): array
|
|
{
|
|
$decoded = json_decode((string) ($template['tech_schema_json'] ?? ''), true);
|
|
if (!is_array($decoded)) {
|
|
return ['version' => (int) ($template['version'] ?? 1), 'items' => []];
|
|
}
|
|
|
|
$items = is_array($decoded['items'] ?? null) ? array_values($decoded['items']) : [];
|
|
|
|
return ['version' => (int) ($decoded['version'] ?? ($template['version'] ?? 1)), 'items' => $items];
|
|
}
|
|
|
|
/**
|
|
* @return array{ok: bool, id?: int, errors: array<string, string>}
|
|
*/
|
|
public function create(int $tenantId, string $code, string $name, int $actorUserId): array
|
|
{
|
|
$code = trim($code);
|
|
$name = trim($name);
|
|
|
|
$errors = [];
|
|
if ($tenantId <= 0) {
|
|
$errors['tenant'] = t('No tenant context');
|
|
}
|
|
if ($code === '') {
|
|
$errors['product_code'] = t('Product code cannot be empty');
|
|
} elseif (!preg_match('/^[A-Za-z0-9._-]+$/', $code)) {
|
|
$errors['product_code'] = t('Product code is invalid');
|
|
} elseif ($this->repository->codeExists($tenantId, $code)) {
|
|
$errors['product_code'] = t('A template with this product code already exists');
|
|
}
|
|
if ($name === '') {
|
|
$errors['product_name'] = t('Product name cannot be empty');
|
|
}
|
|
if ($errors !== []) {
|
|
return ['ok' => false, 'errors' => $errors];
|
|
}
|
|
|
|
$id = $this->repository->insert($tenantId, [
|
|
'product_code' => $code,
|
|
'product_name' => $name,
|
|
'tech_schema_json' => json_encode(['version' => 1, 'items' => []]),
|
|
'active' => 1,
|
|
'version' => 1,
|
|
'created_by' => $actorUserId,
|
|
]);
|
|
|
|
if ($id === null) {
|
|
return ['ok' => false, 'errors' => ['general' => t('Template could not be created')]];
|
|
}
|
|
|
|
return ['ok' => true, 'id' => $id, 'errors' => []];
|
|
}
|
|
|
|
/**
|
|
* @return array{ok: bool, errors: array<string, string>}
|
|
*/
|
|
public function updateMeta(int $tenantId, int $id, string $name, bool $active, int $actorUserId): array
|
|
{
|
|
$name = trim($name);
|
|
if ($name === '') {
|
|
return ['ok' => false, 'errors' => ['product_name' => t('Product name cannot be empty')]];
|
|
}
|
|
if ($this->repository->findById($tenantId, $id) === null) {
|
|
return ['ok' => false, 'errors' => ['general' => t('Template not found')]];
|
|
}
|
|
|
|
$ok = $this->repository->updateMeta($tenantId, $id, $name, $active, $actorUserId);
|
|
|
|
return $ok ? ['ok' => true, 'errors' => []] : ['ok' => false, 'errors' => ['general' => t('Template could not be updated')]];
|
|
}
|
|
|
|
/**
|
|
* Validate + normalize the technical checklist and persist it (version bump).
|
|
*
|
|
* @param array<int, mixed> $items
|
|
* @return array{ok: bool, errors: array<string, string>, version?: int}
|
|
*/
|
|
public function saveTechSchema(int $tenantId, int $id, array $items, int $actorUserId): array
|
|
{
|
|
$template = $this->repository->findById($tenantId, $id);
|
|
if ($template === null) {
|
|
return ['ok' => false, 'errors' => ['general' => t('Template not found')]];
|
|
}
|
|
|
|
if (count($items) > self::MAX_ITEMS) {
|
|
return ['ok' => false, 'errors' => ['schema' => t('Maximum %d items allowed', self::MAX_ITEMS)]];
|
|
}
|
|
|
|
$normalized = [];
|
|
$usedKeys = [];
|
|
foreach ($items as $index => $item) {
|
|
if (!is_array($item)) {
|
|
continue;
|
|
}
|
|
$type = (string) ($item['type'] ?? 'check');
|
|
if (!in_array($type, self::ALLOWED_ITEM_TYPES, true)) {
|
|
return ['ok' => false, 'errors' => ['schema' => t('Item %d has an invalid type', $index + 1)]];
|
|
}
|
|
$label = trim((string) ($item['label'] ?? ''));
|
|
if ($label === '') {
|
|
return ['ok' => false, 'errors' => ['schema' => t('Item %d is missing a label', $index + 1)]];
|
|
}
|
|
|
|
if ($type === 'section') {
|
|
$normalized[] = ['type' => 'section', 'label' => $label];
|
|
continue;
|
|
}
|
|
|
|
$key = trim((string) ($item['key'] ?? ''));
|
|
if ($key === '') {
|
|
$key = self::slugify($label);
|
|
}
|
|
$key = self::slugify($key);
|
|
if ($key === '') {
|
|
return ['ok' => false, 'errors' => ['schema' => t('Item %d has an invalid key', $index + 1)]];
|
|
}
|
|
if (isset($usedKeys[$key])) {
|
|
$suffix = 2;
|
|
while (isset($usedKeys[$key . '_' . $suffix])) {
|
|
$suffix++;
|
|
}
|
|
$key .= '_' . $suffix;
|
|
}
|
|
$usedKeys[$key] = true;
|
|
|
|
$entry = ['type' => 'check', 'key' => $key, 'label' => $label];
|
|
$hint = trim((string) ($item['hint'] ?? ''));
|
|
if ($hint !== '') {
|
|
$entry['hint'] = $hint;
|
|
}
|
|
$markdown = trim((string) ($item['markdown'] ?? ''));
|
|
if ($markdown !== '') {
|
|
$entry['markdown'] = $markdown;
|
|
}
|
|
$normalized[] = $entry;
|
|
}
|
|
|
|
$currentVersion = (int) ($template['version'] ?? 1);
|
|
$newVersion = $currentVersion + 1;
|
|
$schemaJson = json_encode(['version' => $newVersion, 'items' => $normalized], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
|
if (!is_string($schemaJson)) {
|
|
return ['ok' => false, 'errors' => ['schema' => t('Could not encode the checklist')]];
|
|
}
|
|
|
|
$ok = $this->repository->updateSchema($tenantId, $id, $schemaJson, $newVersion, $actorUserId);
|
|
|
|
return $ok
|
|
? ['ok' => true, 'errors' => [], 'version' => $newVersion]
|
|
: ['ok' => false, 'errors' => ['general' => t('Checklist could not be saved')]];
|
|
}
|
|
|
|
private static function slugify(string $text): string
|
|
{
|
|
$text = trim(mb_strtolower($text));
|
|
$text = strtr($text, ['ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss']);
|
|
$text = preg_replace('/[^a-z0-9]+/', '_', $text) ?? '';
|
|
|
|
return trim($text, '_');
|
|
}
|
|
}
|