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:
1 parent
45040b2ca6
commit
7eb462ac7b
53 files changed
+1023
-122
No files matched your search
+3
-7
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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); };
|
||||
|
||||
|
||||
Reference in new issue
Block a user