Compare commits

...
5 Commits
Author SHA1 Message Date
kantarciandClaude Opus 5.5 6f6e5d876c Release 1.29.0
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:56:16 +02:00
kantarciandClaude Opus 5.5 6fa303f901 Save consent without reloading the page
consent.js posts the consent form in the background, the server answers
204 with the cookie, and the script swaps in the embeds and the legal line
from a fresh copy of the page. Embed hosts are in frame-src from the start,
the content address still only after consent. Without JavaScript the form
keeps working with a reload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:56:16 +02:00
kantarciandClaude Opus 5.5 6390c00876 Rework the Hüfner site after the customer meeting
Tree green from the logo everywhere, the 100 emoji inline wherever 100
appears, downloads for the exemption certificates and the PQ certificate,
the image video behind consent, Google reviews, a 48-hour quote promise,
WhatsApp, two job cards and a page for the alpacas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:56:16 +02:00
kantarciandClaude Opus 5.5 6ee05f4c70 Remove the duplicated 1.28.1 status entry
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:35:11 +02:00
kantarciandClaude Opus 5.5 1b6cbb2247 Note the 1.28.1 release in the status
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:34:58 +02:00
13 changed files with 793 additions and 213 deletions

No files matched your search

+1 -1
View File
@@ -397,7 +397,7 @@ Die Seite „Kantarci Markt – Neueröffnung“ (Wilhelm-Drapp-Straße, Baden-B
### Neunte lokale Referenzwebsite: Hüfner Gartenbau
Die Website der Garten- und Landschaftsbau Hüfner GmbH (Iffezheim) liegt in `var/huefner.db` mit Medien in `var/huefner-media`; die Startkonfiguration `huefner` in `.claude/launch.json` nutzt Port 8102, `huefner-prod` denselben Bestand im Produktionsmodus auf Port 8103 zum Messen. `sh scripts/reset-huefner.sh [Entwurfsordner]` kopiert die Fotos aus dem gelieferten Entwurf nach `var/huefner-assets` und schneidet den Baum aus dem Logo als Seitensymbol (`scripts/build-huefner-assets.php`; ohne Argument nimmt es die schon vorbereiteten Bilder), wirft die Installation weg, baut mit `scripts/seed-huefner.php` Gestaltung, Kopf- und Fußbereich, Startseite, Impressum, Datenschutz, 404, Menü, Einstellungen und das Formular `anfrage` auf und holt Barlow und Barlow Condensed auf den eigenen Server. Gemessen mit `MSYS_NO_PATHCONV=1 CMS_URL=http://127.0.0.1:8103 CMS_PATHS=/,/impressum,/datenschutz npm run audit`. Die bewussten Abweichungen vom Entwurf stehen im Kopf des Skripts.
Die Website der Garten- und Landschaftsbau Hüfner GmbH (Iffezheim) liegt in `var/huefner.db` mit Medien in `var/huefner-media`; die Startkonfiguration `huefner` in `.claude/launch.json` nutzt Port 8102, `huefner-prod` denselben Bestand im Produktionsmodus auf Port 8103 zum Messen. `sh scripts/reset-huefner.sh [Entwurfsordner Unterlagenordner]` kopiert die Fotos aus dem gelieferten Entwurf und Umuts Unterlagen (Alpaka-Fotos, PQ-Logo, Galabau-Signet, PQ-Bescheinigung und Freistellungsbescheinigungen als PDF, unter festen Namen) nach `var/huefner-assets` und schneidet den Baum aus dem Logo als Seitensymbol (`scripts/build-huefner-assets.php`; ohne Argumente nimmt es die schon vorbereiteten Dateien), wirft die Installation weg, baut mit `scripts/seed-huefner.php` Gestaltung, Kopf- und Fußbereich, Startseite, Alpakaseite, Impressum, Datenschutz, 404, Menü, Einstellungen und das Formular `anfrage` auf und holt Barlow und Barlow Condensed auf den eigenen Server. Gemessen mit `MSYS_NO_PATHCONV=1 CMS_URL=http://127.0.0.1:8103 CMS_PATHS=/,/alpakas CMS_AUDIT_DIR=reports/lighthouse-huefner-termin node scripts/audit.mjs`. Die Startseite liegt knapp unter dem HTML-Budget von 150 KB aus `test:browser`: wiederkehrende Bausteine tragen ihre Gestaltung deshalb am Behälter. Die bewussten Abweichungen vom Entwurf stehen im Kopf des Skripts.
Zusätzliche Prüfung mit den lokalen Kant-IT-Inhalten: `node scripts/admin-quality-check.mjs` (PHP über `PHP_BINARY` wählbar). Das Skript benötigt `var/kantit.db` und `var/kantit-media`, erstellt eine eigene Datenbankkopie und ein zufälliges Testkonto darin; es ändert die Pilotdatenbank nicht. Screenshots und Bericht landen in `reports/admin-quality/`.
+1 -1
View File
@@ -1 +1 @@
1.28.1
1.29.0
+10
View File
@@ -2,6 +2,16 @@
Stand: 12.10.2026. Arbeitstitel KantSites; Produktname offen.
## ADR 115 – Einwilligung ohne Neuladen (12.10.2026)
Anlass: Umut auf der Hüfner-Seite: „dieses Einwilligen, da lädt die Seite komplett neu. Bekommen wir das auch ohne hin?“ Hinweis und Knopf am eingebetteten Inhalt waren reine Formulare (ADR zur Einwilligung: kein Skript), der Server setzte das Cookie und leitete zurück.
**Entscheidung.** Das Formular bleibt der Weg, ein kleines Skript `public/assets/consent.js` verbessert ihn. Es schickt dasselbe Formular im Hintergrund an `/einwilligung` mit der Kopfzeile `X-Kant-Consent: 1`; der Server antwortet dann mit 204 und dem Cookie statt mit der Weiterleitung (Herkunftsprüfung unverändert). Wurde etwas erlaubt, holt das Skript die Seite noch einmal und übernimmt daraus nur die eingebetteten Inhalte und die Zeile „Rechtliches“ (dort steht jetzt „Einwilligung ändern“); dann verschwindet der Hinweis. Der Fokus geht an den eingesetzten Rahmen bzw. an `#main`. Schlägt etwas fehl, sendet das Skript das Formular normal ab. Die Adresse eines Inhalts steht weiterhin erst nach der Einwilligung im HTML – kein `data-src` im Platzhalter.
**Was sich dafür ändert.** (1) Die Sicherheitsrichtlinie nennt die Hosts aller eingebetteten Inhalte der Seite in `frame-src`, auch solange die Einwilligung fehlt: eine Richtlinie steht beim Laden fest, ein nachträglich eingesetzter Rahmen bräuchte sonst ein Neuladen. Die Erlaubnis lädt nichts. (2) Seiten mit offener Frage (Hinweis offen oder ein Inhalt wartet) senden `script-src 'self'` und laden `consent.js`; nach der Entscheidung ohne wartende Inhalte wieder `script-src 'none'`. (3) Das Skriptbudget von `test:browser` (3 je Seite) zählt `consent.js` nicht mit, weil es nur bis zur Entscheidung da ist.
**Folgen.** Ohne JavaScript bleibt alles wie bisher. Andere Vorlagen als `PageController` (falls sie Einbettungen zeigen) laden das Skript nicht und bleiben beim Neuladen. Ausgeliefert mit 1.29.0.
## ADR 114 – Die Umschreibregel fängt index.php selbst ab (12.10.2026)
Anlass: huefner-gartenbau.de bei IONOS (PHP 8.4.26) antwortete auf `/`, `/install` und `/assets/` mit einem weißen 500er von Apache. Die Notfallschicht (ADR 113) griff nicht, weil PHP gar nicht erreicht wurde. `/index.php/install` lieferte dagegen 200, auch mit der `.htaccess` – die Datei wurde also gelesen, der Fehler lag in der Umschreibregel. Vermutete Ursache: `RewriteCond %{REQUEST_FILENAME} !-f` erkennt dort die bereits umgeschriebene `index.php` nicht als Datei, die Regel greift erneut, Apache bricht nach zehn internen Umleitungen ab.
+24
View File
@@ -1,5 +1,15 @@
# Projektstatus
## Fassung 1.29.0: Einwilligung ohne Neuladen (12.10.2026)
**Anlass:** Umut auf der Hüfner-Seite: Beim Einwilligen ins YouTube-Video lädt die Seite komplett neu.
**Neu im Kern (ADR 115):** `public/assets/consent.js` schickt Hinweis und Knopf am eingebetteten Inhalt im Hintergrund ab (`X-Kant-Consent: 1` → 204 mit Cookie statt Weiterleitung), holt die Seite neu und tauscht nur Einbettungen und „Rechtliches“ aus; Fokus an den Rahmen bzw. an den Seitenanfang. Ohne JavaScript und bei Fehlern bleibt das Formular mit Neuladen. `frame-src` nennt die Hosts aller Einbettungen der Seite auch vor der Einwilligung (die Adresse des Inhalts weiterhin erst danach im HTML). Das Skript lädt nur, solange eine Einwilligung offen ist; `test:browser` zählt es nicht zum Budget von drei Skripten.
**Geprüft:** PHPUnit vollständig 644 grün (neu: 204-Antwort mit Cookie, kein Cookie von fremder Herkunft, Weiterleitung ohne Kopfzeile; angepasst: Skript bei offener Frage, `script-src 'none'` nach der Entscheidung, `frame-src` vor der Einwilligung), `npm test` 41, `lint:twig`, php-cs-fixer. Im Browser auf der Hüfner-Kopie: Knopf am Video, „Nur notwendige“ und „Alle akzeptieren“ ohne Neuladen, Fokus aufs Video, ohne JavaScript weiterhin mit Neuladen, keine Konsolenfehler. `test:browser` grün, Lighthouse Produktion Startseite mobil 96, Desktop 98, sonst 100. `test:editor` nicht gelaufen (Editor unverändert, Skript dort nicht eingebunden).
**Nächster Schritt:** Installationen mit Einwilligungshinweis (huefner-gartenbau.de, Immobilien DEEG) auf 1.29.0 aktualisieren.
## Fassung 1.28.1: weißer 500er bei IONOS durch die Umschreibregel (12.10.2026)
**Anlass:** huefner-gartenbau.de bei IONOS zeigte nach der Einrichtung auf jeder Adresse einen weißen 500er.
@@ -8,10 +18,24 @@
**Geprüft:** Umut hat beide Zeilen auf IONOS eingetragen, danach lädt `/install`; auch mit eingeschalteter `Options`-Zeile. PHPUnit vollständig: 643 Tests grün, eine Warnung aus `UpdaterTest` (fehlende `.env` im Testordner, unabhängig von dieser Änderung); php-cs-fixer sauber.
**Veröffentlicht:** Tag `v1.28.1`, Release-Eintrag mit Archiv (3610 Dateien, 5,3 MB, gebaut aus `var/release-source-1.28.1`), `.sha256` und `install.php`; Prüfsumme nach dem Herunterladen bestätigt. Keine Migration.
**Offen:** Der Installer auf huefner-gartenbau.de war bis zum Abschluss der Einrichtung öffentlich erreichbar.
**Nächster Schritt:** Bestehende IONOS-Installationen auf 1.28.1 aktualisieren.
## Hüfner: Änderungen aus dem Kundentermin (12.10.2026)
**Anlass:** Umuts handschriftliche Notizen vom Hüfner-Termin plus Antworten auf Rückfragen. Nur Inhalt und Seed-Skript, kein Kerncode.
**Erledigt (`scripts/seed-huefner.php`, `build-huefner-assets.php`, `reset-huefner.sh`):** Alle Grüntöne auf das Baumgrün des Logos (#067F30) gedreht, „Premium“ und Hervorhebungen in Baumgrün, kleine Schrift auf Dunkel weiß statt hellgrün (Kontrast). Das „💯“ als Umuts Emoji-Bild überall, wo „100“ steht, im Text (`<mark>100</mark>`: die Zahl bleibt lesbar für Vorleser und KI, sichtbar ist das Bild in Schriftgröße, geschütztes Leerzeichen vor „%“); Quelle `100-emoji.png` in den Unterlagen. „Miteinander. 💯 %.“-Abzeichen jetzt dunkel statt rot. Freistellungsbescheinigungen (§ 13b UStG, § 48b EStG) als PDF bei „Planung & Angebot“ und im Fuß, PQ-Bescheinigung unter „Über uns“ und im Fuß, Galabau-Signet und PQ-Logo. Imagevideo (YouTube, youtube-nocookie) als „Externer Inhalt“ – dafür jetzt Einwilligungshinweis (Ecke) und angepasste Datenschutzerklärung. Google-Bewertungen unter „100 % Qualität. 100 % Zufriedenheit.“ mit 4,9 / über 150 als Beleg, drei echte Rezensionen (gekürzt, Namen abgekürzt), Knopf zum Eintrag. 48-h-Angebotsgarantie in Ablauf und Kontakt, „Leistung“ aus dem Formular, Chips verlinken zum Formular, WhatsApp (0176 30387990) in Kontakt und Karriere, 25 Jahre / 7.000+ Projekte, „Bagger bis 15 t“ raus. Karriere: Facharbeiter und LKW-Fahrer C/CE (wie Instagram) als zwei gleich hohe, aufklappbare Karten (details/summary, SVG-Zeichnungen), „Ein Anruf genügt!“. Neue Seite `/alpakas` mit Umuts Text, Ergänzungen, Galerie und Plakat; „Alpakas“ im Menü, „Ablauf“ aus dem Menü raus. `browser-check.mjs`: PDFs zählen beim „erster interner Link“-Klick nicht mit.
**Geprüft:** `test:browser` auf 8102 für `/`, `/alpakas`, `/datenschutz`, `/impressum` grün (HTML Startseite 148.905 B von 150.000 – knapp). Lighthouse Produktion (`reports/lighthouse-huefner-termin`): `/` mobil 96, Desktop 99; `/alpakas` mobil 95, Desktop 100; Barrierefreiheit, Best Practices, SEO, Agentic Browsing überall 100. Im Browser: Video lädt erst nach Einwilligung, Stellenkarten klappen auf, Telefonbreite ohne waagrechtes Scrollen.
**Bekannt / offen:** Die Freistellungsbescheinigung § 13b gilt nur bis 31.12.2026 – dann neue Datei in die Unterlagen und neu aufbauen. Die Alpaka-Videos (mp4) sind nicht eingebunden (keine Videoablage im Kern). Offen von Umut: Logos der Sponsoren, „Über uns – mehr lesen“, ggf. Stellenbilder aus ChatGPT statt der SVG-Zeichnungen, genauer Verbandsname. Die Sprachtest-Kopie (`var/browser-tests/lang`) teilt `var/huefner-media` und hat nach dem Neuaufbau keine passenden Bilder mehr. Sicherung vorher: `var/backups/huefner-vor-termin-1210/`. Nebenbefund Kern: `CustomCode::parse` lehnt `url('…')` mit Anführungszeichen immer ab (optionales Anführungszeichen im regulären Ausdruck backtrackt).
**Nächster Schritt:** Umuts Durchsicht; fehlende Punkte nachliefern lassen.
## Fassung 1.28.0: Notfallseite, Fehlerprotokoll und Update mit einem Klick (09.10.2026)
**Anlass:** kantarci-reisen.de zeigt nach einer per FTP ersetzten Datenbank auf jeder Adresse eine leere 500-Seite. Umut: „Fang mit 1 und 2 an und verbessere den Update-Prozess … mach es wie bei reiseSoft.“
+91
View File
@@ -0,0 +1,91 @@
// Einwilligung ohne Neuladen (ADR 115).
//
// Ohne dieses Skript schicken der Hinweis und der Knopf am eingebetteten Inhalt ein Formular ab,
// der Server setzt das Cookie und leitet auf dieselbe Seite zurück - die Seite lädt neu. Mit dem
// Skript geht dasselbe Formular im Hintergrund an denselben Endpunkt; die Kopfzeile
// X-Kant-Consent sagt dem Server, dass keine Weiterleitung nötig ist. Danach holt das Skript die
// Seite noch einmal und übernimmt daraus nur, was sich durch die Entscheidung ändert: die
// eingebetteten Inhalte und die Zeile „Rechtliches“. Die Adresse eines Inhalts steht so weiterhin
// erst nach der Einwilligung im Dokument.
//
// Geht dabei etwas schief, schickt das Skript das Formular ganz normal ab: lieber einmal neu
// laden als eine Entscheidung, die nicht ankommt.
(() => {
const forms = document.querySelectorAll('.consent-bar form, .embed-blocked form');
if (forms.length === 0) {
return;
}
const replaceFrom = async () => {
const response = await fetch(location.href, { credentials: 'same-origin', headers: { Accept: 'text/html' } });
if (!response.ok) {
throw new Error('Seite nicht geladen');
}
const fresh = new DOMParser().parseFromString(await response.text(), 'text/html');
for (const blocked of document.querySelectorAll('.embed-blocked[id]')) {
const next = fresh.getElementById(blocked.id);
if (next && !next.classList.contains('embed-blocked')) {
blocked.replaceWith(document.importNode(next, true));
}
}
for (const legal of document.querySelectorAll('.block-legal[id]')) {
const next = fresh.getElementById(legal.id);
if (next) {
legal.replaceWith(document.importNode(next, true));
}
}
};
for (const form of forms) {
form.addEventListener('submit', async (event) => {
event.preventDefault();
const button = event.submitter;
const body = new FormData(form, button);
const placeholder = form.closest('.embed-blocked');
for (const control of form.querySelectorAll('button')) {
control.disabled = true;
}
try {
const saved = await fetch(form.action, { method: 'POST', body, credentials: 'same-origin', headers: { 'X-Kant-Consent': '1' } });
if (!saved.ok) {
throw new Error('Entscheidung nicht gespeichert');
}
// Wer etwas erlaubt hat, bekommt die Inhalte jetzt; wer abgelehnt hat, braucht keinen
// zweiten Abruf - es ändert sich nichts außer dem Hinweis.
if (body.get('choice') !== 'none' && document.querySelector('.embed-blocked')) {
await replaceFrom();
}
document.querySelector('.consent-bar')?.remove();
if (placeholder) {
// Der Knopf ist mit dem Platzhalter verschwunden; der Fokus geht an den Inhalt, der an
// seiner Stelle steht.
const frame = document.getElementById(placeholder.id)?.querySelector('iframe');
frame?.focus();
} else {
// Entschieden im Hinweis: der Fokus beginnt wieder am Seitenanfang, wie nach dem
// Neuladen - der nächste Tab führt zu „Zum Inhalt springen“.
document.getElementById('seitenanfang')?.focus({ preventScroll: true });
}
} catch {
// Rückfall: das Formular normal absenden, samt gedrücktem Knopf.
for (const control of form.querySelectorAll('button')) {
control.disabled = false;
}
withChoice(form, button).submit();
}
});
}
// form.submit() schickt den gedrückten Knopf nicht mit; sein Wert wandert als verstecktes Feld
// ins Formular, damit der Rückfall dieselbe Entscheidung sendet.
function withChoice(form, button) {
if (button?.name) {
const field = document.createElement('input');
field.type = 'hidden';
field.name = button.name;
field.value = button.value;
form.append(field);
}
return form;
}
})();
+10 -5
View File
@@ -69,7 +69,8 @@ try {
const queries = Number(response.headers()['x-kant-queries'] ?? -1);
const html = (await response.text()).length;
const styles = [...transferred.values()].filter(entry => entry.type === 'text/css').reduce((sum, entry) => sum + entry.length, 0);
const scripts = [...transferred.values()].filter(entry => entry.type.includes('javascript')).length;
// consent.js zählt nicht: nur solange eine Einwilligung offen ist (ADR 115).
const scripts = [...transferred.entries()].filter(([url, entry]) => entry.type.includes('javascript') && !url.includes('/assets/consent.js')).length;
const requests = transferred.size;
// Bilder gehören zum Inhalt: eine bebilderte Startseite lädt zu Recht mehr Dateien als eine
// Textseite. Gedeckelt wird deshalb das Beiwerk (Stile, Schriften, Skripte) und das
@@ -114,7 +115,9 @@ try {
assert.equal(await page.locator('h1').count(), 1);
// Strukturierte Daten stehen zwar in einem script-Element, führen aber nichts aus. Gezählt
// wird, was laufen kann - und das nur, wenn die Seite es in ihrer eigenen Richtlinie erlaubt.
const laufend = await page.locator('script:not([type="application/ld+json"])').count();
// consent.js zählt nicht mit: Es steht nur auf der Seite, solange eine Einwilligung offen ist,
// und nach der Entscheidung nicht mehr (ADR 115).
const laufend = await page.locator('script:not([type="application/ld+json"]):not([src^="/assets/consent.js"])').count();
const darfSkripte = !/script-src 'none'/.test(response.headers()['content-security-policy'] ?? '');
assert.ok(darfSkripte ? laufend <= 3 : 0 === laufend, `${path}: ${laufend} Skript(e), erlaubt ${darfSkripte ? 3 : 0}`);
// Einblendungen zu Ende spielen, bevor gemessen wird: mitten in einer Einblendung ist ein
@@ -162,7 +165,8 @@ try {
const hinweis = page.locator('.consent-bar button[value="none"]');
if (await hinweis.count()) {
await hinweis.click();
await page.waitForLoadState('domcontentloaded');
// Mit consent.js ohne Neuladen (ADR 115): warten, bis der Hinweis weg ist.
await page.locator('.consent-bar').waitFor({ state: 'detached' });
}
await page.keyboard.press('Tab');
assert.equal(await page.locator(':focus').innerText(), 'Zum Inhalt springen');
@@ -171,7 +175,8 @@ try {
// Weiter mit der Tastatur: der erste Verweis im Inhalt muss anklickbar sein und tatsächlich
// woandershin führen. Vorher standen hier die Linktexte der Demo - gegen jede andere
// Installation lief die Prüfung damit ins Leere statt etwas zu prüfen.
const ersterLink = page.locator('main a[href^="/"]').first();
// Ein PDF zum Herunterladen ist keine Seite mit Überschrift; es zählt hier nicht mit.
const ersterLink = page.locator('main a[href^="/"]:not([href$=".pdf"])').first();
const ziel = await ersterLink.getAttribute('href');
await ersterLink.click();
assert.equal(new URL(page.url()).pathname, ziel.split('#')[0] || '/');
@@ -189,7 +194,7 @@ try {
await noJs.waitForLoadState('domcontentloaded');
}
assert.equal(await noJs.locator('h1').count(), 1);
const ohneJs = noJs.locator('main a[href^="/"]').first();
const ohneJs = noJs.locator('main a[href^="/"]:not([href$=".pdf"])').first();
const zielOhneJs = await ohneJs.getAttribute('href');
await ohneJs.click();
assert.equal(new URL(noJs.url()).pathname, zielOhneJs.split('#')[0] || '/');
+37 -1
View File
@@ -7,7 +7,12 @@
* den Baum mit den Steinen als quadratisches Seitensymbol (favicon.png) heraus. Der Baum ist im
* Logo links, der Schriftzug rechts; ein Favicon mit Schrift wäre in 32 px unlesbar.
*
* Aufruf: php scripts/build-huefner-assets.php <Quellordner mit images/> <Zielordner>
* Aufruf: php scripts/build-huefner-assets.php <Quellordner mit images/> <Zielordner> [<Unterlagen>]
*
* Das dritte Argument ist Umuts Ordner „unterlagenfrwebsite“ (12.10.2026): Alpaka-Fotos, PQ-Logo,
* Galabau-Signet und die drei PDFs. Sie bekommen hier feste, einfache Namen, damit das Seed-Skript
* nicht an „Freistellungsbescheinigung §48 … bis 11.05.2029.pdf“ hängt. Kommt eine neue
* Bescheinigung, genügt es, sie nach dem gleichen Muster benannt in den Ordner zu legen.
*/
$quelle = rtrim($argv[1] ?? '', '/\\');
$ziel = rtrim($argv[2] ?? '', '/\\');
@@ -43,3 +48,34 @@ $zh = (int) round($h * $massstab);
imagecopyresampled($symbol, $logo, (int) (($kante - $zb) / 2), (int) (($kante - $zh) / 2), $x, $y, $zb, $zh, $b, $h);
imagepng($symbol, $ziel.'/favicon.png');
echo "Bilder kopiert, Seitensymbol geschnitten: $ziel\n";
$unterlagen = rtrim($argv[3] ?? '', '/\\');
if ('' === $unterlagen) {
exit(0);
}
// Muster im Ordner => fester Name. Bei mehreren Treffern gewinnt der letzte in Namensreihenfolge.
$feste = [
'Alpaka (1).jpeg' => 'alpaka-plakat.jpg',
'Alpaka (2).jpeg' => 'alpaka-wiese.jpg',
'Alpaka (3).jpeg' => 'alpaka-stall.jpg',
'Alpaka (4).jpeg' => 'alpaka-blume.jpg',
'Alpaka (6).jpeg' => 'alpaka-zaunbau.jpg',
'Alpaka (9).jpeg' => 'alpaka-rasen.jpg',
'PQ-Logo*.png' => 'pq-logo.png',
'Signum*.png' => 'galabau-signet.png',
'100-emoji.png' => 'hundert.png',
'PQ-Bescheinigung*.pdf' => 'pq-bescheinigung.pdf',
'Freistellungsbescheinigung §13*.pdf' => 'freistellung-13b.pdf',
'Freistellungsbescheinigung §48*.pdf' => 'freistellung-48b.pdf',
];
$dateien = scandir($unterlagen) ?: [];
foreach ($feste as $muster => $name) {
$treffer = array_values(array_filter($dateien, static fn (string $d): bool => fnmatch($muster, $d, FNM_NOESCAPE)));
if ([] === $treffer) {
fwrite(STDERR, "Fehlt in den Unterlagen: $muster\n");
exit(1);
}
sort($treffer);
copy($unterlagen.'/'.end($treffer), $ziel.'/'.$name);
}
echo 'Unterlagen übernommen: '.count($feste)." Dateien\n";
+4 -3
View File
@@ -1,6 +1,7 @@
#!/bin/sh
# Wirft die Hüfner-Installation weg und baut sie neu auf. Nur fuer den Aufbau gedacht.
# Erstes Argument: der entpackte Entwurf (Ordner mit index.html und images/).
# Zweites Argument: Umuts Unterlagen (Alpakas, PQ, Freistellungsbescheinigungen, Signet).
set -e
root="C:/Django Project/KantSites"
php="${PHP_BINARY:-C:/php84/php.exe}"
@@ -8,10 +9,10 @@ export DATABASE_URL="sqlite:///$root/var/huefner.db"
export MEDIA_DIRECTORY="$root/var/huefner-media"
assets="$root/var/huefner-assets"
if [ -n "$1" ]; then
"$php" scripts/build-huefner-assets.php "$1" "$assets"
"$php" scripts/build-huefner-assets.php "$1" "$assets" "$2"
fi
if [ ! -f "$assets/favicon.png" ]; then
echo "Bilder fehlen: erst mit dem Ordner des Entwurfs als Argument aufrufen." >&2
if [ ! -f "$assets/favicon.png" ] || [ ! -f "$assets/pq-bescheinigung.pdf" ]; then
echo "Bilder fehlen: erst mit dem Ordner des Entwurfs und den Unterlagen als Argumente aufrufen." >&2
exit 1
fi
rm -f "$root/var/huefner.db"
+550 -183
View File
File diff suppressed because it is too large. Load diff
+6 -1
View File
@@ -33,7 +33,12 @@ final class ConsentController extends AbstractController
'none' => [],
default => array_map(static fn (mixed $group): string => (string) $group, (array) $request->request->all('groups')),
};
$response = $this->redirect($this->backTo($request));
// consent.js schickt dasselbe Formular im Hintergrund und kennzeichnet das mit dieser
// Kopfzeile: dann keine Weiterleitung, nur das Cookie - die Seite bleibt stehen und das
// Skript tauscht aus, was jetzt geladen werden darf (ADR 115). Ein fremdes Formular kann
// die Kopfzeile nicht setzen; ein fremdes Skript bräuchte dafür eine CORS-Freigabe.
$response = '1' === $request->headers->get('X-Kant-Consent') ? new Response(status: Response::HTTP_NO_CONTENT) : $this->redirect($this->backTo($request));
$response->headers->set('Cache-Control', 'no-store');
// Eine Entscheidung trifft nur der eigene Browser auf dieser Website. Ein fremdes Formular,
// das hierher postet, darf keine Einwilligung eintragen - die Route hat keine Sitzung und
// damit kein CSRF-Token, also entscheidet die Herkunft der Anfrage.
+23 -13
View File
@@ -371,7 +371,13 @@ final class PageController extends AbstractController
// Vorlage holt die Liste von hier, statt sie durch jede Einbindung zu reichen.
$request->attributes->set('kant_alternates', $alternates);
$canonical = null === $adresse ? null : $adresse.(null !== $blaettern && $blaettern['page'] > 1 ? '?seite='.$blaettern['page'] : '');
// Steht eine Einwilligung aus - Hinweis offen oder ein eingebetteter Inhalt wartet -, kommt
// consent.js dazu: Es speichert die Entscheidung ohne Neuladen. Ohne Skript bleibt das
// Formular mit Weiterleitung.
$embeds = $this->embedState($request, $page['document'], ...array_values($areas), ...array_values($sections));
$needsConsent = $embeds['blocked'] || $this->consent->needsBanner($request);
$response = $this->render('page.html.twig', [
'needsConsent' => $needsConsent,
'page' => $page,
'canonical' => $canonical,
'alternates' => $alternates,
@@ -429,8 +435,8 @@ 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 || $needsDocument || [] !== $this->code->scriptsOf($page['document']));
$frames = $this->frameHosts($request, $page['document'], ...array_values($areas), ...array_values($sections));
$wantsScript = ($mitTurnstile || $needsTabs || $needsFormScript || [] !== $pluginAssets['scripts'] || [] !== $areaScripts || $needsSlider || $needsMotion || $needsScrolled || $needsZoom || $needsDocument || $needsConsent || [] !== $this->code->scriptsOf($page['document']));
$frames = $embeds['hosts'];
if ($mitTurnstile) {
$frames[] = 'challenges.cloudflare.com';
}
@@ -453,9 +459,9 @@ final class PageController extends AbstractController
* The generated stylesheet is inline for speed, so it is allowed by its own hash rather than
* by 'unsafe-inline' – any other inline style, injected or not, stays blocked.
*
* @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 list<string> $frameHosts Hosts of the embeds on this page, also of those still
* waiting for consent (ADR 115: consent.js inserts them
* without a reload, and a policy cannot change afterwards)
* @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 = [], bool $frameSelf = false): Response
@@ -1079,23 +1085,27 @@ final class PageController extends AbstractController
}
/**
* The hosts of embeds that may load on this request.
* The hosts of all embeds on this page, and whether one of them still waits for consent.
*
* An embed whose group is not granted contributes nothing: its host must not appear in the
* policy, because the frame is not rendered either.
* Seit ADR 115 steht der Host auch dann in frame-src, wenn die Gruppe noch nicht erlaubt ist:
* Die Richtlinie einer Seite steht beim Laden fest, und consent.js setzt den Rahmen nach der
* Einwilligung ohne Neuladen ein. Die Erlaubnis lädt nichts; die Adresse des Inhalts steht
* weiterhin erst nach der Einwilligung im HTML.
*
* @return list<string>
* @return array{hosts: list<string>, blocked: bool}
*/
private function frameHosts(Request $request, array ...$documents): array
private function embedState(Request $request, array ...$documents): array
{
$hosts = [];
$walk = function (array $blocks) use (&$walk, &$hosts, $request): void {
$blocked = false;
$walk = function (array $blocks) use (&$walk, &$hosts, &$blocked, $request): void {
foreach ($blocks as $block) {
if ('embed' === ($block['type'] ?? null) && $this->consent->granted($request, $block['props']['group'] ?? '')) {
if ('embed' === ($block['type'] ?? null) && '' !== ($block['props']['url'] ?? '')) {
$host = parse_url($block['props']['url'] ?? '', PHP_URL_HOST);
if (is_string($host)) {
$hosts[$host] = true;
}
$blocked = $blocked || !$this->consent->granted($request, $block['props']['group'] ?? '');
}
$walk($block['children'] ?? []);
}
@@ -1104,7 +1114,7 @@ final class PageController extends AbstractController
$walk($document['blocks'] ?? []);
}
return array_keys($hosts);
return ['hosts' => array_keys($hosts), 'blocked' => $blocked];
}
/**
+1
View File
@@ -70,6 +70,7 @@
{% 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) or needsDocument|default(false) %}<script src="/assets/lightbox.js?v={{ kant_version() }}" defer></script>{% endif %}
{% if needsConsent|default(false) and not editing|default(false) %}<script src="/assets/consent.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. #}
+35 -5
View File
@@ -4546,8 +4546,10 @@ Achern']);
self::assertStringContainsString('value="all"', $html);
self::assertStringContainsString('value="none"', $html);
self::assertStringContainsString('/datenschutz', $html);
// No script anywhere: the strict policy stays as it is.
self::assertStringContainsString("script-src 'none'", (string) $response->headers->get('Content-Security-Policy'));
// Seit ADR 115 kommt consent.js dazu, solange die Frage offen ist - als Datei von hier,
// nie inline. Ohne Skript bleibt das Formular mit Weiterleitung.
self::assertStringContainsString('<script src="/assets/consent.js?v=', $html);
self::assertStringContainsString("script-src 'self'", (string) $response->headers->get('Content-Security-Policy'));
$saved = $this->request('/einwilligung', 'POST', ['choice' => 'all', 'back' => '/']);
self::assertSame(302, $saved->getStatusCode());
@@ -4561,6 +4563,9 @@ Achern']);
// With a decision in hand the banner is gone, but the way back to it is not.
$decided = $this->request('/', 'GET', [], [Consent::COOKIE => (string) $cookie->getValue()]);
self::assertStringNotContainsString('consent-bar', $decided->getContent());
// Nichts mehr offen: kein Skript, die Richtlinie wieder streng.
self::assertStringNotContainsString('consent.js', $decided->getContent());
self::assertStringContainsString("script-src 'none'", (string) $decided->headers->get('Content-Security-Policy'));
self::assertStringContainsString('/einwilligung', $decided->getContent());
self::assertSame(200, $this->request('/einwilligung', 'GET', [], [Consent::COOKIE => (string) $cookie->getValue()])->getStatusCode());
}
@@ -4601,10 +4606,35 @@ Achern']);
self::assertStringNotContainsString('<iframe', $html);
self::assertStringNotContainsString('karten.example/einbetten', $html);
self::assertStringContainsString('OpenStreetMap', $html, 'The placeholder names the provider.');
self::assertStringContainsString("frame-src 'none'", $policy);
// Die Erlaubnis steht schon in der Richtlinie (ADR 115): consent.js setzt den Rahmen
// nach der Einwilligung ohne Neuladen ein, und eine Richtlinie ändert sich danach nicht
// mehr. Geladen wird dadurch nichts - es gibt keinen Rahmen und keine Adresse.
self::assertStringContainsString('frame-src https://karten.example', $policy);
self::assertStringContainsString('<script src="/assets/consent.js?v=', $html);
}
}
public function testTheConsentScriptSavesWithoutARedirect(): void
{
$this->askConsent();
// consent.js kennzeichnet seine Anfrage; dann gibt es das Cookie ohne Weiterleitung.
$saved = $this->request('/einwilligung', 'POST', ['choice' => 'all', 'back' => '/'], [], ['HTTP_X_KANT_CONSENT' => '1']);
self::assertSame(204, $saved->getStatusCode());
self::assertNull($saved->headers->get('Location'));
self::assertSame('', $saved->getContent());
$cookie = $saved->headers->getCookies()[0];
self::assertSame(Consent::COOKIE, $cookie->getName());
self::assertStringContainsString('external', (string) $cookie->getValue());
// Von einer fremden Website bleibt es bei der Antwort ohne Cookie, wie beim Formular.
$fremd = $this->request('/einwilligung', 'POST', ['choice' => 'all'], [], ['HTTP_X_KANT_CONSENT' => '1', 'HTTP_SEC_FETCH_SITE' => 'cross-site']);
self::assertSame([], $fremd->headers->getCookies());
// Ohne die Kopfzeile bleibt alles beim Alten: Weiterleitung zurück.
self::assertSame(302, $this->request('/einwilligung', 'POST', ['choice' => 'none', 'back' => '/'])->getStatusCode());
}
public function testTheEmbedButtonGrantsOnlyItsOwnGroupAndKeepsWhatWasGrantedBefore(): void
{
$document = DemoContent::starter('Mit Karte');
@@ -4713,7 +4743,7 @@ Achern']);
}
}
private function request(string $url, string $method = 'GET', array $body = [], array $cookies = []): Response
private function request(string $url, string $method = 'GET', array $body = [], array $cookies = [], array $server = []): Response
{
$previous = [];
foreach (['APP_SECRET' => 'test-only', 'DATABASE_URL' => 'sqlite:///'.str_replace('\\', '/', $this->path), 'SITE_ORIGIN' => 'https://cms.test', 'MEDIA_DIRECTORY' => $this->path.'-media', 'FONT_DIRECTORY' => $this->path.'-fonts', 'ASSET_DIRECTORY' => $this->path.'-assets'] as $key => $value) {
@@ -4722,7 +4752,7 @@ Achern']);
}
$kernel = new Kernel('test', false);
try {
$request = Request::create($url, $method, $body, $cookies);
$request = Request::create($url, $method, $body, $cookies, [], $server);
$response = $kernel->handle($request);
// Counting happens on kernel.terminate, so a test that does not terminate would
// silently test a different application than the one that runs.