- The installer only opens with a per-installation key that install.php creates and shows; the key is deleted with the first account. - Lock files keep .env, the database, backups and logs out of reach when a domain points at the project folder instead of public/. - Linked sections render by their own schema version, so an old section cannot carry unfiltered HTML into a newer page. - Editors no longer move, trash or change areas and post data of published pages; a scheduled publication goes live with the revision that was scheduled (migration 20261012000100). - Form stamps are bound to their form, IPv6 senders count per /64, and a flood per form or overall stops storing with a visible message. - The consent redirect rejects control characters, update targets must look like 1.2.3, and own CSS cannot reach outside its block. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
360 lines
16 KiB
PHP
360 lines
16 KiB
PHP
<?php
|
|
|
|
namespace App\Content;
|
|
|
|
use App\Domain\InvalidDocument;
|
|
|
|
/**
|
|
* Own CSS and own script per element (document schema v13), and the raw HTML of the „Eigenes
|
|
* HTML“ block (schema v24).
|
|
*
|
|
* The three are not comparable in what they can do, so they are not treated alike.
|
|
*
|
|
* **CSS** is parsed and written out again by this class, scoped to the block. A declaration list
|
|
* and nested rules starting with `&` are accepted; anything else is refused. Because the output is
|
|
* re-serialised, a stray `}` cannot escape the block, and `@import` or a `javascript:` URL never
|
|
* gets through. Everyone with the editor role may use it.
|
|
*
|
|
* **HTML** is markup that goes onto the page unchanged. It cannot be made harmless either - it
|
|
* can load images from anywhere and point a form at a foreign address - so it follows the same
|
|
* rule as a script: administrators only, checked against what is already stored. Two tags are
|
|
* refused rather than silently swallowed by the content security policy: `<script>`, which
|
|
* belongs in the script field, and `<iframe>`, which belongs in the „Externer Inhalt“ block,
|
|
* because only that one asks for consent and puts the host into `frame-src`.
|
|
*
|
|
* **Script** is arbitrary JavaScript on the published site. There is no way to make that harmless:
|
|
* whoever may set it can run code in the browser of every visitor and every administrator. It is
|
|
* therefore reserved for administrators, checked on the way in by comparing against what is
|
|
* already stored, delivered as a separate file instead of inline, and never executed in the
|
|
* editor preview. The strict script policy stays in place on every page that carries none.
|
|
*/
|
|
final class CustomCode
|
|
{
|
|
public const MAX_CSS = 4000;
|
|
public const MAX_SCRIPT = 8000;
|
|
public const MAX_HTML = 20000;
|
|
|
|
public static function supports(mixed $schemaVersion): bool
|
|
{
|
|
return is_int($schemaVersion) && $schemaVersion >= 13;
|
|
}
|
|
|
|
/**
|
|
* Checks own CSS and returns it unchanged. Throws with a message meant for the editor.
|
|
*
|
|
* @throws InvalidDocument
|
|
*/
|
|
public function checkCss(string $css): string
|
|
{
|
|
if (mb_strlen($css) > self::MAX_CSS) {
|
|
throw new InvalidDocument(sprintf('Eigenes CSS ist auf %d Zeichen begrenzt.', self::MAX_CSS));
|
|
}
|
|
// Parsing happens in compileCss; doing it here too makes a bad value fail on saving
|
|
// rather than silently disappearing when the page is rendered.
|
|
$this->parse($css);
|
|
|
|
return $css;
|
|
}
|
|
|
|
/** @throws InvalidDocument */
|
|
public function checkScript(string $script): string
|
|
{
|
|
if (mb_strlen($script) > self::MAX_SCRIPT) {
|
|
throw new InvalidDocument(sprintf('Eigenes Skript ist auf %d Zeichen begrenzt.', self::MAX_SCRIPT));
|
|
}
|
|
// The script is delivered as its own file, so a closing tag cannot end anything - but a
|
|
// value containing one is a sign of markup pasted into the wrong field.
|
|
if (false !== stripos($script, '</script')) {
|
|
throw new InvalidDocument('Eigenes Skript enthält keinen HTML-Code. Bitte nur JavaScript einsetzen.');
|
|
}
|
|
|
|
return $script;
|
|
}
|
|
|
|
/**
|
|
* Checks the markup of an „Eigenes HTML“ block and returns it unchanged.
|
|
*
|
|
* @throws InvalidDocument
|
|
*/
|
|
public function checkHtml(string $html): string
|
|
{
|
|
if (mb_strlen($html) > self::MAX_HTML) {
|
|
throw new InvalidDocument(sprintf('Eigenes HTML ist auf %d Zeichen begrenzt.', self::MAX_HTML));
|
|
}
|
|
// Beide Tags wuerden von der Sicherheitsrichtlinie der Seite ohnehin nicht ausgefuehrt
|
|
// bzw. nicht geladen. Eine Meldung mit dem richtigen Weg ist besser als ein Baustein,
|
|
// der etwas annimmt und nachher nichts tut.
|
|
if (1 === preg_match('/<\s*script/i', $html)) {
|
|
throw new InvalidDocument('Eigenes HTML führt keine Skripte aus. Dafür gibt es unter „Erweitert“ das Feld „Eigenes Skript“.');
|
|
}
|
|
if (1 === preg_match('/<\s*iframe/i', $html)) {
|
|
throw new InvalidDocument('Für einen eingebetteten Rahmen gibt es den Baustein „Externer Inhalt“: nur er fragt vorher um Einwilligung und trägt den Anbieter in die Sicherheitsrichtlinie ein.');
|
|
}
|
|
|
|
return $html;
|
|
}
|
|
|
|
/**
|
|
* The raw HTML of every „Eigenes HTML“ block, keyed by block ID.
|
|
*
|
|
* @return array<string, string>
|
|
*/
|
|
public function htmlOf(array $document): array
|
|
{
|
|
$found = [];
|
|
$walk = function (array $blocks) use (&$walk, &$found): void {
|
|
foreach ($blocks as $block) {
|
|
if ('html' === ($block['type'] ?? '') && is_string($block['props']['html'] ?? null) && is_string($block['id'] ?? null)) {
|
|
$found[$block['id']] = $block['props']['html'];
|
|
}
|
|
$walk($block['children'] ?? []);
|
|
}
|
|
};
|
|
$walk($document['blocks'] ?? []);
|
|
|
|
return $found;
|
|
}
|
|
|
|
/**
|
|
* Refuses a write that would add, change or remove raw HTML without the administrator role.
|
|
*
|
|
* @throws InvalidDocument
|
|
*/
|
|
public function assertHtmlUnchanged(array $incoming, array $stored): void
|
|
{
|
|
$before = $this->htmlOf($stored);
|
|
$after = $this->htmlOf($incoming);
|
|
ksort($before);
|
|
ksort($after);
|
|
if ($before !== $after) {
|
|
throw new InvalidDocument('Eigenes HTML darf nur von einem Administrator geändert werden. Die übrigen Änderungen wurden nicht gespeichert.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Turns own CSS into rules scoped to one block.
|
|
*
|
|
* @return string CSS, safe by construction; empty when there is nothing to emit
|
|
*/
|
|
public function compileCss(string $blockId, string $css): string
|
|
{
|
|
if (1 !== preg_match('/^[a-zA-Z0-9_-]{1,64}$/D', $blockId)) {
|
|
return '';
|
|
}
|
|
// Gespeichert wurde nur, was beim Speichern galt. Ist eine Regel inzwischen strenger
|
|
// (ADR 116), fällt das CSS dieses einen Blocks weg - die Seite selbst bleibt erreichbar.
|
|
try {
|
|
$rules = $this->parse($css);
|
|
} catch (InvalidDocument) {
|
|
return '';
|
|
}
|
|
$out = '';
|
|
foreach ($rules as $suffix => $declarations) {
|
|
if ('' === $declarations) {
|
|
continue;
|
|
}
|
|
// The selector is written here, never taken from the input.
|
|
$out .= sprintf('#%s%s{%s}', $blockId, $suffix, $declarations);
|
|
}
|
|
|
|
return $out;
|
|
}
|
|
|
|
/**
|
|
* Splits own CSS into the block's own declarations and its nested rules.
|
|
*
|
|
* @return array<string, string> suffix (''; ':hover'; ' .block-text') to declaration list
|
|
*
|
|
* @throws InvalidDocument
|
|
*/
|
|
private function parse(string $css): array
|
|
{
|
|
$css = trim($css);
|
|
if ('' === $css) {
|
|
return [];
|
|
}
|
|
if (str_contains($css, '@')) {
|
|
throw new InvalidDocument('In eigenem CSS sind @-Regeln wie @import oder @media nicht vorgesehen. Für Gerätewerte gibt es die Vorschaugrößen im Designbereich.');
|
|
}
|
|
if (str_contains($css, '\\')) {
|
|
throw new InvalidDocument('In eigenem CSS sind Backslash-Escapes nicht vorgesehen.');
|
|
}
|
|
if (preg_match('/expression\s*\(/i', $css)) {
|
|
throw new InvalidDocument('In eigenem CSS ist expression() nicht vorgesehen.');
|
|
}
|
|
// A single slash is a local path; two are protocol relative and would load from a
|
|
// third party over whatever scheme the page happens to use.
|
|
if (preg_match('/url\s*\(\s*["\']?\s*(?!(?:\/(?!\/)|https:\/\/))/i', $css)) {
|
|
throw new InvalidDocument('In eigenem CSS sind nur Adressen erlaubt, die mit / oder https:// beginnen.');
|
|
}
|
|
|
|
$rules = ['' => ''];
|
|
$rest = $css;
|
|
// Nested rules first, so what remains is a plain declaration list.
|
|
while (preg_match('/&([^{}]{0,80})\{([^{}]{0,1000})\}/', $rest, $match, PREG_OFFSET_CAPTURE)) {
|
|
// Only the right side is trimmed: a leading space is the descendant combinator,
|
|
// so "& .block-text" must stay "#id .block-text" instead of becoming
|
|
// "#id.block-text", which would target the element itself.
|
|
$suffix = preg_replace('/\s+/', ' ', rtrim($match[1][0])) ?? '';
|
|
// Kein Komma: `&,body{...}` wuerde zu `#id,body{...}` und verliesse das Element.
|
|
if (1 !== preg_match('/^[a-zA-Z0-9 .:>+~_\[\]="\'-]*$/D', $suffix)) {
|
|
throw new InvalidDocument(sprintf('„&%s“ ist als verschachtelter Selektor nicht erlaubt.', $suffix));
|
|
}
|
|
// Geschwister des Elements selbst (`& ~ *`, `& + p`) liegen außerhalb des Blocks - damit
|
|
// ließe sich die ganze Seite umgestalten. Innerhalb des Blocks bleiben `~` und `+`
|
|
// erlaubt. Und Anführungszeichen paarweise: ein offener String verschluckte sonst
|
|
// alle folgenden Regeln der Seite (ADR 116).
|
|
if (1 === preg_match('/^\s*[~+]/', $suffix) || 0 !== substr_count($suffix, '"') % 2 || 0 !== substr_count($suffix, "'") % 2) {
|
|
throw new InvalidDocument(sprintf('„&%s“ reicht über den Block hinaus und ist deshalb nicht erlaubt.', $suffix));
|
|
}
|
|
// Zusammenfuegen statt ersetzen: zweimal derselbe Selektor ist in CSS eine Kaskade,
|
|
// nicht ein Widerruf. Vorher gewann die letzte Regel, und alles, was nur in der
|
|
// ersten stand, verschwand ohne Meldung - gefunden am Projektraster von EB Solar,
|
|
// wo `& .post-card{border:0}` und `& .post-card{transform:none}` nebeneinander
|
|
// standen und der Rahmen des Kerns wieder auftauchte.
|
|
$rules[$suffix] = $this->verbinden($rules[$suffix] ?? '', $this->declarations($match[2][0]));
|
|
$rest = substr_replace($rest, '', $match[0][1], strlen($match[0][0]));
|
|
}
|
|
if (str_contains($rest, '{') || str_contains($rest, '}')) {
|
|
throw new InvalidDocument('Eigenes CSS besteht aus Eigenschaften für dieses Element und aus verschachtelten Regeln, die mit & beginnen. Andere Selektoren sind nicht vorgesehen.');
|
|
}
|
|
// Die Eigenschaften des Elements selbst stehen ueblicherweise vor den verschachtelten
|
|
// Regeln; eine Regel `&{…}` schreibt sie aber in denselben Schluessel.
|
|
$rules[''] = $this->verbinden($this->declarations($rest), $rules['']);
|
|
|
|
return $rules;
|
|
}
|
|
|
|
/** Zwei Deklarationslisten desselben Selektors, in der Reihenfolge, in der sie standen. */
|
|
private function verbinden(string $erste, string $zweite): string
|
|
{
|
|
if ('' === $erste || '' === $zweite) {
|
|
return '' === $erste ? $zweite : $erste;
|
|
}
|
|
|
|
return $erste.';'.$zweite;
|
|
}
|
|
|
|
/** @throws InvalidDocument */
|
|
private function declarations(string $block): string
|
|
{
|
|
$out = [];
|
|
foreach (explode(';', $block) as $declaration) {
|
|
$declaration = trim($declaration);
|
|
if ('' === $declaration) {
|
|
continue;
|
|
}
|
|
$parts = explode(':', $declaration, 2);
|
|
if (2 !== count($parts)) {
|
|
throw new InvalidDocument(sprintf('„%s“ ist keine CSS-Eigenschaft in der Form name: wert.', mb_substr($declaration, 0, 40)));
|
|
}
|
|
$property = strtolower(trim($parts[0]));
|
|
$value = trim($parts[1]);
|
|
if (1 !== preg_match('/^-{0,2}[a-z][a-z0-9-]{0,40}$/D', $property)) {
|
|
throw new InvalidDocument(sprintf('„%s“ ist kein CSS-Eigenschaftsname.', mb_substr($property, 0, 40)));
|
|
}
|
|
// `content` braucht einen Text in Anfuehrungszeichen, sonst gibt es kein ::before und
|
|
// ::after fuer Striche, Verlaeufe oder ein Wasserzeichen. Erlaubt ist ein kurzer Text
|
|
// aus Buchstaben, Ziffern und wenigen Satzzeichen: kein <, kein Backslash, kein
|
|
// weiteres Anfuehrungszeichen, also kein Weg aus dem String oder dem <style>.
|
|
if ('content' === $property && 1 === preg_match('/^(["\'])[\p{L}\p{N} .,:!?·•–-]{0,40}\1$/uD', $value)) {
|
|
$out[] = $property.':'.$value;
|
|
continue;
|
|
}
|
|
// Kein Kommentaranfang und -ende: ein offener Kommentar verschluckte die Regeln danach.
|
|
if ('' === $value || 1 !== preg_match('/^[a-zA-Z0-9 ,.%#()\/:_+*-]{1,300}$/D', $value) || str_contains($value, '/*') || str_contains($value, '*/')) {
|
|
throw new InvalidDocument(sprintf('Der Wert von „%s“ enthält Zeichen, die in eigenem CSS nicht vorgesehen sind.', $property));
|
|
}
|
|
$out[] = $property.':'.$value;
|
|
}
|
|
|
|
return implode(';', $out);
|
|
}
|
|
|
|
/** Every block's own CSS of a document, already scoped. */
|
|
public function compileDocument(array $document): string
|
|
{
|
|
if (!self::supports($document['schemaVersion'] ?? null)) {
|
|
return '';
|
|
}
|
|
$out = '';
|
|
$walk = function (array $blocks) use (&$walk, &$out): void {
|
|
foreach ($blocks as $block) {
|
|
if (is_string($block['css'] ?? null) && '' !== trim($block['css'])) {
|
|
$out .= $this->compileCss((string) ($block['id'] ?? ''), $block['css']);
|
|
}
|
|
$walk($block['children'] ?? []);
|
|
}
|
|
};
|
|
$walk($document['blocks'] ?? []);
|
|
|
|
return $out;
|
|
}
|
|
|
|
/**
|
|
* Every block's own script, keyed by block ID.
|
|
*
|
|
* @return array<string, string>
|
|
*/
|
|
public function scriptsOf(array $document): array
|
|
{
|
|
if (!self::supports($document['schemaVersion'] ?? null)) {
|
|
return [];
|
|
}
|
|
$scripts = [];
|
|
$walk = function (array $blocks) use (&$walk, &$scripts): void {
|
|
foreach ($blocks as $block) {
|
|
if (is_string($block['script'] ?? null) && '' !== trim($block['script']) && is_string($block['id'] ?? null)) {
|
|
$scripts[$block['id']] = $block['script'];
|
|
}
|
|
$walk($block['children'] ?? []);
|
|
}
|
|
};
|
|
$walk($document['blocks'] ?? []);
|
|
|
|
return $scripts;
|
|
}
|
|
|
|
/**
|
|
* The script file for one document: each block's script in its own closure, with the element
|
|
* it belongs to already looked up. A failing script does not stop the others.
|
|
*/
|
|
public function compileScript(array $document): string
|
|
{
|
|
$parts = [];
|
|
foreach ($this->scriptsOf($document) as $blockId => $script) {
|
|
if (1 !== preg_match('/^[a-zA-Z0-9_-]{1,64}$/D', $blockId)) {
|
|
continue;
|
|
}
|
|
$parts[] = sprintf(
|
|
"/* %s */\n(() => {\n const element = document.getElementById(%s);\n if (!element) return;\n try {\n%s\n } catch (error) {\n console.error('Eigenes Skript von %s:', error);\n }\n})();",
|
|
$blockId,
|
|
json_encode($blockId, JSON_THROW_ON_ERROR),
|
|
$script,
|
|
$blockId,
|
|
);
|
|
}
|
|
|
|
return [] === $parts ? '' : implode("\n", $parts)."\n";
|
|
}
|
|
|
|
/**
|
|
* Refuses a write that would add, change or remove a script without the administrator role.
|
|
*
|
|
* Checked by comparing with what is stored, so an editor can keep saving a page that already
|
|
* carries a script without being able to touch it.
|
|
*
|
|
* @throws InvalidDocument
|
|
*/
|
|
public function assertScriptsUnchanged(array $incoming, array $stored): void
|
|
{
|
|
$before = $this->scriptsOf($stored);
|
|
$after = $this->scriptsOf($incoming);
|
|
ksort($before);
|
|
ksort($after);
|
|
if ($before !== $after) {
|
|
throw new InvalidDocument('Eigenes Skript darf nur von einem Administrator geändert werden. Die übrigen Änderungen wurden nicht gespeichert.');
|
|
}
|
|
}
|
|
}
|