Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
436c66ecd3 | ||
|
|
a6a334fe68 | ||
|
|
339a48c810 | ||
|
|
f2b29afe22 | ||
|
|
47292c7266 | ||
|
|
8aee38bde8 | ||
|
|
91ebfde829 |
No files matched your search
@@ -63,6 +63,9 @@ services:
|
||||
arguments:
|
||||
$secret: '%kernel.secret%'
|
||||
$siteOrigin: '%env(SITE_ORIGIN)%'
|
||||
App\EventSubscriber\ErrorLogSubscriber:
|
||||
arguments:
|
||||
$debug: '%kernel.debug%'
|
||||
App\Controller\SeoController:
|
||||
arguments:
|
||||
$siteOrigin: '%env(SITE_ORIGIN)%'
|
||||
|
||||
@@ -2,6 +2,32 @@
|
||||
|
||||
Stand: 09.10.2026. Arbeitstitel KantSites; Produktname offen.
|
||||
|
||||
## ADR 113 – Kein leerer 500er mehr, Update mit einem Klick nach reiseSoft (09.10.2026)
|
||||
|
||||
Anlass: Auf kantarci-reisen.de wurde die Datenbank per FTP ersetzt, ohne vorher den Code zu aktualisieren – danach antwortete jede Adresse mit einer leeren 500-Seite, und nirgends stand, warum. Umut: „Ich meine, dass es nicht Fehler 500 einfach so gibt.“ Und zum Updater: suchen, dann einspielen, dann migrieren soll *ein* Vorgang sein, den man beim Aufruf der Systemseite sieht – „mach es wie bei reiseSoft“.
|
||||
|
||||
**Notfallschicht.** `src/Ops/Notfall.php` hängt an nichts außer PHP und wird in `public/index.php` vor dem Bootstrap geladen. Sie fängt Ausnahmen, die bis zum Front-Controller durchschlagen, und schwere Fehler (Shutdown-Handler: Parse-, Compile-, Speicherfehler), schreibt eine Zeile mit Zeit, Adresse, Fehler und bis zu drei Ursachen nach `var/log/notfall.log` (ab 1 MB nach `.1` rotiert) und gibt eine schlichte Wartungsseite mit 503 und `Retry-After: 300` aus. Damit ist die Datei per FTP lesbar, auch wenn Symfony gar nicht startet – das ist auf geteiltem Hosting oft der einzige Zugang. 503 statt 500, damit Suchmaschinen „gleich wieder da“ verstehen. Einzelheiten stehen nur im Protokoll, nie auf der Seite.
|
||||
|
||||
**Fehler innerhalb des Frameworks.** `ErrorLogSubscriber` hört auf `kernel.exception` mit Priorität −64: nach der Sicherheitsschicht (sonst wird „Zugriff verweigert“ zur Wartungsseite statt zur Anmeldung – so schlug `CmsTest` an), vor der Fehlerseite des Frameworks (−128). Er übergeht HTTP-Fehler unter 500 sowie Anmelde-, Zugriffs- und Abmeldeausnahmen, protokolliert den Rest und setzt außerhalb des Debugmodus dieselbe Wartungsseite. Im Debugmodus bleibt die ausführliche Seite.
|
||||
|
||||
**Update mit einem Klick.** Vorbild ist die Systemseite von reiseSoft: beim Öffnen fragt die Seite selbst nach einer neuen Fassung (`GET /admin/system/update/pruefen`) und zeigt Name, Datum und Hinweise des Release-Eintrags. Ein Knopf, ein Bestätigungsdialog, dann läuft alles durch: `POST …/update/starten` sichert, lädt und prüft das Archiv, schaltet um und endet mit reinem PHP (ADR 112); die Seite ruft danach `POST …/update/abschliessen` mit dem Einmalschlüssel auf – eine neue Anfrage mit dem neuen Code – und der migriert, prüft und schaltet die Wartung aus. Währenddessen fragt sie alle 1,5 s `GET …/update/status` ab und zeigt fünf Schritte: Daten sichern, Fassung laden und prüfen, Umschalten, Datenbank anpassen, Funktion prüfen. Der Fortschritt steht in `var/update/status.json` (Phase, Meldung, von/auf, `bei` = Schritt eines Fehlers; nach 30 Minuten ohne Änderung gilt ein Lauf als abgebrochen).
|
||||
|
||||
**Automatisch zurück.** Die Prüfung ist eine Unteranfrage auf `/` mit dem neuen Code; erwartet wird ein Status unter 400, wenn eine Startseite veröffentlicht ist. Scheitern Migration oder Prüfung, kommt die alte Fassung zurück: Code aus `var/update/<lauf>/alt`, überschriebene Dateien in `public/` aus `alt-public` (neu hinzugekommene werden entfernt, Liste in `public.json`), Datenbank aus der Sicherung des Laufs (`Backup::restore`). Erst danach geht die Wartungsseite aus; der Status heißt `zurueckgerollt`, die Seite markiert den gescheiterten Schritt.
|
||||
|
||||
**Folgen.** Die Endpunkte verlangen die Administratorrolle, schreibende zusätzlich CSRF (Kopfzeile `X-CSRF-Token`) bzw. den Einmalschlüssel. Ohne JavaScript bleiben die bisherigen Formulare unter „Weitere Möglichkeiten“. Wie ADR 112 wirkt der neue Ablauf erst bei Updates, die *von* einer Fassung mit diesem Code ausgehen; die Notfallschicht wirkt sofort nach dem Einspielen. Geprüft Ende zu Ende mit einer Installation aus dem Releasearchiv: Update 1.27.1 → Testfassung in etwa 8 s mit einem Klick; eine absichtlich kaputte Testfassung wurde vollständig zurückgeschaltet (VERSION, Vorlagen, Datenbank, Wartung aus, Startseite 200).
|
||||
|
||||
## ADR 112 – Das Archivupdate läuft in einem Zug durch (09.10.2026)
|
||||
|
||||
Umut nach dem Update von kantarci-reisen.de auf 1.27.0: „Update durchgelaufen, direkt danach kam 500, dann bin ich zurück und hab migrieren können.“ Und: „Können wir den Updater nicht besser machen, dass das alles automatisch abläuft, einfach wie bei WordPress?“
|
||||
|
||||
**Befund.** Lokal mit dem Releasearchiv 1.26.0 und der Reisen-Datenbank vor der Migration nachgestellt: Alle Schritte bis „Dateien sind ersetzt“ laufen sauber, dann stirbt dieselbe Anfrage mit 500. Der PHP-Prozess hat den alten Code geladen, auf der Platte liegt der neue; was danach noch nachgeladen wird (Antwort-Listener, Sitzung, Vorlagen), passt nicht mehr zum Geladenen. ADR 071 hatte das gesehen und die Migrationen deshalb in eine zweite Anfrage gelegt – aber die erste Anfrage lief nach dem Tausch trotzdem durch das ganze Framework weiter.
|
||||
|
||||
**Entscheidung.** Die Anfrage, die tauscht, gibt danach nur noch eine schlichte Seite mit reinem PHP aus (`SystemController::sendAndStop`: Kopfzeilen, Text, `exit`) – kein Framework-Code mehr. Die Seite ruft per `<meta http-equiv="refresh">` sofort `GET /admin/system/abschliessen?token=…` auf. Das ist eine neue Anfrage mit dem neuen Code: Migrationen, Aufräumen, Wartungszustand aus, Weiterleitung zur Systemseite mit „Update auf x.y.z abgeschlossen“. Ein Klick, keine Zwischenseite zum Bedienen, kein 500er.
|
||||
|
||||
**Sicherheit.** Der Einmalschlüssel (32 Hexzeichen) steht in `var/update/offen.json` neben Fassung und Zeitpunkt; der Abschluss verlangt ihn und die Administratorrolle (`hash_equals`). Er ersetzt das CSRF-Token, das die Zwischenseite nicht mehr erzeugen kann. Neu laden findet nichts mehr offen und meldet „bereits abgeschlossen“. Der bisherige Knopf „Migrationen ausführen“ bleibt als Rückweg, falls die Weiterleitung unterdrückt wird; daneben steht ein Link.
|
||||
|
||||
**Folge.** Wirkt ab Updates, die von einer Fassung mit diesem Code ausgehen: von 1.27.0 auf 1.27.1 läuft noch der alte Weg (einmal 500, dann „Migrationen ausführen“), ab 1.27.1 der neue. Geprüft mit einer echten Installation aus dem Archiv, die auf ein lokal gebautes 1.27.1 aktualisiert (Downloader für die Probe auf lokale Dateien umgestellt): Zwischenseite mit 200, Abschluss 302, Fassung 1.27.1, Wartungszustand aus.
|
||||
|
||||
## ADR 111 – Mehrsprachigkeit als Übersetzungsebene, nicht als zweite Seite (Dokumentschema v27, 09.10.2026)
|
||||
|
||||
Umut: „Ich füge weitere Sprache hinzu und man wählt aus und muss dann zu jedem Feld die 2. Sprache hinzufügen. Ich möchte nicht für die 2. Sprache die komplette Seite neu bauen.“ Danach: „`/en/`-Präfix und übersetzte Slugs.“ Das Datenmodell hatte seit ADR 003 eine Spalte `locale` an jeder Seite – also das WPML-Prinzip „eine Sprache = eine eigene Seite“. Genau das wollte Umut nicht.
|
||||
|
||||
@@ -1,5 +1,47 @@
|
||||
# Projektstatus
|
||||
|
||||
## Notfallseite, Fehlerprotokoll und Update mit einem Klick (09.10.2026, noch nicht veröffentlicht)
|
||||
|
||||
**Anlass:** kantarci-reisen.de zeigt nach einer per FTP ersetzten Datenbank auf jeder Adresse eine leere 500-Seite. Umut: „Fang mit 1 und 2 an und verbessere den Update-Prozess … mach es wie bei reiseSoft.“
|
||||
|
||||
**Erledigt (ADR 113):** (1) Wartungsseite mit 503 statt leerem 500er, auch wenn Symfony nicht startet; (2) Fehlerprotokoll `var/log/notfall.log`, per FTP lesbar; (3) Update-Karte auf der Systemseite: sucht beim Öffnen selbst, zeigt die Release-Hinweise, ein Klick mit Bestätigung, Fortschritt in fünf Schritten live, Migration im selben Vorgang, bei Fehler automatisch zurück auf die alte Fassung samt Datenbank.
|
||||
|
||||
**Geprüft:** PHPUnit vollständig 643 grün (neu: `NotfallTest`, Fortschrittsdatei und vollständiges Zurückschalten in `ArchiveUpdateTest`, Endpunkte nur für Administratoren in `SystemScreenTest`), php-cs-fixer. Im Browser mit einer Installation aus dem Releasearchiv 1.27.1: erfolgreiches Update in einem Klick, Zurückschalten bei einer kaputten Testfassung.
|
||||
|
||||
**Bekannt:** Der neue Ablauf greift erst bei Updates, die von der nächsten Fassung ausgehen. kantarci-reisen.de ist noch offline (Ursache unbekannt, Datenbank lokal mit 1.27.0 lauffähig).
|
||||
|
||||
**Nächster Schritt:** Release, dann kantarci-reisen.de wiederherstellen (Notfallschicht hochladen → Protokoll lesen, oder Sicherung zurück) und aktualisieren.
|
||||
|
||||
## Fassung 1.27.1: Update in einem Zug, Kantarci Reisen auf Türkisch (09.10.2026)
|
||||
|
||||
**Anlass:** Umut: Update auf kantarci-reisen.de gab direkt danach einen 500er, nach „zurück“ ließ sich migrieren. „Können wir den Updater besser machen, dass alles automatisch abläuft, wie bei WordPress?“ Und: „Die DB hier komplett mit Übersetzung auf Türkisch fertig machen.“
|
||||
|
||||
**Updater (ADR 112):** Ein Klick spielt ein, die Anfrage endet nach dem Dateitausch mit einer schlichten Seite, die den Abschluss (Migrationen, Wartung aus) selbst aufruft – kein 500er, kein zweiter Knopf. Wirkt ab Updates, die von 1.27.1 ausgehen; 1.27.0 → 1.27.1 läuft noch den alten Weg.
|
||||
|
||||
**Türkisch im Kern vervollständigt:** feste Vorlagentexte und Formularmeldungen komplett auf Türkisch (vorher Teile auf Englisch). Zwei Sprachwahlen auf einer Seite heißen jetzt verschieden („Sprache“ im Kopf, „Sprache wählen“ sonst) – axe meldete sonst `landmark-unique`.
|
||||
|
||||
**Kantarci Reisen (nur Inhalt):** `scripts/translate-kantarci-reisen.php` schaltet Türkisch ein und übersetzt Startseite (67 Texte), Künye (`/tr/kunye`), Gizlilik (`/tr/gizlilik`), 404, Kopf, Fuß, Menü und das Anfrageformular; Anrede „siz“, Rechtstexte mit dem Hinweis, dass die deutsche Fassung gilt. Sprachwahl DE · TR im Kopf (auf dem Telefon ausgeblendet, kein Platz) und „Deutsch · Türkçe“ im Fuß. `reset-kantarci-reisen.sh` ruft das Skript am Ende auf. Ausgeführt auf `var/kantarci-reisen.db` (Sicherung `var/backups/vor-1.27.0/kantarci-reisen-vor-tr.db`).
|
||||
|
||||
**Korrigiert:** Die DM-Sans-600-Dateien von heute Nachmittag waren doch nötig – `FontLibrary::missing()` verlangt je Stärke eine Datei, sonst gibt die Website ihre eigenen Schriften ganz auf. Neu geholt (`cms:fonts:fetch`) und diesmal mit `fonts.css` committet. Live auf kantarci-reisen.de waren sie nie weg.
|
||||
|
||||
**Geprüft:** PHPUnit vollständig 637 grün (neu: Abschluss per Einmalschlüssel, Rolle, falscher Schlüssel, Neuladen), `npm test`, `lint:twig`, php-cs-fixer; Update-Ende-zu-Ende in einer Archivinstallation; `test:browser` auf Reisen (8104) grün; Lighthouse Produktion (`reports/lighthouse-kantarci-reisen-tr`): `/tr`, `/tr/gizlilik`, `/` überall 100, nur Leistung mobil 99 auf `/tr` und `/`. Telefonbreite ohne waagrechtes Scrollen.
|
||||
|
||||
**Veröffentlicht:** Tag `v1.27.1`, Release-Eintrag mit Archiv (3607 Dateien, 5,3 MB, gebaut aus `var/release-source-1.27.1`), `.sha256` und `install.php`; Prüfsumme nach dem Herunterladen bestätigt. Die lokalen Installationen laufen aus diesem Repository und haben 1.27.1 (keine Migration).
|
||||
|
||||
**Für den Livegang der türkischen Fassung:** erst kantarci-reisen.de auf 1.27.1 aktualisieren (sonst fehlen Teile der festen türkischen Texte), dann `var/kantarci-reisen.db` hochladen. Achtung: die hochgeladene Datei ersetzt die Datenbank dort – Einsendungen und Änderungen, die es nur live gibt, gehen dabei verloren.
|
||||
|
||||
## Fassung 1.27.0: Mehrsprachigkeit und übersichtlichere Formulare (09.10.2026)
|
||||
|
||||
**Inhalt:** Mehrsprachigkeit in drei Stufen (ADR 111, Schema v27, Migration `20261009000100`) und die neue Formularverwaltung – Einzelheiten in den drei Einträgen darunter.
|
||||
|
||||
**Geprüft vor dem Release:** PHPUnit vollständig auf dem Release-Stand 636 grün, `npm test` 41, `lint:twig`, php-cs-fixer, `test:admin --admin-only` und `test:editor` (mit axe) grün. Lighthouse im Produktionsmodus auf der Hüfner-Testkopie mit Englisch (`reports/lighthouse-sprachen`): `/en` mobil 97/100/100/100/100, Desktop 99/100/100/100/100; `/` mobil 96, Desktop 99, sonst 100.
|
||||
|
||||
**Veröffentlicht:** Tag `v1.27.0`, Release-Eintrag auf git.kant-it.de mit Archiv (3605 Dateien, 5,2 MB, gebaut aus `var/release-source-1.27.0`), `.sha256` und `install.php`; Prüfsumme nach dem Herunterladen bestätigt.
|
||||
|
||||
**Aufgeräumt:** Zwei nicht eingebundene DM-Sans-600-Dateien aus der Reisen-Installation gelöscht (die variable 400er-Datei deckt 400–700 ab), die nur umsortierte `fonts.css` auf den committeten Stand zurückgesetzt.
|
||||
|
||||
**Nächster Schritt:** Installationen auf 1.27.0 aktualisieren (Migration läuft über den Updater mit). Wer eine zweite Sprache will: Einstellungen → Sprachen, Sprachwahl in den Kopfbereich.
|
||||
|
||||
## Formularverwaltung übersichtlicher (09.10.2026)
|
||||
|
||||
**Anlass:** Umut: Formulare „sehr unübersichtlich … kompakter, nicht so wuchtig und so voll“; bei mehreren Formularen erst eine Übersicht ohne Vorschau, dann in die Seite rein.
|
||||
|
||||
@@ -1049,3 +1049,34 @@ details.form-card > summary.form-card-summary::before, details.form-card[open] >
|
||||
.language-texts h3 { margin: 0 0 6px; font-size: 15px }
|
||||
.language-text-group { display: grid; gap: 6px; border: 1px solid #dfe5e1; border-radius: 8px; padding: 12px 16px; margin: 12px 0; min-width: 0 }
|
||||
.language-text-group legend { font-weight: 600; padding: 0 4px }
|
||||
/* ---- Update mit einem Klick (ADR 113), nach dem Vorbild von reiseSoft --------------------- */
|
||||
.update-wait, .update-aktuell { display: flex; align-items: center; gap: 10px; margin: 0 0 12px }
|
||||
.update-spinner { width: 16px; height: 16px; flex: none; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: update-dreh .8s linear infinite }
|
||||
@keyframes update-dreh { to { transform: rotate(360deg) } }
|
||||
@media (prefers-reduced-motion: reduce) { .update-spinner { animation-duration: 2.4s } }
|
||||
.update-neu { border: 1px solid #b9d7c2; background: #f1f8f2; border-radius: 10px; padding: 16px 18px; margin: 0 0 14px; display: grid; gap: 6px; justify-items: start }
|
||||
.update-neu h3 { margin: 0; font-size: 17px }
|
||||
.update-neu .muted { margin: 0 }
|
||||
.update-neu-label { margin: 0; font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: #1f6b45 }
|
||||
.update-notizen { max-height: 220px; overflow: auto; width: 100%; font-size: 13px; line-height: 1.55; color: #33413a; background: #fff; border: 1px solid #dfe5e1; border-radius: 8px; padding: 10px 12px; margin: 6px 0 10px }
|
||||
.update-lauf { background: #1f2d27; color: #e9efe9; border-radius: 12px; padding: 18px 20px; margin: 0 0 14px; display: grid; gap: 12px; justify-items: start }
|
||||
.update-lauf-kopf { display: flex; align-items: center; gap: 10px; margin: 0; font-weight: 650 }
|
||||
.update-schritte { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; width: 100% }
|
||||
.update-schritt { display: flex; align-items: center; gap: 10px; font-size: 14px }
|
||||
.update-marke { display: grid; place-items: center; width: 22px; height: 22px; flex: none; border-radius: 50%; border: 2px solid #5e6f66; font-size: 12px; font-weight: 700 }
|
||||
.update-schritt.is-erledigt .update-marke { background: #3fa46a; border-color: #3fa46a; color: #fff }
|
||||
.update-schritt.is-aktiv .update-marke { border-color: #f2c14e; box-shadow: 0 0 0 4px #f2c14e33; animation: update-puls 1.2s ease-in-out infinite }
|
||||
.update-schritt.is-offen { color: #8f9e96 }
|
||||
.update-schritt.is-fehler .update-marke { background: #d4553f; border-color: #d4553f; color: #fff }
|
||||
@keyframes update-puls { 50% { box-shadow: 0 0 0 8px #f2c14e11 } }
|
||||
@media (prefers-reduced-motion: reduce) { .update-schritt.is-aktiv .update-marke { animation: none } }
|
||||
.update-meldung { margin: 0; font-size: 13px; color: #c9d4cd }
|
||||
.update-meldung.is-ok { color: #9fdcb5 }
|
||||
.update-meldung.is-fehler { color: #ffb4a6 }
|
||||
.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 }
|
||||
Binary file not shown.
Binary file not shown.
@@ -15,10 +15,6 @@
|
||||
/* latin */
|
||||
@font-face{font-family:'Cormorant Garamond';font-style:normal;font-weight:700;font-display:swap;src:url(/assets/fonts/cormorant-garamond-700-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||
/* latin-ext */
|
||||
@font-face{font-family:'DM Sans';font-style:normal;font-weight:400 700;font-display:swap;src:url(/assets/fonts/dm-sans-400-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF}
|
||||
/* latin */
|
||||
@font-face{font-family:'DM Sans';font-style:normal;font-weight:400 700;font-display:swap;src:url(/assets/fonts/dm-sans-400-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||
/* latin-ext */
|
||||
@font-face{font-family:'Barlow';font-style:normal;font-weight:400;font-display:swap;src:url(/assets/fonts/barlow-400-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF}
|
||||
/* latin */
|
||||
@font-face{font-family:'Barlow';font-style:normal;font-weight:400;font-display:swap;src:url(/assets/fonts/barlow-400-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||
@@ -38,3 +34,7 @@
|
||||
@font-face{font-family:'Barlow Condensed';font-style:normal;font-weight:800;font-display:swap;src:url(/assets/fonts/barlow-condensed-800-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF}
|
||||
/* latin */
|
||||
@font-face{font-family:'Barlow Condensed';font-style:normal;font-weight:800;font-display:swap;src:url(/assets/fonts/barlow-condensed-800-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||
/* latin-ext */
|
||||
@font-face{font-family:'DM Sans';font-style:normal;font-weight:400 700;font-display:swap;src:url(/assets/fonts/dm-sans-400-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF}
|
||||
/* latin */
|
||||
@font-face{font-family:'DM Sans';font-style:normal;font-weight:400 700;font-display:swap;src:url(/assets/fonts/dm-sans-400-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||
@@ -0,0 +1,190 @@
|
||||
/*
|
||||
* Update mit einem Klick (ADR 113), nach dem Vorbild von reiseSoft.
|
||||
*
|
||||
* Beim Öffnen der Systemseite fragt sie selbst, ob es eine neue Fassung gibt, und zeigt deren
|
||||
* Hinweise und einen Knopf. Nach der Bestätigung startet eine Anfrage das Update (Sichern, Laden,
|
||||
* Umschalten); diese Seite fragt währenddessen alle anderthalb Sekunden den Fortschritt ab. Ist
|
||||
* der Tausch durch, ruft sie den Abschluss auf - eine neue Anfrage mit dem neuen Code:
|
||||
* Datenbank anpassen, prüfen, Wartungsseite aus. Schlägt etwas fehl, schaltet der Server zurück
|
||||
* und die Seite sagt es.
|
||||
*
|
||||
* Ohne Skript bleiben die Formulare unter „Weitere Möglichkeiten“.
|
||||
*/
|
||||
const karte = document.querySelector('[data-update]');
|
||||
if (karte) {
|
||||
const live = karte.querySelector('[data-update-live]');
|
||||
const dialog = document.getElementById('update-dialog');
|
||||
const csrf = karte.dataset.csrf;
|
||||
const SCHRITTE = [
|
||||
['sichern', 'Daten sichern'],
|
||||
['laden', 'Fassung laden und prüfen'],
|
||||
['umschalten', 'Umschalten'],
|
||||
['migrieren', 'Datenbank anpassen'],
|
||||
['pruefen', 'Funktion prüfen'],
|
||||
];
|
||||
let ziel = null, timer = null, laeuft = false;
|
||||
|
||||
const el = (tag, className, text) => {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
};
|
||||
const datum = iso => iso ? new Date(iso + 'T00:00:00').toLocaleDateString('de-DE', { day: 'numeric', month: 'long', year: 'numeric' }) : '';
|
||||
const abfragen = async url => {
|
||||
const antwort = await fetch(url, { credentials: 'same-origin', cache: 'no-store', headers: { Accept: 'application/json' } });
|
||||
if (!antwort.ok) throw new Error(String(antwort.status));
|
||||
return antwort.json();
|
||||
};
|
||||
const senden = async (url, daten) => {
|
||||
const antwort = await fetch(url, {
|
||||
method: 'POST', credentials: 'same-origin', cache: 'no-store',
|
||||
headers: { 'X-CSRF-Token': csrf, Accept: 'application/json' },
|
||||
body: new URLSearchParams(daten),
|
||||
});
|
||||
const text = await antwort.text();
|
||||
try { return JSON.parse(text); } catch { throw new Error(`Antwort ${antwort.status}`); }
|
||||
};
|
||||
|
||||
// Die Hinweise der Releaseseite sind Markdown. Gezeigt wird Text mit Zeilen und **fett** -
|
||||
// als Textknoten, nie als HTML: was dort steht, kommt von außen.
|
||||
function notizen(text) {
|
||||
const box = el('div', 'update-notizen');
|
||||
text.split('\n').forEach((zeile, index) => {
|
||||
if (index) box.append(document.createElement('br'));
|
||||
zeile.replace(/^#+\s*/, '').split(/(\*\*[^*]+\*\*)/).forEach(teil => {
|
||||
box.append(teil.startsWith('**') && teil.endsWith('**') && teil.length > 4 ? el('strong', '', teil.slice(2, -2)) : document.createTextNode(teil));
|
||||
});
|
||||
});
|
||||
return box;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Ansicht: Prüfen
|
||||
function warten(text) {
|
||||
live.hidden = false;
|
||||
live.replaceChildren(el('p', 'update-wait', text));
|
||||
live.firstChild.prepend(el('span', 'update-spinner'));
|
||||
}
|
||||
|
||||
function zeigeErgebnis(daten) {
|
||||
live.hidden = false;
|
||||
live.replaceChildren();
|
||||
const letzter = daten.status;
|
||||
if (letzter && !letzter.laeuft && ['fertig', 'fehler', 'zurueckgerollt'].includes(letzter.phase) && letzter.auf) {
|
||||
const box = el('p', `notice ${letzter.phase === 'fertig' ? 'done' : 'error'} update-letztes`);
|
||||
box.append(el('strong', '', letzter.phase === 'fertig' ? `Letztes Update auf ${letzter.auf} erfolgreich.` : `Letztes Update auf ${letzter.auf} nicht durchgelaufen.`));
|
||||
if (letzter.phase !== 'fertig') box.append(' ', letzter.meldung || '');
|
||||
live.append(box);
|
||||
}
|
||||
if (daten.fehler) {
|
||||
const zeile = el('p', 'notice error', daten.fehler + ' ');
|
||||
const nochmal = el('button', 'link-button', 'Erneut fragen'); nochmal.type = 'button';
|
||||
nochmal.addEventListener('click', pruefen);
|
||||
zeile.append(nochmal); live.append(zeile);
|
||||
return;
|
||||
}
|
||||
if (!daten.neu) {
|
||||
live.append(el('p', 'update-aktuell', `Du bist auf dem neuesten Stand (${daten.installiert}).`));
|
||||
return;
|
||||
}
|
||||
ziel = daten.neu.version;
|
||||
const box = el('div', 'update-neu');
|
||||
box.append(el('p', 'update-neu-label', 'Neue Fassung verfügbar'));
|
||||
box.append(el('h3', '', daten.neu.name || `KantSites ${ziel}`));
|
||||
box.append(el('p', 'muted', [ziel, daten.neu.datum ? `veröffentlicht am ${datum(daten.neu.datum)}` : ''].filter(Boolean).join(' · ')));
|
||||
if (daten.neu.notizen) box.append(notizen(daten.neu.notizen));
|
||||
const knopf = el('button', 'primary', 'Jetzt aktualisieren'); knopf.type = 'button';
|
||||
knopf.addEventListener('click', () => { dialog.querySelector('[data-update-ziel]').textContent = ziel; dialog.showModal(); });
|
||||
box.append(knopf);
|
||||
live.append(box);
|
||||
}
|
||||
|
||||
async function pruefen() {
|
||||
warten('Frage nach neuen Fassungen …');
|
||||
try {
|
||||
const daten = await abfragen(karte.dataset.pruefen);
|
||||
if (daten.status?.laeuft) { zeigeLauf(daten.status); verfolgen(); return; }
|
||||
zeigeErgebnis(daten);
|
||||
} catch {
|
||||
zeigeErgebnis({ fehler: 'Die Abfrage ist fehlgeschlagen.' });
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Ansicht: Lauf
|
||||
function zeigeLauf(status, server = true) {
|
||||
live.hidden = false;
|
||||
const box = el('div', 'update-lauf');
|
||||
const kopf = el('p', 'update-lauf-kopf');
|
||||
const ende = ['fertig', 'fehler', 'zurueckgerollt'].includes(status.phase);
|
||||
if (!ende) kopf.append(el('span', 'update-spinner'));
|
||||
kopf.append(ende
|
||||
? (status.phase === 'fertig' ? `Update auf ${status.auf} abgeschlossen` : `Update auf ${status.auf} nicht durchgelaufen`)
|
||||
: `Update auf ${status.auf || ziel || ''} läuft`);
|
||||
box.append(kopf);
|
||||
// Bei einem Fehler oder beim Zurückschalten markiert die Liste den Schritt, in dem es passierte.
|
||||
const gescheitert = ['fehler', 'zurueckgerollt', 'zurueckschalten'].includes(status.phase);
|
||||
const aktuell = gescheitert
|
||||
? Math.max(0, SCHRITTE.findIndex(([phase]) => phase === status.bei))
|
||||
: SCHRITTE.findIndex(([phase]) => phase === status.phase);
|
||||
const liste = el('ol', 'update-schritte');
|
||||
SCHRITTE.forEach(([phase, text], index) => {
|
||||
let zustand = index < aktuell || status.phase === 'fertig' ? 'erledigt' : index === aktuell && !ende ? 'aktiv' : 'offen';
|
||||
if (gescheitert && index === aktuell) zustand = 'fehler';
|
||||
const punkt = el('li', `update-schritt is-${zustand}`);
|
||||
punkt.append(el('span', 'update-marke', zustand === 'erledigt' ? '✓' : zustand === 'fehler' ? '!' : ''), el('span', '', text));
|
||||
punkt.querySelector('.update-marke').setAttribute('aria-hidden', 'true');
|
||||
punkt.append(el('span', 'visually-hidden', { erledigt: ' – erledigt', aktiv: ' – läuft', offen: '', fehler: ' – fehlgeschlagen' }[zustand]));
|
||||
liste.append(punkt);
|
||||
});
|
||||
box.append(liste);
|
||||
const meldung = el('p', `update-meldung${status.phase === 'fertig' ? ' is-ok' : ende ? ' is-fehler' : ''}`,
|
||||
server ? (status.meldung || '') : 'Der Server tauscht gerade die Dateien – die Seite meldet sich gleich wieder.');
|
||||
box.append(meldung);
|
||||
if (ende) {
|
||||
const weiter = el('button', 'secondary', status.phase === 'fertig' ? 'Seite neu laden' : 'Zur Übersicht'); weiter.type = 'button';
|
||||
weiter.addEventListener('click', () => location.reload());
|
||||
box.append(weiter);
|
||||
}
|
||||
live.replaceChildren(box);
|
||||
}
|
||||
|
||||
async function tick() {
|
||||
try {
|
||||
const daten = await abfragen(karte.dataset.status);
|
||||
if (daten.status) zeigeLauf(daten.status);
|
||||
if (daten.installiert) karte.querySelector('[data-update-installiert]').textContent = daten.installiert;
|
||||
if (daten.status && !daten.status.laeuft && !laeuft) stoppen();
|
||||
} catch {
|
||||
// Während des Tauschs kann eine Abfrage scheitern; die nächste klappt wieder.
|
||||
const letzter = live.querySelector('.update-meldung');
|
||||
if (letzter) letzter.textContent = 'Der Server tauscht gerade die Dateien – die Seite meldet sich gleich wieder.';
|
||||
}
|
||||
}
|
||||
function verfolgen() { if (!timer) timer = setInterval(tick, 1500); }
|
||||
function stoppen() { clearInterval(timer); timer = null; }
|
||||
|
||||
async function starten() {
|
||||
dialog.close();
|
||||
laeuft = true;
|
||||
zeigeLauf({ phase: 'sichern', auf: ziel, meldung: 'Das Update beginnt …', laeuft: true });
|
||||
verfolgen();
|
||||
try {
|
||||
const start = await senden(karte.dataset.starten, { version: ziel });
|
||||
if (!start.ok) throw Object.assign(new Error(start.meldung || 'Das Update ist nicht durchgelaufen.'), { status: start.status });
|
||||
const ende = await senden(karte.dataset.abschliessen, { token: start.token });
|
||||
laeuft = false;
|
||||
await tick();
|
||||
stoppen();
|
||||
if (ende.ok) setTimeout(() => location.reload(), 2500);
|
||||
} catch (fehler) {
|
||||
laeuft = false;
|
||||
await tick();
|
||||
stoppen();
|
||||
if (!live.querySelector('.update-meldung.is-fehler')) zeigeLauf(fehler.status || { phase: 'fehler', auf: ziel, meldung: fehler.message });
|
||||
}
|
||||
}
|
||||
|
||||
dialog.querySelector('[data-update-abbrechen]').addEventListener('click', () => dialog.close());
|
||||
dialog.querySelector('[data-update-los]').addEventListener('click', starten);
|
||||
pruefen();
|
||||
}
|
||||
+17
-6
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Kernel;
|
||||
use App\Ops\Notfall;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
// PHP nennt in jeder Antwort seine genaue Fassung („X-Powered-By: PHP/8.4.22"). Das nützt
|
||||
@@ -8,9 +9,19 @@ use Symfony\Component\HttpFoundation\Request;
|
||||
// Hosting laesst sich expose_php nicht umstellen, dieser Aufruf wirkt trotzdem.
|
||||
header_remove('X-Powered-By');
|
||||
|
||||
require dirname(__DIR__).'/config/bootstrap.php';
|
||||
$kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']);
|
||||
$request = Request::createFromGlobals();
|
||||
$response = $kernel->handle($request);
|
||||
$response->send();
|
||||
$kernel->terminate($request, $response);
|
||||
// Die Notfallschicht zuerst und ohne Autoloader (ADR 113): Scheitert alles Weitere - fehlende
|
||||
// Abhängigkeiten, eine unlesbare Datenbank, ein Fehler beim Start -, sehen Besucher eine
|
||||
// Wartungsseite statt einer weißen Seite, und der Grund steht in var/log/notfall.log.
|
||||
require_once dirname(__DIR__).'/src/Ops/Notfall.php';
|
||||
Notfall::einschalten(dirname(__DIR__));
|
||||
|
||||
try {
|
||||
require dirname(__DIR__).'/config/bootstrap.php';
|
||||
$kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']);
|
||||
$request = Request::createFromGlobals();
|
||||
$response = $kernel->handle($request);
|
||||
$response->send();
|
||||
$kernel->terminate($request, $response);
|
||||
} catch (Throwable $fehler) {
|
||||
Notfall::ausnahme($fehler, $_SERVER['REQUEST_URI'] ?? '');
|
||||
}
|
||||
@@ -25,3 +25,5 @@ if [ -f "$root/var/cacert.pem" ]; then
|
||||
else
|
||||
"$php" bin/console cms:fonts:fetch
|
||||
fi
|
||||
# Tuerkisch als zweite Sprache (ADR 111): Seiten, Kopf, Fuss, Menue, Formular, Sprachwahl.
|
||||
"$php" scripts/translate-kantarci-reisen.php "$root/var/kantarci-reisen.db"
|
||||
@@ -0,0 +1,381 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* Kantarci Reisen auf Türkisch (ADR 111).
|
||||
*
|
||||
* Schaltet Türkisch als weitere Sprache ein und trägt die Übersetzung jeder Seite, des Kopf- und
|
||||
* Fußbereichs, des Menüs und des Anfrageformulars ein; in den Kopfbereich kommt die Sprachwahl
|
||||
* DE · TR. Die Seiten werden mit ihrer Übersetzung veröffentlicht. Läuft beliebig oft: jeder Lauf
|
||||
* setzt die Übersetzungen auf den Stand dieser Datei.
|
||||
*
|
||||
* Aufruf: php scripts/translate-kantarci-reisen.php [Datenbankdatei]
|
||||
* Ohne Argument: var/kantarci-reisen.db. reset-kantarci-reisen.sh ruft es nach dem Aufbau auf.
|
||||
*
|
||||
* Anrede „siz“: im Türkischen für ein Reisebüro üblicher als das „du“ der deutschen Fassung.
|
||||
* Impressum und Datenschutz sagen, dass die deutsche Fassung maßgeblich ist.
|
||||
*/
|
||||
|
||||
require dirname(__DIR__).'/vendor/autoload.php';
|
||||
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Content\FormRepository;
|
||||
use App\Content\Languages;
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\PartialRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Content\Translations;
|
||||
use App\Infrastructure\ConnectionFactory;
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
$path = $argv[1] ?? $root.'/var/kantarci-reisen.db';
|
||||
if (!is_file($path)) {
|
||||
fwrite(STDERR, "Keine Datenbank unter {$path}.\n");
|
||||
exit(1);
|
||||
}
|
||||
$db = ConnectionFactory::create('sqlite:///'.str_replace('\\', '/', $path));
|
||||
$settings = new SiteSettings($db, new MediaLibrary($db, $root.'/var/kantarci-reisen-media'));
|
||||
$validator = new DocumentValidator();
|
||||
$pages = new PageRepository($db, $validator, $settings);
|
||||
$partials = new PartialRepository($db, $validator);
|
||||
$menus = new MenuRepository($db, $settings);
|
||||
$forms = new FormRepository($db);
|
||||
$languages = new Languages($db, $settings);
|
||||
|
||||
const SPRACHE = 'tr';
|
||||
$hinweisRecht = '<em>Bu çeviri bilgilendirme amaçlıdır; hukuken bağlayıcı olan Almanca metindir.</em>';
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Seiten: Adresse, Titel, Beschreibung, Suchtitel und die Texte je Block
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$seiten = [
|
||||
'home' => [
|
||||
'title' => 'Başka bir yer için zaman.',
|
||||
'description' => 'Kalbimizde Türkiye. Gözümüz dünyada. Baden-Baden’de kişisel seyahat danışmanlığı: Türkiye tatili, dünya genelinde oteller ve Türkiye uçuşları.',
|
||||
'seoTitle' => 'Kantarci Reisen – Türkiye tatili, oteller ve uçuşlar | Baden-Baden seyahat acentesi',
|
||||
'blocks' => [
|
||||
'hero-klein.text' => 'Kalbimizde Türkiye. Gözümüz dünyada.',
|
||||
'hero-titel.text' => 'Başka bir yer<br>için <em>zaman.</em>',
|
||||
'hero-satz.text' => 'Tende tuz. Yüzde güneş.<br>Ve size uygun bir seyahat.',
|
||||
'hero-knopf.text' => 'Tatilinizi planlayın',
|
||||
'hero-unten-links.text' => 'Kişisel danışmanlık.<br>Rahat seyahat.',
|
||||
'hero-unten-rechts.text' => 'Baden-Baden · Oos',
|
||||
'hero-foto.alt' => 'Güneşli bir plaj terasıyla turkuaz Akdeniz koyu',
|
||||
'hero-etikett.text' => 'Seyahat ilhamı',
|
||||
'hero-bildzeile.text' => '<strong>Yeni favori yeriniz</strong>Biraz daha fazla deniz.',
|
||||
'reisen-klein.text' => 'Sizi nereye çekiyor?',
|
||||
'reisen-titel.text' => 'Sizde gezme isteği var.<br>Bizde fikirler.',
|
||||
'reisen-satz.text' => 'Denizde bir mola, yeni bir favori yer ya da aile ziyareti. Size uygun seyahati birlikte buluyoruz.',
|
||||
'angebot-1-klein.text' => 'Uzmanlık alanımız',
|
||||
'angebot-1-titel.text' => 'Türkiye.<br>Tam size göre.',
|
||||
'angebot-1-satz.text' => 'Türk Rivierası’ndan Ege’ye. Misafirperverlik, sahil ve kültür arasında bir tatil keşfedin – ilk elden ipuçlarıyla.',
|
||||
'angebot-1-link.text' => '<a href="#kontakt">Türkiye tatili sorun</a>',
|
||||
'angebot-2-klein.text' => 'Dünya genelinde oteller',
|
||||
'angebot-2-titel.text' => 'Giriş yapın.<br>Kafanızı dinleyin.',
|
||||
'angebot-2-satz.text' => 'Küçük bir butik otel mi, geniş bir resort mu? Tatiliniz için doğru yeri bulmanıza yardımcı oluyoruz.',
|
||||
'angebot-2-link.text' => '<a href="#kontakt">Otel sorun</a>',
|
||||
'angebot-3-klein.text' => 'Türkiye uçuşları',
|
||||
'angebot-3-titel.text' => 'Bazen hedef<br>evdir.',
|
||||
'angebot-3-satz.text' => 'Tatil, aile ya da sadece özlem için. Size uygun uçuşu buluyoruz – Karlsruhe/Baden-Baden, Stuttgart veya Frankfurt’tan da.',
|
||||
'angebot-3-link.text' => '<a href="#kontakt">Türkiye uçuşu sorun</a>',
|
||||
'beratung-klein.text' => 'Seyahat acenteniz.<br>Doğrudan iletişiminiz.',
|
||||
'beratung-ort-text.text' => 'Kantarci Reisen<br>Ooser Hauptstraße 51<br>76532 Baden-Baden-Oos',
|
||||
'beratung-titel.text' => 'İyi seyahatler<br>bir <em>sohbetle</em> başlar.',
|
||||
'beratung-satz.text' => 'Sizin için neyin önemli olduğunu anlatın. Dinliyor, birlikte düşünüyor ve sizinle planlıyoruz. Kişisel, kolay ve Türkiye’ye özel bir yakınlıkla – ister plaj tatili, ister aile ziyareti, ister İstanbul’a şehir turu.',
|
||||
'beratung-knopf.text' => 'Hadi konuşalım',
|
||||
'schritt-1-titel.text' => 'İsteğinizi anlatın',
|
||||
'schritt-1-satz.text' => 'Form, e-posta ya da doğrudan Oos’taki ofisimizde. Kabaca bir plan yeterli.',
|
||||
'schritt-2-titel.text' => 'Önerileri alın',
|
||||
'schritt-2-satz.text' => 'Otelleri, uçuşları ve tarihleri karşılaştırıyor, size uygun fikirlerle dönüyoruz.',
|
||||
'schritt-3-titel.text' => 'Rahatça yola çıkın',
|
||||
'schritt-3-satz.text' => 'Siz karar verirsiniz, biz rezervasyon yaparız. Belgeler ve irtibat kişisi bizden.',
|
||||
'kontakt-klein.text' => 'Tatile ilk adım',
|
||||
'kontakt-titel.text' => 'Nereye<br><em>gidelim?</em>',
|
||||
'kontakt-satz.text' => 'Bir istek. Kabaca bir plan. Ya da belli bir hedef. Bize anlatın – size kişisel olarak dönüş yapacağız.',
|
||||
'kontakt-whatsapp-satz.text' => 'Kısaca yazmayı mı tercih edersiniz? WhatsApp’ta size kişisel olarak cevap veriyoruz.',
|
||||
'kontakt-hinweis.text' => 'Türkiye seyahatleri · Dünya genelinde oteller · Türkiye uçuşları<br><a href="tel:+491702945935">0170 2945935</a> · <a href="mailto:info@kantarci-reisen.de">info@kantarci-reisen.de</a>',
|
||||
'kontakt-karte-klein.text' => 'Kişisel seyahat danışmanlığı',
|
||||
'kontakt-karte-titel.text' => 'Seyahat talebiniz',
|
||||
'kontakt-karte-satz.text' => 'İsteklerinizi yazın – bir sonraki seyahatiniz burada başlıyor.',
|
||||
'kontakt-karte-hinweis.text' => 'Bilgilerinizi yalnızca talebiniz için kullanıyoruz. Ayrıntılar <a href="/datenschutz">gizlilik bildiriminde</a>.',
|
||||
'reisesoft-etikett.text' => 'Seyahat acenteleri için',
|
||||
'reisesoft-klein.text' => 'Seyahatin diğer tarafı için',
|
||||
'reisesoft-titel.text' => 'Daha az idari iş.<br>Daha çok <em>seyahat acentesi.</em>',
|
||||
'reisesoft-satz.text' => 'reiseSoft, seyahat acenteleri için web platformumuz – kendi ofis günlüğümüzden doğdu. Rezervasyonlar, müşteriler, faturalar ve ödemeler tek yerde; böylece danışmanlığa daha çok zaman kalır.',
|
||||
'funktion-1-text.text' => '<strong>Rezervasyonu Ctrl+V ile aktarın</strong>Amadeus’tan veya havayolu portalından kopyalayın, yapıştırın, kontrol edin – hazır.',
|
||||
'funktion-2-text.text' => '<strong>Fatura ve e-fatura</strong>Ardışık fatura numaraları, ZUGFeRD ve XRechnung, e-posta ile gönderim.',
|
||||
'funktion-3-text.text' => '<strong>Açık kalemler göz önünde</strong>Ödemeleri kaydedin, ciro ve kârı değerlendirin – doğrudan tarayıcıda.',
|
||||
'reisesoft-plattform.text' => 'reiseSoft platformuna',
|
||||
'reisesoft-anfragen.text' => 'reiseSoft’u tanıyın',
|
||||
'reisesoft-bild.alt' => 'Açık kalemler, ciro ve son işlemlerle reiseSoft genel görünümü (örnek veriler) – platforma',
|
||||
'reisesoft-bildzeile.text' => 'Gerçek arayüz, hayali örnek veriler.',
|
||||
// Sprachneutral, aber eingetragen: sonst zeigt der Editor sie als „fehlt“.
|
||||
'schritt-1-nummer.text' => '01', 'schritt-2-nummer.text' => '02', 'schritt-3-nummer.text' => '03',
|
||||
'funktion-1-nummer.text' => '01', 'funktion-2-nummer.text' => '02', 'funktion-3-nummer.text' => '03',
|
||||
'beratung-mail-text.text' => '<a href="mailto:info@kantarci-reisen.de">info@kantarci-reisen.de</a>',
|
||||
'beratung-telefon-text.text' => '<a href="tel:+491702945935">0170 2945935</a>',
|
||||
'reisesoft-name.text' => 'reise<strong>Soft</strong>',
|
||||
'reisesoft-fenster-adresse.text' => 'reisesoft.kantarci-reisen.de',
|
||||
],
|
||||
],
|
||||
'impressum' => [
|
||||
'slug' => 'kunye',
|
||||
'title' => 'Künye',
|
||||
'description' => 'Kantarci Reisen, sahibi I. Kantarci, Baden-Baden – sağlayıcı bilgileri.',
|
||||
'seoTitle' => 'Künye | Kantarci Reisen',
|
||||
'blocks' => [
|
||||
'impressum-label.text' => 'Yasal bilgiler',
|
||||
'impressum-h1.text' => 'Künye',
|
||||
'impressum-vorspann.text' => '§ 5 DDG ve § 18 (2) MStV uyarınca bilgiler. '.$hinweisRecht,
|
||||
'impressum-h2-0.text' => 'Sağlayıcı',
|
||||
'impressum-p-0.text' => 'Kantarci Reisen<br>Sahibi: I. Kantarci<br>Ooser Hauptstraße 51<br>76532 Baden-Baden',
|
||||
'impressum-h2-1.text' => 'İletişim',
|
||||
'impressum-p-1.text' => 'Telefon: <a href="tel:+491702945935">0170 2945935</a><br>E-posta: <a href="mailto:info@kantarci-reisen.de">info@kantarci-reisen.de</a><br>WhatsApp: <a href="https://wa.me/491702945935?text=Merhaba%20Kantarci%20Reisen%2C%20bir%20seyahatle%20ilgileniyorum." rel="noopener">0170 2945935</a>',
|
||||
'impressum-h2-2.text' => 'İçerikten sorumlu',
|
||||
'impressum-p-2.text' => 'I. Kantarci, adres yukarıdaki gibi.',
|
||||
'impressum-h2-3.text' => 'Seyahat aracılığı',
|
||||
'impressum-p-3.text' => 'Kantarci Reisen paket turlar, otel konaklamaları ve uçuşlar gibi seyahat hizmetlerine aracılık eder. İlgili hizmetin sözleşme tarafı tur operatörü, otel veya havayolu şirketidir; rezervasyondan önce size ilettiğimiz onların genel ve taşıma koşulları geçerlidir.',
|
||||
'impressum-h2-4.text' => 'Tüketici uyuşmazlıklarının çözümü',
|
||||
'impressum-p-4.text' => 'Bir tüketici hakem kurulu önündeki uyuşmazlık çözüm sürecine katılmaya hazır ve yükümlü değiliz.',
|
||||
'impressum-h2-5.text' => 'İçerikler için sorumluluk',
|
||||
'impressum-p-5.text' => 'Hizmet sağlayıcı olarak bu sayfalardaki kendi içeriklerimizden genel yasalar çerçevesinde sorumluyuz. Seyahat bilgilerini bilgimiz dahilinde aktarıyoruz; bağlayıcı olan ilgili teklifteki bilgilerdir. Hukuka aykırılıklardan haberdar olduğumuzda ilgili içerikleri derhal kaldırırız.',
|
||||
'impressum-h2-6.text' => 'Bağlantılar için sorumluluk',
|
||||
'impressum-p-6.text' => 'Bu web sitesi <a href="https://reisesoft.kantarci-reisen.de" rel="noopener">reisesoft.kantarci-reisen.de</a> adresindeki reiseSoft platformuna yönlendirir. Yabancı sayfaların içeriklerinden ilgili sağlayıcı sorumludur.',
|
||||
'impressum-h2-7.text' => 'Telif hakkı',
|
||||
'impressum-p-7.text' => 'Bu sayfalarda tarafımızdan oluşturulan içerik ve eserler Alman telif hakkı yasasına tabidir. Telif hakkı sınırları dışında çoğaltma, işleme, yayma ve her türlü kullanım yazılı izne bağlıdır.',
|
||||
'impressum-h2-8.text' => 'Görsel kaynakları',
|
||||
'impressum-p-8.text' => 'Logo: Kantarci Reisen. Ana sayfadaki seyahat fotoğrafı: yapay zekâ ile üretilmiştir. Ekran görüntüsü: hayali örnek verilerle reiseSoft.',
|
||||
],
|
||||
],
|
||||
'datenschutz' => [
|
||||
'slug' => 'gizlilik',
|
||||
'title' => 'Gizlilik',
|
||||
'description' => 'Kantarci Reisen web sitesini ziyaret ederken ve seyahat talebinde hangi verilerin işlendiği.',
|
||||
'seoTitle' => 'Gizlilik | Kantarci Reisen',
|
||||
'blocks' => [
|
||||
'datenschutz-label.text' => 'Verileriniz',
|
||||
'datenschutz-h1.text' => 'Gizlilik',
|
||||
'datenschutz-vorspann.text' => 'Bu web sitesini ziyaret ederken ve bir talepte bulunduğunuzda hangi verilerin işlendiğini ve hangi haklara sahip olduğunuzu burada öğrenebilirsiniz. '.$hinweisRecht,
|
||||
'datenschutz-h2-0.text' => '1. Sorumlu',
|
||||
'datenschutz-p-0.text' => 'Kantarci Reisen, sahibi I. Kantarci, Ooser Hauptstraße 51, 76532 Baden-Baden. Telefon <a href="tel:+491702945935">0170 2945935</a>, e-posta <a href="mailto:info@kantarci-reisen.de">info@kantarci-reisen.de</a>.',
|
||||
'datenschutz-h2-1.text' => '2. Bu web sitesinin ziyareti',
|
||||
'datenschutz-p-1.text' => 'Sayfa açıldığında barındırma hizmeti sağlayıcımız teknik olarak gerekli bağlantı verilerini işler: IP adresi, zaman, istenen adres, tarayıcı bilgileri ve HTTP durumu. Bu, güvenli sunum ve hata ayıklama içindir. Hukuki dayanak, kesintisiz işletimdeki meşru menfaatimizdir, GVKT (DSGVO) md. 6 f. 1 b. f.<br><br>Barındırma hizmeti sağlayıcısıyla GVKT md. 28 uyarınca bir veri işleme sözleşmesi bulunmaktadır. Sunucu kayıtları, bir güvenlik olayının aydınlatılması için daha uzun süre gerekmedikçe en geç yedi gün sonra silinir veya anonimleştirilir.',
|
||||
'datenschutz-h2-2.text' => '3. Çerezler ve harici hizmetler',
|
||||
'datenschutz-p-2.text' => 'Bu web sitesi analiz veya reklam çerezi kullanmaz. Resimler, yazı tipleri ve tasarım kendi sunucumuzdan gelir; üçüncü taraflardan harita, video veya yazı tipi yüklenmez. Bu nedenle bir onay penceresi de yoktur.',
|
||||
'datenschutz-h2-3.text' => '4. Seyahat talebi',
|
||||
'datenschutz-p-3.text' => 'Bize form aracılığıyla yazdığınızda, talebinizi yanıtlamak ve uygun teklifler hazırlamak için adınızı, e-posta adresinizi, isteğe bağlı olarak telefon numaranızı, seyahat dönemini, kişi sayısını ve isteklerinizi işleriz (GVKT md. 6 f. 1 b. b). Talep sistemimizde kaydedilir ve e-posta ile bize iletilir. Talep sonuçlandığında ve yasal saklama yükümlülüğü bulunmadığında silinir.',
|
||||
'datenschutz-h2-4.text' => '5. Rezervasyonlar',
|
||||
'datenschutz-p-4.text' => 'Bir rezervasyon yapılırsa, bunun için gerekli bilgileri (örneğin kimlikteki yolcu adları ve seyahat tarihleri) tur operatörüne, otele veya havayolu şirketine iletiriz (GVKT md. 6 f. 1 b. b). Fatura belgelerini ticaret ve vergi hukukunun gerektirdiği süre boyunca saklarız.',
|
||||
'datenschutz-h2-5.text' => '6. E-posta veya telefonla iletişim',
|
||||
'datenschutz-p-5.text' => 'Bize yazdığınızda veya bizi aradığınızda, talebinizi işlemek için bilgilerinizi işleriz (GVKT md. 6 f. 1 b. b veya f).',
|
||||
'datenschutz-h2-6.text' => '7. WhatsApp',
|
||||
'datenschutz-p-6.text' => 'WhatsApp düğmeleri basit bağlantılardır. Yalnızca birini kullandığınızda, WhatsApp Ireland Limited (Meta) tarafından sunulan WhatsApp açılır. WhatsApp bu sırada telefon numaranızı, mesajlarınızı ve kullanım verilerinizi kendi gizlilik bildirimine göre, AB dışındaki ülkelerde de işler. Mesajınızı yalnızca talebinizi yanıtlamak için kullanırız (GVKT md. 6 f. 1 b. b). Bunu istemiyorsanız bize form, e-posta veya telefonla da ulaşabilirsiniz.',
|
||||
'datenschutz-h2-7.text' => '8. reiseSoft',
|
||||
'datenschutz-p-7.text' => 'reiseSoft platformuna giden bağlantı, reisesoft.kantarci-reisen.de adresindeki ayrı bir uygulamaya yönlendirir. Veriler ancak bağlantıyı takip ettiğinizde orada işlenir; kayıtlı seyahat acenteleri için platformun bildirimleri geçerlidir.',
|
||||
'datenschutz-h2-8.text' => '9. Haklarınız',
|
||||
'datenschutz-p-8.text' => 'Yasal koşullar çerçevesinde bilgi alma, düzeltme, silme, işlemenin kısıtlanması ve veri taşınabilirliği haklarına sahipsiniz. GVKT md. 6 f. 1 b. f’ye dayanan bir işlemeye itiraz edebilirsiniz.',
|
||||
'datenschutz-h2-9.text' => '10. Şikâyet hakkı',
|
||||
'datenschutz-p-9.text' => 'Bir veri koruma denetim makamına şikâyette bulunabilirsiniz; bizim için yetkili makam Baden-Württemberg Veri Koruma ve Bilgi Edinme Özgürlüğü Görevlisi’dir (Landesbeauftragter für den Datenschutz und die Informationsfreiheit Baden-Württemberg).',
|
||||
'datenschutz-p-10.text' => 'Gizlilikle ilgili sorularınızı <a href="mailto:info@kantarci-reisen.de">info@kantarci-reisen.de</a> adresinden memnuniyetle yanıtlarız.',
|
||||
],
|
||||
],
|
||||
'404' => [
|
||||
'title' => 'Sayfa bulunamadı',
|
||||
'description' => 'Aradığınız sayfa mevcut değil veya taşınmış.',
|
||||
'blocks' => [
|
||||
'fehler-label.text' => 'Hata 404',
|
||||
'fehler-titel.text' => 'Maalesef burada<br><em>devam edilemiyor.</em>',
|
||||
'fehler-text.text' => 'Bu sayfa mevcut değil veya taşınmış. Ana sayfadan bir sonraki hedefinizi kolayca bulabilirsiniz.',
|
||||
'fehler-knopf.text' => 'Ana sayfaya',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Kopf, Fuß, Menü, Formular
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$bereiche = [
|
||||
'header' => ['kopf-logo.alt' => 'Kantarci Reisen – ana sayfaya'],
|
||||
'footer' => [
|
||||
'fuss-logo.alt' => 'Kantarci Reisen',
|
||||
'fuss-copyright.text' => '© 2026 Kantarci Reisen',
|
||||
'fuss-spruch.text' => 'Kalbimizde Türkiye. Gözümüz dünyada.',
|
||||
'fuss-reisen-titel.text' => 'Seyahatler',
|
||||
'fuss-reisen-liste.text' => '<a href="/#reisen">Türkiye tatili</a><br><a href="/#reisen">Dünya genelinde oteller</a><br><a href="/#reisen">Türkiye uçuşları</a><br><a href="/#kontakt">Seyahat talebi</a>',
|
||||
'fuss-business-titel.text' => 'Seyahat acenteleri için',
|
||||
'fuss-business-liste.text' => '<a href="/#reisesoft">reiseSoft’u tanıyın</a><br><a href="https://reisesoft.kantarci-reisen.de" rel="noopener">reiseSoft platformuna</a>',
|
||||
'fuss-kontakt-titel.text' => 'İletişim',
|
||||
'fuss-kontakt-text.text' => 'Kantarci Reisen<br>Ooser Hauptstraße 51<br>76532 Baden-Baden<br><a href="tel:+491702945935">0170 2945935</a><br><a href="mailto:info@kantarci-reisen.de">info@kantarci-reisen.de</a><br><a href="https://wa.me/491702945935?text=Merhaba%20Kantarci%20Reisen%2C%20bir%20seyahatle%20ilgileniyorum." rel="noopener">WhatsApp</a>',
|
||||
'fuss-rechtliches.label' => 'Yasal bilgiler',
|
||||
'fuss-agentur.text' => '<a href="https://kant-it.de" rel="noopener">Web sitesi: Kant-IT Solutions</a>',
|
||||
],
|
||||
];
|
||||
|
||||
$menue = [
|
||||
'Reisen' => 'Seyahatler',
|
||||
'Über uns' => 'Hakkımızda',
|
||||
'reiseSoft' => 'reiseSoft',
|
||||
'Kontakt' => 'İletişim',
|
||||
'Reise anfragen' => 'Seyahat talebi',
|
||||
];
|
||||
|
||||
$formular = [
|
||||
'buttonLabel' => 'Talebi gönder',
|
||||
'success' => 'Teşekkürler! Talebiniz bize ulaştı. Size kişisel olarak dönüş yapacağız – genellikle bir iş günü içinde.',
|
||||
'fields' => [
|
||||
'name' => ['label' => 'Adınız', 'help' => 'Ad ve soyad'],
|
||||
'email' => ['label' => 'E-posta adresiniz'],
|
||||
'telefon' => ['label' => 'Telefon', 'help' => 'İsteğe bağlı – sizi aramamızı tercih ederseniz'],
|
||||
'anliegen' => ['label' => 'Konu', 'options' => [
|
||||
'Türkei-Urlaub' => 'Türkiye tatili',
|
||||
'Hotels weltweit' => 'Dünya genelinde oteller',
|
||||
'Flüge in die Türkei' => 'Türkiye uçuşları',
|
||||
'reiseSoft für mein Reisebüro' => 'Seyahat acentem için reiseSoft',
|
||||
'Allgemeine Anfrage' => 'Genel talep',
|
||||
]],
|
||||
'zeitraum' => ['label' => 'Seyahat dönemi', 'help' => 'ör. Pentekost tatili veya Ağustos’ta 2 hafta'],
|
||||
'personen' => ['label' => 'Kişi sayısı'],
|
||||
'wuensche' => ['label' => 'İstekleriniz', 'help' => 'Varış noktası, kalkış havalimanı, bütçe, sizin için önemli olanlar …'],
|
||||
'datenschutz' => ['label' => 'Bilgilerimin gizlilik bildirimine uygun olarak işlenmesini kabul ediyorum.'],
|
||||
],
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Ausführen
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$languages->save([SPRACHE]);
|
||||
echo "Sprache eingeschaltet: Türkisch\n";
|
||||
|
||||
/** Legt die Übersetzung auf das Dokument; meldet Schlüssel, die es im Dokument nicht gibt. */
|
||||
$uebersetzen = static function (array $document, array $daten, string $ort): array {
|
||||
$texte = [];
|
||||
$vorhanden = [];
|
||||
$walk = static function (array $blocks) use (&$walk, &$vorhanden): void {
|
||||
foreach ($blocks as $block) {
|
||||
foreach (Translations::FIELDS[$block['type'] ?? ''] ?? [] as $prop) {
|
||||
$vorhanden[$block['id'].'.'.$prop] = $block['props'][$prop] ?? '';
|
||||
}
|
||||
$walk($block['children'] ?? []);
|
||||
}
|
||||
};
|
||||
$walk($document['blocks']);
|
||||
$translation = [];
|
||||
$source = [];
|
||||
foreach ($daten['blocks'] ?? $daten as $schluessel => $text) {
|
||||
if (!array_key_exists($schluessel, $vorhanden)) {
|
||||
throw new RuntimeException(sprintf('%s: „%s“ gibt es im Dokument nicht.', $ort, $schluessel));
|
||||
}
|
||||
[$id, $prop] = explode('.', $schluessel, 2);
|
||||
$translation['blocks'][$id][$prop] = $text;
|
||||
$source[$schluessel] = Translations::fingerprint((string) $vorhanden[$schluessel]);
|
||||
}
|
||||
foreach (['title', 'description'] as $feld) {
|
||||
if (isset($daten[$feld])) {
|
||||
$translation[$feld] = $daten[$feld];
|
||||
$source[$feld] = Translations::fingerprint((string) $document[$feld]);
|
||||
}
|
||||
}
|
||||
foreach (['slug', 'seoTitle'] as $feld) {
|
||||
if (isset($daten[$feld])) {
|
||||
$translation[$feld] = $daten[$feld];
|
||||
}
|
||||
}
|
||||
$translation['source'] = $source;
|
||||
$document['schemaVersion'] = max((int) $document['schemaVersion'], Translations::SCHEMA);
|
||||
$document['translations'] = [SPRACHE => $translation];
|
||||
|
||||
return $document;
|
||||
};
|
||||
|
||||
foreach ($seiten as $pfad => $daten) {
|
||||
$zeile = $db->fetchAssociative('SELECT id, version FROM page WHERE path = ? AND deleted_at IS NULL', [$pfad]);
|
||||
if (false === $zeile) {
|
||||
throw new RuntimeException("Seite /{$pfad} fehlt.");
|
||||
}
|
||||
$entwurf = $pages->findForEditor($zeile['id']);
|
||||
$dokument = $uebersetzen($entwurf['document'], $daten, '/'.$pfad);
|
||||
$revision = $pages->saveDraft($zeile['id'], $dokument, $entwurf['version']);
|
||||
$pages->publish($zeile['id'], $revision, $entwurf['version'] + 1);
|
||||
$zaehlung = Translations::progress($dokument, SPRACHE);
|
||||
printf("Seite /%s: %d von %d Texten übersetzt, veröffentlicht\n", $pfad, $zaehlung['done'], $zaehlung['total']);
|
||||
}
|
||||
|
||||
foreach ($bereiche as $art => $texte) {
|
||||
$bereich = $partials->findByKind($art);
|
||||
$dokument = $uebersetzen($bereich['document'], $texte, $art);
|
||||
if ('header' === $art) {
|
||||
// Die Sprachwahl rechts neben dem Menü, als Kürzel. Auf dem Telefon bleibt sie neben dem
|
||||
// Menü-Knopf stehen; das Menü selbst rückt mit margin-left:auto nach rechts.
|
||||
$zeile = &$dokument['blocks'][0]['children'][0];
|
||||
$zeile['children'] = array_values(array_filter($zeile['children'], static fn (array $b): bool => 'kopf-sprache' !== $b['id']));
|
||||
foreach ($zeile['children'] as &$kind) {
|
||||
if ('kopf-menue' === $kind['id'] && !str_contains($kind['css'] ?? '', 'margin-left:auto')) {
|
||||
$kind['css'] = "&{margin-left:auto}\n".($kind['css'] ?? '');
|
||||
}
|
||||
}
|
||||
unset($kind);
|
||||
$zeile['children'][] = [
|
||||
'id' => 'kopf-sprache', 'type' => 'language', 'props' => ['display' => 'code'],
|
||||
// Auf dem Telefon ist neben Logo, Anfrageknopf und Menü kein Platz - dort steht die
|
||||
// Sprachwahl im Fuß.
|
||||
'layout' => ['visibility' => 'hide-mobile'],
|
||||
'style' => [
|
||||
'desktop' => ['fontSize' => '14px', 'fontWeight' => '600', 'color' => '@tinte', 'margin' => ['left' => '20px']],
|
||||
'mobile' => ['margin' => ['left' => '0px']],
|
||||
],
|
||||
'css' => "& a{border-radius:4px;letter-spacing:.04em}\n& a[aria-current]{box-shadow:inset 0 -2px 0 #ff753d}\n& a:hover{text-decoration:none;background:#f1f5f5}",
|
||||
];
|
||||
unset($zeile);
|
||||
}
|
||||
if ('footer' === $art) {
|
||||
// Unten neben der Rechtszeile, auf jedem Gerät - auf dem Telefon der Weg zur Sprache.
|
||||
$sprache = [
|
||||
'id' => 'fuss-sprache', 'type' => 'language', 'props' => ['display' => 'name'],
|
||||
'style' => ['desktop' => ['fontSize' => '13px']],
|
||||
'css' => "& a{padding:2px 6px;color:inherit}
|
||||
& a[aria-current]{box-shadow:inset 0 -2px 0 #ff753d}",
|
||||
];
|
||||
$einsetzen = static function (array $blocks) use (&$einsetzen, $sprache): array {
|
||||
foreach ($blocks as $i => $block) {
|
||||
$kinder = array_values(array_filter($block['children'] ?? [], static fn (array $b): bool => 'fuss-sprache' !== $b['id']));
|
||||
if ('fuss-links' === $block['id']) {
|
||||
array_unshift($kinder, $sprache);
|
||||
}
|
||||
$blocks[$i]['children'] = $einsetzen($kinder);
|
||||
}
|
||||
|
||||
return $blocks;
|
||||
};
|
||||
$dokument['blocks'] = $einsetzen($dokument['blocks']);
|
||||
}
|
||||
$partials->save($bereich['id'], $dokument, $bereich['version']);
|
||||
$zaehlung = Translations::progress($dokument, SPRACHE);
|
||||
printf("Bereich %s: %d von %d Texten übersetzt\n", $art, $zaehlung['done'], $zaehlung['total']);
|
||||
}
|
||||
|
||||
$alle = $menus->all();
|
||||
$kopfmenue = $alle['header'];
|
||||
$beschriften = static function (array $items) use (&$beschriften, $menue): array {
|
||||
foreach ($items as $i => $item) {
|
||||
if (isset($menue[$item['label']])) {
|
||||
$items[$i]['labels'] = [SPRACHE => $menue[$item['label']]];
|
||||
}
|
||||
$items[$i]['children'] = $beschriften($item['children'] ?? []);
|
||||
}
|
||||
|
||||
return $items;
|
||||
};
|
||||
$menus->save('header', $kopfmenue['name'], $beschriften($kopfmenue['items']), $kopfmenue['version']);
|
||||
echo "Menü: ".count($menue)." Beschriftungen\n";
|
||||
|
||||
$form = $forms->findBySlug('reiseanfrage');
|
||||
$einstellungen = $form['settings'];
|
||||
$einstellungen['translations'] = [SPRACHE => $formular];
|
||||
$forms->save('reiseanfrage', $form['name'], array_map(static fn (array $f): array => [...$f, 'images' => $f['images'] ?: '', 'min' => $f['min'] ?? '', 'max' => $f['max'] ?? ''], $form['fields']), $einstellungen, $form['version']);
|
||||
echo "Formular reiseanfrage übersetzt\n";
|
||||
|
||||
echo "Fertig: /tr, /tr/kunye, /tr/gizlilik\n";
|
||||
@@ -439,6 +439,20 @@ final class FormRepository
|
||||
'Bitte gib mindestens %s an.' => 'Please enter at least %s.',
|
||||
'Bitte gib einen Wert zwischen %s und %s an.' => 'Please enter a value between %s and %s.',
|
||||
],
|
||||
'tr' => [
|
||||
'Bitte wähle nur aus den angebotenen Optionen.' => 'Lütfen yalnızca sunulan seçeneklerden seçin.',
|
||||
'Bitte wähle mindestens eine Option.' => 'Lütfen en az bir seçenek seçin.',
|
||||
'Bitte kürze die Eingabe auf höchstens %d Zeichen.' => 'Lütfen girişinizi en fazla %d karaktere kısaltın.',
|
||||
'Ohne diese Einwilligung können wir die Anfrage nicht bearbeiten.' => 'Bu onay olmadan talebinizi işleme alamayız.',
|
||||
'Bitte wähle eine der angebotenen Optionen.' => 'Lütfen sunulan seçeneklerden birini seçin.',
|
||||
'Dieses Feld wird benötigt.' => 'Bu alan zorunludur.',
|
||||
'Bitte gib eine gültige E-Mail-Adresse an.' => 'Lütfen geçerli bir e-posta adresi girin.',
|
||||
'Bitte gib eine gültige Telefonnummer an.' => 'Lütfen geçerli bir telefon numarası girin.',
|
||||
'Bitte gib eine Zahl an.' => 'Lütfen bir sayı girin.',
|
||||
'Bitte gib höchstens %s an.' => 'Lütfen en fazla %s girin.',
|
||||
'Bitte gib mindestens %s an.' => 'Lütfen en az %s girin.',
|
||||
'Bitte gib einen Wert zwischen %s und %s an.' => 'Lütfen %s ile %s arasında bir değer girin.',
|
||||
],
|
||||
];
|
||||
|
||||
private static function message(?string $locale, string $text, mixed ...$args): string
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Command\SiteOriginCommand;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Domain\InvalidPage;
|
||||
use App\Ops\ArchiveUpdate;
|
||||
use App\Ops\Backup;
|
||||
@@ -11,8 +13,10 @@ use App\Ops\Installation;
|
||||
use App\Ops\OriginCheck;
|
||||
use App\Ops\Updater;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
/**
|
||||
@@ -24,7 +28,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
*/
|
||||
final class SystemController extends AbstractController
|
||||
{
|
||||
public function __construct(private Updater $updater, private Backup $backup, private ArchiveUpdate $archive, private Installation $installation, private string $adminPath, private string $databaseUrl, private string $mediaDirectory, private string $projectDir, private string $siteOrigin, private OriginCheck $originCheck)
|
||||
public function __construct(private Updater $updater, private Backup $backup, private ArchiveUpdate $archive, private Installation $installation, private string $adminPath, private string $databaseUrl, private string $mediaDirectory, private string $projectDir, private string $siteOrigin, private OriginCheck $originCheck, private HttpKernelInterface $kernel, private PageRepository $pages, private SiteSettings $settings)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -174,13 +178,14 @@ final class SystemController extends AbstractController
|
||||
|
||||
return $this->redirectToRoute('admin_system');
|
||||
}
|
||||
// Alles, was nach dem Dateitausch noch gebraucht wird, steht vorher fest: Danach darf diese
|
||||
// Anfrage keinen Code mehr nachladen (siehe sendAndStop).
|
||||
$abschluss = '/'.trim($this->adminPath, '/').'/system/abschliessen?token=';
|
||||
$ergebnis = $this->archive->run($request->request->getString('target') ?: null);
|
||||
$request->getSession()->set('update_steps', $ergebnis['steps']);
|
||||
if ($ergebnis['migrate'] ?? false) {
|
||||
$this->addFlash('success', 'Die Dateien sind ersetzt. Jetzt noch die Migrationen - der Knopf steht oben.');
|
||||
|
||||
return $this->redirectToRoute('admin_system');
|
||||
$this->sendAndStop((string) $ergebnis['fassung'], $abschluss.$ergebnis['token'], $ergebnis['steps']);
|
||||
}
|
||||
$request->getSession()->set('update_steps', $ergebnis['steps']);
|
||||
$this->addFlash($ergebnis['ok'] ? 'success' : 'error', $ergebnis['ok']
|
||||
? 'Es war nichts einzuspielen.'
|
||||
: 'Das Update ist nicht durchgelaufen. Die Schritte stehen unten.');
|
||||
@@ -188,6 +193,180 @@ final class SystemController extends AbstractController
|
||||
return $this->redirectToRoute('admin_system');
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------------
|
||||
// Update mit einem Klick (ADR 113), nach dem Vorbild von reiseSoft: Die Systemseite fragt
|
||||
// beim Öffnen selbst nach einer neuen Fassung, ein Knopf startet das Update, und die Seite
|
||||
// zeigt Schritt für Schritt, was gerade passiert - bis „abgeschlossen“ oder „zurückgeschaltet“.
|
||||
// -----------------------------------------------------------------------------------------
|
||||
|
||||
/** Gibt es eine neue Fassung? Für die Systemseite, die das beim Öffnen fragt. */
|
||||
#[Route('/%kant.admin_path%/system/update/pruefen', name: 'admin_update_check', methods: ['GET'])]
|
||||
public function updateCheck(): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
$installiert = $this->updater->version();
|
||||
$neueste = $this->archive->latest();
|
||||
$antwort = ['installiert' => $installiert, 'status' => $this->archive->status(), 'offen' => null !== $this->archive->pending()];
|
||||
if (null === $neueste) {
|
||||
$antwort['fehler'] = 'Die Releaseseite antwortet gerade nicht. Später noch einmal versuchen.';
|
||||
} elseif (version_compare($neueste['version'], $installiert, '>')) {
|
||||
$antwort['neu'] = ['version' => $neueste['version'], 'name' => $neueste['name'], 'notizen' => $neueste['notes'], 'datum' => substr($neueste['published'], 0, 10)];
|
||||
}
|
||||
|
||||
return new JsonResponse($antwort, 200, ['Cache-Control' => 'no-store']);
|
||||
}
|
||||
|
||||
/** Der Fortschritt, alle zwei Sekunden abgefragt, solange das Update läuft. */
|
||||
#[Route('/%kant.admin_path%/system/update/status', name: 'admin_update_status', methods: ['GET'])]
|
||||
public function updateStatus(): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
|
||||
return new JsonResponse(['status' => $this->archive->status(), 'installiert' => $this->updater->version()], 200, ['Cache-Control' => 'no-store']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Startet das Update: Sichern, Laden, Prüfen, Umschalten. Die Antwort kommt nach dem Tausch
|
||||
* als reines JSON ohne Framework (siehe sendJsonAndStop); den Abschluss ruft die Seite
|
||||
* danach selbst auf.
|
||||
*/
|
||||
#[Route('/%kant.admin_path%/system/update/starten', name: 'admin_update_start', methods: ['POST'])]
|
||||
public function updateStart(Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
if (!$this->isCsrfTokenValid('system', $request->headers->get('X-CSRF-Token'))) {
|
||||
return new JsonResponse(['ok' => false, 'meldung' => 'Deine Sitzung ist abgelaufen. Bitte die Seite neu laden.'], 403);
|
||||
}
|
||||
@set_time_limit(0);
|
||||
$ergebnis = $this->archive->run($request->request->getString('version') ?: null);
|
||||
if ($ergebnis['migrate'] ?? false) {
|
||||
$this->sendJsonAndStop(['ok' => true, 'token' => $ergebnis['token'], 'fassung' => $ergebnis['fassung']]);
|
||||
}
|
||||
$letzter = end($ergebnis['steps']) ?: ['detail' => ''];
|
||||
|
||||
return new JsonResponse(['ok' => $ergebnis['ok'], 'meldung' => $letzter['detail'], 'status' => $this->archive->status()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Abschluss in einer frischen Anfrage mit dem neuen Code: Migrationen, Prüfung der
|
||||
* Startseite, Wartung aus - oder Zurückschalten auf die vorherige Fassung. Die Antwort ist
|
||||
* wieder reines JSON, denn nach einem Zurückschalten liegt erneut anderer Code auf der Platte.
|
||||
*/
|
||||
#[Route('/%kant.admin_path%/system/update/abschliessen', name: 'admin_update_finish', methods: ['POST'])]
|
||||
public function updateFinish(Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
$offen = $this->archive->pending();
|
||||
if (null === $offen) {
|
||||
return new JsonResponse(['ok' => true, 'meldung' => 'Das Update ist bereits abgeschlossen.', 'status' => $this->archive->status()]);
|
||||
}
|
||||
if (!is_string($offen['token'] ?? null) || !hash_equals($offen['token'], $request->request->getString('token'))) {
|
||||
return new JsonResponse(['ok' => false, 'meldung' => 'Dieser Abschluss gehört zu keinem offenen Update.'], 403);
|
||||
}
|
||||
@set_time_limit(0);
|
||||
$ergebnis = $this->archive->finish($this->installation, $this->pruefen(...));
|
||||
$this->sendJsonAndStop(['ok' => $ergebnis['ok'], 'status' => $this->archive->status()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Läuft die neue Fassung wirklich? Datenbank, Startseite in der Datenbank und die Startseite
|
||||
* als Seite - als Unteranfrage, die an der Wartungsseite vorbeigeht. null heißt: alles gut.
|
||||
*/
|
||||
private function pruefen(): ?string
|
||||
{
|
||||
$home = $this->settings->homePath();
|
||||
if (null === $this->pages->findPublished($home)) {
|
||||
return sprintf('Die Startseite /%s ist nicht lesbar.', $home);
|
||||
}
|
||||
$antwort = $this->kernel->handle(Request::create('/'), HttpKernelInterface::SUB_REQUEST, false);
|
||||
if ($antwort->getStatusCode() >= 400) {
|
||||
return sprintf('Die Startseite antwortet mit %d.', $antwort->getStatusCode());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Wie sendAndStop, nur als JSON für die Systemseite. */
|
||||
private function sendJsonAndStop(array $daten): never
|
||||
{
|
||||
while (ob_get_level() > 0) {
|
||||
ob_end_clean();
|
||||
}
|
||||
http_response_code(200);
|
||||
header('Content-Type: application/json; charset=UTF-8');
|
||||
header('Cache-Control: no-store');
|
||||
header('X-Robots-Tag: noindex');
|
||||
echo json_encode($daten, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Der zweite Schritt des Archivupdates, aufgerufen von der Zwischenseite selbst (ADR 112).
|
||||
*
|
||||
* Läuft in einer eigenen Anfrage und damit mit dem Code der neuen Fassung: Migrationen,
|
||||
* Aufräumen, Wartungszustand aus. Der Einmalschlüssel aus var/update/offen.json ersetzt das
|
||||
* CSRF-Token, das die Zwischenseite nicht mehr erzeugen kann; ohne Administratorrolle geht es
|
||||
* ohnehin nicht. Ein zweiter Aufruf (Neuladen) findet nichts mehr offen und meldet das.
|
||||
*/
|
||||
#[Route('/%kant.admin_path%/system/abschliessen', name: 'admin_system_finish', methods: ['GET'])]
|
||||
public function finishArchive(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
$offen = $this->archive->pending();
|
||||
if (null === $offen) {
|
||||
$this->addFlash('success', 'Das Update ist bereits abgeschlossen.');
|
||||
|
||||
return $this->redirectToRoute('admin_system');
|
||||
}
|
||||
if (!is_string($offen['token'] ?? null) || !hash_equals($offen['token'], $request->query->getString('token'))) {
|
||||
return $this->back('Dieser Link gehört zu keinem offenen Update. Schließe es oben mit „Migrationen ausführen“ ab.');
|
||||
}
|
||||
@set_time_limit(0);
|
||||
$ergebnis = $this->archive->finish($this->installation);
|
||||
$request->getSession()->set('update_steps', $ergebnis['steps']);
|
||||
$this->addFlash($ergebnis['ok'] ? 'success' : 'error', $ergebnis['ok']
|
||||
? sprintf('Update auf %s abgeschlossen. Die Website ist wieder erreichbar.', $offen['fassung'])
|
||||
: 'Die Migrationen sind fehlgeschlagen. Die Website bleibt im Wartungszustand.');
|
||||
|
||||
return $this->redirectToRoute('admin_system');
|
||||
}
|
||||
|
||||
/**
|
||||
* Beendet die Anfrage, die gerade die Dateien getauscht hat, mit einer schlichten Seite.
|
||||
*
|
||||
* Dieser Prozess hat den alten Code im Speicher, auf der Platte liegt der neue. Alles, was
|
||||
* jetzt noch nachgeladen würde - Antwort-Listener, Sitzung, Vorlagen -, käme aus der neuen
|
||||
* Fassung und passte nicht zu dem, was schon geladen ist. Genau das endete vorher mit einem
|
||||
* 500er, obwohl das Update gelungen war. Deshalb hier nur noch PHP selbst: Kopfzeilen, Text,
|
||||
* Ende. Die Seite ruft den Abschluss sofort selbst auf, ein Link steht für den Fall daneben,
|
||||
* dass der Browser das Weiterleiten unterdrückt.
|
||||
*
|
||||
* @param list<array{step: string, ok: bool, detail: string}> $schritte
|
||||
*/
|
||||
private function sendAndStop(string $fassung, string $weiter, array $schritte): never
|
||||
{
|
||||
while (ob_get_level() > 0) {
|
||||
ob_end_clean();
|
||||
}
|
||||
$e = static fn (string $text): string => htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
$liste = '';
|
||||
foreach ($schritte as $schritt) {
|
||||
$liste .= '<li>'.($schritt['ok'] ? '✓ ' : '! ').$e($schritt['step']).'</li>';
|
||||
}
|
||||
http_response_code(200);
|
||||
header('Content-Type: text/html; charset=UTF-8');
|
||||
header('Cache-Control: no-store');
|
||||
header('X-Robots-Tag: noindex');
|
||||
header("Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'");
|
||||
echo '<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">'
|
||||
.'<meta http-equiv="refresh" content="1;url='.$e($weiter).'"><title>Update wird abgeschlossen</title>'
|
||||
.'<style>body{font:16px/1.5 system-ui,sans-serif;background:#f3f5f2;color:#203b34;margin:0;display:grid;place-items:center;min-height:100vh}main{background:#fff;border:1px solid #dfe5e1;border-radius:12px;padding:28px 32px;max-width:460px}h1{font-size:20px;margin:0 0 8px}ul{padding-left:18px;color:#46514c;font-size:14px}a{color:#1f5f45}</style></head>'
|
||||
.'<body><main><h1>Update auf '.$e($fassung).' wird abgeschlossen …</h1>'
|
||||
.'<p role="status">Die neuen Dateien sind da. Jetzt laufen die Migrationen, danach geht es von selbst zur Systemseite.</p>'
|
||||
.'<ul>'.$liste.'</ul><p><a href="'.$e($weiter).'">Weiter, falls nichts passiert</a></p></main></body></html>';
|
||||
exit;
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/system/update', name: 'admin_system_update', methods: ['POST'])]
|
||||
public function update(Request $request): Response
|
||||
{
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\EventSubscriber;
|
||||
|
||||
use App\Ops\Notfall;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
use Symfony\Component\Security\Core\Exception\AuthenticationException;
|
||||
use Symfony\Component\Security\Core\Exception\LogoutException;
|
||||
|
||||
/**
|
||||
* Jeder Serverfehler, den das Framework abfängt, steht auch in var/log/notfall.log (ADR 113).
|
||||
*
|
||||
* Ohne das gab es auf dem Hosting kein eigenes Protokoll: Symfony schreibt nach stderr, und das
|
||||
* landet - wenn überhaupt - im Fehlerlog des Hostings, das ein Betreiber erst suchen muss. 404 und
|
||||
* andere gewollte Antworten gehören nicht hinein. Die Antwort selbst wird zur Wartungsseite.
|
||||
*/
|
||||
final class ErrorLogSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(private bool $debug = false)
|
||||
{
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
// Nach der Sicherheitsschicht (Priorität 1): „Zugriff verweigert“ ist dort eine
|
||||
// Weiterleitung zur Anmeldung, kein Notfall. Vor der Fehlerseite des Frameworks (-128),
|
||||
// damit der Eintrag auch dann entsteht, wenn sie selbst scheitert.
|
||||
return [KernelEvents::EXCEPTION => ['onException', -64]];
|
||||
}
|
||||
|
||||
public function onException(ExceptionEvent $event): void
|
||||
{
|
||||
$fehler = $event->getThrowable();
|
||||
if ($event->hasResponse() || $fehler instanceof AuthenticationException || $fehler instanceof AccessDeniedException || $fehler instanceof LogoutException
|
||||
|| ($fehler instanceof HttpExceptionInterface && $fehler->getStatusCode() < 500)) {
|
||||
return;
|
||||
}
|
||||
Notfall::protokollieren($fehler, $event->getRequest()->getMethod().' '.$event->getRequest()->getRequestUri());
|
||||
// Besucher sehen statt einer englischen „Oops“-Seite die Wartungsseite mit 503: für
|
||||
// Suchmaschinen „gleich wieder da“. Im Entwicklungsmodus bleibt die ausführliche Seite.
|
||||
if (!$this->debug) {
|
||||
$event->setResponse(new Response(Notfall::html(), Response::HTTP_SERVICE_UNAVAILABLE, Notfall::KOPFZEILEN));
|
||||
}
|
||||
}
|
||||
}
|
||||
+190
-7
@@ -51,6 +51,21 @@ final class ArchiveUpdate
|
||||
/** Das Protokoll - dieselbe Datei, in die auch der Updater über Git schreibt. */
|
||||
private const PROTOKOLL = 'var/updates.log';
|
||||
|
||||
/**
|
||||
* Der Fortschritt für die Systemseite (ADR 113): Phase, Meldung, von welcher auf welche Fassung.
|
||||
* Die Seite fragt ihn alle zwei Sekunden ab, während eine andere Anfrage das Update ausführt -
|
||||
* das Vorbild ist reiseSoft.
|
||||
*/
|
||||
public const STATUS = 'var/update/status.json';
|
||||
|
||||
/** Die Phasen, die die Systemseite als Schritte zeigt, in ihrer Reihenfolge. */
|
||||
public const PHASEN = ['sichern', 'laden', 'umschalten', 'migrieren', 'pruefen'];
|
||||
|
||||
/** Was mergePublic() überschrieben oder neu angelegt hat - für das Zurückschalten. */
|
||||
private array $publicAlt = [];
|
||||
private array $publicNeu = [];
|
||||
private ?string $publicSicherung = null;
|
||||
|
||||
public function __construct(
|
||||
private string $root,
|
||||
private Updater $updater,
|
||||
@@ -85,6 +100,10 @@ final class ArchiveUpdate
|
||||
$version = $treffer[1];
|
||||
|
||||
return [
|
||||
// Was die Releaseseite über die Fassung sagt - die Systemseite zeigt es vor dem Knopf.
|
||||
'name' => (string) ($daten['name'] ?? ''),
|
||||
'notes' => (string) ($daten['body'] ?? ''),
|
||||
'published' => (string) ($daten['published_at'] ?? ''),
|
||||
'version' => $version,
|
||||
'url' => sprintf('%s/%s/kantsites-%s.zip', $this->releaseBase, $tag, $version),
|
||||
'checksum' => $this->checksumOf(sprintf('%s/%s/kantsites-%s.zip.sha256', $this->releaseBase, $tag, $version)),
|
||||
@@ -118,6 +137,12 @@ final class ArchiveUpdate
|
||||
$melden = function (array &$schritte, string $schritt, bool $ok, string $detail = ''): bool {
|
||||
$schritte[] = ['step' => $schritt, 'ok' => $ok, 'detail' => $detail];
|
||||
$this->protokoll(sprintf('%s %s: %s', $ok ? 'ok ' : 'FEHLER', $schritt, $detail));
|
||||
// Ein gescheiterter Schritt vor dem Tausch: die bisherige Fassung läuft unverändert.
|
||||
// Die Sperre meldet ein anderes, laufendes Update - dessen Fortschritt bleibt stehen.
|
||||
// Ist die Releaseseite nicht erreichbar, hat nichts begonnen; dann bleibt alles, wie es ist.
|
||||
if (!$ok && !in_array($schritt, ['Sperre', 'Fassung ermitteln'], true)) {
|
||||
$this->phase('fehler', $detail.' Die bisherige Fassung läuft unverändert weiter.', ['laeuft' => false]);
|
||||
}
|
||||
|
||||
return $ok;
|
||||
};
|
||||
@@ -136,6 +161,7 @@ final class ArchiveUpdate
|
||||
|
||||
return ['ok' => true, 'steps' => $schritte];
|
||||
}
|
||||
$this->phase('sichern', 'Datenbank und Medien werden gesichert …', ['laeuft' => true, 'von' => $this->updater->version(), 'auf' => $ziel, 'seit' => gmdate('c')], true);
|
||||
|
||||
$lock = $this->updater->lock();
|
||||
if (null === $lock) {
|
||||
@@ -165,6 +191,7 @@ final class ArchiveUpdate
|
||||
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.');
|
||||
|
||||
@@ -211,6 +238,7 @@ final class ArchiveUpdate
|
||||
}
|
||||
$melden($schritte, 'Entpacken', true, 'Vollständig: VERSION und vendor/ sind vorhanden.');
|
||||
|
||||
$this->phase('umschalten', 'Wartungsseite an, die neuen Dateien werden eingesetzt …');
|
||||
$this->updater->maintenance(true);
|
||||
$melden($schritte, 'Wartungszustand', true, 'Die Website antwortet währenddessen mit 503.');
|
||||
|
||||
@@ -229,11 +257,14 @@ final class ArchiveUpdate
|
||||
++$ersetzt;
|
||||
}
|
||||
// public/ wird Datei für Datei aktualisiert: Schriften und erzeugte Bündel gehören dem
|
||||
// Betreiber und wären sonst weg.
|
||||
// Betreiber und wären sonst weg. Was dabei überschrieben wird, kommt vorher beiseite,
|
||||
// damit ein Zurückschalten auch public/ zurückbringt.
|
||||
$this->publicSicherung = $arbeit.'/alt-public';
|
||||
$this->mergePublic($quelle.'/public', $this->root.'/public');
|
||||
// Ebenso plugins/: die mitgelieferten Plugins werden aktualisiert, ein vom Betreiber
|
||||
// installiertes Plugin bleibt, weil sein Ordner im Archiv nicht vorkommt.
|
||||
$this->mergePublic($quelle.'/plugins', $this->root.'/plugins');
|
||||
@file_put_contents($arbeit.'/public.json', json_encode(['alt' => $this->publicAlt, 'neu' => $this->publicNeu], JSON_UNESCAPED_SLASHES));
|
||||
} catch (\Throwable $fehler) {
|
||||
foreach (self::ERSETZEN as $eintrag) {
|
||||
if (is_dir($alt.'/'.$eintrag) || is_file($alt.'/'.$eintrag)) {
|
||||
@@ -256,23 +287,32 @@ final class ArchiveUpdate
|
||||
// Speicher - Klassen, die PHP schon geladen hat, laedt es nicht neu. Die Migrationen
|
||||
// gehoeren deshalb in eine eigene Anfrage, die mit der neuen Fassung startet. Der
|
||||
// Wartungszustand bleibt so lange an.
|
||||
// Ein Einmalschlüssel für den Abschluss: Die Seite, die diese Anfrage noch ausgibt, ruft
|
||||
// damit den zweiten Schritt selbst auf - ohne Knopf, und ohne dass eine fremde Seite ihn
|
||||
// auslösen könnte (dazu braucht es zusätzlich eine Anmeldung als Administrator).
|
||||
$token = bin2hex(random_bytes(16));
|
||||
@file_put_contents($this->root.'/'.self::MARKE, json_encode([
|
||||
'fassung' => $ziel,
|
||||
'seit' => gmdate('c'),
|
||||
'alt' => basename($arbeit),
|
||||
'token' => $token,
|
||||
// Für das Zurückschalten: von welcher Fassung, und welche Sicherung dazu gehört.
|
||||
'von' => (string) ($this->status()['von'] ?? ''),
|
||||
'sicherung' => (string) ($sicherung['name'] ?? ''),
|
||||
], JSON_UNESCAPED_SLASHES));
|
||||
$this->phase('migrieren', 'Die neuen Dateien sind da. Die Datenbank wird angepasst …');
|
||||
$melden($schritte, 'Dateien sind ersetzt', true, sprintf(
|
||||
'Fassung %s liegt bereit. Jetzt fehlen noch die Migrationen - sie laufen im nächsten Schritt mit dem neuen Code.',
|
||||
$ziel,
|
||||
));
|
||||
|
||||
return ['ok' => true, 'steps' => $schritte, 'migrate' => true];
|
||||
return ['ok' => true, 'steps' => $schritte, 'migrate' => true, 'token' => $token, 'fassung' => $ziel];
|
||||
}
|
||||
|
||||
/**
|
||||
* Steht ein zweiter Schritt aus? Gibt Fassung und Zeitpunkt zurück, sonst null.
|
||||
*
|
||||
* @return array{fassung: string, seit: string, alt: string}|null
|
||||
* @return array{fassung: string, seit: string, alt: string, token?: string}|null
|
||||
*/
|
||||
public function pending(): ?array
|
||||
{
|
||||
@@ -297,25 +337,49 @@ final class ArchiveUpdate
|
||||
*
|
||||
* @return array{ok: bool, steps: list<array{step: string, ok: bool, detail: string}>}
|
||||
*/
|
||||
public function finish(Installation $installation): array
|
||||
/**
|
||||
* @param (callable(): ?string)|null $pruefen prüft die neue Fassung nach den Migrationen und
|
||||
* gibt null zurück oder den Grund, warum sie nicht
|
||||
* läuft - dann wird zurückgeschaltet
|
||||
*/
|
||||
public function finish(Installation $installation, ?callable $pruefen = null): array
|
||||
{
|
||||
$schritte = [];
|
||||
$offen = $this->pending();
|
||||
$this->protokoll(sprintf('--- Migrationen nach dem Update, %s UTC', gmdate('Y-m-d H:i:s')));
|
||||
$this->phase('migrieren', 'Die Datenbank wird angepasst …', ['laeuft' => true]);
|
||||
try {
|
||||
$namen = $installation->migrate();
|
||||
$schritte[] = ['step' => 'Migrationen', 'ok' => true, 'detail' => [] === $namen
|
||||
? 'Es stand keine an - die Datenbank war bereits auf dem Stand der neuen Fassung.'
|
||||
: sprintf('%d ausgeführt: %s.', count($namen), implode(', ', $namen))];
|
||||
} catch (\Throwable $fehler) {
|
||||
$schritte[] = ['step' => 'Migrationen', 'ok' => false, 'detail' => $fehler->getMessage().' Die Website bleibt im Wartungszustand, bis das geklärt ist.'];
|
||||
$schritte[] = ['step' => 'Migrationen', 'ok' => false, 'detail' => $fehler->getMessage()];
|
||||
$this->protokoll('FEHLER Migrationen: '.$fehler->getMessage());
|
||||
|
||||
return ['ok' => false, 'steps' => $schritte];
|
||||
return $this->zurueckschalten($offen, 'Die Datenbank ließ sich nicht anpassen: '.$fehler->getMessage(), $schritte);
|
||||
}
|
||||
$this->protokoll('ok Migrationen: '.$schritte[0]['detail']);
|
||||
|
||||
if (null !== $pruefen) {
|
||||
$this->phase('pruefen', 'Die neue Fassung wird geprüft …');
|
||||
try {
|
||||
$problem = $pruefen();
|
||||
} catch (\Throwable $fehler) {
|
||||
$problem = $fehler->getMessage();
|
||||
}
|
||||
if (null !== $problem) {
|
||||
$schritte[] = ['step' => 'Funktion prüfen', 'ok' => false, 'detail' => $problem];
|
||||
$this->protokoll('FEHLER Funktion prüfen: '.$problem);
|
||||
|
||||
return $this->zurueckschalten($offen, 'Die neue Fassung lief nicht: '.$problem, $schritte);
|
||||
}
|
||||
$schritte[] = ['step' => 'Funktion prüfen', 'ok' => true, 'detail' => 'Startseite und Datenbank antworten.'];
|
||||
$this->protokoll('ok Funktion prüfen: Startseite und Datenbank antworten.');
|
||||
}
|
||||
|
||||
@unlink($this->root.'/'.self::MARKE);
|
||||
$this->updater->maintenance(false);
|
||||
$this->protokoll('ok Migrationen: '.$schritte[0]['detail']);
|
||||
foreach (self::ENTFALLEN_IN_PUBLIC as $eintrag) {
|
||||
@unlink($this->root.'/public/'.$eintrag);
|
||||
}
|
||||
@@ -324,10 +388,109 @@ final class ArchiveUpdate
|
||||
'Fassung %s läuft. Der vorherige Stand liegt unter var/update/ (die zwei jüngsten bleiben).',
|
||||
$this->updater->version(),
|
||||
)];
|
||||
$this->phase('fertig', sprintf('Fassung %s läuft.', $this->updater->version()), ['laeuft' => false]);
|
||||
|
||||
return ['ok' => true, 'steps' => $schritte];
|
||||
}
|
||||
|
||||
/**
|
||||
* Zurück auf die Fassung von vorher: Code aus var/update/<lauf>/alt, die überschriebenen
|
||||
* Dateien in public/ und plugins/, dann die Datenbank aus der Sicherung vor dem Update.
|
||||
* Erst danach geht die Wartungsseite aus - eine halb zurückgeschaltete Website darf niemand
|
||||
* sehen.
|
||||
*/
|
||||
private function zurueckschalten(?array $offen, string $grund, array $schritte): array
|
||||
{
|
||||
$bei = $this->status()['phase'] ?? null;
|
||||
$this->phase('zurueckschalten', 'Etwas ist schiefgegangen – die bisherige Fassung wird wiederhergestellt …', ['bei' => in_array($bei, self::PHASEN, true) ? $bei : null]);
|
||||
$arbeit = $this->root.'/var/update/'.basename((string) ($offen['alt'] ?? ''));
|
||||
$fehler = [];
|
||||
if (null === $offen || !is_dir($arbeit.'/alt')) {
|
||||
$fehler[] = 'Der beiseitegelegte Stand fehlt.';
|
||||
} else {
|
||||
@mkdir($arbeit.'/verworfen', 0o775, true);
|
||||
foreach (self::ERSETZEN as $eintrag) {
|
||||
if (!file_exists($arbeit.'/alt/'.$eintrag)) {
|
||||
continue;
|
||||
}
|
||||
if (file_exists($this->root.'/'.$eintrag) && !@rename($this->root.'/'.$eintrag, $arbeit.'/verworfen/'.$eintrag)) {
|
||||
$fehler[] = $eintrag.' ließ sich nicht beiseitelegen.';
|
||||
continue;
|
||||
}
|
||||
if (!@rename($arbeit.'/alt/'.$eintrag, $this->root.'/'.$eintrag)) {
|
||||
$fehler[] = $eintrag.' ließ sich nicht zurücklegen.';
|
||||
}
|
||||
}
|
||||
$liste = json_decode((string) @file_get_contents($arbeit.'/public.json'), true);
|
||||
foreach ((array) ($liste['neu'] ?? []) as $relativ) {
|
||||
@unlink($this->root.'/'.$relativ);
|
||||
}
|
||||
foreach ((array) ($liste['alt'] ?? []) as $relativ) {
|
||||
if (!@copy($arbeit.'/alt-public/'.$relativ, $this->root.'/'.$relativ)) {
|
||||
$fehler[] = $relativ.' ließ sich nicht zurückkopieren.';
|
||||
}
|
||||
}
|
||||
}
|
||||
if ('' !== (string) ($offen['sicherung'] ?? '')) {
|
||||
$wieder = $this->backup->restore((string) $offen['sicherung']);
|
||||
if (!$wieder['ok']) {
|
||||
$fehler[] = 'Die Datenbank ließ sich nicht aus der Sicherung zurückholen.';
|
||||
}
|
||||
}
|
||||
$von = (string) ($offen['von'] ?? '');
|
||||
if ([] === $fehler) {
|
||||
@unlink($this->root.'/'.self::MARKE);
|
||||
$this->updater->maintenance(false);
|
||||
$meldung = sprintf('%s Zurückgeschaltet auf %s – die Website läuft wie vorher.', $grund, '' === $von ? 'die vorherige Fassung' : $von);
|
||||
$schritte[] = ['step' => 'Zurückgeschaltet', 'ok' => true, 'detail' => $meldung];
|
||||
$this->protokoll('ok Zurückgeschaltet: '.$meldung);
|
||||
$this->phase('zurueckgerollt', $meldung, ['laeuft' => false]);
|
||||
} else {
|
||||
// Bleibt die Wartungsseite an, sieht niemand eine halbe Website; der Rest ist Handarbeit.
|
||||
$meldung = $grund.' Das Zurückschalten gelang nicht vollständig: '.implode(' ', $fehler).' Die Wartungsseite bleibt an.';
|
||||
$schritte[] = ['step' => 'Zurückschalten', 'ok' => false, 'detail' => $meldung];
|
||||
$this->protokoll('FEHLER Zurückschalten: '.$meldung);
|
||||
$this->phase('fehler', $meldung, ['laeuft' => false]);
|
||||
}
|
||||
|
||||
return ['ok' => false, 'steps' => $schritte, 'zurueckgeschaltet' => [] === $fehler];
|
||||
}
|
||||
|
||||
/** Der letzte Fortschritt, oder null, wenn noch nie ein Update über diesen Weg lief. */
|
||||
public function status(): ?array
|
||||
{
|
||||
$daten = json_decode((string) @file_get_contents($this->root.'/'.self::STATUS), true);
|
||||
if (!is_array($daten)) {
|
||||
return null;
|
||||
}
|
||||
// Ein Lauf, der seit einer halben Stunde nichts mehr meldet, ist abgebrochen - etwa weil
|
||||
// das Hosting den Aufruf beendet hat. Dann soll die Seite nicht ewig „läuft“ zeigen.
|
||||
if (($daten['laeuft'] ?? false) && strtotime((string) ($daten['aktualisiert'] ?? '')) < time() - 1800) {
|
||||
$daten['laeuft'] = false;
|
||||
$daten['phase'] = 'fehler';
|
||||
$daten['meldung'] = 'Das Update hat seit einer halben Stunde nichts mehr gemeldet und gilt als abgebrochen. Den Stand zeigt das Protokoll unten.';
|
||||
}
|
||||
|
||||
return $daten;
|
||||
}
|
||||
|
||||
/** Schreibt den Fortschritt - in einem Zug, damit die Seite nie eine halbe Datei liest. */
|
||||
private function phase(string $phase, string $meldung, array $mehr = [], bool $neu = false): void
|
||||
{
|
||||
$daten = $neu ? [] : ($this->status() ?? []);
|
||||
// Bei einem Fehler zählt, in welchem Schritt er passierte - die Seite markiert genau den.
|
||||
if (in_array($phase, ['fehler', 'zurueckgerollt'], true) && !array_key_exists('bei', $mehr)) {
|
||||
$mehr['bei'] = in_array($daten['phase'] ?? null, self::PHASEN, true) ? $daten['phase'] : ($daten['bei'] ?? null);
|
||||
}
|
||||
$daten = [...$daten, ...$mehr, 'phase' => $phase, 'meldung' => $meldung, 'aktualisiert' => gmdate('c')];
|
||||
$datei = $this->root.'/'.self::STATUS;
|
||||
@mkdir(dirname($datei), 0o775, true);
|
||||
$zwischen = $datei.'.'.bin2hex(random_bytes(4)).'.tmp';
|
||||
if (false !== @file_put_contents($zwischen, json_encode($daten, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE))) {
|
||||
@rename($zwischen, $datei);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Zeile ins Protokoll, und zwar sofort.
|
||||
*
|
||||
@@ -361,6 +524,7 @@ final class ArchiveUpdate
|
||||
|
||||
continue;
|
||||
}
|
||||
$this->merken($ziel);
|
||||
if ('.htaccess' === $eintrag && is_file($ziel)) {
|
||||
$this->mergeHtaccess($quelle, $ziel);
|
||||
|
||||
@@ -370,6 +534,25 @@ final class ArchiveUpdate
|
||||
}
|
||||
}
|
||||
|
||||
/** Merkt sich eine Datei, bevor sie überschrieben oder neu angelegt wird. */
|
||||
private function merken(string $ziel): void
|
||||
{
|
||||
if (null === $this->publicSicherung) {
|
||||
return;
|
||||
}
|
||||
$relativ = ltrim(substr($ziel, strlen($this->root)), '/');
|
||||
if (!is_file($ziel)) {
|
||||
$this->publicNeu[] = $relativ;
|
||||
|
||||
return;
|
||||
}
|
||||
$kopie = $this->publicSicherung.'/'.$relativ;
|
||||
@mkdir(dirname($kopie), 0o775, true);
|
||||
if (@copy($ziel, $kopie)) {
|
||||
$this->publicAlt[] = $relativ;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Die neue .htaccess uebernehmen, aber die Zeilen, die der Betreiber eingeschaltet hat,
|
||||
* eingeschaltet lassen: HTTPS-Zwang und HSTS. Vorher kam jedes Update mit den auskommentierten
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace App\Ops;
|
||||
|
||||
/**
|
||||
* Die letzte Sicherung, wenn die Anwendung nicht einmal mehr eine Fehlerseite zustande bringt
|
||||
* (ADR 113).
|
||||
*
|
||||
* Kantarci Reisen am 09.10.2026: Datenbank per FTP ersetzt, danach antwortete jede Adresse mit
|
||||
* einem leeren 500er - für Besucher eine weiße Seite, für den Betreiber kein Hinweis, warum. Der
|
||||
* Grund stand nur im Fehlerprotokoll des Hostings.
|
||||
*
|
||||
* Diese Klasse hängt an nichts aus dem Framework: kein Container, keine Datenbank, keine
|
||||
* Vorlage. Sie schreibt den Grund nach var/log/notfall.log (per FTP lesbar, außerhalb von
|
||||
* public/) und zeigt Besuchern eine schlichte Wartungsseite mit 503 - für Suchmaschinen heißt das
|
||||
* „gleich wieder da“, nicht „kaputt“. Details stehen nie auf der Seite.
|
||||
*/
|
||||
final class Notfall
|
||||
{
|
||||
public const PROTOKOLL = 'var/log/notfall.log';
|
||||
/** Größer wird die Datei nicht; die ältere Hälfte wandert nach notfall.log.1. */
|
||||
private const GRENZE = 1048576;
|
||||
/** Fehlerarten, nach denen PHP abbricht - nur die gehören auf die Notfallseite. */
|
||||
private const ABBRUCH = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR];
|
||||
|
||||
public const KOPFZEILEN = [
|
||||
'Content-Type' => 'text/html; charset=UTF-8',
|
||||
'Retry-After' => '300',
|
||||
'Cache-Control' => 'no-store',
|
||||
'X-Robots-Tag' => 'noindex',
|
||||
'Content-Security-Policy' => "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'",
|
||||
];
|
||||
|
||||
private static ?string $wurzel = null;
|
||||
private static bool $gezeigt = false;
|
||||
|
||||
/** Einmal ganz am Anfang von public/index.php. */
|
||||
public static function einschalten(string $wurzel): void
|
||||
{
|
||||
self::$wurzel = $wurzel;
|
||||
register_shutdown_function(static function (): void {
|
||||
$fehler = error_get_last();
|
||||
if (null === $fehler || !in_array($fehler['type'], self::ABBRUCH, true)) {
|
||||
return;
|
||||
}
|
||||
self::melden(sprintf('PHP-Abbruch: %s in %s:%d', $fehler['message'], $fehler['file'], $fehler['line']));
|
||||
self::seite();
|
||||
// Danach soll nichts mehr ausgeben - auch nicht die Fehlerseite des Frameworks, das
|
||||
// sich ebenfalls fürs Herunterfahren angemeldet hat. exit in einer Shutdown-Funktion
|
||||
// beendet die übrigen.
|
||||
exit;
|
||||
});
|
||||
}
|
||||
|
||||
/** Eine Ausnahme, die aus dem Framework herausgefallen ist. */
|
||||
public static function ausnahme(\Throwable $fehler, string $adresse = ''): void
|
||||
{
|
||||
self::melden(self::beschreiben($fehler, $adresse));
|
||||
self::seite();
|
||||
}
|
||||
|
||||
/** Ein Fehler, den das Framework abgefangen und als 500 beantwortet hat - nur protokollieren. */
|
||||
public static function protokollieren(\Throwable $fehler, string $adresse = ''): void
|
||||
{
|
||||
self::melden(self::beschreiben($fehler, $adresse));
|
||||
}
|
||||
|
||||
public static function beschreiben(\Throwable $fehler, string $adresse = ''): string
|
||||
{
|
||||
$zeile = sprintf('%s: %s in %s:%d', $fehler::class, $fehler->getMessage(), $fehler->getFile(), $fehler->getLine());
|
||||
// Die eigentliche Ursache steckt oft eine Ebene tiefer, etwa das SQLite-Problem unter
|
||||
// einer Doctrine-Ausnahme.
|
||||
for ($tiefer = $fehler->getPrevious(), $n = 0; null !== $tiefer && $n < 3; $tiefer = $tiefer->getPrevious(), ++$n) {
|
||||
$zeile .= sprintf(' | Ursache: %s: %s', $tiefer::class, $tiefer->getMessage());
|
||||
}
|
||||
|
||||
return ('' === $adresse ? '' : $adresse.' – ').$zeile;
|
||||
}
|
||||
|
||||
private static function melden(string $text): void
|
||||
{
|
||||
$wurzel = self::$wurzel ?? dirname(__DIR__, 2);
|
||||
$datei = $wurzel.'/'.self::PROTOKOLL;
|
||||
if (!is_dir(dirname($datei))) {
|
||||
@mkdir(dirname($datei), 0o775, true);
|
||||
}
|
||||
if (is_file($datei) && filesize($datei) > self::GRENZE) {
|
||||
@rename($datei, $datei.'.1');
|
||||
}
|
||||
// Zeilenumbrüche aus Meldungen würden das Protokoll zerreißen; eine Zeile je Ereignis.
|
||||
$text = str_replace(["\r", "\n"], ' ', $text);
|
||||
@file_put_contents($datei, sprintf("[%s UTC] %s\n", gmdate('Y-m-d H:i:s'), mb_substr($text, 0, 4000)), FILE_APPEND | LOCK_EX);
|
||||
}
|
||||
|
||||
/** Die Seite für Besucher. Ohne Details, ohne Abhängigkeiten, ohne fremde Quellen. */
|
||||
private static function seite(): void
|
||||
{
|
||||
if (self::$gezeigt || PHP_SAPI === 'cli') {
|
||||
return;
|
||||
}
|
||||
self::$gezeigt = true;
|
||||
while (ob_get_level() > 0) {
|
||||
@ob_end_clean();
|
||||
}
|
||||
if (!headers_sent()) {
|
||||
http_response_code(503);
|
||||
foreach (self::KOPFZEILEN as $name => $wert) {
|
||||
header($name.': '.$wert);
|
||||
}
|
||||
}
|
||||
echo self::html();
|
||||
}
|
||||
|
||||
/** Die Wartungsseite als Text, auch für den Fehler-Listener im Framework. */
|
||||
public static function html(): string
|
||||
{
|
||||
return '<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">'
|
||||
.'<title>Gleich wieder da</title><style>body{margin:0;min-height:100vh;display:grid;place-items:center;font:17px/1.6 system-ui,-apple-system,"Segoe UI",sans-serif;background:#f4f6f5;color:#1f2d27}'
|
||||
.'main{max-width:460px;padding:32px;text-align:center}h1{font-size:26px;line-height:1.25;margin:0 0 12px}p{margin:0;color:#46514c}</style></head>'
|
||||
.'<body><main><h1>Die Website ist gleich wieder da.</h1><p>Gerade wird etwas erneuert. Bitte versuche es in ein paar Minuten noch einmal.</p>'
|
||||
.'<p lang="en" style="margin-top:14px;font-size:14px">This website will be back shortly.</p></main></body></html>';
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,7 @@ final class LanguageExtension extends AbstractExtension
|
||||
'Navigation' => 'Navigation',
|
||||
'Nach oben' => 'Back to top',
|
||||
'Sprache' => 'Language',
|
||||
'Sprache wählen' => 'Choose language',
|
||||
// Formulare
|
||||
'Dieses Formular ist nicht mehr vorhanden.' => 'This form is no longer available.',
|
||||
'Bitte prüfe 1 Eingabe. Die betroffenen Felder sind unten gekennzeichnet.' => 'Please check 1 entry. The field concerned is marked below.',
|
||||
@@ -105,6 +106,7 @@ final class LanguageExtension extends AbstractExtension
|
||||
'Alle akzeptieren' => 'Tout accepter',
|
||||
'Nur notwendige' => 'Uniquement nécessaires',
|
||||
'Sprache' => 'Langue',
|
||||
'Sprache wählen' => 'Choisir la langue',
|
||||
],
|
||||
'tr' => [
|
||||
'Zum Inhalt springen' => 'İçeriğe geç',
|
||||
@@ -118,6 +120,67 @@ final class LanguageExtension extends AbstractExtension
|
||||
'Alle akzeptieren' => 'Tümünü kabul et',
|
||||
'Nur notwendige' => 'Yalnızca gerekli',
|
||||
'Sprache' => 'Dil',
|
||||
'Sprache wählen' => 'Dil seçin',
|
||||
// Vollständig, damit eine türkische Seite nirgends auf Englisch ausweicht (09.10.2026).
|
||||
'Brotkrumen' => 'Sayfa yolu',
|
||||
'Soziale Profile' => 'Sosyal medya',
|
||||
'Zum Blättern seitwärts scrollen' => 'Gezinmek için yana kaydırın',
|
||||
'Anbieter:' => 'Sağlayıcı:',
|
||||
'Inhalt laden und einwilligen' => 'İçeriği yükle ve onayla',
|
||||
'Einwilligung ändern' => 'Onayı değiştir',
|
||||
'Bildergalerie' => 'Resim galerisi',
|
||||
'Bild' => 'Resim',
|
||||
'vergrößern' => 'büyüt',
|
||||
'Ansehen' => 'İncele',
|
||||
'Weitere Beiträge' => 'Diğer yazılar',
|
||||
'Vorheriger Beitrag' => 'Önceki yazı',
|
||||
'Nächster Beitrag' => 'Sonraki yazı',
|
||||
'Einzeln wählen' => 'Tek tek seç',
|
||||
'Auswahl speichern' => 'Seçimi kaydet',
|
||||
'Zweite Navigation' => 'İkinci menü',
|
||||
'Fußzeile' => 'Alt bilgi',
|
||||
'Rechtliches' => 'Yasal bilgiler',
|
||||
'Navigation' => 'Menü',
|
||||
'Dieses Formular ist nicht mehr vorhanden.' => 'Bu form artık mevcut değil.',
|
||||
'Bitte prüfe 1 Eingabe. Die betroffenen Felder sind unten gekennzeichnet.' => 'Lütfen 1 girişi kontrol edin. İlgili alan aşağıda işaretlenmiştir.',
|
||||
'Bitte prüfe %d Eingaben. Die betroffenen Felder sind unten gekennzeichnet.' => 'Lütfen %d girişi kontrol edin. İlgili alanlar aşağıda işaretlenmiştir.',
|
||||
'Pflichtfeld' => 'zorunlu alan',
|
||||
'in' => '',
|
||||
'bitte wählen' => 'lütfen seçin',
|
||||
'Zum Absenden dieses Formulars wird JavaScript benötigt. Schreib uns sonst gern direkt eine E-Mail.' => 'Bu formu göndermek için JavaScript gereklidir. Dilerseniz bize doğrudan e-posta gönderebilirsiniz.',
|
||||
'Zusammenfassung' => 'Özet',
|
||||
'Bitte prüfen Sie Ihre Angaben. Über „Ändern“ kommen Sie zum jeweiligen Schritt zurück.' => 'Lütfen bilgilerinizi kontrol edin. „Değiştir“ ile ilgili adıma geri dönebilirsiniz.',
|
||||
'Schritte des Formulars' => 'Form adımları',
|
||||
'Schritt' => 'Adım',
|
||||
'Zurück' => 'Geri',
|
||||
'Weiter' => 'İleri',
|
||||
'Ändern' => 'Değiştir',
|
||||
'Ja' => 'Evet',
|
||||
'Bitte wähle mindestens eine Option.' => 'Lütfen en az bir seçenek seçin.',
|
||||
'Absenden' => 'Gönder',
|
||||
'Vielen Dank. Deine Nachricht ist angekommen.' => 'Teşekkürler. Mesajınız bize ulaştı.',
|
||||
'Beiträge' => 'Yazılar',
|
||||
'Beitrag' => 'yazı',
|
||||
'Kategorie' => 'Kategori',
|
||||
'Schlagwort' => 'Etiket',
|
||||
'Alle Beiträge, die neuesten zuerst.' => 'Tüm yazılar, en yenisi önce.',
|
||||
'Beiträge in der Kategorie „%s“.' => '„%s“ kategorisindeki yazılar.',
|
||||
'Beiträge zum Schlagwort „%s“.' => '„%s“ etiketli yazılar.',
|
||||
'von' => 'yazan:',
|
||||
'← Neuere' => '← Daha yeni',
|
||||
'Ältere →' => 'Daha eski →',
|
||||
'Seite %d von %d' => 'Sayfa %d / %d',
|
||||
'Externe Inhalte und Einwilligung' => 'Harici içerikler ve onay',
|
||||
'Diese Website lädt bestimmte Inhalte erst, wenn du zustimmst. Ohne Zustimmung wird die Seite vollständig angezeigt, nur eingebettete Inhalte anderer Anbieter bleiben leer.' => 'Bu web sitesi bazı içerikleri ancak onayınızdan sonra yükler. Onay vermeseniz de sayfa eksiksiz görüntülenir; yalnızca diğer sağlayıcıların yerleştirilmiş içerikleri boş kalır.',
|
||||
'Statistik' => 'İstatistik',
|
||||
'Externe Inhalte' => 'Harici içerikler',
|
||||
'Messung, wie die Website benutzt wird.' => 'Web sitesinin nasıl kullanıldığının ölçülmesi.',
|
||||
'Inhalte von anderen Anbietern, etwa Karten, Videos, Bewertungen oder ein Chat. Sie sehen die Adresse dieser Seite und deine IP-Adresse, sobald sie geladen werden.' => 'Harita, video, değerlendirme veya sohbet gibi diğer sağlayıcıların içerikleri. Yüklendiklerinde bu sayfanın adresini ve IP adresinizi görürler.',
|
||||
'Seite nicht gefunden' => 'Sayfa bulunamadı',
|
||||
'Diese Seite ist nicht mehr verfügbar.' => 'Bu sayfa artık mevcut değil.',
|
||||
'Die gesuchte Seite ist nicht verfügbar. Über die Startseite findest du wieder zum richtigen Inhalt.' => 'Aradığınız sayfa mevcut değil. Ana sayfadan doğru içeriğe ulaşabilirsiniz.',
|
||||
'Die Adresse ist möglicherweise veraltet oder wurde falsch eingegeben. Über die Startseite findest du wieder zum richtigen Inhalt.' => 'Adres eskimiş veya yanlış yazılmış olabilir. Ana sayfadan doğru içeriğe ulaşabilirsiniz.',
|
||||
'Zur Startseite' => 'Ana sayfaya',
|
||||
],
|
||||
];
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{% extends 'admin/shell.html.twig' %}
|
||||
{% set nav = 'system' %}
|
||||
{% block title %}System{% endblock %}
|
||||
{% block scripts %}<script type="module" src="/assets/system-update.js?v={{ kant_version() }}"></script>{% endblock %}
|
||||
{% block heading %}System und Update{% endblock %}
|
||||
{% block lead %}Fassung, Updateweg und Sicherungen dieser Installation.{% endblock %}
|
||||
{% block content %}
|
||||
@@ -137,10 +138,17 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card">
|
||||
{# Update mit einem Klick (ADR 113), nach dem Vorbild von reiseSoft: Beim Öffnen fragt die Seite
|
||||
selbst nach einer neuen Fassung, zeigt die Hinweise dazu und einen Knopf. Danach laufen die
|
||||
Schritte sichtbar durch - bis „abgeschlossen“ oder „zurückgeschaltet“. Ohne Skript bleiben die
|
||||
Formulare darunter. #}
|
||||
<div class="card update-card" id="update" data-update
|
||||
data-installiert="{{ state.version }}" data-csrf="{{ csrf_token('system') }}"
|
||||
data-pruefen="{{ path('admin_update_check') }}" data-status="{{ path('admin_update_status') }}"
|
||||
data-starten="{{ path('admin_update_start') }}" data-abschliessen="{{ path('admin_update_finish') }}">
|
||||
<div class="card-head">
|
||||
<h2>{{ state.worktree ? 'Update aus dem Releasearchiv' : 'Update einspielen' }}</h2>
|
||||
<span class="muted">{{ state.worktree ? 'Alternative zum Weg über Git' : 'holt die neue Fassung' }}</span>
|
||||
<h2>Update</h2>
|
||||
<span class="muted">Installiert <strong data-update-installiert>{{ state.version }}</strong></span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if archivePending %}
|
||||
@@ -150,35 +158,30 @@
|
||||
<input type="hidden" name="finish" value="1">
|
||||
<button class="primary" type="submit">Migrationen ausführen und freigeben →</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="muted">Ohne Git: Archiv laden, Prüfsumme vergleichen, Datenbank und Medien sichern, Wartungszustand ein, Dateien tauschen. Der vorherige Stand wird beiseitegelegt, nicht gelöscht.</p>
|
||||
{# Das Kleingedruckte steht zugeklappt: Wer aktualisiert, braucht zuerst den Knopf. #}
|
||||
<details class="hint">
|
||||
<summary>Was unberührt bleibt</summary>
|
||||
<p class="field-help"><code>var/</code>, <code>.env</code>, <code>.env.local</code>, die geladenen Schriften und das erzeugte Stylesheet-Bündel.</p>
|
||||
<p class="field-help">Danach folgt ein zweiter Knopf für die Migrationen. Das ist kein Umweg: Erst eine neue Anfrage startet mit dem Code der neuen Fassung.</p>
|
||||
</details>
|
||||
{% if archiveVersion %}
|
||||
<p class="notice done">Verfügbar: <strong>{{ archiveVersion }}</strong> (installiert: {{ state.version }}).</p>
|
||||
{% elseif asked %}
|
||||
<p class="field-help">Die Releaseseite nennt nichts Neueres als {{ state.version }}.</p>
|
||||
{% else %}
|
||||
<p class="field-help"><a href="{{ path('admin_system', {pruefen: 1}) }}">Nachsehen, ob es etwas Neues gibt</a> – dauert einen Moment.</p>
|
||||
{% endif %}
|
||||
<form action="{{ path('admin_system_archive') }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
|
||||
<div class="fields">
|
||||
<div>
|
||||
<label for="archive-target">Fassung</label>
|
||||
<input id="archive-target" name="target" maxlength="20" placeholder="{{ archiveVersion ?: '1.4.2' }}" pattern="[0-9]+\.[0-9]+\.[0-9]+">
|
||||
<p class="field-help">Leer lassen für die neueste veröffentlichte Fassung.</p>
|
||||
<div class="update-live" data-update-live role="status" aria-live="polite" hidden></div>
|
||||
<details class="hint update-manual">
|
||||
<summary>Weitere Möglichkeiten</summary>
|
||||
<p class="field-help">Ohne Git: Archiv laden, Prüfsumme vergleichen, Datenbank und Medien sichern, Wartungsseite an, Dateien tauschen, Datenbank anpassen, prüfen. Unberührt bleiben <code>var/</code>, <code>.env</code>, die geladenen Schriften und das erzeugte Stylesheet-Bündel.</p>
|
||||
<form action="{{ path('admin_system_archive') }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
|
||||
<div class="fields">
|
||||
<div>
|
||||
<label for="archive-target">Bestimmte Fassung</label>
|
||||
<input id="archive-target" name="target" maxlength="20" placeholder="1.4.2" pattern="[0-9]+\.[0-9]+\.[0-9]+">
|
||||
<p class="field-help">Leer lassen für die neueste veröffentlichte Fassung. Dieser Weg funktioniert auch ohne JavaScript.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="primary" type="submit">Update aus dem Archiv einspielen →</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<button class="secondary" type="submit">Update aus dem Archiv einspielen</button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
<dialog id="update-dialog" class="update-dialog" aria-labelledby="update-dialog-title">
|
||||
<h2 id="update-dialog-title">Auf <span data-update-ziel></span> aktualisieren?</h2>
|
||||
<p>Vorher werden Datenbank und Medien automatisch gesichert. Die Website zeigt etwa eine Minute lang eine Wartungsseite. Schlägt etwas fehl, wird automatisch auf {{ state.version }} zurückgeschaltet.</p>
|
||||
<div class="dialog-actions"><button type="button" class="secondary" data-update-abbrechen>Abbrechen</button><button type="button" class="primary" data-update-los>Update starten</button></div>
|
||||
</dialog>
|
||||
|
||||
{% if state.worktree %}
|
||||
<div class="card">
|
||||
|
||||
@@ -305,7 +305,7 @@
|
||||
{% set sprachen = app.request.attributes.get('kant_alternates')|default([]) %}
|
||||
{% if editing|default(false) and sprachen|length < 2 %}{% set sprachen = [{locale: 'de', native: 'Deutsch', href: '#', current: true}, {locale: 'en', native: 'English', href: '#', current: false}] %}{% endif %}
|
||||
{% if sprachen|length > 1 %}
|
||||
<nav id="{{ block.id }}" class="block-language{{ designClasses }}" aria-label="{{ 'Sprache'|kant_t }}">
|
||||
<nav id="{{ block.id }}" class="block-language{{ designClasses }}" aria-label="{{ (inHeader|default(false) ? 'Sprache' : 'Sprache wählen')|kant_t }}">
|
||||
<ul>{% for sprache in sprachen %}<li><a href="{{ sprache.href }}" hreflang="{{ sprache.locale }}" lang="{{ sprache.locale }}"{% if sprache.current %} aria-current="page"{% endif %}>{% if props.display|default('code') == 'name' %}{{ sprache.native }}{% else %}{{ sprache.locale|upper }}<span class="visually-hidden"> {{ sprache.native }}</span>{% endif %}</a></li>{% endfor %}</ul>
|
||||
</nav>
|
||||
{% elseif editing|default(false) %}<p id="{{ block.id }}" class="block-unavailable{{ designClasses }}" role="note">Sprachwahl</p>
|
||||
|
||||
@@ -2,14 +2,24 @@
|
||||
|
||||
namespace App\Tests;
|
||||
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\PartialRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Infrastructure\ConnectionFactory;
|
||||
use App\Ops\ArchiveUpdate;
|
||||
use App\Ops\Backup;
|
||||
use App\Ops\CommandRunner;
|
||||
use App\Ops\Download;
|
||||
use App\Ops\Installation;
|
||||
use App\Ops\Updater;
|
||||
use App\Security\CmsUser;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactory;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasher;
|
||||
|
||||
/**
|
||||
* Das Update über das Releasearchiv - der Weg für Installationen ohne Git.
|
||||
@@ -109,6 +119,10 @@ final class ArchiveUpdateTest extends TestCase
|
||||
$offen = $this->updater()->pending();
|
||||
self::assertNotNull($offen, 'Ohne Marke findet niemand mehr den zweiten Schritt.');
|
||||
self::assertSame('1.1.0', $offen['fassung']);
|
||||
// Mit dem Einmalschlüssel ruft die Zwischenseite den Abschluss selbst auf (ADR 112).
|
||||
self::assertMatchesRegularExpression('/^[a-f0-9]{32}$/D', (string) ($offen['token'] ?? ''));
|
||||
self::assertSame($offen['token'], $ergebnis['token']);
|
||||
self::assertSame('1.1.0', $ergebnis['fassung']);
|
||||
}
|
||||
|
||||
public function testNothingIsPendingBeforeAnUpdateAndAfterAFailedOne(): void
|
||||
@@ -268,6 +282,77 @@ final class ArchiveUpdateTest extends TestCase
|
||||
}
|
||||
}
|
||||
|
||||
public function testTheProgressFileFollowsTheUpdateAndAFinishedOneSaysSo(): void
|
||||
{
|
||||
// Die Systemseite fragt diesen Fortschritt ab, während eine andere Anfrage das Update
|
||||
// ausführt (ADR 113) - Phase, Meldung, von welcher auf welche Fassung.
|
||||
$this->releases->release(self::BASE, self::API, '1.1.0', $this->archive('1.1.0'));
|
||||
$updater = $this->updater();
|
||||
self::assertNull($updater->status());
|
||||
self::assertSame('Fortschritt live.', $updater->latest()['notes'] ?? null, 'Die Hinweise der Releaseseite gehören zur neuen Fassung.');
|
||||
|
||||
$ergebnis = $updater->run();
|
||||
self::assertTrue($ergebnis['ok'], $this->explain($ergebnis));
|
||||
$status = $updater->status();
|
||||
self::assertSame(['migrieren', true, '1.0.0', '1.1.0'], [$status['phase'], $status['laeuft'], $status['von'], $status['auf']]);
|
||||
|
||||
$fertig = $this->updater()->finish($this->installation());
|
||||
self::assertTrue($fertig['ok'], $this->explain($fertig));
|
||||
self::assertSame(['fertig', false], [$this->updater()->status()['phase'], $this->updater()->status()['laeuft']]);
|
||||
self::assertFileDoesNotExist($this->root.'/'.Updater::MAINTENANCE_FILE);
|
||||
}
|
||||
|
||||
public function testANewVersionThatDoesNotRunIsSwitchedBackCompletely(): void
|
||||
{
|
||||
// Wie bei reiseSoft: Läuft die neue Fassung nach den Migrationen nicht, kommt die alte
|
||||
// zurück - Code, public/ und Datenbank -, und erst dann geht die Wartungsseite aus.
|
||||
$this->releases->release(self::BASE, self::API, '1.1.0', $this->archive('1.1.0'));
|
||||
$this->db->insert('website', ['id' => 'default', 'name' => 'Vorher', 'default_locale' => 'de', 'theme' => '{}']);
|
||||
$ergebnis = $this->updater()->run();
|
||||
self::assertTrue($ergebnis['ok'], $this->explain($ergebnis));
|
||||
self::assertSame('1.1.0', trim((string) file_get_contents($this->root.'/VERSION')));
|
||||
// Etwas, das die neue Fassung in der Datenbank anrichtet, bevor die Prüfung scheitert.
|
||||
$this->db->update('website', ['name' => 'Nachher'], ['id' => 'default']);
|
||||
|
||||
$ende = $this->updater()->finish($this->installation(), static fn (): string => 'Die Startseite antwortet mit 500.');
|
||||
|
||||
self::assertFalse($ende['ok']);
|
||||
self::assertTrue($ende['zurueckgeschaltet'] ?? false, $this->explain($ende));
|
||||
self::assertSame('1.0.0', trim((string) file_get_contents($this->root.'/VERSION')));
|
||||
self::assertFileExists($this->root.'/src/Alt.php');
|
||||
self::assertFileDoesNotExist($this->root.'/src/Neu.php');
|
||||
self::assertSame("<?php // alt\n", file_get_contents($this->root.'/public/index.php'), 'Auch public/ kommt zurück.');
|
||||
self::assertSame('body{color:alt}', file_get_contents($this->root.'/public/assets/site.css'));
|
||||
self::assertSame('SCHRIFT', file_get_contents($this->root.'/public/assets/fonts/eigene.woff2'));
|
||||
self::assertFileDoesNotExist($this->root.'/public/assets/fonts/andere.woff2');
|
||||
$this->db->close();
|
||||
$this->db = ConnectionFactory::create('sqlite:///'.$this->root.'/var/cms.db');
|
||||
self::assertSame('Vorher', $this->db->fetchOne("SELECT name FROM website WHERE id = 'default'"), 'Die Datenbank kommt aus der Sicherung vor dem Update.');
|
||||
self::assertFileDoesNotExist($this->root.'/'.Updater::MAINTENANCE_FILE, 'Zurückgeschaltet heißt: die Website läuft wieder.');
|
||||
self::assertNull($this->updater()->pending());
|
||||
$status = $this->updater()->status();
|
||||
self::assertSame(['zurueckgerollt', 'pruefen', false], [$status['phase'], $status['bei'], $status['laeuft']], 'Gescheitert ist die Prüfung - genau dieser Schritt wird markiert.');
|
||||
self::assertStringContainsString('Zurückgeschaltet auf 1.0.0', $status['meldung']);
|
||||
}
|
||||
|
||||
private function installation(): Installation
|
||||
{
|
||||
$settings = new SiteSettings($this->db, new MediaLibrary($this->db, $this->root.'/var/media'));
|
||||
$validator = new DocumentValidator();
|
||||
|
||||
return new Installation(
|
||||
$this->db,
|
||||
new PageRepository($this->db, $validator, $settings),
|
||||
new PartialRepository($this->db, $validator),
|
||||
new MenuRepository($this->db, $settings),
|
||||
$settings,
|
||||
new UserPasswordHasher(new PasswordHasherFactory([CmsUser::class => ['algorithm' => 'auto']])),
|
||||
$this->root,
|
||||
$this->root.'/var/media',
|
||||
'test-secret',
|
||||
);
|
||||
}
|
||||
|
||||
private function updater(): ArchiveUpdate
|
||||
{
|
||||
$runner = new CommandRunner();
|
||||
@@ -299,7 +384,8 @@ final class ArchiveUpdateTest extends TestCase
|
||||
'bin/console' => "<?php // neu\n",
|
||||
'config/services.yaml' => "services:\n",
|
||||
'templates/base.html.twig' => 'neu',
|
||||
'migrations.php' => "<?php return ['neu'];\n",
|
||||
'migrations.php' => "<?php return ['table_storage' => ['table_name' => 'migration_versions'], 'migrations_paths' => ['DoctrineMigrations' => __DIR__.'/migrations'], 'all_or_nothing' => false];\n",
|
||||
'migrations/.keep' => '',
|
||||
'cli-config.php' => "<?php // neu\n",
|
||||
'composer.json' => '{"name":"neu"}',
|
||||
'composer.lock' => '{"packages":["neu"]}',
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests;
|
||||
|
||||
use App\EventSubscriber\ErrorLogSubscriber;
|
||||
use App\Ops\Notfall;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
||||
|
||||
/**
|
||||
* Kein leerer 500er mehr (ADR 113).
|
||||
*
|
||||
* Kantarci Reisen am 09.10.2026: Datenbank per FTP ersetzt, danach eine weiße Seite auf jeder
|
||||
* Adresse und kein Hinweis darauf, warum. Seitdem sehen Besucher eine Wartungsseite mit 503, und
|
||||
* der Grund steht in var/log/notfall.log.
|
||||
*/
|
||||
final class NotfallTest extends TestCase
|
||||
{
|
||||
private string $root;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->root = str_replace('\\', '/', sys_get_temp_dir()).'/kant-notfall-'.bin2hex(random_bytes(6));
|
||||
mkdir($this->root, 0o775, true);
|
||||
Notfall::einschalten($this->root);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
foreach (glob($this->root.'/var/log/*') ?: [] as $datei) {
|
||||
unlink($datei);
|
||||
}
|
||||
@rmdir($this->root.'/var/log');
|
||||
@rmdir($this->root.'/var');
|
||||
@rmdir($this->root);
|
||||
}
|
||||
|
||||
private function ereignis(\Throwable $fehler): ExceptionEvent
|
||||
{
|
||||
$kernel = $this->createStub(HttpKernelInterface::class);
|
||||
|
||||
return new ExceptionEvent($kernel, Request::create('/leistungen'), HttpKernelInterface::MAIN_REQUEST, $fehler);
|
||||
}
|
||||
|
||||
public function testAServerErrorBecomesAMaintenancePageAndALogLine(): void
|
||||
{
|
||||
$ursache = new \PDOException('file is not a database');
|
||||
$ereignis = $this->ereignis(new \RuntimeException('Abfrage fehlgeschlagen', 0, $ursache));
|
||||
|
||||
(new ErrorLogSubscriber(debug: false))->onException($ereignis);
|
||||
|
||||
$antwort = $ereignis->getResponse();
|
||||
self::assertNotNull($antwort);
|
||||
self::assertSame(503, $antwort->getStatusCode(), 'Für Suchmaschinen: gleich wieder da, nicht kaputt.');
|
||||
self::assertSame('300', $antwort->headers->get('Retry-After'));
|
||||
self::assertStringContainsString('Die Website ist gleich wieder da.', (string) $antwort->getContent());
|
||||
self::assertStringNotContainsString('database', (string) $antwort->getContent(), 'Einzelheiten gehören ins Protokoll, nicht auf die Seite.');
|
||||
$protokoll = (string) file_get_contents($this->root.'/'.Notfall::PROTOKOLL);
|
||||
self::assertStringContainsString('GET /leistungen – RuntimeException: Abfrage fehlgeschlagen', $protokoll);
|
||||
self::assertStringContainsString('Ursache: PDOException: file is not a database', $protokoll, 'Die eigentliche Ursache steckt eine Ebene tiefer.');
|
||||
}
|
||||
|
||||
public function testANotFoundIsNoEmergencyAndDevelopmentKeepsItsDetailedPage(): void
|
||||
{
|
||||
$nichtGefunden = $this->ereignis(new NotFoundHttpException('Gibt es nicht'));
|
||||
(new ErrorLogSubscriber(debug: false))->onException($nichtGefunden);
|
||||
self::assertNull($nichtGefunden->getResponse());
|
||||
self::assertFileDoesNotExist($this->root.'/'.Notfall::PROTOKOLL);
|
||||
|
||||
// „Zugriff verweigert“ führt zur Anmeldung - die Sicherheitsschicht entscheidet, nicht der Notfall.
|
||||
$verweigert = $this->ereignis(new \Symfony\Component\Security\Core\Exception\AccessDeniedException());
|
||||
(new ErrorLogSubscriber(debug: false))->onException($verweigert);
|
||||
self::assertNull($verweigert->getResponse());
|
||||
self::assertFileDoesNotExist($this->root.'/'.Notfall::PROTOKOLL);
|
||||
|
||||
$entwicklung = $this->ereignis(new \LogicException('Fehler beim Entwickeln'));
|
||||
(new ErrorLogSubscriber(debug: true))->onException($entwicklung);
|
||||
self::assertNull($entwicklung->getResponse(), 'Im Entwicklungsmodus bleibt die ausführliche Fehlerseite.');
|
||||
self::assertStringContainsString('LogicException: Fehler beim Entwickeln', (string) file_get_contents($this->root.'/'.Notfall::PROTOKOLL));
|
||||
}
|
||||
|
||||
public function testTheFrontControllerLoadsTheEmergencyLayerBeforeAnythingElse(): void
|
||||
{
|
||||
$einstieg = (string) file_get_contents(dirname(__DIR__).'/public/index.php');
|
||||
self::assertLessThan(strpos($einstieg, 'config/bootstrap.php'), strpos($einstieg, 'Notfall::einschalten'), 'Scheitert schon der Start, muss die Notfallschicht stehen.');
|
||||
self::assertStringContainsString('catch (Throwable $fehler)', $einstieg);
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ final class ReleaseOnDisk extends Download
|
||||
/** Legt ein vollständiges Release ab: Releaseliste, Archiv und Prüfsumme. */
|
||||
public function release(string $base, string $api, string $version, string $archiv, ?string $checksum = null): void
|
||||
{
|
||||
$this->serve($api.'/releases/latest', json_encode(['tag_name' => 'v'.$version], JSON_THROW_ON_ERROR));
|
||||
$this->serve($api.'/releases/latest', json_encode(['tag_name' => 'v'.$version, 'name' => 'KantSites '.$version, 'body' => 'Fortschritt live.'], JSON_THROW_ON_ERROR));
|
||||
$this->serve(sprintf('%s/v%s/kantsites-%s.zip', $base, $version, $version), $archiv);
|
||||
$this->serve(sprintf('%s/v%s/kantsites-%s.zip.sha256', $base, $version, $version), sprintf(
|
||||
"%s kantsites-%s.zip\n",
|
||||
|
||||
@@ -265,6 +265,79 @@ final class SystemScreenTest extends WebTestCase
|
||||
self::assertStringContainsString('Fassung ermitteln', (string) $client->getResponse()->getContent());
|
||||
}
|
||||
|
||||
public function testTheSecondUpdateStepRunsByItselfWithItsOneTimeKey(): void
|
||||
{
|
||||
// Umut am 09.10.2026: Update eingespielt, „direkt danach kam 500“, zurück, dann migriert.
|
||||
// Die Anfrage, die die Dateien tauscht, stirbt am gemischten Code. Seit ADR 112 endet sie
|
||||
// mit einer schlichten Seite, die den Abschluss über diesen Weg selbst aufruft.
|
||||
$marke = dirname(__DIR__).'/'.ArchiveUpdate::MARKE;
|
||||
@mkdir(dirname($marke), 0o775, true);
|
||||
file_put_contents($marke, json_encode(['fassung' => '9.9.9', 'seit' => gmdate('c'), 'alt' => 'x', 'token' => str_repeat('a', 32)]));
|
||||
// Die Testdatenbank entsteht aus dem Schema, nicht aus Migrationen. Eine echte
|
||||
// Installation kennt ihre ausgeführten Migrationen - dasselbe Protokoll hier.
|
||||
$this->db->executeStatement('CREATE TABLE IF NOT EXISTS migration_versions (version VARCHAR(191) NOT NULL PRIMARY KEY, executed_at DATETIME DEFAULT NULL, execution_time INTEGER DEFAULT NULL)');
|
||||
foreach (glob(dirname(__DIR__).'/migrations/Version*.php') ?: [] as $datei) {
|
||||
$this->db->executeStatement('INSERT OR IGNORE INTO migration_versions (version, executed_at, execution_time) VALUES (?, ?, 0)', ['DoctrineMigrations\\'.basename($datei, '.php'), gmdate('Y-m-d H:i:s')]);
|
||||
}
|
||||
try {
|
||||
$redaktion = $this->login('editor');
|
||||
$redaktion->request('GET', '/admin/system/abschliessen?token='.str_repeat('a', 32));
|
||||
self::assertSame(403, $redaktion->getResponse()->getStatusCode(), 'Nur ein Administrator schließt ein Update ab.');
|
||||
self::assertFileExists($marke);
|
||||
|
||||
$admin = $this->login();
|
||||
$admin->request('GET', '/admin/system/abschliessen?token=falsch');
|
||||
$admin->followRedirect();
|
||||
self::assertStringContainsString('gehört zu keinem offenen Update', (string) $admin->getResponse()->getContent());
|
||||
self::assertFileExists($marke, 'Ein falscher Schlüssel schließt nichts ab.');
|
||||
|
||||
$admin->request('GET', '/admin/system/abschliessen?token='.str_repeat('a', 32));
|
||||
self::assertSame('/admin/system', parse_url((string) $admin->getResponse()->headers->get('Location'), PHP_URL_PATH));
|
||||
$admin->followRedirect();
|
||||
self::assertStringContainsString('Update auf 9.9.9 abgeschlossen', (string) $admin->getResponse()->getContent());
|
||||
self::assertFileDoesNotExist($marke, 'Nach dem Abschluss ist nichts mehr offen.');
|
||||
|
||||
// Neu laden ist harmlos.
|
||||
$admin->request('GET', '/admin/system/abschliessen?token='.str_repeat('a', 32));
|
||||
$admin->followRedirect();
|
||||
self::assertStringContainsString('bereits abgeschlossen', (string) $admin->getResponse()->getContent());
|
||||
} finally {
|
||||
@unlink($marke);
|
||||
}
|
||||
}
|
||||
|
||||
public function testTheOneClickUpdateEndpointsAreForAdministratorsOnly(): void
|
||||
{
|
||||
// Der Fortschritt für die Systemseite (ADR 113): Redakteure sehen ihn nicht, ein Start
|
||||
// braucht das Token der Sitzung, ein Abschluss den Einmalschlüssel des laufenden Updates.
|
||||
$redaktion = $this->login('editor');
|
||||
$redaktion->request('GET', '/admin/system/update/status');
|
||||
self::assertSame(403, $redaktion->getResponse()->getStatusCode());
|
||||
|
||||
$admin = $this->login();
|
||||
$admin->request('GET', '/admin/system/update/status');
|
||||
self::assertSame(200, $admin->getResponse()->getStatusCode());
|
||||
self::assertArrayHasKey('installiert', json_decode((string) $admin->getResponse()->getContent(), true));
|
||||
|
||||
$admin->request('POST', '/admin/system/update/starten', [], [], ['HTTP_X_CSRF_TOKEN' => 'erfunden']);
|
||||
self::assertSame(403, $admin->getResponse()->getStatusCode());
|
||||
self::assertStringContainsString('Sitzung ist abgelaufen', (string) $admin->getResponse()->getContent());
|
||||
|
||||
$admin->request('POST', '/admin/system/update/abschliessen', ['token' => 'egal']);
|
||||
self::assertStringContainsString('bereits abgeschlossen', (string) $admin->getResponse()->getContent(), 'Ohne offenes Update gibt es nichts abzuschließen.');
|
||||
|
||||
$marke = dirname(__DIR__).'/'.ArchiveUpdate::MARKE;
|
||||
@mkdir(dirname($marke), 0o775, true);
|
||||
file_put_contents($marke, json_encode(['fassung' => '9.9.9', 'seit' => gmdate('c'), 'alt' => 'x', 'token' => str_repeat('b', 32)]));
|
||||
try {
|
||||
$admin->request('POST', '/admin/system/update/abschliessen', ['token' => 'falsch']);
|
||||
self::assertSame(403, $admin->getResponse()->getStatusCode());
|
||||
self::assertFileExists($marke);
|
||||
} finally {
|
||||
@unlink($marke);
|
||||
}
|
||||
}
|
||||
|
||||
public function testNobodyButAnAdministratorCanStartAMigration(): void
|
||||
{
|
||||
// Ein Knopf, der die Datenbank umbaut, ist das lohnendste Ziel auf diesem Bildschirm.
|
||||
|
||||
Reference in new issue
Block a user