Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e3d8931f6 | ||
|
|
e25f2650ff | ||
|
|
ead47cc924 | ||
|
|
0d61aa0f22 |
No files matched your search
@@ -1,6 +1,14 @@
|
||||
# Architekturentscheidungen
|
||||
|
||||
Stand: 27.10.2026. Arbeitstitel KantSites; Produktname offen.
|
||||
Stand: 10.11.2026. Arbeitstitel KantSites; Produktname offen.
|
||||
|
||||
## ADR 128 – Lesen ist nachsichtig, Schreiben streng; Kennung statt Versionsspalte (10.11.2026)
|
||||
|
||||
**Kontext:** Die Komplettanalyse vom 10.11.2026 fand drei Stellen, an denen ein gespeicherter Stand zur Falle wurde: Seiten wurden bei jedem Lesen voll geprüft, samt eigenem CSS – eine später verschärfte Regel ließ sie mit 500 antworten und im Editor nicht mehr öffnen; Einstellungen und Design überschrieb der zweite von zwei Admins still; Sicherungen galten als unversehrt, auch wenn Mediendateien fehlten.
|
||||
|
||||
**Entscheidung:** (1) `DocumentValidator` parst eigenes CSS nur noch beim Schreiben. Beim Lesen bleibt die Strukturprüfung, das CSS eines Blocks fällt in `compileCss()` weg, wenn es nicht mehr gilt – wie ADR 116 es für Bereiche schon vorsah. Ein beim Lesen geprüftes Dokument wird je Anfrage über seinen Inhalt gemerkt und nicht noch einmal geprüft. (2) `SiteSettings::stamp()` und `Theme::stamp()` liefern einen Hash des gespeicherten JSON; die Formulare tragen ihn als `expected`, `save()` vergleicht in derselben Transaktion und wirft `StaleWrite`. (3) `Backup::create()` wirft, wenn eine Mediendatei nicht vollständig ankommt, und räumt den angefangenen Ordner weg; `intact()` zählt die Mediendateien; `rotate()` zählt nur unversehrte Sicherungen und entfernt beschädigte nach einer Stunde.
|
||||
|
||||
**Folgen:** Keine Migration: Die Kennung ist ein Hash, keine Spalte. Ein Formular ohne `expected` (aus einem vor dem Update geladenen Bildschirm, aus Tests, aus dem Installer) speichert ungeprüft wie bisher. Eigene Skripte werden weiter auch beim Lesen geprüft. Eine Sicherung scheitert jetzt hörbar, wo sie vorher unvollständig gelang – bei vollem Speicher gibt es dann keine neue, aber die alten vollständigen bleiben. Nicht umgesetzt: der MySQL-Rückweg nach einem zurückgerollten Update (neu angelegte Tabellen bleiben stehen); alle Installationen laufen auf SQLite.
|
||||
|
||||
## ADR 127 – Plugins mit eigenem PHP-Code, wie bei WordPress (09.11.2026)
|
||||
|
||||
|
||||
+24
-1
@@ -1,5 +1,26 @@
|
||||
# Projektstatus
|
||||
|
||||
## Fassung 1.41.0: Stabilität – Altbestand lesbar, Konflikte, Sicherungen, Rückfragen (10.11.2026)
|
||||
|
||||
**Anlass:** Umut nach 1.40.1: „ja weiter bitte“ – das zweite Paket aus der Komplettanalyse. ADR 128.
|
||||
|
||||
**Erledigt (keine Migration, kein Schemawechsel):**
|
||||
- F4/P5: Eine Seite, deren gespeichertes CSS einer strengeren Regel nicht mehr genügt, antwortet mit 200 und lässt sich im Editor öffnen; nur dieses CSS fällt weg. Speichern verlangt weiter gültiges CSS. Beim Lesen wird ein Dokument je Anfrage einmal geprüft statt bis zu siebenmal.
|
||||
- F12: Einstellungen und Design lehnen das Speichern aus einem veralteten Formular ab (`stamp()`, Feld `expected`).
|
||||
- F6: Eine Sicherung scheitert, wenn eine Mediendatei nicht vollständig ankommt, und hinterlässt keinen halben Ordner. „Unversehrt“ zählt die Medien mit. Die Rotation zählt nur unversehrte.
|
||||
- F9: Beim Archiv-Update entsteht die Sicherung hinter der Wartungsseite.
|
||||
- F10: Paketimport legt erst alle Medien ab und entfernt dann die alten; scheitert das, sagt die Meldung, dass die Inhalte schon die des Pakets sind, und nennt die Sicherung. Eine vorher eingeschaltete Wartung bleibt an.
|
||||
- F11: `cms:restore` legt die SQLite-Datei erst daneben ab, entfernt Journal und WAL und ersetzt dann; bei einer Sicherung aus einer älteren Fassung steht der Hinweis auf die Migrationen in den Schritten.
|
||||
- E2: Rückfrage im Stil der Verwaltung (`public/assets/confirm.js`, `kantConfirm({title, message, confirmLabel, variant, onConfirm})`, Formulare mit `data-confirm-title`) vor: Seite endgültig löschen, Konto löschen, Sektion entfernen, Versandprotokoll leeren, Weiterleitung entfernen, Formularfeld entfernen. Fokus liegt auf „Abbrechen“. Ohne JavaScript bleibt es ein Formular ohne Frage.
|
||||
- E4: Strg+S im Editor übernimmt das Feld, in dem gerade getippt wird.
|
||||
- E5: Entf löscht den gewählten Baustein nur noch, wenn der Fokus in der Vorschau oder im Strukturbaum liegt, und nie bei offenem Dialog.
|
||||
|
||||
**Geprüft:** PHPUnit 699 grün (PHP 8.4, vier neue Tests), `npm test` 41, php-cs-fixer, Twig-Lint, `test:editor` grün. Im Browser (Playwright, Pilotkopie): Weiterleitung entfernen – Dialog mit Titel und rotem Knopf, Fokus auf „Abbrechen“, Escape lässt alles stehen und gibt den Fokus zurück, Bestätigen entfernt; 390 px ohne Überstand. Editor: getipptes CSS steht nach Strg+S im gespeicherten Entwurf; Entf bei offenem Dialog und auf einem Reiter löscht nichts, im Strukturbaum wie bisher. Aufnahmen unter `reports/1.41/`. Nicht geprüft: PHP 8.2, `test:browser`, MySQL, ein Update und ein Paketimport auf einem Hosting, ein voller Datenträger (die Abbruchwege bei Sicherung und Medientausch sind gelesen, nicht nachgestellt), die übrigen vier Rückfragen einzeln im Browser.
|
||||
|
||||
**Bekannte Fehler / offen:** F8 (MySQL-Rückweg lässt neue Tabellen stehen) nicht umgesetzt, da keine MySQL-Installation existiert. `test:accordion` weiter veraltet. Aus der Analyse offen: Geschwindigkeit (Bilder statisch, Bildstufen), Autosave, Einstellungen aufteilen, Symbole als SVG, Barrierefreiheit der Archive.
|
||||
|
||||
**Nächster Schritt:** Zweig `stabil-1.41` nach `main`, Tag `v1.41.0`, Releaseeintrag – nach Umuts Freigabe.
|
||||
|
||||
## Fassung 1.40.1: Symfony-Sicherheitsupdate und acht Korrekturen aus der Analyse (10.11.2026)
|
||||
|
||||
**Anlass:** Umut nach der Komplettanalyse: „Ja, 1.40.1 bauen“. Alle Installationen laufen auf SQLite (Umut), der MySQL-Punkt ist also Vorsorge.
|
||||
@@ -18,7 +39,9 @@
|
||||
|
||||
**Bekannte Fehler:** `test:accordion` scheitert schon vor dieser Fassung an der Auswahl im Strukturbaum (Skript kennt die Seitenleiste seit 1.11 nicht). `tests/pilot-fixture.php` migriert die Kopie nicht. Alle übrigen Punkte der Analyse sind offen.
|
||||
|
||||
**Nächster Schritt:** Zweig `release-1.40.1` nach `main`, Tag `v1.40.1`, Releaseeintrag – nach Umuts Freigabe. Danach 1.41 (Stabilität) laut Bericht.
|
||||
**Release:** Tag `v1.40.1` auf `main`, Releaseeintrag mit Zip (3705 Dateien, 6,1 MB), `.sha256` und `install.php`, gebaut aus sauberem Checkout des Tags (ohne ImmoScout-Plugin); anonymer Download stimmt mit der Prüfsumme überein (a6c1fac3…), `releases/latest` meldet v1.40.1. Die gespeicherten Git-Zugangsdaten waren abgelaufen; Umut hat sich neu angemeldet und selbst gepusht.
|
||||
|
||||
**Nächster Schritt:** In der Zentrale „Alle aktualisieren“ auf 1.40.1 (kant-it.de steht noch auf 1.37.0). Danach 1.41 (Stabilität) laut Bericht.
|
||||
|
||||
## Komplettanalyse 1.40.0 (10.11.2026)
|
||||
|
||||
|
||||
+4
-4
@@ -69,10 +69,10 @@ Alles bis hierhin setzt eine Git-Arbeitskopie voraus. Auf geteiltem Hosting gibt
|
||||
Der Ablauf ist derselbe wie oben, nur ohne `git`:
|
||||
|
||||
1. **Fassung ermitteln.** Die Releaseliste nennt den neuesten Tag. Antwortet sie nicht – viele Hoster lassen keine ausgehenden Verbindungen zu –, endet es hier, und es wurde nichts angefasst.
|
||||
2. **Sichern.** Datenbank und Medien nach `var/backups/`, vor dem ersten Handgriff.
|
||||
3. **Archiv laden und Prüfsumme vergleichen.** Stimmt sie nicht, wird nicht entpackt. Die Adresse steht in der Konfiguration, nicht in der Anfrage.
|
||||
4. **Entpacken und auf Vollständigkeit prüfen.** Fehlen `VERSION` oder `vendor/`, ist es kein brauchbares Release – und das fällt auf, bevor der Wartungszustand angeht.
|
||||
5. **Wartungszustand an, Dateien ersetzen.** `bin`, `config`, `migrations`, `src`, `templates`, `vendor`, `composer.json`, `composer.lock`, `migrations.php`, `cli-config.php`, `VERSION`. Der alte Stand wandert nach `var/update/<zeitstempel>/alt/`, er wird nicht gelöscht.
|
||||
2. **Archiv laden und Prüfsumme vergleichen.** Stimmt sie nicht, wird nicht entpackt. Die Adresse steht in der Konfiguration, nicht in der Anfrage.
|
||||
3. **Entpacken und auf Vollständigkeit prüfen.** Fehlen `VERSION` oder `vendor/`, ist es kein brauchbares Release – und das fällt auf, bevor der Wartungszustand angeht.
|
||||
4. **Wartungszustand an, dann sichern.** Datenbank und Medien nach `var/backups/`. Seit 1.41.0 hinter der Wartungsseite: Vorher entstand die Sicherung vor dem Laden, und was in dieser Zeit noch hereinkam, fehlte nach einem Zurückschalten. Scheitert die Sicherung – auch an einer einzelnen Mediendatei –, geht die Wartung wieder aus und nichts wird ersetzt.
|
||||
5. **Dateien ersetzen.** `bin`, `config`, `migrations`, `src`, `templates`, `vendor`, `composer.json`, `composer.lock`, `migrations.php`, `cli-config.php`, `VERSION`. Der alte Stand wandert nach `var/update/<zeitstempel>/alt/`, er wird nicht gelöscht.
|
||||
6. **`public/` wird zusammengeführt, nicht ersetzt.** Dort liegen Dateien der Anwendung und Dateien des Betreibers nebeneinander: `assets/fonts` (hochgeladene Schriften) und `assets/build` (das erzeugte Stylesheet-Bündel) bleiben unangetastet.
|
||||
7. **Zwischenspeicher leeren.**
|
||||
8. **Zweiter Knopf: Migrationen.** Sie laufen in einer **eigenen Anfrage**, weil ein PHP-Prozess, der sich selbst ausgetauscht hat, mit den Klassen weiterarbeitet, die er beim Start geladen hat. Erst danach geht der Wartungszustand aus.
|
||||
|
||||
+10
-4
@@ -1155,7 +1155,13 @@ details.form-card > summary.form-card-summary::before, details.form-card[open] >
|
||||
.update-lauf .secondary { background: transparent; color: #fff; border-color: #ffffff66 }
|
||||
.update-letztes { margin: 0 0 12px }
|
||||
.update-manual { margin-top: 6px }
|
||||
.update-dialog { max-width: 460px; border: 0; border-radius: 12px; padding: 24px 26px; box-shadow: 0 18px 48px #0003 }
|
||||
.update-dialog::backdrop { background: #1f2d2766 }
|
||||
.update-dialog h2 { margin: 0 0 10px; font-size: 18px }
|
||||
.update-dialog .dialog-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px }
|
||||
.update-dialog, .confirm-dialog { max-width: 460px; border: 0; border-radius: 12px; padding: 24px 26px; box-shadow: 0 18px 48px #0003 }
|
||||
.update-dialog::backdrop, .confirm-dialog::backdrop { background: #1f2d2766 }
|
||||
.update-dialog h2, .confirm-dialog h2 { margin: 0 0 10px; font-size: 18px }
|
||||
.update-dialog .dialog-actions, .confirm-dialog .dialog-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px }
|
||||
/* Die Rückfrage vor etwas Endgültigem (confirm.js). */
|
||||
.confirm-dialog { width: calc(100vw - 32px); color: var(--ink) }
|
||||
.confirm-dialog p { margin: 0; color: var(--muted); line-height: 1.5 }
|
||||
.confirm-dialog .dialog-actions .primary, .confirm-dialog .dialog-actions .secondary { margin: 0 }
|
||||
.primary.danger { background: var(--danger); border-color: var(--danger) }
|
||||
.primary.danger:hover:not(:disabled) { background: #6f251b; border-color: #6f251b }
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Rückfrage im Stil der Verwaltung - statt confirm() des Browsers, und statt gar keiner.
|
||||
*
|
||||
* Ein Formular meldet sich mit data-confirm-title an (dazu optional data-confirm-text,
|
||||
* data-confirm-label und data-confirm-variant="danger"). Sein Absenden wird angehalten, bis der
|
||||
* Dialog bestätigt ist. Ohne JavaScript bleibt es ein gewöhnliches Formular.
|
||||
*
|
||||
* Skripte rufen kantConfirm({ title, message, confirmLabel, variant, onConfirm }) auf.
|
||||
*/
|
||||
(() => {
|
||||
let dialog = null;
|
||||
let pending = null;
|
||||
let opener = null;
|
||||
|
||||
const build = () => {
|
||||
dialog = document.createElement('dialog');
|
||||
dialog.className = 'confirm-dialog';
|
||||
dialog.setAttribute('aria-labelledby', 'confirm-dialog-title');
|
||||
dialog.setAttribute('aria-describedby', 'confirm-dialog-text');
|
||||
const title = document.createElement('h2'); title.id = 'confirm-dialog-title';
|
||||
const text = document.createElement('p'); text.id = 'confirm-dialog-text';
|
||||
const actions = document.createElement('div'); actions.className = 'dialog-actions';
|
||||
const cancel = document.createElement('button'); cancel.type = 'button'; cancel.className = 'secondary'; cancel.textContent = 'Abbrechen'; cancel.dataset.confirmCancel = '';
|
||||
const ok = document.createElement('button'); ok.type = 'button'; ok.dataset.confirmOk = '';
|
||||
actions.append(cancel, ok);
|
||||
dialog.append(title, text, actions);
|
||||
cancel.addEventListener('click', () => dialog.close());
|
||||
ok.addEventListener('click', () => { const run = pending; pending = null; dialog.close(); run?.(); });
|
||||
// Escape und der Klick neben den Dialog schließen ohne Folgen.
|
||||
dialog.addEventListener('click', event => { if (event.target === dialog) dialog.close(); });
|
||||
dialog.addEventListener('close', () => { pending = null; opener?.focus?.({ preventScroll: true }); opener = null; });
|
||||
document.body.append(dialog);
|
||||
};
|
||||
|
||||
const ask = ({ title, message = '', confirmLabel = 'Bestätigen', variant = 'primary', onConfirm }) => {
|
||||
if (!dialog) build();
|
||||
if (dialog.open) dialog.close();
|
||||
opener = document.activeElement;
|
||||
dialog.querySelector('h2').textContent = title;
|
||||
const text = dialog.querySelector('p'); text.textContent = message; text.hidden = '' === message;
|
||||
const ok = dialog.querySelector('[data-confirm-ok]');
|
||||
ok.textContent = confirmLabel;
|
||||
ok.className = 'danger' === variant ? 'primary danger' : 'primary';
|
||||
pending = onConfirm;
|
||||
dialog.showModal();
|
||||
// Der Fokus liegt auf „Abbrechen“: Die Eingabetaste darf nichts Endgültiges auslösen.
|
||||
dialog.querySelector('[data-confirm-cancel]').focus();
|
||||
};
|
||||
window.kantConfirm = ask;
|
||||
|
||||
document.addEventListener('submit', event => {
|
||||
const form = event.target;
|
||||
if (!(form instanceof HTMLFormElement) || !form.dataset.confirmTitle || form.dataset.confirmed) return;
|
||||
event.preventDefault();
|
||||
const submitter = event.submitter;
|
||||
ask({
|
||||
title: form.dataset.confirmTitle,
|
||||
message: form.dataset.confirmText || '',
|
||||
confirmLabel: form.dataset.confirmLabel || submitter?.textContent.trim() || 'Bestätigen',
|
||||
variant: form.dataset.confirmVariant || 'primary',
|
||||
onConfirm: () => {
|
||||
form.dataset.confirmed = '1';
|
||||
// requestSubmit nimmt den Knopf mit, falls er einen Namen und Wert trägt.
|
||||
submitter && submitter.form === form ? form.requestSubmit(submitter) : form.requestSubmit();
|
||||
},
|
||||
});
|
||||
});
|
||||
})();
|
||||
+14
-2
@@ -1920,7 +1920,15 @@ function onKey(event) {
|
||||
if (!history || busy) return;
|
||||
const key = event.key.toLowerCase();
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
if (key === 's') { event.preventDefault(); save(); }
|
||||
if (key === 's') {
|
||||
event.preventDefault();
|
||||
// Viele Felder schreiben erst beim Verlassen ins Dokument (eigenes CSS, Zahlen im Design).
|
||||
// Ohne diesen Schritt sicherte Strg+S den Stand ohne das, was gerade getippt wurde - und
|
||||
// meldete trotzdem „gespeichert“.
|
||||
const field = document.activeElement;
|
||||
if (field && field.ownerDocument === document && inField(field) && !field.isContentEditable) field.blur();
|
||||
save();
|
||||
}
|
||||
// Keep native text-field undo intact; the toolbar always undoes the complete document.
|
||||
if (inField(event.target)) return;
|
||||
if (key === 'z' || key === 'y') { event.preventDefault(); (event.shiftKey || key === 'y') ? history.redo() : history.undo(); changed(); }
|
||||
@@ -1935,7 +1943,11 @@ function onKey(event) {
|
||||
}
|
||||
// Delete removes the selected element - but never while a field or the preview text is
|
||||
// being typed in, and never something the sidebar buttons would refuse.
|
||||
if ((event.key === 'Delete' || event.key === 'Backspace') && selected && !inField(event.target) && !inlineEdit && !$('#delete').disabled) {
|
||||
// Auch nicht, während ein Dialog offen ist oder der Fokus auf einem Bedienelement der
|
||||
// Seitenleiste liegt: Entf auf einer Kachel der Mediathek löschte sonst den gewählten Baustein.
|
||||
const where = event.target;
|
||||
const onStage = !where?.closest || where.ownerDocument !== document || where === document.body || Boolean(where.closest('#structure, .canvas-scroll'));
|
||||
if ((event.key === 'Delete' || event.key === 'Backspace') && selected && onStage && !document.querySelector('dialog[open]') && !inField(event.target) && !inlineEdit && !$('#delete').disabled) {
|
||||
event.preventDefault(); actions.delete(selected);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,7 +316,12 @@ if (root) {
|
||||
if (toggle) { const card = toggle.closest('[data-field]'); setOpen(card, !card.classList.contains('is-open')); refresh(); return; }
|
||||
const button = event.target.closest('[data-remove-field]');
|
||||
if (!button) return;
|
||||
button.closest('[data-field]').remove(); refresh(); add.focus();
|
||||
const card = button.closest('[data-field]');
|
||||
const name = card.querySelector('[data-field-label]')?.value.trim();
|
||||
const remove = () => { card.remove(); refresh(); add.focus(); };
|
||||
// Ein eben angelegtes, leeres Feld verschwindet ohne Frage; alles andere fragt nach.
|
||||
if (!name || !window.kantConfirm) { remove(); return; }
|
||||
window.kantConfirm({ title: `Feld „${name}“ entfernen?`, message: 'Bereits eingegangene Anfragen behalten ihre Angaben. Wirksam wird es erst mit „Speichern“.', confirmLabel: 'Feld entfernen', variant: 'danger', onConfirm: remove });
|
||||
});
|
||||
list.addEventListener('input', event => {
|
||||
const card = event.target.closest('[data-field]');
|
||||
|
||||
@@ -9,6 +9,9 @@ final class DocumentValidator
|
||||
{
|
||||
private bool $writing = false;
|
||||
|
||||
/** @var array<string, true> beim Lesen bereits geprüfte Dokumente dieser Anfrage */
|
||||
private array $passed = [];
|
||||
|
||||
public function __construct(private StyleCompiler $styles = new StyleCompiler(), private CustomCode $code = new CustomCode(), private ?PluginRegistry $plugins = null)
|
||||
{
|
||||
}
|
||||
@@ -59,6 +62,24 @@ final class DocumentValidator
|
||||
* lässt unpassende Eigenschaften ohnehin nicht durch.
|
||||
*/
|
||||
public function validate(array $document, bool $requireHeading = true, bool $writing = false): void
|
||||
{
|
||||
// Beim Lesen wird dasselbe Dokument in einer Anfrage oft mehrfach geholt (Seite,
|
||||
// Brotkrumen, strukturierte Daten). Geprüft wird es einmal; der Schlüssel ist sein Inhalt,
|
||||
// ein geändertes Dokument ist also ein anderes.
|
||||
$seen = null;
|
||||
if (!$writing) {
|
||||
$seen = hash('xxh128', serialize($document)).($requireHeading ? '1' : '0');
|
||||
if (isset($this->passed[$seen])) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
$this->check($document, $requireHeading, $writing);
|
||||
if (null !== $seen) {
|
||||
$this->passed[$seen] = true;
|
||||
}
|
||||
}
|
||||
|
||||
private function check(array $document, bool $requireHeading, bool $writing): void
|
||||
{
|
||||
$this->writing = $writing;
|
||||
$schemaVersion = $document['schemaVersion'] ?? null;
|
||||
@@ -133,7 +154,7 @@ final class DocumentValidator
|
||||
$this->require(is_string($key) && strlen($key) <= 110 && is_string($hash) && 1 === preg_match('/^[a-f0-9]{8}$/D', $hash), 'Ungültiger Vermerk zur Übersetzung.');
|
||||
}
|
||||
try {
|
||||
$this->validate(Translations::apply($document, $locale), $requireHeading, $this->writing);
|
||||
$this->check(Translations::apply($document, $locale), $requireHeading, $this->writing);
|
||||
} catch (InvalidDocument $error) {
|
||||
throw new InvalidDocument(sprintf('%s (%s): %s', Languages::CATALOG[$locale]['name'], $locale, $error->getMessage()), previous: $error);
|
||||
}
|
||||
@@ -171,7 +192,17 @@ final class DocumentValidator
|
||||
// and is answered in the controller, not here.
|
||||
$this->require(CustomCode::supports($schemaVersion), 'Own code requires schema v13.');
|
||||
$this->require(is_string($block[$own]), sprintf('"%s" must be text.', $own));
|
||||
'css' === $own ? $this->code->checkCss($block[$own]) : $this->code->checkScript($block[$own]);
|
||||
// Eigenes CSS wird nur beim Schreiben geparst. Was gespeichert ist, galt beim Speichern;
|
||||
// ist eine Regel inzwischen strenger, lässt compileCss() das CSS dieses einen Blocks
|
||||
// weg (ADR 116). Hier beim Lesen zu werfen hieße: die Seite antwortet mit 500 und lässt
|
||||
// sich nicht einmal mehr zum Reparieren öffnen.
|
||||
if ('css' === $own) {
|
||||
if ($this->writing) {
|
||||
$this->code->checkCss($block[$own]);
|
||||
}
|
||||
} else {
|
||||
$this->code->checkScript($block[$own]);
|
||||
}
|
||||
}
|
||||
if (isset($block['style'])) {
|
||||
// Free styling: any property of StyleSchema, per device, with an optional hover value.
|
||||
|
||||
@@ -219,7 +219,17 @@ final class SiteSettings
|
||||
return $this->loaded = [...self::DEFAULTS, ...(is_array($stored) ? $stored : [])];
|
||||
}
|
||||
|
||||
public function save(array $input): void
|
||||
/**
|
||||
* Kennung des gespeicherten Stands. Das Formular trägt sie mit; hat inzwischen jemand anderes
|
||||
* gespeichert, passt sie nicht mehr, und save() lehnt ab, statt dessen Änderung zu überschreiben.
|
||||
*/
|
||||
public function stamp(): string
|
||||
{
|
||||
return hash('xxh128', (string) $this->db->fetchOne('SELECT settings FROM website WHERE id = ?', ['default']));
|
||||
}
|
||||
|
||||
/** @param string|null $expected die Kennung aus stamp(), als das Formular geladen wurde; null prüft nicht */
|
||||
public function save(array $input, ?string $expected = null): void
|
||||
{
|
||||
$values = self::DEFAULTS;
|
||||
// Assigned separately, so saving company data cannot clear the error page.
|
||||
@@ -412,10 +422,18 @@ final class SiteSettings
|
||||
}
|
||||
|
||||
$this->loaded = null;
|
||||
$this->db->executeStatement('UPDATE website SET settings = ? WHERE id = ?', [
|
||||
json_encode($values, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
|
||||
'default',
|
||||
]);
|
||||
$this->db->transactional(function () use ($values, $expected): void {
|
||||
if (null !== $expected) {
|
||||
$lock = $this->db->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\SQLitePlatform ? '' : ' FOR UPDATE';
|
||||
if (!hash_equals(hash('xxh128', (string) $this->db->fetchOne('SELECT settings FROM website WHERE id = ?'.$lock, ['default'])), $expected)) {
|
||||
throw new StaleWrite('Die Einstellungen wurden inzwischen an anderer Stelle geändert. Die Seite zeigt jetzt den aktuellen Stand – bitte trage deine Änderung dort noch einmal ein.');
|
||||
}
|
||||
}
|
||||
$this->db->executeStatement('UPDATE website SET settings = ? WHERE id = ?', [
|
||||
json_encode($values, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
|
||||
'default',
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/** Change only the 404 assignment, preserving settings saved in other forms. */
|
||||
|
||||
+20
-5
@@ -108,7 +108,14 @@ final class Theme
|
||||
];
|
||||
}
|
||||
|
||||
public function save(array $colors, array $fonts, array $base, ?array $buttons = null): void
|
||||
/** Kennung des gespeicherten Stands, wie SiteSettings::stamp(). */
|
||||
public function stamp(): string
|
||||
{
|
||||
return hash('xxh128', (string) $this->db->fetchOne('SELECT theme FROM website WHERE id = ?', ['default']));
|
||||
}
|
||||
|
||||
/** @param string|null $expected die Kennung aus stamp(), als das Formular geladen wurde; null prüft nicht */
|
||||
public function save(array $colors, array $fonts, array $base, ?array $buttons = null, ?string $expected = null): void
|
||||
{
|
||||
$theme = [
|
||||
'colors' => $this->colors($colors),
|
||||
@@ -132,10 +139,18 @@ final class Theme
|
||||
throw new InvalidPage('Die Grundschrift verweist auf eine Schrift, die es nicht gibt.');
|
||||
}
|
||||
}
|
||||
$this->db->executeStatement('UPDATE website SET theme = ? WHERE id = ?', [
|
||||
json_encode($theme, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
|
||||
'default',
|
||||
]);
|
||||
$this->db->transactional(function () use ($theme, $expected): void {
|
||||
if (null !== $expected) {
|
||||
$lock = $this->db->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\SQLitePlatform ? '' : ' FOR UPDATE';
|
||||
if (!hash_equals(hash('xxh128', (string) $this->db->fetchOne('SELECT theme FROM website WHERE id = ?'.$lock, ['default'])), $expected)) {
|
||||
throw new \App\Domain\StaleWrite('Das Design wurde inzwischen an anderer Stelle geändert – etwa durch eine im Editor gespeicherte Stilvorlage. Die Seite zeigt jetzt den aktuellen Stand – bitte trage deine Änderung dort noch einmal ein.');
|
||||
}
|
||||
}
|
||||
$this->db->executeStatement('UPDATE website SET theme = ? WHERE id = ?', [
|
||||
json_encode($theme, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
|
||||
'default',
|
||||
]);
|
||||
});
|
||||
// After the write, never before: saving reads the theme again, and an early reset
|
||||
// would only refill the memo with the state that is about to be replaced.
|
||||
$this->loaded = null;
|
||||
|
||||
@@ -57,6 +57,7 @@ final class SettingsController extends AbstractController
|
||||
|
||||
return $this->render('admin/settings.html.twig', [
|
||||
'settings' => $settings,
|
||||
'settingsStamp' => $this->settings->stamp(),
|
||||
'businessTypes' => SiteSettings::BUSINESS_TYPES,
|
||||
'legalSelection' => $legal,
|
||||
'notFoundSelection' => $this->notFoundPage->selected(),
|
||||
@@ -124,8 +125,9 @@ final class SettingsController extends AbstractController
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
try {
|
||||
$this->settings->save($request->request->all());
|
||||
} catch (InvalidPage $e) {
|
||||
// Ohne Kennung (ein Formular von vor dem Update) wird nicht geprüft, sondern gespeichert.
|
||||
$this->settings->save($request->request->all(), $request->request->getString('expected') ?: null);
|
||||
} catch (InvalidPage|StaleWrite $e) {
|
||||
return $this->back($e->getMessage());
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ final class ThemeController extends AbstractController
|
||||
|
||||
return $this->render('admin/theme.html.twig', [
|
||||
'theme' => $theme,
|
||||
'themeStamp' => $this->theme->stamp(),
|
||||
'fallbacks' => Theme::FALLBACKS,
|
||||
'weights' => Theme::WEIGHTS,
|
||||
'buttonOptions' => ButtonDesign::OPTIONS,
|
||||
@@ -93,8 +94,8 @@ final class ThemeController extends AbstractController
|
||||
$fonts[] = $row;
|
||||
}
|
||||
try {
|
||||
$this->theme->save($request->request->all('colors'), $fonts, $request->request->all('base'), $request->request->has('buttons') ? $request->request->all('buttons') : null);
|
||||
} catch (InvalidPage $e) {
|
||||
$this->theme->save($request->request->all('colors'), $fonts, $request->request->all('base'), $request->request->has('buttons') ? $request->request->all('buttons') : null, $request->request->getString('expected') ?: null);
|
||||
} catch (InvalidPage|\App\Domain\StaleWrite $e) {
|
||||
$this->addFlash('error', $e->getMessage());
|
||||
|
||||
return $this->redirectToRoute('admin_theme');
|
||||
|
||||
+15
-10
@@ -195,16 +195,7 @@ final class ArchiveUpdate
|
||||
return ['ok' => false, 'steps' => $schritte];
|
||||
}
|
||||
|
||||
// Erst sichern, dann anfassen. Eine Sicherung nach dem Ersetzen wäre keine.
|
||||
try {
|
||||
$sicherung = $this->backup->create('vor dem Update');
|
||||
$melden($schritte, 'Sicherung', true, sprintf('Datenbank und Medien liegen unter var/backups/%s.', (string) ($sicherung['name'] ?? 'sicherung')));
|
||||
$this->phase('laden', sprintf('Fassung %s wird geladen …', $ziel));
|
||||
} catch (\Throwable $fehler) {
|
||||
$melden($schritte, 'Sicherung', false, 'Die Sicherung ist fehlgeschlagen: '.$fehler->getMessage().' Ohne sie wird nichts ersetzt.');
|
||||
|
||||
return ['ok' => false, 'steps' => $schritte];
|
||||
}
|
||||
$this->phase('laden', sprintf('Fassung %s wird geladen …', $ziel));
|
||||
|
||||
$archiv = $arbeit.'/kantsites-'.$ziel.'.zip';
|
||||
// Direkt in die Datei, nicht erst als Zeichenkette in den Speicher: ein Archiv von einigen
|
||||
@@ -250,6 +241,20 @@ final class ArchiveUpdate
|
||||
$this->updater->maintenance(true);
|
||||
$melden($schritte, 'Wartungszustand', true, 'Die Website antwortet währenddessen mit 503.');
|
||||
|
||||
// Erst sichern, dann anfassen - aber erst jetzt, hinter der Wartungsseite. Vorher entstand
|
||||
// die Sicherung vor dem Laden und Entpacken: Was in diesen Sekunden bis Minuten noch
|
||||
// hereinkam (eine Anfrage über ein Formular, ein gespeicherter Entwurf), fehlte in ihr und
|
||||
// war nach einem Zurückschalten weg.
|
||||
try {
|
||||
$sicherung = $this->backup->create('vor dem Update');
|
||||
$melden($schritte, 'Sicherung', true, sprintf('Datenbank und Medien liegen unter var/backups/%s.', (string) ($sicherung['name'] ?? 'sicherung')));
|
||||
} catch (\Throwable $fehler) {
|
||||
$this->updater->maintenance(false);
|
||||
$melden($schritte, 'Sicherung', false, 'Die Sicherung ist fehlgeschlagen: '.$fehler->getMessage().' Ohne sie wird nichts ersetzt.');
|
||||
|
||||
return ['ok' => false, 'steps' => $schritte];
|
||||
}
|
||||
|
||||
$alt = $arbeit.'/alt';
|
||||
@mkdir($alt, 0o775, true);
|
||||
$ersetzt = 0;
|
||||
|
||||
+72
-10
@@ -69,6 +69,21 @@ final class Backup
|
||||
throw new InvalidPage(sprintf('Das Verzeichnis %s ließ sich nicht anlegen. Schreibrechte auf var/ prüfen.', $directory));
|
||||
}
|
||||
|
||||
// Scheitert etwas - typisch: der Speicher ist voll -, bleibt kein halber Ordner liegen. Der
|
||||
// zählte sonst bei der Rotation als eine der neuesten Sicherungen und verdrängte eine
|
||||
// vollständige.
|
||||
try {
|
||||
return $this->write($name, $directory, $reason, $now);
|
||||
} catch (\Throwable $fehler) {
|
||||
$this->remove($directory);
|
||||
|
||||
throw $fehler;
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array{name: string, path: string, manifest: array} */
|
||||
private function write(string $name, string $directory, string $reason, \DateTimeImmutable $now): array
|
||||
{
|
||||
$database = $this->copyDatabase($directory);
|
||||
$media = $this->copyMedia($directory.'/medien');
|
||||
$manifest = [
|
||||
@@ -88,7 +103,10 @@ final class Backup
|
||||
// Not in here, and it is worth saying so where somebody reads it.
|
||||
'enthaelt_nicht' => ['.env', '.env.local', 'vendor/', 'Quellcode'],
|
||||
];
|
||||
file_put_contents($directory.'/'.self::MANIFEST, json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
|
||||
$text = json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
|
||||
if (strlen($text) !== @file_put_contents($directory.'/'.self::MANIFEST, $text)) {
|
||||
throw new InvalidPage('Das Verzeichnis der Sicherung ließ sich nicht vollständig schreiben. Freien Speicherplatz prüfen.');
|
||||
}
|
||||
|
||||
return ['name' => $name, 'path' => $directory, 'manifest' => $manifest];
|
||||
}
|
||||
@@ -116,13 +134,23 @@ final class Backup
|
||||
return $backups;
|
||||
}
|
||||
|
||||
/** Removes the oldest beyond the limit. Returns the names it removed. */
|
||||
/**
|
||||
* Removes the oldest beyond the limit. Returns the names it removed.
|
||||
*
|
||||
* Gezählt werden nur unversehrte Sicherungen: eine beschädigte darf keine vollständige
|
||||
* verdrängen. Beschädigte verschwinden selbst - aber erst nach einer Stunde, denn ein Ordner
|
||||
* ohne Verzeichnis kann auch eine Sicherung sein, die eine andere Anfrage gerade schreibt.
|
||||
*/
|
||||
public function rotate(int $keep = self::KEEP): array
|
||||
{
|
||||
$removed = [];
|
||||
foreach (array_slice($this->all(), max(1, $keep)) as $backup) {
|
||||
$this->remove($backup['path']);
|
||||
$removed[] = $backup['name'];
|
||||
$intact = 0;
|
||||
foreach ($this->all() as $backup) {
|
||||
$drop = $backup['intact'] ? ++$intact > max(1, $keep) : (int) @filemtime($backup['path']) < time() - 3600;
|
||||
if ($drop) {
|
||||
$this->remove($backup['path']);
|
||||
$removed[] = $backup['name'];
|
||||
}
|
||||
}
|
||||
|
||||
return $removed;
|
||||
@@ -184,7 +212,21 @@ final class Backup
|
||||
}
|
||||
} else {
|
||||
$this->db->close();
|
||||
$copied = @copy($path.'/'.basename($file), $file);
|
||||
// Erst vollständig daneben ablegen, dann in einem Zug an die Stelle setzen: eine halb
|
||||
// geschriebene Datenbank sieht so nie jemand. Journal und WAL der alten Datei müssen
|
||||
// weg - SQLite spielte sie sonst beim nächsten Öffnen in die zurückgeholte Datei ein.
|
||||
$quelle = $path.'/'.basename($file);
|
||||
$neben = $file.'.wiederherstellung';
|
||||
$copied = @copy($quelle, $neben) && filesize($quelle) === filesize($neben);
|
||||
if ($copied) {
|
||||
foreach (['-journal', '-wal', '-shm'] as $rest) {
|
||||
@unlink($file.$rest);
|
||||
}
|
||||
// Unter Windows scheitert das Umbenennen, solange ein anderer Prozess die Datei
|
||||
// offen hat; dann bleibt der bisherige Weg.
|
||||
$copied = @rename($neben, $file) || @copy($neben, $file);
|
||||
}
|
||||
@unlink($neben);
|
||||
if (!$step($steps, 'Datenbank zurückschreiben', $copied, $copied ? basename($file).' ersetzt.' : 'Die Datei ließ sich nicht ersetzen.')) {
|
||||
return ['steps' => $steps, 'ok' => false];
|
||||
}
|
||||
@@ -194,6 +236,12 @@ final class Backup
|
||||
// by the very operation that is meant to prevent loss.
|
||||
$restored = $this->restoreMedia($path.'/medien');
|
||||
$step($steps, 'Medien ergänzen', true, sprintf('%d Datei(en) zurückgeschrieben. Neuere Dateien bleiben erhalten.', $restored));
|
||||
// Eine Sicherung aus einer älteren Fassung bringt deren Datenbankschema mit. Der Code hier
|
||||
// ist neuer und erwartet Spalten, die es dann noch nicht gibt - das muss dastehen, sonst
|
||||
// sucht man die 500er an der falschen Stelle.
|
||||
if (version_compare((string) $manifest['fassung'], $this->version(), '<')) {
|
||||
$step($steps, 'Migrationen', true, sprintf('Die Sicherung stammt aus Fassung %s, installiert ist %s: Jetzt in der Verwaltung unter System „Migrationen ausführen“ – oder: php bin/console doctrine:migrations:migrate.', $manifest['fassung'], $this->version()));
|
||||
}
|
||||
|
||||
return ['steps' => $steps, 'ok' => true];
|
||||
}
|
||||
@@ -303,10 +351,17 @@ final class Backup
|
||||
$count = 0;
|
||||
$bytes = 0;
|
||||
foreach (glob($this->mediaDirectory.'/*') ?: [] as $file) {
|
||||
if (is_file($file) && @copy($file, $target.'/'.basename($file))) {
|
||||
++$count;
|
||||
$bytes += (int) filesize($file);
|
||||
if (!is_file($file)) {
|
||||
continue;
|
||||
}
|
||||
$size = (int) filesize($file);
|
||||
// Eine Kopie, die fehlt oder kürzer ist, ist keine: lieber gar keine Sicherung als
|
||||
// eine, die vollständig aussieht und beim Zurückspielen Bilder vermissen lässt.
|
||||
if (!@copy($file, $target.'/'.basename($file)) || $size !== (int) @filesize($target.'/'.basename($file))) {
|
||||
throw new InvalidPage(sprintf('Die Mediendatei %s ließ sich nicht sichern. Freien Speicherplatz und Schreibrechte auf var/backups prüfen.', basename($file)));
|
||||
}
|
||||
++$count;
|
||||
$bytes += $size;
|
||||
}
|
||||
|
||||
return ['count' => $count, 'bytes' => $bytes];
|
||||
@@ -350,7 +405,14 @@ final class Backup
|
||||
return false;
|
||||
}
|
||||
|
||||
return hash_equals((string) ($manifest['datenbank']['sha256'] ?? ''), (string) hash_file('sha256', $file));
|
||||
if (!hash_equals((string) ($manifest['datenbank']['sha256'] ?? ''), (string) hash_file('sha256', $file))) {
|
||||
return false;
|
||||
}
|
||||
// Auch die Medien gehören dazu: fehlt eine der gezählten Dateien, ist die Sicherung nicht
|
||||
// mehr die, die das Verzeichnis beschreibt.
|
||||
$erwartet = $manifest['medien']['dateien'] ?? null;
|
||||
|
||||
return !is_int($erwartet) || $erwartet <= count(array_filter(glob($path.'/medien/*') ?: [], 'is_file'));
|
||||
}
|
||||
|
||||
private function size(string $path): int
|
||||
|
||||
+53
-18
@@ -207,19 +207,34 @@ final class SitePackage
|
||||
return $fail();
|
||||
}
|
||||
|
||||
// Eine Wartung, die schon vorher an war, hat jemand mit Absicht eingeschaltet - die
|
||||
// hebt ein Import nicht auf.
|
||||
$inWartung = is_file($this->root.'/'.Updater::MAINTENANCE_FILE);
|
||||
$this->updater->maintenance(true);
|
||||
try {
|
||||
$media = $this->extractMedia($zip);
|
||||
$this->write($tables, $withUsers);
|
||||
$step('Inhalte ersetzen', true, $this->summary($tables, $withUsers));
|
||||
$moved = $this->replaceMedia($media);
|
||||
$step('Medien ersetzen', true, sprintf('%d Datei(en) übernommen.', $moved));
|
||||
} catch (\Throwable $e) {
|
||||
$step('Inhalte ersetzen', false, $e->getMessage().' Die Datenbank blieb unverändert; die Sicherung liegt bereit.');
|
||||
try {
|
||||
$media = $this->extractMedia($zip);
|
||||
$this->write($tables, $withUsers);
|
||||
} catch (\Throwable $e) {
|
||||
$step('Inhalte ersetzen', false, $e->getMessage().' Die Datenbank blieb unverändert; die Sicherung liegt bereit.');
|
||||
|
||||
return $fail();
|
||||
return $fail();
|
||||
}
|
||||
$step('Inhalte ersetzen', true, $this->summary($tables, $withUsers));
|
||||
// Ab hier ist die Datenbank die des Pakets. Scheitern die Medien, sagt die Meldung
|
||||
// genau das - und nennt die Sicherung, mit der sich beides zurücknehmen lässt.
|
||||
try {
|
||||
$moved = $this->replaceMedia($media);
|
||||
} catch (\Throwable $e) {
|
||||
$step('Medien ersetzen', false, sprintf('%s Die Inhalte sind bereits die des Pakets, die Medien nicht vollständig. Zurück auf den Stand vorher: cms:restore %s.', $e->getMessage(), $safety['name']));
|
||||
|
||||
return $fail();
|
||||
}
|
||||
$step('Medien ersetzen', true, sprintf('%d Datei(en) übernommen.', $moved));
|
||||
} finally {
|
||||
$this->updater->maintenance(false);
|
||||
if (!$inWartung) {
|
||||
$this->updater->maintenance(false);
|
||||
}
|
||||
if (isset($media)) {
|
||||
$this->remove($media);
|
||||
}
|
||||
@@ -435,25 +450,45 @@ final class SitePackage
|
||||
return $target;
|
||||
}
|
||||
|
||||
/** Leert den Medienordner (die Sicherung hat ihn) und legt die Dateien des Pakets hinein. */
|
||||
/**
|
||||
* Legt die Dateien des Pakets in den Medienordner und entfernt danach, was nicht dazugehört.
|
||||
*
|
||||
* In dieser Reihenfolge: Erst wenn jede Datei des Pakets angekommen ist, verschwinden die
|
||||
* alten. Vorher wurde zuerst geleert und dann kopiert, ohne nachzuzählen - bei knappem
|
||||
* Speicher meldete der Import Erfolg, und die Website verwies auf Bilder, die es nicht gab.
|
||||
*
|
||||
* @throws \RuntimeException wenn eine Datei nicht ankommt; der Medienordner hat dann noch alle alten Dateien
|
||||
*/
|
||||
private function replaceMedia(string $source): int
|
||||
{
|
||||
if (!is_dir($this->mediaDirectory) && !@mkdir($this->mediaDirectory, 0o775, true)) {
|
||||
throw new \RuntimeException('Der Medienordner ließ sich nicht anlegen.');
|
||||
}
|
||||
$neu = [];
|
||||
foreach (scandir($source) ?: [] as $name) {
|
||||
if (1 !== preg_match(self::MEDIA_NAME, $name)) {
|
||||
continue;
|
||||
}
|
||||
$size = (int) filesize($source.'/'.$name);
|
||||
$target = $this->mediaDirectory.'/'.$name;
|
||||
// Verschieben braucht keinen zweiten Platz auf der Platte; über Laufwerksgrenzen
|
||||
// hinweg bleibt das Kopieren.
|
||||
if (!@rename($source.'/'.$name, $target) && !@copy($source.'/'.$name, $target)) {
|
||||
throw new \RuntimeException(sprintf('Die Mediendatei %s ließ sich nicht ablegen. Freien Speicherplatz prüfen.', $name));
|
||||
}
|
||||
clearstatcache(true, $target);
|
||||
if ($size !== (int) @filesize($target)) {
|
||||
throw new \RuntimeException(sprintf('Die Mediendatei %s kam unvollständig an. Freien Speicherplatz prüfen.', $name));
|
||||
}
|
||||
$neu[$name] = true;
|
||||
}
|
||||
foreach (scandir($this->mediaDirectory) ?: [] as $name) {
|
||||
if (1 === preg_match(self::MEDIA_NAME, $name)) {
|
||||
if (!isset($neu[$name]) && 1 === preg_match(self::MEDIA_NAME, $name)) {
|
||||
@unlink($this->mediaDirectory.'/'.$name);
|
||||
}
|
||||
}
|
||||
$count = 0;
|
||||
foreach (scandir($source) ?: [] as $name) {
|
||||
if (1 === preg_match(self::MEDIA_NAME, $name) && @copy($source.'/'.$name, $this->mediaDirectory.'/'.$name)) {
|
||||
++$count;
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
return \count($neu);
|
||||
}
|
||||
|
||||
private function summary(array $tables, bool $withUsers): string
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
{%- endfor %}
|
||||
}}</script>
|
||||
<script src="/assets/color-field.js?v={{ kant_version() }}" defer></script>
|
||||
<script src="/assets/confirm.js?v={{ kant_version() }}" defer></script>
|
||||
{% block styles %}{% endblock %}
|
||||
{% block scripts %}{% endblock %}
|
||||
{# Eigener Block, nicht Teil von `scripts`: Bildschirme wie die Mediathek ersetzen `scripts`
|
||||
|
||||
@@ -170,7 +170,7 @@
|
||||
{# The class is what the security test looks for: an editor must not even be
|
||||
offered this. #}
|
||||
<div class="delete-panel">
|
||||
<form action="{{ path('admin_delete', {id: page.id}) }}" method="post">
|
||||
<form action="{{ path('admin_delete', {id: page.id}) }}" method="post" data-confirm-title="„{{ page.title }}“ endgültig löschen?" data-confirm-text="Die Seite wird mit allen Fassungen gelöscht. Das lässt sich nicht rückgängig machen." data-confirm-label="Endgültig löschen" data-confirm-variant="danger">
|
||||
<input type="hidden" name="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
||||
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
|
||||
|
||||
@@ -123,7 +123,7 @@
|
||||
{% if mail.protokoll is empty %}
|
||||
<span class="muted">var/log/mail.log</span>
|
||||
{% else %}
|
||||
<form action="{{ path('admin_mail_clear') }}" method="post">
|
||||
<form action="{{ path('admin_mail_clear') }}" method="post" data-confirm-title="Versandprotokoll leeren?" data-confirm-text="Alle Einträge in var/log/mail.log werden entfernt." data-confirm-label="Protokoll leeren" data-confirm-variant="danger">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
|
||||
<button class="text-button" type="submit">Protokoll leeren</button>
|
||||
</form>
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
<div class="sheet-part">
|
||||
<h4>Entfernen</h4>
|
||||
<p>Entfernt „{{ partial.name }}“.{% if partial.usedBy is not empty %} <strong>Noch eingebunden in {{ partial.usedBy|join(', ') }}</strong> – dort erscheint danach nichts mehr.{% endif %}</p>
|
||||
<form action="{{ path('admin_partial_delete', {id: partial.id}) }}" method="post">
|
||||
<form action="{{ path('admin_partial_delete', {id: partial.id}) }}" method="post" data-confirm-title="Sektion „{{ partial.name }}“ entfernen?" data-confirm-text="{% if partial.usedBy is not empty %}Sie ist noch eingebunden in {{ partial.usedBy|join(', ') }} – dort erscheint danach nichts mehr. {% endif %}Das lässt sich nicht rückgängig machen." data-confirm-label="Sektion entfernen" data-confirm-variant="danger">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('partials') }}">
|
||||
<button class="subtle danger-text" type="submit">Sektion entfernen</button>
|
||||
</form>
|
||||
|
||||
@@ -104,6 +104,7 @@
|
||||
{% if is_granted('ROLE_ADMIN') %}
|
||||
<form action="{{ path('admin_settings_save') }}" method="post" class="card card-body">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('settings') }}">
|
||||
<input type="hidden" name="expected" value="{{ settingsStamp }}">
|
||||
<div class="fields">
|
||||
<div>
|
||||
<label for="legalName">Name des Unternehmens</label>
|
||||
@@ -390,7 +391,7 @@
|
||||
<td>{% if entry.to %}<code>/{{ entry.to }}</code>{% else %}<span class="muted">—</span>{% endif %}</td>
|
||||
<td>{{ entry.status }}</td>
|
||||
<td>
|
||||
<form action="{{ path('admin_redirect_save') }}" method="post">
|
||||
<form action="{{ path('admin_redirect_save') }}" method="post" data-confirm-title="Weiterleitung von /{{ entry.from }} entfernen?" data-confirm-text="Die alte Adresse antwortet danach mit „nicht gefunden“." data-confirm-label="Entfernen" data-confirm-variant="danger">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('settings') }}">
|
||||
<input type="hidden" name="remove" value="{{ entry.id }}">
|
||||
<button class="text-button danger-text" type="submit" aria-label="Weiterleitung von /{{ entry.from }} entfernen">Entfernen</button>
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
{% set emptyFont = {key: '', name: '', family: '', source: 'google', fallback: 'sans-serif', weights: [400, 700]} %}
|
||||
<form action="{{ path('admin_theme_save') }}" method="post" class="theme-form stack" data-theme-editor data-palettes="{{ palettes|json_encode|e('html_attr') }}" data-color-names="{{ colorNames|json_encode|e('html_attr') }}">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('theme') }}">
|
||||
<input type="hidden" name="expected" value="{{ themeStamp }}">
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
<div class="sheet-part">
|
||||
<h4>Konto löschen</h4>
|
||||
<p>Entfernt „{{ account.username }}“. Gespeicherte Inhalte bleiben unberührt – Seiten und Fassungen hängen nicht am Konto. Das eigene Konto lässt sich nicht löschen; deshalb bleibt immer mindestens ein Administrator übrig.</p>
|
||||
<form action="{{ path('admin_user_delete', {id: account.id}) }}" method="post">
|
||||
<form action="{{ path('admin_user_delete', {id: account.id}) }}" method="post" data-confirm-title="Konto „{{ account.username }}“ löschen?" data-confirm-text="Die Person kann sich danach nicht mehr anmelden. Seiten und Fassungen bleiben." data-confirm-label="Konto löschen" data-confirm-variant="danger">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('users') }}">
|
||||
<button class="subtle danger-text" type="submit">Konto löschen</button>
|
||||
</form>
|
||||
|
||||
@@ -95,7 +95,7 @@ final class ArchiveUpdateTest extends TestCase
|
||||
self::assertFileExists($this->root.'/var/cache/prod/running-service.php', 'The update request still loads compiled services after replacing files.');
|
||||
self::assertTrue($ergebnis['migrate'] ?? false, 'Nach dem Ersetzen fehlen noch die Migrationen.');
|
||||
self::assertSame(
|
||||
['Fassung ermitteln', 'Sicherung', 'Archiv laden', 'Prüfsumme', 'Entpacken', 'Wartungszustand', 'Dateien ersetzen', 'Zwischenspeicher', 'Dateien sind ersetzt'],
|
||||
['Fassung ermitteln', 'Archiv laden', 'Prüfsumme', 'Entpacken', 'Wartungszustand', 'Sicherung', 'Dateien ersetzen', 'Zwischenspeicher', 'Dateien sind ersetzt'],
|
||||
array_column($ergebnis['steps'], 'step'),
|
||||
'Die Reihenfolge ist der Punkt: gesichert wird vor dem ersten Handgriff, geprüft vor dem Entpacken.',
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ use App\Content\PostRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Content\Theme;
|
||||
use App\Domain\InvalidDocument;
|
||||
use App\Domain\StaleWrite;
|
||||
use App\Infrastructure\ConnectionFactory;
|
||||
use App\Kernel;
|
||||
use App\Ops\ArchiveUpdate;
|
||||
@@ -163,6 +164,65 @@ final class BacklogTest extends TestCase
|
||||
self::assertStringContainsString('Dieses Feld wird benötigt.', $antwort->getContent());
|
||||
}
|
||||
|
||||
public function testAPageWhoseStoredCssNoLongerPassesStaysReachableAndEditable(): void
|
||||
{
|
||||
// Gespeichert wurde, was beim Speichern galt. Ist eine Regel inzwischen strenger, fällt das
|
||||
// CSS dieses einen Blocks weg (ADR 116) - die Seite antwortet nicht mit 500 und lässt sich
|
||||
// zum Reparieren öffnen. Vorher warf schon das Lesen.
|
||||
$document = DemoContent::starter('Altbestand');
|
||||
$document['blocks'][] = ['id' => 'alt-text', 'type' => 'text', 'props' => ['text' => 'Bleibt lesbar'], 'css' => 'color: red;'];
|
||||
$this->publishHome($document);
|
||||
$alt = '& ~ p { color: red }';
|
||||
try {
|
||||
(new DocumentValidator())->validate([...$document, 'schemaVersion' => 23, 'blocks' => [...array_slice($document['blocks'], 0, -1), ['id' => 'alt-text', 'type' => 'text', 'props' => ['text' => 'Bleibt lesbar'], 'css' => $alt]]], writing: true);
|
||||
self::fail('Dieses CSS muss beim Speichern abgelehnt werden, sonst prüft der Test nichts.');
|
||||
} catch (InvalidDocument) {
|
||||
}
|
||||
foreach ($this->db->fetchAllAssociative('SELECT id, document FROM revision WHERE page_id = ?', ['page-1']) as $fassung) {
|
||||
$this->db->update('revision', ['document' => str_replace('color: red;', $alt, $fassung['document'])], ['id' => $fassung['id']]);
|
||||
}
|
||||
|
||||
$antwort = $this->request('/');
|
||||
self::assertSame(200, $antwort->getStatusCode());
|
||||
self::assertStringContainsString('Bleibt lesbar', $antwort->getContent());
|
||||
self::assertStringNotContainsString('~ p', $antwort->getContent(), 'Das abgelehnte CSS wird nicht ausgegeben.');
|
||||
$entwurf = $this->pages->findForEditor('page-1');
|
||||
self::assertSame($alt, end($entwurf['document']['blocks'])['css'], 'Im Editor steht es noch da und lässt sich korrigieren.');
|
||||
|
||||
// Speichern verlangt weiter gültiges CSS.
|
||||
$this->expectException(InvalidDocument::class);
|
||||
$this->pages->saveDraft('page-1', $entwurf['document'], $entwurf['version']);
|
||||
}
|
||||
|
||||
public function testSettingsAndThemeRefuseAWriteFromAnOutdatedForm(): void
|
||||
{
|
||||
// Zwei Admins haben dasselbe Formular offen. Der zweite überschrieb bisher still, was der
|
||||
// erste gespeichert hatte. Jetzt trägt das Formular eine Kennung des geladenen Stands.
|
||||
$werte = ['legalName' => 'Erste GmbH'];
|
||||
$geladen = $this->site->stamp();
|
||||
$this->site->save($werte, $geladen);
|
||||
self::assertNotSame($geladen, $this->site->stamp());
|
||||
try {
|
||||
$this->site->save([...$werte, 'legalName' => 'Zweite GmbH'], $geladen);
|
||||
self::fail('Der veraltete Stand muss abgelehnt werden.');
|
||||
} catch (StaleWrite) {
|
||||
}
|
||||
self::assertSame('Erste GmbH', $this->site->load()['legalName']);
|
||||
$this->site->save([...$werte, 'legalName' => 'Zweite GmbH'], $this->site->stamp());
|
||||
self::assertSame('Zweite GmbH', $this->site->load()['legalName']);
|
||||
|
||||
$theme = new Theme($this->db);
|
||||
$stand = $theme->load();
|
||||
$geladen = $theme->stamp();
|
||||
$theme->save($stand['colors'], $stand['fonts'], [...$stand['base'], 'linkUnderline' => null], null, $geladen);
|
||||
try {
|
||||
$theme->save($stand['colors'], $stand['fonts'], $stand['base'], null, $geladen);
|
||||
self::fail('Der veraltete Stand muss abgelehnt werden.');
|
||||
} catch (StaleWrite) {
|
||||
}
|
||||
self::assertFalse($theme->load()['base']['linkUnderline'], 'Die erste Änderung bleibt.');
|
||||
}
|
||||
|
||||
public function testAFormInTheFooterCanBeSentFromAnArchive(): void
|
||||
{
|
||||
(new FormRepository($this->db))->save('kontakt', 'Kontakt', [
|
||||
|
||||
@@ -196,6 +196,30 @@ final class BackupTest extends TestCase
|
||||
self::assertSame([$names[1], $names[0]], $removed);
|
||||
}
|
||||
|
||||
public function testADamagedBackupNeitherCountsAsIntactNorPushesOutACompleteOne(): void
|
||||
{
|
||||
$alt = $this->backup->create('Test', new \DateTimeImmutable('2026-09-10 10:00:00', new \DateTimeZone('UTC')));
|
||||
$neu = $this->backup->create('Test', new \DateTimeImmutable('2026-09-11 10:00:00', new \DateTimeZone('UTC')));
|
||||
// Fehlt eine der gezählten Mediendateien, ist die Sicherung nicht mehr unversehrt.
|
||||
unlink($neu['path'].'/medien/bild-480.webp');
|
||||
// Ein abgebrochener Lauf von früher: ein Ordner ohne Verzeichnis, der neueste von allen.
|
||||
$halb = $this->backup->directory().'/sicherung-20260912-100000';
|
||||
mkdir($halb);
|
||||
touch($halb, time() - 7200);
|
||||
// Und einer von eben - das kann eine Sicherung sein, die gerade geschrieben wird.
|
||||
$laufend = $this->backup->directory().'/sicherung-20260913-100000';
|
||||
mkdir($laufend);
|
||||
touch($neu['path'], time() - 7200);
|
||||
|
||||
$zustand = array_column($this->backup->all(), 'intact', 'name');
|
||||
self::assertSame([basename($laufend) => false, basename($halb) => false, $neu['name'] => false, $alt['name'] => true], $zustand);
|
||||
|
||||
// Vorher zählten die beschädigten als die neuesten, und die vollständige flog raus.
|
||||
$entfernt = $this->backup->rotate(1);
|
||||
self::assertEqualsCanonicalizing([basename($halb), $neu['name']], $entfernt);
|
||||
self::assertSame([basename($laufend), $alt['name']], array_column($this->backup->all(), 'name'));
|
||||
}
|
||||
|
||||
public function testTwoBackupsInTheSameSecondGetTwoDirectories(): void
|
||||
{
|
||||
// This is not academic: a restore takes a safety copy, and that lands in the same second
|
||||
|
||||
@@ -90,6 +90,16 @@ final class SitePackageTest extends TestCase
|
||||
self::assertFileDoesNotExist($this->sites['ziel']['root'].'/var/maintenance');
|
||||
}
|
||||
|
||||
public function testAnImportLeavesAMaintenanceStateThatWasAlreadyOn(): void
|
||||
{
|
||||
// Wer die Wartung vorher von Hand eingeschaltet hat, will sie nach dem Import noch haben.
|
||||
file_put_contents($this->sites['ziel']['root'].'/var/maintenance', "an\n");
|
||||
$result = $this->sites['ziel']['package']->import($this->sites['quelle']['package']->export());
|
||||
self::assertTrue($result['ok'], json_encode($result['steps'], JSON_UNESCAPED_UNICODE));
|
||||
self::assertFileExists($this->sites['ziel']['root'].'/var/maintenance');
|
||||
self::assertSame([str_repeat('a', 32).'-480.webp'], array_values(array_diff(scandir($this->sites['ziel']['root'].'/var/media'), ['.', '..'])));
|
||||
}
|
||||
|
||||
public function testAccountsComeAlongOnlyWhenChosenOnBothSides(): void
|
||||
{
|
||||
// Paket ohne Konten, Häkchen gesetzt: abgelehnt, nichts geändert.
|
||||
|
||||
Reference in new issue
Block a user