Publiczna aplikacja fdmtech.pl: renderer CMS, formularz audytu, szablony i logotypy

This commit is contained in:
FDMTech
2026-10-09 15:22:48 +02:00
commit 48b544c6b3
32 changed files with 1914 additions and 0 deletions
+150
View File
@@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
namespace FDMTech;
/** Defense in depth for the restricted CMS publication dialect, not arbitrary web content. */
final class Content
{
private const PROPERTIES = 'color background-color font-family font-size font-weight font-style line-height letter-spacing text-align text-decoration text-transform white-space word-break overflow-wrap margin margin-top margin-right margin-bottom margin-left padding padding-top padding-right padding-bottom padding-left width min-width max-width height min-height max-height display flex flex-direction flex-wrap flex-grow flex-shrink flex-basis align-items align-self justify-content gap row-gap column-gap grid-template-columns grid-template-rows grid-column grid-row border border-width border-style border-color border-radius border-top border-bottom border-left border-right box-sizing box-shadow opacity overflow object-fit object-position list-style-type vertical-align';
public static function safeUrl(string $url, bool $navigation = false): bool
{
// Decode only for validation, so encoded protocol-relative paths cannot bypass the guard.
$decoded = rawurldecode($url);
if ($url === '' || preg_match('/[\x00-\x20\x7f\\\\<>"\']/', $decoded) || str_contains($decoded, '%')) {
return false;
}
if (str_starts_with($decoded, '/') && !str_starts_with($decoded, '//')) {
return true;
}
if (!$navigation && preg_match('/^#[a-zA-Z][\w-]*$/D', $url)) {
return true;
}
if (strtolower((string) parse_url($url, PHP_URL_SCHEME)) === 'https') {
return filter_var($url, FILTER_VALIDATE_URL) !== false && !parse_url($url, PHP_URL_USER) && !parse_url($url, PHP_URL_PASS);
}
return !$navigation && (bool) preg_match('/^(?:mailto:[a-zA-Z0-9.!#$%&*+\/=^_`{|}~-]+@[a-zA-Z0-9.-]+|tel:\+?[0-9()-]+)$/D', $url);
}
public static function html(string $html): string
{
if (strlen($html) > 1000000) {
throw new \RuntimeException('CMS HTML exceeds contract limit');
}
if (trim($html) === '') {
return '';
}
$document = new \DOMDocument('1.0', 'UTF-8');
$previous = libxml_use_internal_errors(true);
try {
$document->loadHTML('<?xml encoding="UTF-8"><html><body>' . $html . '</body></html>', LIBXML_NONET);
$allowed = ['div', 'p', 'span', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'a', 'img', 'ul', 'ol', 'li',
'strong', 'em', 'b', 'i', 'br', 'hr', 'blockquote', 'table', 'thead', 'tbody', 'tr', 'th', 'td'];
$clean = static function (\DOMNode $parent) use (&$clean, $allowed): void {
foreach (iterator_to_array($parent->childNodes) as $node) {
if ($node instanceof \DOMElement) {
if (!in_array($node->tagName, $allowed, true)) {
$parent->removeChild($node);
continue;
}
foreach (iterator_to_array($node->attributes) as $attribute) {
$name = $attribute->name;
$value = $attribute->value;
$valid = match ($name) {
'class' => (bool) preg_match('/^[a-zA-Z0-9_ -]{0,500}$/D', $value),
'id' => $value !== 'cms-content' && (bool) preg_match('/^[a-zA-Z][a-zA-Z0-9_-]{0,159}$/D', $value),
'href' => $node->tagName === 'a' && self::safeUrl($value),
'src' => $node->tagName === 'img' && self::safeUrl($value, true),
'alt', 'title' => true,
'width', 'height', 'colspan', 'rowspan' => (bool) preg_match('/^[0-9]{1,4}$/D', $value),
default => false,
};
if (!$valid) {
$node->removeAttribute($name);
}
}
$clean($node);
} elseif (!$node instanceof \DOMText) {
$parent->removeChild($node);
}
}
};
$body = $document->getElementsByTagName('body')->item(0);
$clean($body);
$result = '';
foreach ($body->childNodes as $node) {
$result .= $document->saveHTML($node);
}
return $result;
} finally {
libxml_clear_errors();
libxml_use_internal_errors($previous);
}
}
public static function css(string $css): string
{
if (strlen($css) > 200000 || preg_match('/[\\\\<>\x00-\x08\x0b\x0c\x0e-\x1f]|\/\*/', $css)) {
throw new \RuntimeException('Invalid CMS stylesheet');
}
$result = '';
$offset = 0;
while ($offset < strlen($css)) {
if (preg_match('/\G\s+/', $css, $space, 0, $offset)) {
$offset += strlen($space[0]);
continue;
}
if (preg_match('/\G@media\s+(?:only\s+)?(?:screen\s+and\s+)?\((max|min)-width:\s*(\d{1,4})px\)\s*\{((?:[^{}]*\{[^{}]*\}\s*)*)\}/i', $css, $media, 0, $offset)) {
$result .= '@media (' . strtolower($media[1]) . '-width:' . $media[2] . 'px){' . self::css($media[3]) . '}';
$offset += strlen($media[0]);
continue;
}
if (!preg_match('/\G([^{}]+)\{([^{}]*)\}/', $css, $rule, 0, $offset)) {
throw new \RuntimeException('Unsupported CMS stylesheet syntax');
}
$offset += strlen($rule[0]);
$selectors = [];
foreach (explode(',', $rule[1]) as $selector) {
$selector = trim($selector);
if (!preg_match('/^[a-zA-Z0-9_.#* >+~-]+(?::(?:hover|focus|focus-visible|first-child|last-child))?$/D', $selector)) {
throw new \RuntimeException('Invalid CMS selector');
}
if (in_array($selector, ['#cms-content', 'body', 'html', 'html body'], true)) {
$selectors[] = '#cms-content';
continue;
}
if (str_starts_with($selector, '#cms-content')) {
if (!str_starts_with($selector, '#cms-content ')) {
throw new \RuntimeException('Invalid CMS root selector');
}
$selector = ltrim(substr($selector, strlen('#cms-content')));
}
// Root siblings escape the content boundary. Descendant siblings remain inside it.
if (!preg_match('/^(?:>\s*)?[a-zA-Z0-9_.#*]/', $selector)) {
throw new \RuntimeException('CMS selector escapes content scope');
}
$selectors[] = '#cms-content ' . $selector;
}
$declarations = [];
foreach (explode(';', $rule[2]) as $declaration) {
$parts = explode(':', $declaration, 2);
$property = strtolower(trim($parts[0]));
$value = trim($parts[1] ?? '');
if (!in_array($property, explode(' ', self::PROPERTIES), true)
|| !preg_match('/^[a-zA-Z0-9\s#.,%()\-+\'"\/]+$/D', $value)) {
continue;
}
preg_match_all('/([a-zA-Z-]+)\s*\(/', $value, $functions);
if (array_diff(array_map('strtolower', $functions[1]), ['rgb', 'rgba', 'hsl', 'hsla', 'repeat', 'minmax', 'calc'])) {
continue;
}
$declarations[] = $property . ':' . $value;
}
if ($declarations !== []) {
$result .= implode(',', $selectors) . '{' . implode(';', $declarations) . '}';
}
}
return $result;
}
}