Redirect the other www spelling to SITE_ORIGIN, hold roles to the role help and fix the audit findings

SITE_ORIGIN alone decides whether the site is www or not: CanonicalHostSubscriber
sends the other spelling there with 301, cms:site:origin writes the setting, the
system screen shows it. The roles now match the role help (editor: drafts,
publisher: live content, admin: design, settings, users, system). The installer no
longer reopens on an unreachable database, the session cookie follows ADMIN_PATH,
percent-encoded credentials no longer break the container, consent only accepts
same-site decisions, JSON-LD is HTML-safe, own CSS stays in its block, plus the
editor, updater, download and public-output findings from the full review
(ADR 099, ADR 100).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Fable 5.1 committed 2026-09-15 20:15:20 +02:00
1 parent 45040b2ca6
commit 7eb462ac7b
53 files changed
+1023 -122

No files matched your search

+3 -7
View File
@@ -14,13 +14,9 @@ RewriteRule ^ index.php [L]
#RewriteCond %{HTTP:X-Forwarded-Proto} !=https
#RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
# Eine Domain, eine Adresse. Ohne diese Umleitung ist dieselbe Seite unter www und ohne www
# erreichbar - zwei Adressen fuer denselben Inhalt, und Suchmaschinen muessen raten, welche gilt.
# Die Richtung muss zu SITE_ORIGIN passen; hier die Fassung "immer mit www".
#RewriteCond %{HTTP_HOST} !^www\. [NC]
#RewriteCond %{HTTP_HOST} !^localhost [NC]
#RewriteCond %{HTTP_HOST} !^127\.0\.0\.1 [NC]
#RewriteRule ^ https://www.%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
# Eine Domain, eine Adresse - mit oder ohne www. Das erledigt die Anwendung selbst nach der
# Einstellung SITE_ORIGIN in .env: die jeweils andere Schreibweise antwortet mit 301 dorthin.
# Hier braucht es dafuer keine Regel; eine zweite Stelle wuerde nur auseinanderlaufen.
# Uebertragen wird komprimiert. HTML, CSS, JavaScript und XML schrumpfen dabei auf etwa ein
# Viertel; Bilder und Schriften sind bereits komprimiert und bleiben aussen vor.
+1 -1
View File
@@ -8,7 +8,7 @@ const node = (tag, className, text) => {
};
const hexPattern = /^#[0-9a-f]{6}([0-9a-f]{2})?$/i;
export function createColorControl({ id, label, value = '', inheritedValue = '', colors, onChange, onComplete }) {
export function createColorControl({ id, label, value = '', inheritedValue = '', colors, onChange, onComplete = () => {} }) {
const wrap = node('div', 'color-control');
const trigger = node('button', 'color-trigger');
trigger.type = 'button'; trigger.id = id;
+37 -13
View File
@@ -121,16 +121,21 @@ export function createBlock(type) {
/** Raises the document schema to the version that knows this block type. */
export function upgradeForType(document, type) {
if (type === 'gallery') upgradeDocument(document, 3);
if (type === 'postlist') upgradeDocument(document, 5);
// Die Fassung, die der Server fuer die Eigenschaften verlangt, mit denen createBlock den
// Baustein anlegt - nicht die, in der der Typ zuerst auftauchte. Sonst steht ein neuer
// Baustein sofort mit 422 in der Vorschau und laesst sich nicht speichern.
if (type === 'postlist') upgradeDocument(document, 18);
if (type === 'postnav') upgradeDocument(document, 18);
if (type === 'legal') upgradeDocument(document, 19);
if (['iconcard', 'stat', 'quote', 'logos', 'cta', 'contact', 'divider', 'spacer', 'accordion', 'accordionitem'].includes(type)) upgradeDocument(document, 6);
if (type === 'tabs' || type === 'tabitem') upgradeDocument(document, 7);
if (type === 'form') upgradeDocument(document, 8);
if (type === 'breadcrumb') upgradeDocument(document, 9);
if (type === 'menu' || type === 'partialref') upgradeDocument(document, 11);
if (type === 'menu') upgradeDocument(document, 16);
if (type === 'partialref') upgradeDocument(document, 11);
if (type === 'embed') upgradeDocument(document, 14);
if (['icon', 'progress', 'slider'].includes(type)) upgradeDocument(document, 15);
if (type === 'icon' || type === 'progress') upgradeDocument(document, 15);
if (type === 'slider') upgradeDocument(document, 17);
if (type === 'social') upgradeDocument(document, 23);
}
@@ -193,7 +198,9 @@ export function moveBlock(document, selected, targetId, inside = false) {
if (!source || !target || selected === targetId || locate(source.block.children || [], targetId)) return false;
if (inside && !isContainer(target.block)) return false;
if (inside && !canContain(target.block, source.block)) return false;
if (!inside && target.parent && !canContain(target.parent, source.block)) return false;
// Neben ein Element der obersten Ebene darf nur, was dort auch stehen darf - eine Spalte oder
// ein Akkordeoneintrag nicht.
if (!inside && !dropList(document, target.parent?.id ?? null, source.block)) return false;
source.siblings.splice(source.index, 1);
const destination = locate(document.blocks, targetId);
if (inside) (destination.block.children ??= []).push(source.block);
@@ -224,10 +231,18 @@ function place(document, block, parentId, index) {
return block.id;
}
/** Inserts a new block at an exact position instead of behind the selection. */
/**
* Inserts a new block at an exact position instead of behind the selection.
*
* Erst pruefen, ob das Ziel den Baustein nimmt, dann die Fassung anheben: sonst bliebe bei einer
* Ablehnung nur die Schemaaenderung uebrig - ein Verlaufsschritt, ein "ungespeichert", und nichts
* zu sehen.
*/
export function insertBlockAt(document, type, parentId, index) {
const block = createBlock(type);
if (!dropList(document, parentId, block)) return null;
upgradeForType(document, type);
return place(document, createBlock(type), parentId, index);
return place(document, block, parentId, index);
}
/**
@@ -333,12 +348,19 @@ export function mediaProps(media) {
export function insertMedia(document, selected, items) {
const target = locate(document.blocks, selected)?.block;
upgradeDocument(document, 3);
if (target?.type === 'image') { target.props = { ...target.props, ...mediaProps(items[0]), caption: target.props.caption || '' }; return target.id; }
if (target?.type === 'image') {
upgradeDocument(document, 3);
target.props = { ...target.props, ...mediaProps(items[0]), caption: target.props.caption || '' };
return target.id;
}
let parent = target;
if (target?.type !== 'gallery') {
const id = insertBlock(document, 'gallery', selected); parent = locate(document.blocks, id).block;
// Ein Akkordeon nimmt nur Eintraege: dann gibt es keine Galerie und nichts zu tun.
const id = insertBlock(document, 'gallery', selected);
if (id === null) return null;
parent = locate(document.blocks, id).block;
}
upgradeDocument(document, 3);
for (const item of items) { const image = createBlock('image'); image.props = mediaProps(item); (parent.children ??= []).push(image); }
return parent.id;
}
@@ -399,17 +421,19 @@ function buildRow(columns) {
/** A row at an exact position, for a marked insertion point or a drop in the preview. */
export function insertRowAt(document, columns, parentId, index) {
const row = buildRow(columns);
if (!dropList(document, parentId, row)) return null;
upgradeDocument(document);
return place(document, buildRow(columns), parentId, index);
return place(document, row, parentId, index);
}
export function insertRow(document, columns, selected) {
const row = buildRow(columns);
upgradeDocument(document);
// Same placement rule as a single element, so the row does not land somewhere else than the
// toolbox promised.
const target = insertionTarget(document, selected, 'row');
return target ? place(document, row, target.parentId, target.index) : null;
if (!target) return null;
upgradeDocument(document);
return place(document, buildRow(columns), target.parentId, target.index);
}
export function insertPreset(document, name, selected) {
+20 -9
View File
@@ -123,12 +123,20 @@ function controls() {
$('#move-up').disabled ||= current.index === 0;
$('#move-down').disabled ||= current.index === current.siblings.length - 1;
$('#nest').disabled ||= current.index === 0 || !canContain(current.siblings[current.index - 1], current.block);
$('#outdent').disabled ||= !current.parent;
// Ausruecken heisst: neben den Elternblock. Was dort nicht stehen darf (eine Spalte, ein
// Akkordeoneintrag), bleibt, wo es ist.
$('#outdent').disabled ||= !current.parent || !dropList(history.document, locate(history.document.blocks, current.parent.id)?.parent?.id ?? null, current.block);
$('#go-parent').disabled ||= !current.parent;
}
updateLibrary();
$('#presets').querySelectorAll('button').forEach(button => button.disabled = !ready || busy);
$('#properties').querySelectorAll('input,textarea,select,button').forEach(input => input.disabled = busy);
// Waehrend des Speicherns ist alles gesperrt - und danach nur das wieder frei, was diese
// Sperre geschlossen hat. Das Design-Bedienfeld schaltet Knoepfe gezielt ab ("Gestaltung
// einfuegen" ohne Zwischenablage), und die muessen abgeschaltet bleiben.
$('#properties').querySelectorAll('input,textarea,select,button').forEach(input => {
if (busy) { if (!input.disabled) { input.disabled = true; input.dataset.busyLock = '1'; } }
else if (input.dataset.busyLock) { input.disabled = false; delete input.dataset.busyLock; }
});
document.querySelectorAll('[data-panel],[data-viewport],.sidebar-nav [role=tab],#tree-search,#library-search,#expand-all,#collapse-all').forEach(input => input.disabled = !ready || busy);
$('[data-panel="design"]').disabled ||= !current;
$('#open-media-library').disabled = !ready || busy;
@@ -281,7 +289,7 @@ function renderContent(block, panel) {
for (const [val, text] of Object.entries(options.choices)) { const option = document.createElement('option'); option.value = val; option.textContent = text; input.append(option); }
} else input = document.createElement(options.multiline ? 'textarea' : 'input');
input.id = id; input.value = value ?? '';
if (options.number && !options.choices) { input.type = 'number'; input.min = options.min || '1'; input.max = options.max || '8000'; }
if (options.number && !options.choices) { input.type = 'number'; input.min = options.min ?? '1'; input.max = options.max ?? '8000'; }
if (options.checkbox) { input.type = 'checkbox'; input.checked = Boolean(value); labelEl.className = 'check-label'; labelEl.prepend(input); }
if (options.maxLength) input.maxLength = options.maxLength;
let previousChoice = input.value;
@@ -339,7 +347,7 @@ function renderContent(block, panel) {
if (current) current.props.text = markup;
}, `rich-${block.id}`);
controls(); schedulePreview();
}, { label: 'Text' });
}, { label: 'Text', plain: history.document.schemaVersion < 12 });
const label = document.createElement('label'); label.textContent = 'Text';
const help = document.createElement('p'); help.className = 'field-help';
help.textContent = 'Fett, kursiv, Listen und Links. Über HTML lässt sich die Auszeichnung direkt bearbeiten. Der Server lässt nur eine feste Liste an Auszeichnungen durch; alles andere wird beim Speichern entfernt, der Text bleibt.';
@@ -431,11 +439,11 @@ function renderContent(block, panel) {
}
if (block.type === 'progress') {
field('Beschriftung', 'label', block.props.label, { maxLength: 120 });
field('Wert (%)', 'value', block.props.value, { number: true, help: 'Zwischen 0 und 100. Der Wert steht auch im Markup, nicht nur in der Balkenbreite.' });
field('Wert (%)', 'value', block.props.value, { number: true, min: 0, max: 100, help: 'Zwischen 0 und 100. Der Wert steht auch im Markup, nicht nur in der Balkenbreite.' });
field('Wert anzeigen', 'showValue', block.props.showValue, { checkbox: true });
}
if (block.type === 'slider') {
field('Automatisch weiterblättern (Sekunden, 0 = aus)', 'autoplay', block.props.autoplay || 0, { number: true, help: 'Ab 3 Sekunden. Lädt ein kleines Skript, nur auf Seiten mit einem solchen Slider. Bei „weniger Bewegung“ im Betriebssystem bleibt er stehen, und die erste Berührung stoppt ihn endgültig.' });
field('Automatisch weiterblättern (Sekunden, 0 = aus)', 'autoplay', block.props.autoplay || 0, { number: true, min: 0, help: 'Ab 3 Sekunden. Lädt ein kleines Skript, nur auf Seiten mit einem solchen Slider. Bei „weniger Bewegung“ im Betriebssystem bleibt er stehen, und die erste Berührung stoppt ihn endgültig.' });
field('Punkte und Pfeile', 'dots', block.props.dots === true, { checkbox: true, help: 'Zeichnet der Browser selbst, ohne Skript. Wo er es noch nicht kann, bleibt ein Schiebefeld.' });
for (const [key, label] of [['desktopVisible', 'Sichtbar auf Desktop'], ['tabletVisible', 'Sichtbar auf Tablet'], ['mobileVisible', 'Sichtbar auf Mobil']]) {
field(label, key, block.props[key], { number: true, help: key === 'desktopVisible' ? 'Wie viele Folien gleichzeitig zu sehen sind, 1 bis 6.' : undefined });
@@ -582,7 +590,6 @@ function renderAdvanced(block, panel) {
: 'Eigenes Skript kann nur ein Administrator ändern, weil es Code im Browser jedes Besuchers ausführt. Der Inhalt ist hier nur zu sehen.', 7);
if (!mayScript) {
scriptInput.readOnly = true;
scriptInput.setAttribute('aria-describedby', 'own-script-locked');
}
}
@@ -711,7 +718,9 @@ function chooseMedia(multiple) {
const target = selected;
openMedia({ multiple, onChoose: items => {
if (!items.length || busy) return;
selected = history.change(doc => insertMedia(doc, target, items)); changed();
const id = history.change(doc => insertMedia(doc, target, items));
if (id === null) { notify('Hier lassen sich keine Bilder einfügen – wähle eine Sektion, Spalte oder Galerie.', true); return; }
selected = id; changed();
} });
}
@@ -1325,7 +1334,9 @@ $('#delete').addEventListener('click', () => actions.delete(selected));
for (const [id, direction] of [['move-up', -1], ['move-down', 1]]) $(`#${id}`).addEventListener('click', () => actions.move(selected, direction));
$('#nest').addEventListener('click', () => { history.change(doc => { const item = locate(doc.blocks, selected); const previous = item.siblings[item.index - 1]; if (previous) moveBlock(doc, selected, previous.id, true); }); changed(); });
$('#outdent').addEventListener('click', () => {
history.change(doc => { const item = locate(doc.blocks, selected); if (!item?.parent) return; const parent = locate(doc.blocks, item.parent.id); item.siblings.splice(item.index, 1); parent.siblings.splice(parent.index + 1, 0, item.block); }); changed();
// Derselbe Weg wie ein Zug im Baum, mit denselben Regeln: neben den Elternblock, sofern der
// Grosselternblock den Baustein nimmt.
history.change(doc => { const item = locate(doc.blocks, selected); if (!item?.parent) return; const parent = locate(doc.blocks, item.parent.id); moveBlockTo(doc, selected, parent.parent?.id ?? null, parent.index + 1); }); changed();
});
// ---------------------------------------------------------------------------------------------
// The preview as a working surface: outline, element toolbar, insert points, drag and drop.
+6 -3
View File
@@ -39,7 +39,7 @@ function button(label, title, onClick, className = 'rt-button') {
* Builds the control. `apply` is called with the markup whenever it changes; the caller decides
* what to do with it (history entry, preview refresh).
*/
export function createRichText(value, apply, { label = 'Text' } = {}) {
export function createRichText(value, apply, { label = 'Text', plain = false } = {}) {
const wrap = document.createElement('div');
wrap.className = 'rich-text';
@@ -55,13 +55,16 @@ export function createRichText(value, apply, { label = 'Text' } = {}) {
surface.setAttribute('role', 'textbox');
surface.setAttribute('aria-multiline', 'true');
surface.setAttribute('aria-label', label);
surface.innerHTML = value ?? '';
// Unter Schema v12 ist der Text Klartext: "Preis <b>5 Euro</b>" ist dann eine Zeichenfolge, kein
// Markup, und darf beim Oeffnen nicht als HTML gelesen werden.
if (plain) surface.textContent = value ?? '';
else surface.innerHTML = value ?? '';
const source = document.createElement('textarea');
source.className = 'rt-source';
source.hidden = true;
source.setAttribute('aria-label', `${label} als HTML`);
source.value = value ?? '';
source.value = plain ? surface.innerHTML : (value ?? '');
const push = markup => { source.value = markup; apply(markup); };