Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e47f278a13 | ||
|
|
e8a9dcdf5c | ||
|
|
04927b9563 | ||
|
|
3b1dfc0948 |
No files matched your search
+2
-2
@@ -25,9 +25,9 @@
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/browser-tests/pilot-3fc9a10171d3/pilot.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantit-media'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8091 -t public bin/dev-router.php"
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/browser-tests/pilot-a1c96276da5c/pilot.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantit-media'; & C:/php84/php.exe -d upload_max_filesize=24M -d post_max_size=26M -d memory_limit=256M -S 127.0.0.1:8092 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8091
|
||||
"port": 8092
|
||||
},
|
||||
{
|
||||
"name": "elektro-bernhard",
|
||||
|
||||
@@ -284,7 +284,7 @@ Unter Linux/macOS `npm` und `npx` ohne `.cmd`; in CI `npx playwright install --w
|
||||
|
||||
Die Verwaltung liegt unter `/admin`. `php bin/console cms:admin:path meine-verwaltung` legt sie auf eine andere Adresse: der Wert landet in `.env`, gilt für Routen, Firewall und Schutzkopfzeilen, und wird in der `robots.txt` bewusst nicht genannt. Ohne Argument zeigt der Befehl die aktuelle Adresse.
|
||||
|
||||
Die öffentliche Adresse der Website steht als `SITE_ORIGIN` in `.env` – mit oder ohne `www`, so wie sie in Canonical, Sitemap, Feed und strukturierten Daten stehen soll. `php bin/console cms:site:origin https://www.meine-firma.de` prüft und schreibt sie; ohne Argument zeigt der Befehl die aktuelle. Die jeweils andere Schreibweise (`meine-firma.de` ↔ `www.meine-firma.de`) leitet die Anwendung selbst mit 301 auf die eingetragene um, mit Pfad und Abfrage, auch für die Verwaltung. Ein ganz anderer Host – die Vorschauadresse des Hosters, eine IP-Adresse, `localhost` – bleibt erreichbar, damit eine falsch eingetragene Adresse niemanden aussperrt; der Bildschirm „System“ zeigt dann, unter welcher Adresse die Verwaltung gerade läuft und welche eingetragen ist. HTTPS erzwingt die Anwendung nicht; dafür bleiben die drei Zeilen in `public/.htaccess`.
|
||||
Die öffentliche Adresse der Website steht als `SITE_ORIGIN` in `.env` – mit oder ohne `www`, so wie sie in Canonical, Sitemap, Feed und strukturierten Daten stehen soll. `php bin/console cms:site:origin https://www.meine-firma.de` prüft und schreibt sie; ohne Argument zeigt der Befehl die aktuelle. Dasselbe geht in der Verwaltung unter „System“, mit einem Link auf die andere Schreibweise. Dort wird vorher geprüft, ob die Website unter der neuen Adresse antwortet. Die jeweils andere Schreibweise (`meine-firma.de` ↔ `www.meine-firma.de`) leitet die Anwendung selbst mit 301 auf die eingetragene um, mit Pfad und Abfrage, auch für die Verwaltung. Ein ganz anderer Host – die Vorschauadresse des Hosters, eine IP-Adresse, `localhost` – bleibt erreichbar, damit eine falsch eingetragene Adresse niemanden aussperrt; der Bildschirm „System“ zeigt dann, unter welcher Adresse die Verwaltung gerade läuft und welche eingetragen ist. HTTPS erzwingt die Anwendung nicht; dafür bleiben die drei Zeilen in `public/.htaccess`.
|
||||
|
||||
`composer format` formatiert PHP. `composer build` wärmt den Symfony-Produktionscache; öffentliche CSS-Dateien sind direkt auslieferbar, es ist kein Frontend-Bundler nötig. Bei Änderungen an `public/assets/design-tokens.json` einmal `npm.cmd run build:design` ausführen und die erzeugte CSS-Datei mitliefern; Node ist auf dem Hosting nicht erforderlich. Nach Änderungen an Produktionskonfiguration/Templates `php bin/console cache:clear --env=prod --no-debug` ausführen. Falls lokale Tests alte Templates verwenden, vor PHPUnit auch `php bin/console cache:clear --env=test --no-debug` ausführen.
|
||||
|
||||
|
||||
@@ -38,6 +38,9 @@ services:
|
||||
App\Content\MediaLibrary:
|
||||
arguments:
|
||||
$directory: '%env(projectdir:MEDIA_DIRECTORY)%'
|
||||
App\Content\DocumentLibrary:
|
||||
arguments:
|
||||
$directory: '%env(projectdir:MEDIA_DIRECTORY)%'
|
||||
# Die Schriften liegen im oeffentlichen Ordner: sie sollen vom Webserver direkt ausgeliefert
|
||||
# werden, ohne PHP dazwischen - das ist der Punkt der Uebung.
|
||||
# Die Stellen, die wissen muessen, wie die Verwaltung heisst.
|
||||
@@ -79,6 +82,11 @@ services:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
$adminPath: '%kant.admin_path%'
|
||||
$environment: '%kernel.environment%'
|
||||
# Eigene kurze Zeitgrenzen statt des Download-Dienstes mit denen fuer Releasearchive.
|
||||
App\Ops\OriginCheck:
|
||||
arguments:
|
||||
$secret: '%kernel.secret%'
|
||||
$download: null
|
||||
App\Command\SiteOriginCommand:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
|
||||
@@ -128,7 +128,7 @@ Umut wollte für die DEEG-Website „kein Kontaktformular weiteres, sondern so e
|
||||
|
||||
**Anlass:** Umut: „URL sollte aus env kommen, www oder ohne, die Seite sollte dann darauf reagieren.“ Bis dahin stand die Umleitung auskommentiert in `public/.htaccess` und musste von Hand zur Einstellung `SITE_ORIGIN` passen (LIVEGANG, Punkt 3). Zwei Stellen für eine Entscheidung laufen auseinander – und dann nennt jede Seite in Canonical, Sitemap und Feed eine Adresse, die weiterleitet.
|
||||
|
||||
**Entscheidung:** `SITE_ORIGIN` in `.env` ist die einzige Quelle. `CanonicalHostSubscriber` (`kernel.request`, Priorität 48, also vor Wartungszustand und Installer) vergleicht den Host der Anfrage mit dem von `SITE_ORIGIN` und schickt die jeweils andere Schreibweise – `example.de` ↔ `www.example.de` – mit 301 auf die eingetragene, mit Pfad und Abfrage, Schema und Port aus `SITE_ORIGIN`. Das gilt auch für die Verwaltung: ein Host, ein Sitzungscookie. Die Antwort trägt `Cache-Control: public, max-age=3600`, damit ein Browser die Umleitung behält, ohne dass eine Korrektur der Einstellung ein Jahr lang unsichtbar bliebe. Der www-Block in `.htaccess` ist weg; die Datei erklärt, wer es jetzt macht. `php bin/console cms:site:origin <adresse>` prüft (Schema, Host, kein Pfad) und schreibt die Einstellung, wie `cms:admin:path` es für die Verwaltung tut. Der Bildschirm „System“ zeigt die eingetragene Adresse, ihre Quelle und einen Hinweis, wenn die Verwaltung gerade unter einem anderen Host läuft.
|
||||
**Entscheidung:** `SITE_ORIGIN` in `.env` ist die einzige Quelle. `CanonicalHostSubscriber` (`kernel.request`, Priorität 48, also vor Wartungszustand und Installer) vergleicht den Host der Anfrage mit dem von `SITE_ORIGIN` und schickt die jeweils andere Schreibweise – `example.de` ↔ `www.example.de` – mit 301 auf die eingetragene, mit Pfad und Abfrage, Schema und Port aus `SITE_ORIGIN`. Das gilt auch für die Verwaltung: ein Host, ein Sitzungscookie. Die Antwort trägt `Cache-Control: public, max-age=3600`, damit ein Browser die Umleitung behält, ohne dass eine Korrektur der Einstellung ein Jahr lang unsichtbar bliebe. Der www-Block in `.htaccess` ist weg; die Datei erklärt, wer es jetzt macht. `php bin/console cms:site:origin <adresse>` prüft (Schema, Host, kein Pfad) und schreibt die Einstellung, wie `cms:admin:path` es für die Verwaltung tut. Der Bildschirm „System“ zeigt die eingetragene Adresse, ihre Quelle und einen Hinweis, wenn die Verwaltung gerade unter einem anderen Host läuft. Seit 1.21.8 lässt sie sich dort auch ändern, wie die „WordPress-Adresse“: Das Feld schreibt über `Environment::write` in `.env`, aber erst nachdem `OriginCheck` die Installation unter der neuen Adresse erreicht hat (`GET /.well-known/kantsites-origin` liefert einen HMAC aus `APP_SECRET`). Eine Umstellung auf www ohne DNS oder Zertifikat würde sonst jede Seite samt Verwaltung ins Leere leiten. „Trotzdem übernehmen“ erscheint erst nach einer Ablehnung. Kommt `SITE_ORIGIN` aus der Server-Umgebung, gibt es kein Feld.
|
||||
|
||||
**Bewusst nicht:** ein fremder Host wird nicht umgeleitet. Die Vorschauadresse des Hosters, eine IP-Adresse, `localhost`, eine Subdomain – alles bleibt erreichbar, auch wenn `SITE_ORIGIN` etwas anderes nennt. Sonst sperrte eine falsch eingetragene Adresse jeden aus, samt der Verwaltung, die man bräuchte, um es zu bemerken. Nur lesende Anfragen (GET, HEAD) werden umgeleitet: ein 301 macht aus einem POST ein GET und verwirft die Eingabe. Und HTTPS erzwingt die Anwendung nicht: hinter einem Proxy sieht PHP oft nur `http`, ohne konfigurierte vertrauenswürdige Proxys wäre das eine Schleife. Die drei Zeilen dafür bleiben in `.htaccess`, wo der Webserver die Verbindung wirklich kennt.
|
||||
|
||||
|
||||
+23
-1
@@ -1,5 +1,17 @@
|
||||
# Projektstatus
|
||||
|
||||
## PDFs in der Mediathek, als Linkziel und im Betrachter (22.09.2026)
|
||||
|
||||
**Anlass:** Umut: „kann man aktuell PDFs hochladen und als Button verlinken? … und ein Inline PDF Viewer wie Bild-Popup“, danach: „ja setz um, einfacher Browser-Betrachter“.
|
||||
|
||||
- **Hochladen:** Das Upload-Feld der Mediathek nimmt jetzt neben JPEG, PNG und WebP auch PDFs bis 20 MB an. Ob eine Datei ein PDF ist, entscheidet ihr Inhalt (`%PDF-` in den ersten 1024 Bytes), nicht die Endung. Die PDFs stehen in einer eigenen Tabelle `document` (Migration `20260917000100`) und nicht in `media`. Jede Bildauswahl erwartet dort Varianten mit Breite und Höhe. Die Datei liegt unverändert als `<id>.pdf` im Medienordner und geht deshalb mit jeder Sicherung mit. Die Mediathek zeigt die PDFs unter den Bildern, mit Titel und Archivieren.
|
||||
- **Ausliefern:** `/media/<id>.pdf` kommt mit `Content-Type: application/pdf`, `nosniff`, `inline` und dem Titel als Dateinamen, `X-Robots-Tag: noindex` und `frame-ancestors 'self'`: Einbetten darf es nur die eigene Website. Archivierte PDFs bleiben erreichbar, wie Bilder.
|
||||
- **Verlinken:** Jedes Linkziel (Button, Bild, Symbolkarte, CTA, Symbol) hat den Knopf „PDF aus Mediathek wählen“ mit einer Liste, Suche und Hochladen direkt im Dialog. Ins Feld kommt die Adresse `/media/<id>.pdf`, ein gewöhnlicher lokaler Pfad, also ohne neue Schemaversion.
|
||||
- **Betrachter:** `public/assets/lightbox.js` (dieselbe Datei wie der Bildbetrachter, damit eine Seite kein Skript mehr lädt) zeigt ein verlinktes PDF in einem `<dialog>` mit `<iframe>` und nutzt dafür den PDF-Betrachter des Browsers, dazu Herunterladen, „Im neuen Tab“ und Schließen. Wo der Browser keinen eingebauten Betrachter hat (`navigator.pdfViewerEnabled`) oder das Gerät ein Touchgerät ist, öffnet sich das PDF im neuen Tab: Android-Chrome hat keinen Betrachter, iOS-Safari zeigt im Rahmen nur die erste Seite. Ist „In neuem Tab öffnen“ gesetzt oder das Skript nicht geladen, bleibt es ein normaler Link. Skript und `frame-src 'self'` kommen nur auf Seiten, deren Dokumente (auch Kopf, Fuß, Sektionen und Fließtext) auf ein PDF der Mediathek zeigen.
|
||||
- **Grenzen:** Hochgeladene PDFs sind über ihre Adresse öffentlich. Kein PDF.js, also sieht der Betrachter je nach Browser anders aus. Chrome zeigt im Kopf des Betrachters die Kennung aus der Adresse, nicht den Titel. PDFs werden nicht gelöscht, nur archiviert.
|
||||
|
||||
**Geprüft:** zwei neue Tests (`CmsTest::testAPdfFromTheLibraryIsALinkTargetAndOpensInTheViewer`, `EditorSecurityTest::testPdfsGoThroughTheMediaUploadAndTheEditorPicker`: Ablehnung von Nicht-PDFs, Kopfzeilen, 404, Skript und Richtlinie nur mit PDF-Link, Formular-Upload, API mit Token, Archiv). Im Browser mit der Pilotkopie: Klick auf den Button öffnet das zweiseitige PDF im Popup, Schließen gibt den Fokus an den Button zurück, bei 375 px mit Touch öffnet es einen neuen Tab. Der Auswahldialog im Editor ist nur per Syntaxprüfung geprüft, nicht im Browser. Dafür wäre eine Anmeldung nötig.
|
||||
|
||||
## Siebte Installation: Landingpage „KANTARCI Immobilien – Mietanfrage“ (18.09.2026)
|
||||
|
||||
**Anlass:** Umut: „neues Projekt wir brauchen eine einfache landingpage nach gleichem prinzip. einfache landingpage impressum und datenschutz notwendig? baus ein“ – dazu das Paket `KANTARCI-Immobilien-Landingpage.zip` (Next.js/Vinext für Cloudflare mit D1-Datenbank: Kopf, dunkler Hero, Werteleiste, Mietanfrage, Fuß, ein Banner als einziges Bild). Später: „Formular gefällt mir noch nicht kannst du ein multipage formular machen ggfls mit icon checkbox“ und die Anbieterdaten (Geschäftsführer Ridvan Kantarci, Ooser Hauptstr. 51, 76532 Baden-Baden, info@kantarci-immo.de).
|
||||
@@ -13,6 +25,16 @@
|
||||
|
||||
**Geprüft:** Aufbau per Reset-Skript fehlerfrei; Startseite, Impressum, Datenschutz 200, unbekannte Adresse 404; im Browser (1280 px und 375 px, kein seitliches Scrollen) alle drei Schritte, Bedingung Personenzahl, Zusammenfassung und Absenden – die Anfrage liegt vollständig in `form_submission`. Mailversand ist lokal nicht eingerichtet. Lighthouse und axe noch nicht gelaufen.
|
||||
|
||||
## Adresse der Website in der Verwaltung (21.09.2026)
|
||||
|
||||
**Anlass:** Umut: „ja bau die Einstellung in der Verwaltung ein sodass es in die env eingetragen wird und übernommen wird“ – mit oder ohne www wie die „WordPress-Adresse“, statt nur über `cms:site:origin` oder die `.env` von Hand.
|
||||
|
||||
- **System → Diese Installation → Adresse** hat ein Feld „Adresse der Website“ und einen Link, der direkt auf die andere Schreibweise (mit/ohne www) umstellt. Gespeichert wird `SITE_ORIGIN` in `.env` über `Environment::write`. Das gilt ab der nächsten Anfrage, weil `%env()%` zur Laufzeit gelesen wird. Kommt `SITE_ORIGIN` aus der Server-Umgebung, gibt es kein Feld, und ein POST wird abgewiesen.
|
||||
- **Schutz vor dem Aussperren:** Vor dem Speichern fragt die Installation sich selbst unter der neuen Adresse an (`OriginCheck`, `GET /.well-known/kantsites-origin`). Zurückkommen muss ein HMAC aus `APP_SECRET`, den nur diese Installation kennt. Ohne DNS-Eintrag oder Zertifikat für www wird nichts geändert – sonst leitete die andere Schreibweise ins Leere, die Verwaltung eingeschlossen. Erst nach einer Ablehnung erscheint „Trotzdem übernehmen“ (etwa wenn der Server sich selbst nicht erreicht, oder bei http im lokalen Betrieb). Die Prüfadresse ist auch im Wartungszustand erreichbar.
|
||||
- Wechselt die Verwaltung dadurch den Host, ist dort eine neue Anmeldung nötig. Das Sitzungscookie gilt nur für die Schreibweise, unter der es entstand. Die Meldung sagt das.
|
||||
|
||||
**Geprüft:** zwei neue Tests in `SystemScreenTest` (Server-Umgebung ohne Feld, Ablehnung einer nicht erreichbaren Adresse ohne Schreiben in `.env`, falsche Eingaben, Prüfadresse; `OriginCheck` mit fremder und eigener Kennung). Im Browser mit der Pilotkopie bei 1280 und 375 px (kein seitliches Scrollen) Feld, Ablehnung und „Trotzdem übernehmen“ angesehen.
|
||||
|
||||
## Favicon auch für die Google-Suche (21.09.2026)
|
||||
|
||||
**Anlass:** Umut: Google zeigt bei Elektro Bernhard weiter das KantSites-K, obwohl ein eigenes Symbol gesetzt ist – „mach das im Kern sobald man Favicon setzt alles ersetzen“.
|
||||
@@ -1061,7 +1083,7 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
|
||||
## Bekannte Einschränkungen
|
||||
|
||||
- Gestaltung über geprüfte Tokens, keine beliebigen Pixelwerte, eigenen Schriften oder freien Farben. Noch keine Inline-Textbearbeitung, Formulare, dynamischen Menüs, Plugin-/Themeverwaltung oder WordPress-Import. Abschnittsvorlagen sind lokale Blockrezepte, keine globale Templateverwaltung.
|
||||
- Mediathek derzeit für statische Bilder: keine Videos/PDFs/SVGs, Lightbox/Slideshow, freie Bildzuschnitte, Ordner, Paginierung oder physische Löschung. Bild-URLs sind öffentlich; Archivieren schützt keine vertraulichen Dateien. Originale werden durch optimierte Varianten ersetzt. Einblendungen starten beim Laden, nicht beim Scrollen.
|
||||
- Mediathek derzeit für statische Bilder und PDFs (seit 22.09.2026): keine Videos/SVGs, Lightbox/Slideshow, freie Bildzuschnitte, Ordner, Paginierung oder physische Löschung. Bild-URLs sind öffentlich; Archivieren schützt keine vertraulichen Dateien. Originale werden durch optimierte Varianten ersetzt. Einblendungen starten beim Laden, nicht beim Scrollen.
|
||||
- Sektionen bieten die vorhandenen Farb-/Breitentokens geräteübergreifend. Benutzerverwaltung und Passwortänderung sind noch nicht als Browseroberfläche verfügbar.
|
||||
- **Der Aufgabenplan ist von Anfragen abhängig.** Ohne Besucher und ohne geöffnetes Backend läuft nichts; minutengenau ist nur ein echter Cronjob oder der URL-Cronjob des Hosters. Unter Apache mit mod_php belegt eine laufende Aufgabe einen PHP-Prozess, nachdem die Seite beim Besucher ist (dort gibt es kein `fastcgi_finish_request()`) – deshalb sind die Grenzen je Seitenaufruf klein (2 Aufgaben, 20 Sekunden) und der Auslöser abschaltbar. Die Sicherung ist die schwerste Aufgabe und läuft trotzdem mit: eine tägliche Sicherung ist mehr wert als keine. **Nicht geprüft:** das Verhalten unter mod_php und unter echter Last – hier lief alles gegen den eingebauten PHP-Server.
|
||||
- **Offene redaktionelle Feststellung:** `/grundlagen` trägt einen im Editor gesetzten Textblock in `#d08080` auf hellem Grund – 2,75:1, unter der AA-Schwelle. Die Farbe wurde **nicht** verändert, weil sie eine Nutzerentscheidung ist; die Prüfung nennt sie bei jedem Lauf, und die Kontrastwarnung im Editor meldet sie ebenfalls.
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260917000100 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Dokumente: PDFs in der Mediathek, zum Verlinken und fuer den Betrachter.';
|
||||
}
|
||||
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// Eine eigene Tabelle statt einer Art-Spalte in media: jede Stelle, die ein Bild sucht,
|
||||
// erwartet Varianten mit Breite und Hoehe. Ein PDF dort waere ein Bild ohne Bild - an
|
||||
// einem Dutzend Stellen. Getrennt kann keine Bildauswahl je ein Dokument finden.
|
||||
$document = $schema->createTable('document');
|
||||
$document->addColumn('id', 'string', ['length' => 32]);
|
||||
$document->addColumn('title', 'string', ['length' => 160]);
|
||||
$document->addColumn('bytes', 'integer');
|
||||
$document->addColumn('archived', 'boolean', ['default' => false]);
|
||||
$document->addColumn('version', 'integer', ['default' => 1]);
|
||||
$document->addColumn('created_at', 'datetime_immutable');
|
||||
$document->setPrimaryKey(['id']);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$schema->dropTable('document');
|
||||
}
|
||||
}
|
||||
@@ -684,6 +684,14 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.facts { display: grid; grid-template-columns: minmax(0, auto) minmax(0, 1fr); gap: 8px 20px; margin: 14px 0 0; font-size: 13px }
|
||||
.facts dt { color: var(--muted) }
|
||||
.facts dd { margin: 0; overflow-wrap: anywhere }
|
||||
/* Die Adresse der Website auf der Systemseite: Feld und Knopf in einer Zeile, am Telefon untereinander. */
|
||||
.origin-form { margin-top: 4px; max-width: 560px }
|
||||
.origin-form label:first-of-type { margin-top: 10px }
|
||||
.origin-row { display: flex; flex-wrap: wrap; gap: 8px }
|
||||
.origin-row input { flex: 1 1 240px; width: auto; box-sizing: border-box; min-height: 40px; height: 40px }
|
||||
.card-body .origin-row .secondary { margin: 0 }
|
||||
.origin-form .check-label { margin-top: 10px }
|
||||
.link-button { border: 0; background: none; padding: 0; min-height: 0; font: inherit; color: var(--accent); text-decoration: underline; cursor: pointer }
|
||||
.check-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 10px; font-size: 13px; line-height: 1.55 }
|
||||
.check-list li { display: flex; gap: 11px; align-items: flex-start }
|
||||
.check-list li > span { min-width: 0; overflow-wrap: anywhere }
|
||||
@@ -967,3 +975,5 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.theme-save .muted { font-size: 12px }
|
||||
.theme-form .button-defaults { margin: 0; background: var(--paper) }
|
||||
@media (max-width: 1000px) { .type-layout { grid-template-columns: 1fr } }
|
||||
/* Ein Dokument in der Mediathek: drei Buchstaben statt eines Zeichens. */
|
||||
.row-icon-text { font-size: 11px; font-weight: 700; letter-spacing: .04em }
|
||||
+14
-7
@@ -1,5 +1,5 @@
|
||||
import { EditorHistory, labels, blockLabel, blockIcon, isPluginBlock, setStyle, effectiveStyle, locate, clone, isContainer, canContain, insertBlock, insertBlockAt, insertionTarget, moveBlockTo, duplicateBlock, moveBlock, devices, effectiveDesign, setDesign, blockPath, insertPreset, insertRow, insertRowAt, setEffect, insertMedia, upgradeDocument, CATEGORIES, ROW_COLUMNS, whyNot, dropList } from './editor-model.js';
|
||||
import { openMedia, createMediaPreview } from './media.js';
|
||||
import { openMedia, openDocuments, createMediaPreview } from './media.js';
|
||||
import { createDesignPanel } from './design-panel.js';
|
||||
import { renderButtonControls, setButtonDesign } from './button-design.js';
|
||||
import { createRichText } from './rich-text.js';
|
||||
@@ -330,7 +330,14 @@ function renderContent(block, panel) {
|
||||
input.addEventListener('blur', () => { history.mergeKey = null; });
|
||||
if (options.checkbox) container.append(labelEl); else container.append(labelEl, input);
|
||||
if (options.help) { const help = document.createElement('p'); help.className = 'field-help'; help.id = `${id}-help`; help.textContent = options.help; input.setAttribute('aria-describedby', help.id); container.append(help); }
|
||||
if (key === 'href') field('In neuem Tab öffnen', 'newTab', block.props.newTab === true, { checkbox: true, container });
|
||||
if (key === 'href') {
|
||||
// Ein PDF ist ein Linkziel wie jedes andere: seine Adresse steht danach im Feld. Ohne neuen
|
||||
// Tab zeigt die Website es im Betrachter über der Seite.
|
||||
const pdf = document.createElement('button'); pdf.type = 'button'; pdf.className = 'secondary href-document'; pdf.textContent = 'PDF aus Mediathek wählen';
|
||||
pdf.addEventListener('click', () => openDocuments({ onChoose: item => { input.value = item.src; applyValue(); history.mergeKey = null; } }));
|
||||
container.append(pdf);
|
||||
field('In neuem Tab öffnen', 'newTab', block.props.newTab === true, { checkbox: true, container });
|
||||
}
|
||||
};
|
||||
if (!block) {
|
||||
field('Seitentitel', 'title', history.document.title, { maxLength: 160 });
|
||||
@@ -373,7 +380,7 @@ function renderContent(block, panel) {
|
||||
field('Symbol', 'icon', block.props.icon, { choices: Object.fromEntries(ICONS.map(name => [name, name])) });
|
||||
field('Titel', 'title', block.props.title, { maxLength: 120 });
|
||||
field('Text', 'text', block.props.text, { multiline: true, maxLength: 600 });
|
||||
field('Linkziel', 'href', block.props.href || '', { help: 'Optional. Lokaler Pfad, Sprungmarke oder HTTPS-Adresse.' });
|
||||
field('Linkziel', 'href', block.props.href || '', { help: 'Optional. Lokaler Pfad, Sprungmarke oder HTTPS-Adresse – oder ein PDF.' });
|
||||
}
|
||||
if (block.type === 'stat') {
|
||||
field('Zahl', 'value', block.props.value, { maxLength: 16, help: 'Zum Beispiel 15+, 24/7 oder 100 %.' });
|
||||
@@ -389,7 +396,7 @@ function renderContent(block, panel) {
|
||||
field('Titel', 'title', block.props.title, { maxLength: 160 });
|
||||
field('Text', 'text', block.props.text, { multiline: true, maxLength: 600 });
|
||||
field('Beschriftung der Schaltfläche', 'buttonText', block.props.buttonText, { maxLength: 80 });
|
||||
field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse.' });
|
||||
field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse – oder ein PDF.' });
|
||||
}
|
||||
if (block.type === 'contact') {
|
||||
field('Telefon', 'phone', block.props.phone || '', { maxLength: 80, help: 'Wird als anrufbarer Link ausgegeben.' });
|
||||
@@ -410,7 +417,7 @@ function renderContent(block, panel) {
|
||||
field('Größe', 'size', String(block.props.size), { number: true, choices: Object.fromEntries(ICON_SIZES.map(size => [String(size), `${size} px`])), help: 'Feste Stufen, weil die Größe eine Klasse ist – ein freier Wert hätte keine und das Symbol wäre unsichtbar.' });
|
||||
field('Nur Dekoration', 'decorative', block.props.decorative, { checkbox: true, help: 'Dekorative Symbole werden Screenreadern verschwiegen. Trägt das Symbol eine Aussage, nimm das Häkchen weg und beschrifte es.' });
|
||||
if (!block.props.decorative) field('Beschriftung', 'label', block.props.label, { maxLength: 120 });
|
||||
field('Linkziel', 'href', block.props.href || '', { help: 'Optional. Lokaler Pfad, Sprungmarke oder HTTPS-Adresse.' });
|
||||
field('Linkziel', 'href', block.props.href || '', { help: 'Optional. Lokaler Pfad, Sprungmarke oder HTTPS-Adresse – oder ein PDF.' });
|
||||
}
|
||||
if (block.type === 'social') {
|
||||
field('Größe', 'size', String(block.props.size), { number: true, choices: Object.fromEntries(ICON_SIZES.map(size => [String(size), `${size} px`])), help: 'Die Adressen stehen unter Einstellungen → Unternehmensdaten → Soziale Profile. Farbe, Abstand und Ausrichtung unter Design.' });
|
||||
@@ -527,7 +534,7 @@ function renderContent(block, panel) {
|
||||
field('Auszug zeigen', 'showExcerpt', block.props.showExcerpt !== false, { checkbox: true });
|
||||
field('Kategorie zeigen', 'showTerms', block.props.showTerms !== false, { checkbox: true, help: 'Alle drei aus: ein Raster aus Bild und Titel, wie es zu Projekten passt.' });
|
||||
}
|
||||
if (block.type === 'button') field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse.' });
|
||||
if (block.type === 'button') field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse – oder ein PDF.' });
|
||||
if (block.type === 'image') {
|
||||
renderImageWidth(block, panel);
|
||||
imagePreview = createMediaPreview({ src: block.props.sources?.[0]?.src || block.props.src, label: block.props.alt || block.props.caption });
|
||||
@@ -536,7 +543,7 @@ function renderContent(block, panel) {
|
||||
const showLinkOptions = value => { linkOptions.hidden = value !== 'link'; };
|
||||
const clickAction = block.props?.click || (block.props?.href ? 'link' : 'keine');
|
||||
field('Beim Klick', 'click', clickAction, { choices: { keine: 'Nichts tun', vergroessern: 'Bild vergrößern', link: 'Zu einer Adresse führen' }, onInput: showLinkOptions });
|
||||
field('Linkziel', 'href', block.props.href || '', { container: linkOptions, help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse. Für ein Logo im Kopfbereich meist /.' });
|
||||
field('Linkziel', 'href', block.props.href || '', { container: linkOptions, help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse – oder ein PDF. Für ein Logo im Kopfbereich meist /.' });
|
||||
showLinkOptions(clickAction); panel.append(linkOptions);
|
||||
const picker = document.createElement('button'); picker.className = 'secondary'; picker.textContent = 'Bild aus Mediathek wählen';
|
||||
picker.addEventListener('click', () => chooseMedia(false)); panel.append(picker);
|
||||
|
||||
@@ -99,3 +99,86 @@
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
/*
|
||||
* Der PDF-Betrachter - in derselben Datei wie der Bildbetrachter, damit eine Seite mit beidem
|
||||
* nicht zwei Skripte laedt.
|
||||
*
|
||||
* Ohne dieses Skript ist ein Link auf ein PDF ein gewöhnlicher Link: der Browser öffnet die
|
||||
* Datei. Das Skript zeigt sie stattdessen in einem Rahmen über der Seite, mit dem Betrachter,
|
||||
* den der Browser ohnehin mitbringt - kein eigener Nachbau, der ein Megabyte wiegt.
|
||||
*
|
||||
* Wo der Browser keinen solchen Betrachter hat oder ihn im Rahmen nur halb zeigt, bleibt es beim
|
||||
* neuen Tab: Chrome auf Android hat keinen, und Safari auf dem iPhone zeigt im Rahmen nur die
|
||||
* erste Seite. Ein Link mit „In neuem Tab öffnen“ bleibt ohnehin, was er ist.
|
||||
*/
|
||||
(() => {
|
||||
const PDF = /^\/media\/[a-f0-9]{32}\.pdf$/;
|
||||
const links = [...document.querySelectorAll('a[href]')].filter(link => {
|
||||
const url = new URL(link.href, location.href);
|
||||
|
||||
return url.origin === location.origin && PDF.test(url.pathname) && '_blank' !== link.target;
|
||||
});
|
||||
if (0 === links.length) {
|
||||
return;
|
||||
}
|
||||
const eingebettet = false !== navigator.pdfViewerEnabled && !matchMedia('(pointer: coarse)').matches;
|
||||
|
||||
let dialog = null;
|
||||
let rahmen = null;
|
||||
let titel = null;
|
||||
let laden = null;
|
||||
|
||||
const baue = () => {
|
||||
dialog = document.createElement('dialog');
|
||||
dialog.className = 'pdf-dialog';
|
||||
dialog.setAttribute('aria-labelledby', 'pdf-dialog-title');
|
||||
dialog.innerHTML = `
|
||||
<div class="pdf-bar">
|
||||
<p class="pdf-title" id="pdf-dialog-title"></p>
|
||||
<a class="pdf-action pdf-download" download>Herunterladen</a>
|
||||
<a class="pdf-action pdf-open" target="_blank" rel="noopener">Im neuen Tab</a>
|
||||
<button class="pdf-close" type="button" aria-label="Schließen">×</button>
|
||||
</div>
|
||||
<iframe class="pdf-frame" title="PDF-Dokument"></iframe>
|
||||
`;
|
||||
document.body.append(dialog);
|
||||
rahmen = dialog.querySelector('.pdf-frame');
|
||||
titel = dialog.querySelector('.pdf-title');
|
||||
laden = [dialog.querySelector('.pdf-download'), dialog.querySelector('.pdf-open')];
|
||||
dialog.querySelector('.pdf-close').addEventListener('click', () => dialog.close());
|
||||
dialog.addEventListener('click', event => {
|
||||
if (event.target === dialog) {
|
||||
dialog.close();
|
||||
}
|
||||
});
|
||||
// Der Rahmen wird geleert, sonst läuft ein eingebettetes Video oder Audio im PDF weiter
|
||||
// und das nächste Öffnen zeigt kurz das vorige Dokument.
|
||||
dialog.addEventListener('close', () => rahmen.removeAttribute('src'));
|
||||
};
|
||||
|
||||
for (const link of links) {
|
||||
link.addEventListener('click', event => {
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || 1 === event.button) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (!eingebettet) {
|
||||
window.open(link.href, '_blank', 'noopener');
|
||||
|
||||
return;
|
||||
}
|
||||
if (null === dialog) {
|
||||
baue();
|
||||
}
|
||||
const name = (link.getAttribute('aria-label') || link.textContent || '').trim();
|
||||
titel.textContent = name || 'Dokument';
|
||||
rahmen.title = name ? `PDF: ${name}` : 'PDF-Dokument';
|
||||
for (const aktion of laden) {
|
||||
aktion.href = link.href;
|
||||
}
|
||||
rahmen.src = link.href;
|
||||
dialog.showModal();
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -28,3 +28,13 @@
|
||||
.media-dialog> .panel-hint{margin-top:20px}
|
||||
.media-details>p{font-size:12px}
|
||||
@media(max-width:767px){.media-dialog{padding:18px}.media-tools,.media-layout{grid-template-columns:1fr}.media-details{padding:20px 0 0;border-left:0;border-top:1px solid var(--line)}.media-grid{max-height:280px;grid-template-columns:repeat(2,minmax(0,1fr))}.media-footer{align-items:stretch;flex-direction:column}.media-heading{gap:8px}}
|
||||
/* PDFs: eine Liste statt eines Rasters - es gibt nichts zu sehen außer dem Titel. */
|
||||
.document-dialog{max-width:720px}
|
||||
.document-list{display:grid;gap:8px;max-height:360px;overflow:auto;padding:4px}
|
||||
.document-card{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:12px;border:2px solid var(--line);border-radius:9px;padding:10px 12px;background:white;color:var(--ink);text-align:left}
|
||||
.document-card[aria-pressed=true]{border-color:var(--accent);background:#edf4dc}
|
||||
.document-card strong{font-size:13px;overflow-wrap:anywhere}
|
||||
.document-card span{font-size:11px;color:var(--muted)}
|
||||
.document-card .document-badge{font-weight:700;color:#8b2f22;background:#fff0eb;border-radius:5px;padding:4px 6px;letter-spacing:.04em}
|
||||
.media-error{color:#8b2f22;background:#fff0eb;padding:12px;border-radius:6px}
|
||||
.href-document{margin:6px 0 4px}
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Dragging images onto the upload field, and saying which files are about to go up.
|
||||
* Dragging images and PDFs onto the upload field, and saying which files are about to go up.
|
||||
*
|
||||
* Everything here is an addition. Without this script the field is still a file input with a
|
||||
* label: clicking it opens the file picker and the form submits the same way. The script never
|
||||
@@ -9,19 +9,19 @@ const zone = document.querySelector('[data-dropzone]');
|
||||
const input = zone?.querySelector('input[type="file"]');
|
||||
const chosen = document.querySelector('[data-chosen]');
|
||||
if (zone && input && chosen) {
|
||||
const ACCEPTED = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
const ACCEPTED = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'];
|
||||
|
||||
const describe = (files, ignored = 0) => {
|
||||
if (!files.length) {
|
||||
chosen.textContent = ignored
|
||||
? `Keine Bilddatei dabei. ${ignored} Datei(en) übersprungen – erlaubt sind JPEG, PNG und WebP.`
|
||||
? `Keine passende Datei dabei. ${ignored} Datei(en) übersprungen – erlaubt sind JPEG, PNG, WebP und PDF.`
|
||||
: 'Noch keine Datei gewählt.';
|
||||
return;
|
||||
}
|
||||
const names = [...files].map(file => file.name).join(', ');
|
||||
const size = [...files].reduce((total, file) => total + file.size, 0);
|
||||
chosen.textContent = `${files.length} Datei(en) gewählt (${(size / 1048576).toFixed(1)} MB): ${names}`
|
||||
+ (ignored ? ` · ${ignored} Datei(en) übersprungen, erlaubt sind JPEG, PNG und WebP.` : '');
|
||||
+ (ignored ? ` · ${ignored} Datei(en) übersprungen, erlaubt sind JPEG, PNG, WebP und PDF.` : '');
|
||||
};
|
||||
|
||||
input.addEventListener('change', () => describe(input.files));
|
||||
|
||||
@@ -140,4 +140,70 @@ export function openMedia({ multiple = false, onChoose, needsAlt = true } = {})
|
||||
api().then(data => { items = data.items; token = data.csrfToken; lock(false); render(); }).catch(err => { lock(false); error(err.message || 'Medien konnten nicht geladen werden. Bitte erneut öffnen.'); });
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein PDF aus der Mediathek wählen - oder eines hochladen und gleich wählen.
|
||||
*
|
||||
* Ein eigener, schmaler Dialog statt einer Betriebsart des Bilderdialogs: ein PDF hat keine
|
||||
* Vorschau, keinen Alternativtext und keine Mehrfachauswahl. Gewählt wird die Adresse, die dann
|
||||
* im Linkziel steht.
|
||||
*/
|
||||
export function openDocuments({ onChoose } = {}) {
|
||||
const opener = document.activeElement;
|
||||
const dialog = element('dialog', '', 'media-dialog document-dialog');
|
||||
dialog.setAttribute('aria-labelledby', 'document-title');
|
||||
dialog.innerHTML = `<div class="media-heading"><div><p class="eyebrow">DOKUMENTE</p><h2 id="document-title">PDF wählen</h2></div><button class="secondary" id="document-close">Schließen</button></div>
|
||||
<p class="panel-hint">PDF bis 20 MB. Es wird unverändert abgelegt und ist über seine Adresse öffentlich erreichbar – nichts Vertrauliches hochladen. Ohne „In neuem Tab öffnen“ zeigt die Website es in einem Betrachter über der Seite.</p>
|
||||
<div class="media-tools"><div><label for="document-search">PDFs suchen</label><input id="document-search" type="search" placeholder="Titel"></div><div><label for="document-upload">PDF hochladen</label><input id="document-upload" type="file" accept="application/pdf,.pdf" disabled></div></div>
|
||||
<p id="document-status" role="status" aria-live="polite">Wird geladen …</p><p id="document-error" class="media-error" role="alert" hidden></p>
|
||||
<div id="document-list" class="document-list" role="group" aria-label="PDF auswählen"></div>
|
||||
<div class="media-footer"><span></span><button id="document-insert" class="primary" disabled>PDF übernehmen</button></div>`;
|
||||
document.body.append(dialog);
|
||||
const $ = selector => dialog.querySelector(selector);
|
||||
let items = [], chosen = null, token;
|
||||
const error = message => { $('#document-error').textContent = message; $('#document-error').hidden = !message; };
|
||||
const size = bytes => `${(bytes / 1048576).toLocaleString('de', { maximumFractionDigits: 1, minimumFractionDigits: 1 })} MB`;
|
||||
const base = document.querySelector('meta[name="admin-base"]').content + '/api/dokumente';
|
||||
function render() {
|
||||
const query = $('#document-search').value.toLocaleLowerCase('de');
|
||||
const visible = items.filter(item => !item.archived && item.title.toLocaleLowerCase('de').includes(query));
|
||||
$('#document-list').replaceChildren();
|
||||
for (const item of visible) {
|
||||
const card = element('button', '', 'document-card'); card.type = 'button';
|
||||
card.setAttribute('aria-pressed', String(chosen?.id === item.id));
|
||||
card.append(element('span', 'PDF', 'document-badge'), element('strong', item.title), element('span', size(item.bytes)));
|
||||
card.addEventListener('click', () => { chosen = item; render(); card.focus(); });
|
||||
card.addEventListener('dblclick', () => { chosen = item; $('#document-insert').click(); });
|
||||
$('#document-list').append(card);
|
||||
}
|
||||
if (!visible.length) $('#document-list').append(element('p', items.length ? 'Kein PDF mit diesem Titel.' : 'Noch keine PDFs. Lade oben eines hoch.', 'muted'));
|
||||
$('#document-status').textContent = `${items.filter(item => !item.archived).length} PDFs${chosen ? ` · gewählt: ${chosen.title}` : ''}`;
|
||||
$('#document-insert').disabled = !chosen;
|
||||
}
|
||||
$('#document-search').addEventListener('input', render);
|
||||
$('#document-close').addEventListener('click', () => dialog.close());
|
||||
dialog.addEventListener('close', () => { dialog.remove(); if (opener?.isConnected) opener.focus(); });
|
||||
$('#document-upload').addEventListener('change', async () => {
|
||||
const file = $('#document-upload').files[0]; if (!file) return;
|
||||
error('');
|
||||
if (file.size > 20 * 1024 * 1024) { error(`${file.name}: größer als 20 MB`); $('#document-upload').value = ''; return; }
|
||||
$('#document-status').textContent = `Wird hochgeladen: ${file.name}`;
|
||||
try {
|
||||
const form = new FormData(); form.append('file', file);
|
||||
const response = await fetch(base + '/upload', { method: 'POST', headers: { 'X-CSRF-Token': token }, body: form });
|
||||
if (response.redirected) throw new Error('Sitzung abgelaufen. Bitte erneut anmelden.');
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(data.error || `Upload fehlgeschlagen (${response.status}).`);
|
||||
items.unshift(data.item); chosen = data.item;
|
||||
} catch (err) { error(`${file.name}: ${err.message || 'Verbindung unterbrochen'}`); }
|
||||
$('#document-upload').value = ''; render();
|
||||
});
|
||||
$('#document-insert').addEventListener('click', () => { if (chosen) { onChoose?.(chosen); dialog.close(); } });
|
||||
dialog.showModal();
|
||||
fetch(base, { credentials: 'same-origin' }).then(async response => {
|
||||
if (!response.ok || response.redirected) throw new Error('PDFs konnten nicht geladen werden. Bitte neu anmelden.');
|
||||
// Hochladen erst mit Token - sonst weist der Server eine zu früh gewählte Datei ab.
|
||||
const data = await response.json(); items = data.items; token = data.csrfToken; $('#document-upload').disabled = false; render();
|
||||
}).catch(err => { $('#document-status').textContent = ''; error(err.message); });
|
||||
}
|
||||
|
||||
document.querySelector('#open-media-library')?.addEventListener('click', () => openMedia());
|
||||
@@ -557,6 +557,19 @@ html.kant-top-armed.kant-top-shown .scroll-top{opacity:1;visibility:visible;tran
|
||||
.zoom-close,.zoom-prev,.zoom-next{width:46px;height:46px}
|
||||
.zoom-prev{left:8px}.zoom-next{right:8px}
|
||||
}
|
||||
/* Der PDF-Betrachter: fast bildschirmfüllend, mit einer schmalen Leiste darüber. Den Rest macht
|
||||
der Betrachter des Browsers - Blättern, Zoom, Drucken. */
|
||||
.pdf-dialog{border:0;padding:0;width:min(96vw,1200px);height:94vh;max-width:none;max-height:none;background:#1b212c;color:#fff;border-radius:12px;overflow:hidden}
|
||||
.pdf-dialog[open]{display:flex;flex-direction:column}
|
||||
.pdf-dialog::backdrop{background:rgba(8,12,20,.88)}
|
||||
.pdf-bar{display:flex;align-items:center;gap:10px;padding:10px 10px 10px 18px}
|
||||
.pdf-title{flex:1;min-width:0;margin:0;font-size:15px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#fff}
|
||||
.pdf-action{color:#fff;font-size:13px;text-decoration:none;padding:9px 14px;border-radius:999px;background:rgba(255,255,255,.12);white-space:nowrap}
|
||||
.pdf-action:hover{background:rgba(255,255,255,.24)}
|
||||
.pdf-close{display:grid;place-items:center;width:40px;height:40px;border-radius:999px;border:0;background:rgba(255,255,255,.14);color:#fff;font-size:24px;line-height:1;cursor:pointer}
|
||||
.pdf-close:hover{background:rgba(255,255,255,.26)}
|
||||
.pdf-action:focus-visible,.pdf-close:focus-visible{outline:3px solid #fff;outline-offset:2px}
|
||||
.pdf-frame{flex:1;width:100%;border:0;background:#525659}
|
||||
|
||||
.has-anchor{position:relative}.block-anchor{position:absolute;top:0;left:0;width:1px;height:1px;pointer-events:none;scroll-margin-top:110px}.nav-label{display:inline-flex;align-items:center;gap:.5em}.nav-label>.block-icon{width:1.1em;height:1.1em;color:inherit;flex:none}
|
||||
|
||||
|
||||
@@ -360,6 +360,22 @@ try {
|
||||
const previous = document.querySelector('#preview').contentDocument?.querySelector(`#${id}`)?.previousElementSibling;
|
||||
return Boolean(previous?.classList.contains('button'));
|
||||
}, headingId);
|
||||
// Ein PDF als Linkziel: im Dialog hochladen, übernehmen, die Adresse steht im Feld.
|
||||
await page.getByRole('button', { name: 'PDF aus Mediathek wählen', exact: true }).click();
|
||||
const documentDialog = page.locator('dialog.document-dialog');
|
||||
await documentDialog.waitFor();
|
||||
await documentDialog.locator('#document-upload:enabled').waitFor({ state: 'attached' });
|
||||
await documentDialog.locator('#document-upload').setInputFiles({ name: 'Preisliste.pdf', mimeType: 'application/pdf', buffer: Buffer.from('%PDF-1.4\n%%EOF\n') });
|
||||
await documentDialog.locator('.document-card[aria-pressed="true"]').waitFor();
|
||||
assert.equal(await documentDialog.locator('#document-error').isHidden(), true, 'the PDF upload reports no error');
|
||||
const pdfAxe = await new AxeBuilder({ page }).include('dialog.document-dialog').withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
|
||||
assert.deepEqual(pdfAxe.violations.map(v => v.id), [], 'the PDF dialog has no accessibility violations');
|
||||
await documentDialog.getByRole('button', { name: 'PDF übernehmen', exact: true }).click();
|
||||
await documentDialog.waitFor({ state: 'detached' });
|
||||
assert.match(await page.locator('#property-href').inputValue(), /^\/media\/[a-f0-9]{32}\.pdf$/);
|
||||
await page.waitForFunction(() => /^\/media\/[a-f0-9]{32}\.pdf$/.test(document.querySelector('#preview').contentDocument?.querySelector('a.button')?.getAttribute('href') ?? ''));
|
||||
checks.push({ name: 'a PDF from the library as a link target', passed: true });
|
||||
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
|
||||
await page.waitForFunction(count => document.querySelectorAll('#structure [data-block-id]').length === count, beforeButtons);
|
||||
checks.push({ name: 'canvas toolbar, insert points and drag from the toolbox', passed: true });
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace App\Content;
|
||||
|
||||
use App\Domain\StaleWrite;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
||||
/**
|
||||
* PDFs neben den Bildern der Mediathek.
|
||||
*
|
||||
* Ein Bild wird beim Hochladen neu berechnet und nur in dieser Fassung ausgeliefert - was auch
|
||||
* immer in der Datei steckte, ist danach weg. Bei einem PDF geht das nicht: es wird so
|
||||
* ausgeliefert, wie es kam. Deshalb die Pruefung am Anfang der Datei statt an der Endung, und
|
||||
* beim Ausliefern Kopfzeilen, die den Browser nichts anderes darin sehen lassen als ein PDF.
|
||||
*
|
||||
* Die Dateien liegen im Medienordner, als <id>.pdf - so gehen sie mit jeder Sicherung mit.
|
||||
*/
|
||||
final class DocumentLibrary
|
||||
{
|
||||
/** Ein Prospekt oder eine Preisliste, kein Katalog mit tausend Fotos. */
|
||||
public const MAX_BYTES = 20 * 1024 * 1024;
|
||||
|
||||
/** Die Adresse eines Dokuments. So steht sie auch in einem Link-Feld. */
|
||||
public const PATH = '#^/media/([a-f0-9]{32})\.pdf$#D';
|
||||
|
||||
public function __construct(private Connection $db, private string $directory)
|
||||
{
|
||||
}
|
||||
|
||||
/** @return list<array<string, mixed>> neueste zuerst */
|
||||
public function list(bool $withArchived = true): array
|
||||
{
|
||||
return array_map($this->present(...), $this->db->fetchAllAssociative(
|
||||
'SELECT * FROM document'.($withArchived ? '' : ' WHERE archived = 0').' ORDER BY created_at DESC, id DESC',
|
||||
));
|
||||
}
|
||||
|
||||
public function find(string $id): ?array
|
||||
{
|
||||
if (!preg_match('/^[a-f0-9]{32}$/D', $id)) {
|
||||
return null;
|
||||
}
|
||||
$row = $this->db->fetchAssociative('SELECT * FROM document WHERE id = ?', [$id]);
|
||||
|
||||
return $row ? $this->present($row) : null;
|
||||
}
|
||||
|
||||
public function upload(UploadedFile $file): array
|
||||
{
|
||||
if (!$file->isValid() || $file->getSize() > self::MAX_BYTES) {
|
||||
throw new \InvalidArgumentException('Upload fehlgeschlagen. Maximal 20 MB pro PDF; das PHP-Hosting kann ein kleineres Limit setzen.');
|
||||
}
|
||||
// Ein PDF beginnt mit %PDF- - in den ersten 1024 Bytes, so lesen es auch die Betrachter.
|
||||
// Die Endung oder der Typ, den der Browser mitschickt, sagen darueber nichts.
|
||||
$head = (string) file_get_contents($file->getPathname(), false, null, 0, 1024);
|
||||
if (!str_contains($head, '%PDF-')) {
|
||||
throw new \InvalidArgumentException('Das ist kein PDF. Bitte eine PDF-Datei wählen.');
|
||||
}
|
||||
if (!is_dir($this->directory) && !mkdir($this->directory, 0775, true) && !is_dir($this->directory)) {
|
||||
throw new \RuntimeException('Medienordner ist nicht beschreibbar.');
|
||||
}
|
||||
$id = bin2hex(random_bytes(16));
|
||||
$path = $this->path($id);
|
||||
if (!@copy($file->getPathname(), $path) || !is_file($path)) {
|
||||
throw new \RuntimeException('Das Dokument konnte nicht gespeichert werden.');
|
||||
}
|
||||
try {
|
||||
$name = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME);
|
||||
$title = mb_strcut(preg_replace('/[\x00-\x1F\x7F]/u', '', $name) ?: 'Dokument', 0, 160, 'UTF-8');
|
||||
$row = ['id' => $id, 'title' => $title, 'bytes' => (int) filesize($path), 'archived' => 0, 'version' => 1, 'created_at' => gmdate('Y-m-d H:i:s')];
|
||||
$this->db->insert('document', $row);
|
||||
} catch (\Throwable $error) {
|
||||
unlink($path);
|
||||
throw $error;
|
||||
}
|
||||
|
||||
return $this->present($row);
|
||||
}
|
||||
|
||||
public function update(string $id, array $data): array
|
||||
{
|
||||
if (!is_string($data['title'] ?? null) || '' === trim($data['title']) || strlen($data['title']) > 160 || !is_bool($data['archived'] ?? null) || !is_int($data['version'] ?? null)) {
|
||||
throw new \InvalidArgumentException('Titel (max. 160 Bytes) und Version prüfen.');
|
||||
}
|
||||
$count = $this->db->executeStatement('UPDATE document SET title = ?, archived = ?, version = version + 1 WHERE id = ? AND version = ?', [$data['title'], (int) $data['archived'], $id, $data['version']]);
|
||||
if (1 !== $count) {
|
||||
throw new StaleWrite();
|
||||
}
|
||||
|
||||
return $this->find($id) ?? throw new StaleWrite();
|
||||
}
|
||||
|
||||
/** Die Datei eines Dokuments, oder null. Archivierte werden weiter ausgeliefert, wie Bilder. */
|
||||
public function file(string $id): ?string
|
||||
{
|
||||
if (null === $this->find($id)) {
|
||||
return null;
|
||||
}
|
||||
$path = $this->path($id);
|
||||
|
||||
return is_file($path) ? $path : null;
|
||||
}
|
||||
|
||||
private function path(string $id): string
|
||||
{
|
||||
return $this->directory.'/'.$id.'.pdf';
|
||||
}
|
||||
|
||||
private function present(array $row): array
|
||||
{
|
||||
return [
|
||||
'id' => $row['id'],
|
||||
'title' => $row['title'],
|
||||
'bytes' => (int) $row['bytes'],
|
||||
'archived' => (bool) $row['archived'],
|
||||
'version' => (int) $row['version'],
|
||||
'created_at' => $row['created_at'],
|
||||
'src' => '/media/'.$row['id'].'.pdf',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Content\DocumentLibrary;
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Domain\StaleWrite;
|
||||
@@ -10,12 +11,13 @@ use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
|
||||
|
||||
final class MediaController extends AbstractController
|
||||
{
|
||||
public function __construct(private MediaLibrary $media, private CsrfTokenManagerInterface $csrf, private SiteSettings $siteSettings)
|
||||
public function __construct(private MediaLibrary $media, private CsrfTokenManagerInterface $csrf, private SiteSettings $siteSettings, private DocumentLibrary $documents)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -27,7 +29,7 @@ final class MediaController extends AbstractController
|
||||
$archived = $request->query->getBoolean('archiv');
|
||||
$query = mb_substr(trim($request->query->getString('q')), 0, 160);
|
||||
$library = $this->media->page($request->query->getInt('seite', 1), $archived, query: $query);
|
||||
$view = ['library' => $library, 'archived' => $archived, 'query' => $query];
|
||||
$view = ['library' => $library, 'archived' => $archived, 'query' => $query, 'documents' => $this->documents->list($archived)];
|
||||
if ($request->isXmlHttpRequest()) {
|
||||
return $this->json([
|
||||
'html' => $this->renderView('admin/media-results.html.twig', $view),
|
||||
@@ -53,16 +55,18 @@ final class MediaController extends AbstractController
|
||||
$done = 0;
|
||||
foreach ($files as $file) {
|
||||
try {
|
||||
$this->media->upload($file);
|
||||
// Bilder und PDFs gehen durch dasselbe Feld. Welche Art es ist, entscheidet der
|
||||
// Inhalt der Datei; die Endung zaehlt nur, wenn der Inhalt nichts sagt.
|
||||
$this->isPdf($file) ? $this->documents->upload($file) : $this->media->upload($file);
|
||||
++$done;
|
||||
} catch (\InvalidArgumentException $error) {
|
||||
return $this->back($request, sprintf('%s (%s)%s', $error->getMessage(), $file->getClientOriginalName(), $done > 0 ? sprintf(' %d Bild(er) davor wurden hochgeladen.', $done) : ''));
|
||||
return $this->back($request, sprintf('%s (%s)%s', $error->getMessage(), $file->getClientOriginalName(), $done > 0 ? sprintf(' %d Datei(en) davor wurden hochgeladen.', $done) : ''));
|
||||
} catch (\RuntimeException) {
|
||||
return $this->back($request, 'Das Bild konnte nicht verarbeitet werden. GD/WebP und die Schreibrechte des Medienordners prüfen.');
|
||||
return $this->back($request, sprintf('%s konnte nicht verarbeitet werden. GD/WebP und die Schreibrechte des Medienordners prüfen.', $file->getClientOriginalName()));
|
||||
}
|
||||
}
|
||||
|
||||
return $this->back($request, sprintf('%d Bild(er) hochgeladen.', $done), 'success');
|
||||
return $this->back($request, sprintf('%d Datei(en) hochgeladen.', $done), 'success');
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/media/{id}', name: 'admin_media_save', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])]
|
||||
@@ -91,6 +95,38 @@ final class MediaController extends AbstractController
|
||||
return $this->back($request, 'Die Bildangaben wurden gespeichert.', 'success');
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/media/dokument/{id}', name: 'admin_document_save', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])]
|
||||
public function formUpdateDocument(Request $request, string $id): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
if (!$this->isCsrfTokenValid('media-form', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back($request, 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
if (!$this->documents->find($id)) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
try {
|
||||
$this->documents->update($id, [
|
||||
'title' => $request->request->getString('title'),
|
||||
'archived' => $request->request->getBoolean('archived'),
|
||||
'version' => $request->request->getInt('version'),
|
||||
]);
|
||||
} catch (StaleWrite) {
|
||||
return $this->back($request, 'Das Dokument wurde inzwischen anderswo geändert. Lade die Seite neu und trage die Angaben erneut ein.');
|
||||
} catch (\InvalidArgumentException $error) {
|
||||
return $this->back($request, $error->getMessage());
|
||||
}
|
||||
|
||||
return $this->back($request, 'Die Angaben zum Dokument wurden gespeichert.', 'success');
|
||||
}
|
||||
|
||||
private function isPdf(UploadedFile $file): bool
|
||||
{
|
||||
$mime = $file->getMimeType();
|
||||
|
||||
return 'application/pdf' === $mime || (!str_starts_with((string) $mime, 'image/') && 'pdf' === strtolower($file->getClientOriginalExtension()));
|
||||
}
|
||||
|
||||
/** Back to the page the form was on, so a change does not jump to the first page. */
|
||||
private function back(Request $request, string $message, string $type = 'error'): Response
|
||||
{
|
||||
@@ -112,6 +148,53 @@ final class MediaController extends AbstractController
|
||||
return $this->json(['items' => $this->media->list(), 'csrfToken' => $this->csrf->getToken('media')->getValue()]);
|
||||
}
|
||||
|
||||
/** Die PDFs fuer die Auswahl im Link-Feld. Wenige genug, um sie auf einmal zu schicken. */
|
||||
#[Route('/%kant.admin_path%/api/dokumente', methods: ['GET'])]
|
||||
public function listDocuments(): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
|
||||
return $this->json(['items' => $this->documents->list(), 'csrfToken' => $this->csrf->getToken('media')->getValue()]);
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/api/dokumente/upload', methods: ['POST'])]
|
||||
#[Route('/%kant.admin_path%/api/dokumente/{id}', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])]
|
||||
public function writeDocument(Request $request, ?string $id = null): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
if (!$this->isCsrfTokenValid('media', $request->headers->get('X-CSRF-Token'))) {
|
||||
return $this->json(['error' => 'Sitzung abgelaufen. Bitte erneut anmelden.'], 403);
|
||||
}
|
||||
try {
|
||||
if (null === $id) {
|
||||
$file = $request->files->get('file');
|
||||
if (!$file instanceof UploadedFile) {
|
||||
throw new \InvalidArgumentException('Keine Datei empfangen. Bitte das PHP-Uploadlimit und die Dateigröße prüfen.');
|
||||
}
|
||||
|
||||
return $this->json(['item' => $this->documents->upload($file)], 201);
|
||||
}
|
||||
if (!$this->documents->find($id)) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
if (strlen($request->getContent()) > 4096) {
|
||||
throw new \InvalidArgumentException('Angaben zum Dokument sind zu groß.');
|
||||
}
|
||||
$data = json_decode($request->getContent(), true, 16, JSON_THROW_ON_ERROR);
|
||||
if (!is_array($data)) {
|
||||
throw new \InvalidArgumentException('Ungültige Angaben zum Dokument.');
|
||||
}
|
||||
|
||||
return $this->json(['item' => $this->documents->update($id, $data)]);
|
||||
} catch (StaleWrite) {
|
||||
return $this->json(['error' => 'Das Dokument wurde inzwischen geändert. Die Auswahl bitte neu öffnen.'], 409);
|
||||
} catch (\InvalidArgumentException|\JsonException $error) {
|
||||
return $this->json(['error' => $error->getMessage()], 422);
|
||||
} catch (\RuntimeException) {
|
||||
return $this->json(['error' => 'Das Dokument konnte nicht gespeichert werden. Schreibrechte des Medienordners prüfen.'], 503);
|
||||
}
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/api/media/{id}', requirements: ['id' => '[a-f0-9]{32}'], methods: ['GET'])]
|
||||
public function read(string $id): Response
|
||||
{
|
||||
@@ -240,4 +323,29 @@ final class MediaController extends AbstractController
|
||||
|
||||
return new BinaryFileResponse($path, headers: ['Content-Type' => 'image/webp', 'X-Content-Type-Options' => 'nosniff', 'Cache-Control' => 'public, max-age=31536000, immutable', 'Content-Security-Policy' => "default-src 'none'"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein PDF, so wie es hochgeladen wurde.
|
||||
*
|
||||
* inline, damit der Browser es anzeigt statt es abzulegen - mit dem Titel als Dateinamen fuer
|
||||
* den Fall, dass jemand es speichert. Einbetten darf es nur die eigene Website: der
|
||||
* Betrachter zeigt es in einem Rahmen, eine fremde Seite soll das nicht koennen.
|
||||
*/
|
||||
#[Route('/media/{id}.pdf', name: 'document_serve', requirements: ['id' => '[a-f0-9]{32}'], methods: ['GET'])]
|
||||
public function serveDocument(string $id): Response
|
||||
{
|
||||
$path = $this->documents->file($id) ?? throw $this->createNotFoundException();
|
||||
$name = trim(preg_replace('/[\\\\\/\x00-\x1F\x7F"]+/u', ' ', $this->documents->find($id)['title'])).'.pdf';
|
||||
$fallback = trim(preg_replace('/[^A-Za-z0-9 ._-]+/', '-', (string) iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $name)), '- ');
|
||||
$response = new BinaryFileResponse($path, headers: [
|
||||
'Content-Type' => 'application/pdf',
|
||||
'X-Content-Type-Options' => 'nosniff',
|
||||
'Cache-Control' => 'public, max-age=86400',
|
||||
'Content-Security-Policy' => "frame-ancestors 'self'",
|
||||
'X-Robots-Tag' => 'noindex',
|
||||
]);
|
||||
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_INLINE, $name, str_ends_with($fallback, '.pdf') && '.pdf' !== $fallback ? $fallback : 'dokument.pdf');
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -130,6 +130,10 @@ final class PageController extends AbstractController
|
||||
// Stylesheet und Skript eines Plugin-Bausteins kommen nur dorthin, wo er vorkommt.
|
||||
$pluginAssets = $this->plugins->assetsFor($documents);
|
||||
$needsZoom = (bool) array_filter($documents, fn (array $doc): bool => $this->hasZoom($doc));
|
||||
// Ein Link auf ein PDF der Mediathek - in einem Button, einem Bild oder im Fliesstext.
|
||||
// Gesucht wird im gespeicherten Dokument als Text: so findet sich auch ein Link, den
|
||||
// jemand in einen formatierten Absatz geschrieben hat.
|
||||
$needsDocument = (bool) array_filter($documents, static fn (array $doc): bool => 1 === preg_match('#/media/[a-f0-9]{32}\.pdf#', json_encode($doc, JSON_UNESCAPED_SLASHES) ?: ''));
|
||||
// Only the header changes with the scroll position; a scrolled value anywhere in it asks
|
||||
// for the small script that marks the page as scrolled.
|
||||
// Den Nach-oben-Knopf blendet dasselbe Skript ein; ohne es bleibt er dauerhaft sichtbar.
|
||||
@@ -198,6 +202,9 @@ final class PageController extends AbstractController
|
||||
// Ein Bild, das sich vergroessern laesst, ist ohne Skript ein Verweis auf die
|
||||
// grosse Fassung. Das Skript kommt nur dazu, wo es etwas zu tun hat.
|
||||
'needsZoom' => $needsZoom,
|
||||
// Der PDF-Betrachter, nach demselben Muster und im selben Skript: ohne es ein
|
||||
// gewoehnlicher Link.
|
||||
'needsDocument' => $needsDocument,
|
||||
// Own scripts arrive as one file per page, never inline, so the policy can stay
|
||||
// hash based and no page without a script loses its strict setting.
|
||||
'pageScript' => [] === $this->code->scriptsOf($page['document']) ? null : '/assets/seite/'.$page['id'].'.js',
|
||||
@@ -228,7 +235,7 @@ final class PageController extends AbstractController
|
||||
// Die Pruefung braucht ein Skript von Cloudflare und einen Rahmen dafuer. Beides wird nur
|
||||
// dort erlaubt, wo tatsaechlich ein Formular steht - nicht auf jeder Seite der Website.
|
||||
$mitTurnstile = $this->turnstile->isActive() && (bool) array_filter($documents, fn (array $doc): bool => $this->hasType($doc, 'form'));
|
||||
$wantsScript = ($mitTurnstile || $needsTabs || $needsFormScript || [] !== $pluginAssets['scripts'] || [] !== $areaScripts || $needsSlider || $needsMotion || $needsScrolled || $needsZoom || [] !== $this->code->scriptsOf($page['document']));
|
||||
$wantsScript = ($mitTurnstile || $needsTabs || $needsFormScript || [] !== $pluginAssets['scripts'] || [] !== $areaScripts || $needsSlider || $needsMotion || $needsScrolled || $needsZoom || $needsDocument || [] !== $this->code->scriptsOf($page['document']));
|
||||
$frames = $this->frameHosts($request, $page['document'], ...array_values($areas), ...array_values($sections));
|
||||
if ($mitTurnstile) {
|
||||
$frames[] = 'challenges.cloudflare.com';
|
||||
@@ -238,7 +245,7 @@ final class PageController extends AbstractController
|
||||
$response->headers->set('X-Robots-Tag', 'noindex, follow');
|
||||
}
|
||||
|
||||
return $this->shell($response, $wantsScript, $styles, $frames);
|
||||
return $this->shell($response, $wantsScript, $styles, $frames, $needsDocument);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -252,8 +259,9 @@ final class PageController extends AbstractController
|
||||
* @param list<string> $frameHosts Hosts of embeds that are actually rendered on this response.
|
||||
* Nothing is added to the policy for an embed still waiting
|
||||
* for consent.
|
||||
* @param bool $frameSelf whether the page may frame its own PDFs (the viewer)
|
||||
*/
|
||||
private function shell(Response $response, bool $allowScript = false, string $styles = '', array $frameHosts = []): Response
|
||||
private function shell(Response $response, bool $allowScript = false, string $styles = '', array $frameHosts = [], bool $frameSelf = false): Response
|
||||
{
|
||||
$styleSources = ["'self'"];
|
||||
if ('' !== $styles) {
|
||||
@@ -280,7 +288,9 @@ final class PageController extends AbstractController
|
||||
implode(' ', $scriptSources),
|
||||
implode(' ', $styleSources),
|
||||
implode(' ', $fontSources),
|
||||
[] === $frameHosts ? "'none'" : implode(' ', array_map(static fn (string $host): string => 'https://'.$host, $frameHosts)),
|
||||
// Der PDF-Betrachter zeigt eine Datei dieser Website in einem Rahmen - nur dort, wo
|
||||
// eine Seite auf ein PDF verlinkt, und nur von hier.
|
||||
[] === $frameHosts && !$frameSelf ? "'none'" : implode(' ', [...($frameSelf ? ["'self'"] : []), ...array_map(static fn (string $host): string => 'https://'.$host, $frameHosts)]),
|
||||
));
|
||||
|
||||
return $response;
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Command\SiteOriginCommand;
|
||||
use App\Domain\InvalidPage;
|
||||
use App\Ops\ArchiveUpdate;
|
||||
use App\Ops\Backup;
|
||||
use App\Ops\Environment;
|
||||
use App\Ops\Installation;
|
||||
use App\Ops\OriginCheck;
|
||||
use App\Ops\Updater;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -21,7 +24,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)
|
||||
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)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -55,6 +58,7 @@ final class SystemController extends AbstractController
|
||||
// ein ganz anderer Host (Vorschauadresse des Hosters, IP) bleibt erreichbar und
|
||||
// bekommt hier den Hinweis, dass die Website woanders eingetragen ist.
|
||||
'origin' => $this->originFacts($request),
|
||||
'originRejected' => $request->getSession()->remove('origin_rejected'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -258,6 +262,62 @@ final class SystemController extends AbstractController
|
||||
: 'Der Wartungszustand ist aufgehoben.', 'success');
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Adresse der Website, wie „WordPress-Adresse“ in WordPress: sie landet in .env als
|
||||
* SITE_ORIGIN und gilt ab der nächsten Anfrage.
|
||||
*
|
||||
* Vorher fragt die Installation sich selbst unter der neuen Adresse an. Eine Umstellung auf
|
||||
* www ohne DNS-Eintrag leitete sonst jede Seite ins Leere, die Verwaltung eingeschlossen.
|
||||
* Wer weiß, was er tut (Server erreicht sich selbst nicht, http im lokalen Betrieb), kann die
|
||||
* Prüfung ausdrücklich übergehen.
|
||||
*/
|
||||
#[Route('/%kant.admin_path%/system/adresse', name: 'admin_system_origin', methods: ['POST'])]
|
||||
public function origin(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
if (!$this->isCsrfTokenValid('system', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
if ('.env' !== $this->environmentSource('SITE_ORIGIN')) {
|
||||
return $this->back('Die Adresse ist in der Server-Umgebung gesetzt und hat dort Vorrang. Sie lässt sich nur beim Hoster ändern.');
|
||||
}
|
||||
try {
|
||||
$neu = SiteOriginCommand::normalize($request->request->getString('origin'));
|
||||
} catch (\InvalidArgumentException $error) {
|
||||
return $this->back($error->getMessage());
|
||||
}
|
||||
if ($neu === rtrim(trim($this->siteOrigin), '/')) {
|
||||
return $this->back('Die Adresse ist unverändert.', 'success');
|
||||
}
|
||||
if (!$request->request->getBoolean('force') && null !== $problem = $this->originCheck->problem($neu)) {
|
||||
$request->getSession()->set('origin_rejected', $neu);
|
||||
|
||||
return $this->back($problem.' Nichts geändert.');
|
||||
}
|
||||
try {
|
||||
Environment::write($this->projectDir, ['SITE_ORIGIN' => $neu]);
|
||||
} catch (\RuntimeException $error) {
|
||||
return $this->back($error->getMessage());
|
||||
}
|
||||
|
||||
// Die Anmeldung gilt nur für die Schreibweise, unter der sie entstand. Wechselt die
|
||||
// Verwaltung dadurch den Host, ist dort eine neue Anmeldung fällig - das sagt die Meldung,
|
||||
// solange sie noch auf dem alten Host steht.
|
||||
return $this->back(sprintf(
|
||||
'Die Website ist jetzt eingetragen als %s. Canonical, Sitemap und Feed nennen diese Adresse, die Schreibweise %s www leitet dorthin um.%s',
|
||||
$neu,
|
||||
str_starts_with((string) parse_url($neu, PHP_URL_HOST), 'www.') ? 'ohne' : 'mit',
|
||||
strtolower($request->getHost()) !== strtolower((string) parse_url($neu, PHP_URL_HOST)) ? ' Unter der neuen Adresse ist eine neue Anmeldung nötig.' : '',
|
||||
), 'success');
|
||||
}
|
||||
|
||||
/** Die Gegenstelle zur Prüfung oben: öffentlich, und sie verrät nichts als diese Kennung. */
|
||||
#[Route(OriginCheck::PATH, name: 'site_origin_probe', methods: ['GET'], priority: 10)]
|
||||
public function originProbe(): Response
|
||||
{
|
||||
return new Response($this->originCheck->token(), 200, ['Content-Type' => 'text/plain; charset=UTF-8', 'Cache-Control' => 'no-store', 'X-Robots-Tag' => 'noindex']);
|
||||
}
|
||||
|
||||
private function back(string $message, string $type = 'error'): Response
|
||||
{
|
||||
$this->addFlash($type, $message);
|
||||
|
||||
@@ -35,7 +35,7 @@ final class MaintenanceSubscriber implements EventSubscriberInterface
|
||||
return;
|
||||
}
|
||||
$path = $event->getRequest()->getPathInfo();
|
||||
if (preg_match('#^/('.preg_quote($this->adminPath, '#').'|assets/|media/|favicon)#', $path)) {
|
||||
if (preg_match('#^/('.preg_quote($this->adminPath, '#').'|assets/|media/|favicon|\.well-known/kantsites-origin)#', $path)) {
|
||||
return;
|
||||
}
|
||||
$since = @file_get_contents($this->projectDir.'/'.Updater::MAINTENANCE_FILE);
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Ops;
|
||||
|
||||
/**
|
||||
* Prüft, ob eine neue Adresse der Website wirklich bei dieser Installation ankommt.
|
||||
*
|
||||
* Die Adresse entscheidet, wohin die Schreibweise mit oder ohne www umgeleitet wird - für die
|
||||
* Website und für die Verwaltung. Stellt jemand auf www um, und www hat keinen DNS-Eintrag oder
|
||||
* kein Zertifikat, leitet danach jede Seite ins Leere, und in die Verwaltung kommt man nur noch
|
||||
* über FTP. Deshalb wird vor dem Speichern angefragt: Unter der neuen Adresse muss eine Kennung
|
||||
* zurückkommen, die nur diese Installation kennt - abgeleitet aus APP_SECRET, ohne es zu verraten.
|
||||
*/
|
||||
class OriginCheck
|
||||
{
|
||||
public const PATH = '/.well-known/kantsites-origin';
|
||||
|
||||
private Download $download;
|
||||
|
||||
public function __construct(private string $secret, ?Download $download = null)
|
||||
{
|
||||
// Kurz: die Anfrage geht an den eigenen Server und hält die Verwaltung so lange auf.
|
||||
$this->download = $download ?? new Download(5, 10);
|
||||
}
|
||||
|
||||
public function token(): string
|
||||
{
|
||||
return hash_hmac('sha256', 'site-origin', $this->secret);
|
||||
}
|
||||
|
||||
/** Null, wenn die Adresse hier ankommt - sonst der Grund in einem Satz. */
|
||||
public function problem(string $origin): ?string
|
||||
{
|
||||
if (!str_starts_with($origin, 'https://')) {
|
||||
return 'Geprüft werden nur HTTPS-Adressen. Eine Adresse mit http:// lässt sich nur mit „Trotzdem übernehmen“ speichern.';
|
||||
}
|
||||
$antwort = $this->download->get($origin.self::PATH);
|
||||
if (null === $antwort) {
|
||||
$grund = $this->download->reason();
|
||||
|
||||
return 'Unter '.$origin.' antwortet diese Website nicht'.('' !== $grund ? ': '.rtrim($grund, '.').'.' : '.').' DNS-Eintrag und Zertifikat für diese Domain prüfen.';
|
||||
}
|
||||
if (!hash_equals($this->token(), trim($antwort))) {
|
||||
return 'Unter '.$origin.' antwortet eine andere Website. Zeigt die Domain auf diesen Server?';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
{% block title %}Mediathek{% endblock %}
|
||||
{% block scripts %}<script type="module" src="/assets/media-drop.js?v={{ kant_version() }}"></script><script type="module" src="/assets/live-search.js?v={{ kant_version() }}"></script>{% endblock %}
|
||||
{% block heading %}Mediathek{% endblock %}
|
||||
{% block lead %}Alle Bilder, neueste zuerst. Beim Hochladen wird jedes Bild in WebP umgewandelt und in mehreren Breiten abgelegt; die Seite liefert dann die passende aus.{% endblock %}
|
||||
{% block lead %}Alle Bilder und PDFs, neueste zuerst. Beim Hochladen wird jedes Bild in WebP umgewandelt und in mehreren Breiten abgelegt; die Seite liefert dann die passende aus. PDFs bleiben, wie sie sind, und lassen sich in jedem Linkziel wählen.{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
{% if is_granted('ROLE_EDITOR') %}
|
||||
@@ -19,13 +19,13 @@
|
||||
<div class="dropzone" data-dropzone>
|
||||
<label class="dropzone-label" for="files">
|
||||
<span class="dropzone-icon" aria-hidden="true">▩</span>
|
||||
<strong>Bilder hierher ziehen</strong>
|
||||
<strong>Bilder oder PDFs hierher ziehen</strong>
|
||||
<span class="dropzone-hint">oder klicken, um Dateien zu wählen</span>
|
||||
</label>
|
||||
<input id="files" name="files[]" type="file" accept="image/jpeg,image/png,image/webp" multiple class="dropzone-input">
|
||||
<input id="files" name="files[]" type="file" accept="image/jpeg,image/png,image/webp,application/pdf,.pdf" multiple class="dropzone-input">
|
||||
</div>
|
||||
<p class="dropzone-chosen" data-chosen role="status">Noch keine Datei gewählt.</p>
|
||||
<p class="field-help">JPEG, PNG oder WebP, höchstens 8 MB je Bild. Mehrere Dateien sind möglich; das Hosting kann ein kleineres Limit setzen.</p>
|
||||
<p class="field-help">JPEG, PNG oder WebP bis 8 MB je Bild, PDF bis 20 MB. Mehrere Dateien sind möglich; das Hosting kann ein kleineres Limit setzen. Hochgeladene Dateien sind über ihre Adresse öffentlich – nichts Vertrauliches hochladen.</p>
|
||||
<button class="primary" type="submit">Hochladen</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
@@ -38,4 +38,51 @@
|
||||
</form>
|
||||
{% include 'admin/search-feedback.html.twig' %}
|
||||
<div id="list-results" class="stack" role="region" aria-label="Suchergebnisse" tabindex="-1">{% include 'admin/media-results.html.twig' %}</div>
|
||||
|
||||
{# Die PDFs stehen unter den Bildern und ohne Suche: es sind wenige, und ein Titel reicht, um
|
||||
eines wiederzufinden. Der Schalter für das Archiv oben gilt auch hier. #}
|
||||
<section class="stack" aria-labelledby="documents-heading">
|
||||
<div class="section-head">
|
||||
<h2 id="documents-heading">Dokumente <span class="count">{{ documents|length }}</span></h2>
|
||||
</div>
|
||||
{% if documents is empty %}
|
||||
<div class="empty">
|
||||
<h3>Keine PDFs in der Mediathek.</h3>
|
||||
<p>{{ is_granted('ROLE_EDITOR') ? 'Lade oben ein PDF hoch. Danach lässt es sich in jedem Linkziel wählen – etwa für einen Button „Preisliste“.' : 'Ein Redakteur kann PDFs hochladen.' }}</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="rows">
|
||||
{% for item in documents %}
|
||||
<article class="row row-stack">
|
||||
<div class="row-line">
|
||||
<span class="row-icon row-icon-text" aria-hidden="true">PDF</span>
|
||||
<div class="row-main">
|
||||
<h3><a href="{{ item.src }}" target="_blank" rel="noopener">{{ item.title }}</a></h3>
|
||||
<p class="muted">{{ (item.bytes / 1048576)|number_format(1, ',') }} MB · <code>{{ item.src }}</code>{% if item.archived %} · archiviert{% endif %}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% if is_granted('ROLE_EDITOR') %}
|
||||
<details class="sheet">
|
||||
<summary aria-label="Angaben zu {{ item.title }}">Angaben</summary>
|
||||
<div class="sheet-body">
|
||||
<form action="{{ path('admin_document_save', {id: item.id}) }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('media-form') }}">
|
||||
<input type="hidden" name="version" value="{{ item.version }}">
|
||||
<input type="hidden" name="seite" value="{{ library.page }}"><input type="hidden" name="q" value="{{ query }}">
|
||||
<input type="hidden" name="archiv" value="{{ archived ? '1' : '' }}">
|
||||
<label for="document-title-{{ item.id }}">Titel</label>
|
||||
<input id="document-title-{{ item.id }}" name="title" maxlength="160" required value="{{ item.title }}">
|
||||
<p class="field-help">Wird zum Dateinamen, wenn jemand das PDF speichert, und steht über dem Betrachter.</p>
|
||||
<label class="check-label" for="document-archived-{{ item.id }}"><input id="document-archived-{{ item.id }}" name="archived" type="checkbox" value="1"{{ item.archived ? ' checked' : '' }}> Archiviert</label>
|
||||
<p class="field-help">Archivierte PDFs erscheinen nicht mehr in der Auswahl, bleiben aber erreichbar – ein Link darauf geht nicht ins Leere.</p>
|
||||
<button class="primary" type="submit">Angaben speichern</button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -40,9 +40,28 @@
|
||||
{% if origin.passt %}
|
||||
<span class="muted">– Canonical, Sitemap und Feed nennen sie; die Schreibweise {{ origin.adresse matches '{^https?://www\.}' ? 'ohne' : 'mit' }} www leitet hierher um.</span>
|
||||
{% else %}
|
||||
<br><span class="check-bad"><strong>Diese Verwaltung läuft gerade unter <code>{{ origin.host }}</code>.</strong> Canonical, Sitemap und Feed nennen trotzdem die eingetragene Adresse. Stimmt die nicht mehr: <code>php bin/console cms:site:origin https://…</code> oder <code>SITE_ORIGIN</code> in <code>.env</code> ändern.</span>
|
||||
<br><span class="check-bad"><strong>Diese Verwaltung läuft gerade unter <code>{{ origin.host }}</code>.</strong> Canonical, Sitemap und Feed nennen trotzdem die eingetragene Adresse. Stimmt die nicht mehr, unten ändern.</span>
|
||||
{% endif %}
|
||||
{% if origin.quelle == '.env' %}
|
||||
{# Wie „WordPress-Adresse“: eine Stelle für mit oder ohne www. Vor dem Speichern fragt
|
||||
die Installation sich selbst unter der neuen Adresse an. #}
|
||||
{% set host = origin.adresse|split('://')|last|split(':')|first %}
|
||||
{% set twin = host starts with 'www.' ? host|slice(4) : 'www.' ~ host %}
|
||||
<form class="origin-form" action="{{ path('admin_system_origin') }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
|
||||
<label for="origin">Adresse der Website</label>
|
||||
<div class="origin-row">
|
||||
<input id="origin" name="origin" type="url" inputmode="url" maxlength="200" required placeholder="https://www.meine-firma.de" value="{{ originRejected ?: origin.adresse }}" aria-describedby="origin-help">
|
||||
<button class="secondary" type="submit">Adresse speichern</button>
|
||||
</div>
|
||||
{% if originRejected %}
|
||||
<label class="check-label" for="origin-force"><input id="origin-force" type="checkbox" name="force" value="1"> Trotzdem übernehmen – ich habe DNS und Zertifikat geprüft</label>
|
||||
{% endif %}
|
||||
<p class="field-help" id="origin-help">Nur Schema und Domain. Vor dem Speichern wird geprüft, ob die Website unter der neuen Adresse antwortet – sonst leitete die andere Schreibweise ins Leere, die Verwaltung eingeschlossen.{% if origin.adresse and host matches '/^[a-z0-9-]+(\.[a-z0-9-]+)+$/' and not (host matches '/^[0-9.]+$/') %} Umstellen auf <button class="link-button" type="submit" name="origin" value="{{ origin.adresse|split('://')|first }}://{{ twin }}" formnovalidate>{{ twin }}</button>.{% endif %}</p>
|
||||
</form>
|
||||
{% else %}
|
||||
<br><span class="muted">Eingestellt in der Server-Umgebung; sie hat Vorrang vor <code>.env</code> und lässt sich nur beim Hoster ändern.</span>
|
||||
{% endif %}
|
||||
<br><span class="muted">Eingestellt in <code>{{ origin.quelle }}</code>; Server-Umgebungsvariablen haben Vorrang.</span>
|
||||
</dd>
|
||||
{% if state.worktree %}
|
||||
<dt>Stand</dt><dd><code>{{ state.describe }}</code>{% if state.commit != 'unbekannt' %} · Commit <code>{{ state.commit }}</code>{% endif %}</dd>
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
{% if needsSlider|default(false) %}<script type="module" src="/assets/slider.js?v={{ kant_version() }}"></script>{% endif %}
|
||||
{% if needsMotion|default(false) %}<script type="module" src="/assets/motion.js?v={{ kant_version() }}"></script>{% endif %}
|
||||
{% if needsScrolled|default(false) %}<script type="module" src="/assets/scrolled.js?v={{ kant_version() }}"></script>{% endif %}
|
||||
{% if needsZoom|default(false) %}<script src="/assets/lightbox.js?v={{ kant_version() }}" defer></script>{% endif %}
|
||||
{% if needsZoom|default(false) or needsDocument|default(false) %}<script src="/assets/lightbox.js?v={{ kant_version() }}" defer></script>{% endif %}
|
||||
{% if not editing|default(false) %}{% for src in pluginScripts|default([]) %}<script src="{{ src }}" defer></script>{% endfor %}{% endif %}
|
||||
{# Own scripts of this page, as a file. Never rendered in the editor preview: the preview must
|
||||
show the layout, not run code that could change the page under the editor's hands. #}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Tests;
|
||||
use App\Content\Consent;
|
||||
use App\Content\CustomCode;
|
||||
use App\Content\DemoContent;
|
||||
use App\Content\DocumentLibrary;
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Content\FontLibrary;
|
||||
use App\Content\FormRepository;
|
||||
@@ -1153,6 +1154,61 @@ final class CmsTest extends TestCase
|
||||
self::assertStringContainsString("script-src 'none'", (string) $zweite->headers->get('Content-Security-Policy'));
|
||||
}
|
||||
|
||||
public function testAPdfFromTheLibraryIsALinkTargetAndOpensInTheViewer(): void
|
||||
{
|
||||
// Umut: „PDFs hochladen und als Button verlinken - und ein Inline-PDF-Viewer wie das
|
||||
// Bild-Popup." Das PDF bleibt, wie es kam; geprüft wird, dass es eines ist.
|
||||
$documents = new DocumentLibrary($this->db, $this->path.'-media');
|
||||
$file = tempnam(sys_get_temp_dir(), 'kant-pdf-');
|
||||
try {
|
||||
file_put_contents($file, '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>');
|
||||
try {
|
||||
$documents->upload(new UploadedFile($file, 'preisliste.pdf', test: true));
|
||||
self::fail('Eine Datei, die kein PDF ist, darf nicht als PDF durchgehen.');
|
||||
} catch (\InvalidArgumentException) {
|
||||
}
|
||||
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM document'));
|
||||
|
||||
file_put_contents($file, "%PDF-1.4\n1 0 obj << /Type /Catalog >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n");
|
||||
$item = $documents->upload(new UploadedFile($file, 'Preisliste 2026.pdf', test: true));
|
||||
} finally {
|
||||
@unlink($file);
|
||||
}
|
||||
self::assertSame('Preisliste 2026', $item['title']);
|
||||
self::assertSame('/media/'.$item['id'].'.pdf', $item['src']);
|
||||
|
||||
// Ausgeliefert als PDF, zum Anzeigen, mit dem Titel als Dateinamen, und einbetten darf es
|
||||
// nur die eigene Website.
|
||||
$antwort = $this->request($item['src']);
|
||||
self::assertSame(200, $antwort->getStatusCode());
|
||||
self::assertSame('application/pdf', $antwort->headers->get('Content-Type'));
|
||||
self::assertSame('nosniff', $antwort->headers->get('X-Content-Type-Options'));
|
||||
self::assertSame("frame-ancestors 'self'", $antwort->headers->get('Content-Security-Policy'));
|
||||
self::assertStringStartsWith('inline;', (string) $antwort->headers->get('Content-Disposition'));
|
||||
self::assertStringContainsString('Preisliste 2026.pdf', (string) $antwort->headers->get('Content-Disposition'));
|
||||
self::assertSame(404, $this->request('/media/'.str_repeat('a', 32).'.pdf')->getStatusCode());
|
||||
|
||||
// Ein Button zeigt auf das PDF - ein gewöhnlicher lokaler Pfad, ohne neues Schema.
|
||||
$document = DemoContent::starter('Startseite');
|
||||
$document['blocks'][] = ['id' => 'preise', 'type' => 'button', 'props' => ['text' => 'Preisliste', 'href' => $item['src']]];
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||||
$seite = $this->request('/');
|
||||
$html = $seite->getContent();
|
||||
self::assertStringContainsString('href="'.$item['src'].'"', $html);
|
||||
self::assertStringContainsString('<script src="/assets/lightbox.js?v=', $html);
|
||||
$csp = (string) $seite->headers->get('Content-Security-Policy');
|
||||
self::assertStringContainsString("frame-src 'self'", $csp);
|
||||
self::assertStringContainsString("script-src 'self'", $csp);
|
||||
|
||||
// Ohne Link auf ein PDF bleibt es beim alten: kein Skript, kein Rahmen.
|
||||
$ohne = DemoContent::starter('Startseite');
|
||||
$version = $this->pages->findForEditor('page-1')['version'];
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $ohne, $version), $version + 1);
|
||||
$zweite = $this->request('/');
|
||||
self::assertStringNotContainsString('lightbox.js', $zweite->getContent());
|
||||
self::assertStringContainsString("frame-src 'none'", (string) $zweite->headers->get('Content-Security-Policy'));
|
||||
}
|
||||
|
||||
public function testLegalLinksAppearOnlyWhereAnAuthorPlacesThem(): void
|
||||
{
|
||||
// Die hinterlegten Adressen erzeugen keine zusätzliche Leiste unter dem Footer.
|
||||
|
||||
@@ -738,6 +738,47 @@ final class EditorSecurityTest extends WebTestCase
|
||||
}
|
||||
}
|
||||
|
||||
public function testPdfsGoThroughTheMediaUploadAndTheEditorPicker(): void
|
||||
{
|
||||
$client = $this->login('editor');
|
||||
$crawler = $client->request('GET', '/admin/media');
|
||||
$token = $crawler->filter('form[action="/admin/media/upload"] input[name="_csrf_token"]')->attr('value');
|
||||
$path = tempnam(sys_get_temp_dir(), 'kant-form-pdf-');
|
||||
try {
|
||||
file_put_contents($path, "%PDF-1.7\n%%EOF\n");
|
||||
// Dasselbe Feld wie für Bilder: der Inhalt entscheidet, wohin die Datei geht.
|
||||
$client->request('POST', '/admin/media/upload', ['_csrf_token' => $token], ['files' => [new UploadedFile($path, 'Flyer.pdf', test: true)]]);
|
||||
self::assertResponseRedirects('/admin/media');
|
||||
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM media'));
|
||||
self::assertSame('Flyer', $this->db->fetchOne('SELECT title FROM document'));
|
||||
$crawler = $client->followRedirect();
|
||||
self::assertStringContainsString('1 Datei(en) hochgeladen', $crawler->filter('.notice.ok')->text());
|
||||
self::assertCount(1, $crawler->filter('section[aria-labelledby="documents-heading"] .row'));
|
||||
|
||||
// Die Auswahl im Editor: Liste mit Token, Hochladen nur mit Token.
|
||||
$client->request('GET', '/admin/api/dokumente');
|
||||
self::assertResponseIsSuccessful();
|
||||
$data = json_decode($client->getResponse()->getContent(), true);
|
||||
self::assertSame('Flyer', $data['items'][0]['title']);
|
||||
$client->request('POST', '/admin/api/dokumente/upload', files: ['file' => new UploadedFile($path, 'zweites.pdf', test: true)]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
file_put_contents($path, '<?php echo 1;');
|
||||
$client->request('POST', '/admin/api/dokumente/upload', files: ['file' => new UploadedFile($path, 'getarnt.pdf', test: true)], server: ['HTTP_X_CSRF_TOKEN' => $data['csrfToken']]);
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM document'));
|
||||
|
||||
// Archivieren nimmt es aus der Auswahl-Übersicht, die Datei bleibt erreichbar.
|
||||
$id = $data['items'][0]['id'];
|
||||
$client->request('POST', '/admin/api/dokumente/'.$id, server: ['CONTENT_TYPE' => 'application/json', 'HTTP_X_CSRF_TOKEN' => $data['csrfToken']], content: json_encode(['title' => 'Flyer 2026', 'archived' => true, 'version' => 1]));
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertCount(0, $client->request('GET', '/admin/media')->filter('section[aria-labelledby="documents-heading"] .row'));
|
||||
$client->request('GET', '/media/'.$id.'.pdf');
|
||||
self::assertResponseIsSuccessful();
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function testMediaPreviewReadsSmallAndArchivedImagesWithoutListingTheLibrary(): void
|
||||
{
|
||||
$id = str_repeat('a', 32);
|
||||
@@ -785,7 +826,7 @@ final class EditorSecurityTest extends WebTestCase
|
||||
$crawler = $client->followRedirect();
|
||||
self::assertCount(2, $crawler->filter('.library-tile'));
|
||||
self::assertCount(1, $crawler->filter('.notice.ok'));
|
||||
self::assertStringContainsString('2 Bild(er) hochgeladen', $crawler->filter('.notice.ok')->text());
|
||||
self::assertStringContainsString('2 Datei(en) hochgeladen', $crawler->filter('.notice.ok')->text());
|
||||
|
||||
$id = (string) $this->db->fetchOne('SELECT id FROM media LIMIT 1');
|
||||
$client->request('POST', '/admin/media/'.$id, ['_csrf_token' => $token, 'title' => 'Bergblick', 'alt' => 'Grüne Berglandschaft', 'archived' => '1', 'version' => 1]);
|
||||
|
||||
@@ -14,6 +14,7 @@ use App\Ops\ArchiveUpdate;
|
||||
use App\Ops\Backup;
|
||||
use App\Ops\CommandRunner;
|
||||
use App\Ops\Download;
|
||||
use App\Ops\OriginCheck;
|
||||
use App\Ops\Updater;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
@@ -133,7 +134,7 @@ final class SystemScreenTest extends WebTestCase
|
||||
$html = (string) $client->getResponse()->getContent();
|
||||
self::assertStringContainsString('https://cms.test', $html);
|
||||
self::assertStringContainsString('läuft gerade unter <code>localhost</code>', $html);
|
||||
self::assertStringContainsString('cms:site:origin', $html, 'Der Weg, es zu aendern, steht dabei.');
|
||||
self::assertStringContainsString('Stimmt die nicht mehr, unten ändern.', $html);
|
||||
|
||||
// Unter der eingetragenen Adresse selbst: kein Hinweis, dafuer die Richtung der Umleitung.
|
||||
$client->request('GET', 'https://cms.test/admin/login');
|
||||
@@ -149,6 +150,85 @@ final class SystemScreenTest extends WebTestCase
|
||||
self::assertSame('https://cms.test/admin/system', $client->getResponse()->headers->get('Location'));
|
||||
}
|
||||
|
||||
public function testTheAddressIsSetInTheAdministrationButOnlyWhereItReallyArrives(): void
|
||||
{
|
||||
// Umut: „bau die Einstellung in der Verwaltung ein sodass es in die env eingetragen wird“ -
|
||||
// wie die WordPress-Adresse. Im Test kommt SITE_ORIGIN aus der Server-Umgebung; dort hat
|
||||
// sie Vorrang, also gibt es kein Feld, und auch ein POST ändert nichts.
|
||||
$client = $this->login();
|
||||
$client->request('GET', '/admin/system');
|
||||
$html = (string) $client->getResponse()->getContent();
|
||||
self::assertStringContainsString('lässt sich nur beim Hoster ändern', $html);
|
||||
self::assertStringNotContainsString('name="origin"', $html);
|
||||
$token = (string) $client->getCrawler()->filter('form[action$="/system/wartung"] input[name=_csrf_token]')->attr('value');
|
||||
$client->request('POST', '/admin/system/adresse', ['_csrf_token' => $token, 'origin' => 'https://www.cms.test']);
|
||||
$client->followRedirect();
|
||||
self::assertStringContainsString('in der Server-Umgebung gesetzt', (string) $client->getResponse()->getContent());
|
||||
|
||||
// Steht sie in .env, gibt es das Feld samt Umschalter auf die andere Schreibweise.
|
||||
$geladen = $_SERVER['SYMFONY_DOTENV_VARS'] ?? null;
|
||||
$_SERVER['SYMFONY_DOTENV_VARS'] = 'SITE_ORIGIN';
|
||||
try {
|
||||
$client->request('GET', '/admin/system');
|
||||
$html = (string) $client->getResponse()->getContent();
|
||||
self::assertStringContainsString('name="origin" type="url"', $html);
|
||||
self::assertStringContainsString('value="https://www.cms.test" formnovalidate>www.cms.test</button>', $html);
|
||||
|
||||
// Eine Adresse, unter der diese Website nicht antwortet, wird nicht gespeichert - sonst
|
||||
// leitete die andere Schreibweise ins Leere. Das Feld behält sie, und erst jetzt
|
||||
// erscheint „Trotzdem übernehmen“.
|
||||
$client->request('POST', '/admin/system/adresse', ['_csrf_token' => $token, 'origin' => 'https://kein-server.invalid/']);
|
||||
$client->followRedirect();
|
||||
$html = (string) $client->getResponse()->getContent();
|
||||
self::assertStringContainsString('Unter https://kein-server.invalid antwortet diese Website nicht', $html);
|
||||
self::assertStringContainsString('Nichts geändert.', $html);
|
||||
self::assertStringContainsString('value="https://kein-server.invalid"', $html);
|
||||
self::assertStringContainsString('name="force"', $html);
|
||||
foreach (['https://cms.test/start' => 'ohne Pfad', 'ftp://cms.test' => 'beginnt mit https://'] as $falsch => $meldung) {
|
||||
$client->request('POST', '/admin/system/adresse', ['_csrf_token' => $token, 'origin' => $falsch]);
|
||||
$client->followRedirect();
|
||||
self::assertStringContainsString($meldung, (string) $client->getResponse()->getContent());
|
||||
}
|
||||
} finally {
|
||||
if (null === $geladen) {
|
||||
unset($_SERVER['SYMFONY_DOTENV_VARS']);
|
||||
} else {
|
||||
$_SERVER['SYMFONY_DOTENV_VARS'] = $geladen;
|
||||
}
|
||||
}
|
||||
self::assertStringContainsString('SITE_ORIGIN', (string) file_get_contents(dirname(__DIR__).'/.env'));
|
||||
self::assertStringNotContainsString('kein-server', (string) file_get_contents(dirname(__DIR__).'/.env'));
|
||||
|
||||
// Die Gegenstelle der Prüfung: öffentlich, ohne Anmeldung, und nur die Kennung.
|
||||
$client->request('GET', '/.well-known/kantsites-origin');
|
||||
self::assertSame(200, $client->getResponse()->getStatusCode());
|
||||
self::assertSame(hash_hmac('sha256', 'site-origin', 'isolated-test-secret'), $client->getResponse()->getContent());
|
||||
}
|
||||
|
||||
public function testTheAddressCheckWantsThisInstallationsOwnAnswer(): void
|
||||
{
|
||||
$antwort = null;
|
||||
$download = new class($antwort) extends Download {
|
||||
public function __construct(private ?string &$antwort)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function get(string $url): ?string
|
||||
{
|
||||
return $this->antwort;
|
||||
}
|
||||
};
|
||||
$check = new OriginCheck('geheim', $download);
|
||||
self::assertStringContainsString('antwortet diese Website nicht', (string) $check->problem('https://www.example.de'));
|
||||
$antwort = hash_hmac('sha256', 'site-origin', 'anderes-geheimnis');
|
||||
self::assertStringContainsString('antwortet eine andere Website', (string) $check->problem('https://www.example.de'));
|
||||
$antwort = $check->token().'
|
||||
';
|
||||
self::assertNull($check->problem('https://www.example.de'));
|
||||
self::assertStringContainsString('nur HTTPS', (string) $check->problem('http://www.example.de'));
|
||||
}
|
||||
|
||||
public function testTheScreenShowsWhatTheArchiveUpdaterDidInsteadOfDyingOnIt(): void
|
||||
{
|
||||
// Der Fehler, den Umut auf seinem Hosting gesehen hat: Das Update lief, legte seine
|
||||
|
||||
@@ -21,6 +21,7 @@ use DoctrineMigrations\Version20260913000100;
|
||||
use DoctrineMigrations\Version20260914000100;
|
||||
use DoctrineMigrations\Version20260915000100;
|
||||
use DoctrineMigrations\Version20260916000100;
|
||||
use DoctrineMigrations\Version20260917000100;
|
||||
use Psr\Log\NullLogger;
|
||||
|
||||
/**
|
||||
@@ -53,6 +54,7 @@ final class TestSchema
|
||||
Version20260914000100::class,
|
||||
Version20260915000100::class,
|
||||
Version20260916000100::class,
|
||||
Version20260917000100::class,
|
||||
] as $migration) {
|
||||
(new $migration($db, new NullLogger()))->up($schema);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user