Serve images without PHP, add the 720 and 1280 steps, trim two hot paths
- Image widths are 480, 720, 960, 1280, 1600 and 2400. A phone needed 721 pixels and got 960. Existing images receive the new steps from a task; stored pages pick them up at render time, so no document is rewritten. Mobile backgrounds use 720. - A variant served once is mirrored to public/media, and a rewrite rule lets the web server deliver it from there. var/media stays the source. - The hero preload follows the image each device really shows. - The post navigation reads the order and then two posts instead of up to 500 documents. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
1 parent
dca2665138
commit
7d0369bada
32 files changed
+550
-57
No files matched your search
@@ -18,6 +18,8 @@
|
||||
# damit auf einem frischen Server nichts fehlt und nichts von Google geladen wird. Andere
|
||||
# Installationen holen ihre eigenen mit `cms:fonts:fetch` - deren Dateien gehoeren nicht hierher.
|
||||
/public/assets/build/
|
||||
# Das Abbild der Bildvarianten fuer den Webserver; es fuellt sich im Betrieb von selbst.
|
||||
/public/media/
|
||||
|
||||
# Zentrale (sites.kant-it.de): Konfiguration und Daten gehoeren dem Server.
|
||||
/zentrale/config.php
|
||||
|
||||
@@ -9,6 +9,17 @@
|
||||
* what public/.htaccess does. This script reproduces that behaviour for local development.
|
||||
*/
|
||||
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
|
||||
// Wie die Regel in public/.htaccess: Ein Bild der Mediathek, das im Abbild public/media/ liegt,
|
||||
// geht ohne die Anwendung raus (ADR 129). Ohne das maesse man hier weiter den Weg ueber PHP.
|
||||
if (1 === preg_match('#^/media/[a-f0-9]{32}/[0-9]{1,4}$#D', $path) && is_file(__DIR__.'/../public'.$path.'.webp')) {
|
||||
header('Content-Type: image/webp');
|
||||
header('Cache-Control: public, max-age=31536000, immutable');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('Content-Length: '.filesize(__DIR__.'/../public'.$path.'.webp'));
|
||||
readfile(__DIR__.'/../public'.$path.'.webp');
|
||||
|
||||
exit;
|
||||
}
|
||||
$file = __DIR__.'/../public'.$path;
|
||||
if ('/' !== $path && !str_contains($path, '..') && is_file($file)) {
|
||||
// Dieselben Antwortkoepfe wie public/.htaccess sie auf einem Webserver setzt: Stylesheets,
|
||||
|
||||
@@ -10,6 +10,9 @@ parameters:
|
||||
# das Projekt schreiben, und eine Installation mit schreibgeschuetztem public/ ebenfalls.
|
||||
kant.fonts_dir: '%kernel.project_dir%/public/assets/fonts'
|
||||
kant.asset_dir: '%kernel.project_dir%/public/assets'
|
||||
# Das Abbild der Bildvarianten, das der Webserver ohne PHP ausliefert (App\Content\MediaMirror,
|
||||
# ADR 129). MEDIA_MIRROR_DIRECTORY stellt es um; in Tests ist es aus.
|
||||
kant.media_mirror_dir: '%kernel.project_dir%/public/media'
|
||||
kant.mail_log: '%kernel.project_dir%/var/log/mail.log'
|
||||
kant.mailer_dsn: 'sendmail://default'
|
||||
# Die Einstellung, die die Verwaltung anzeigt und ausprobiert; Tests setzen sie fest.
|
||||
@@ -69,6 +72,10 @@ services:
|
||||
App\Content\StyleBundle:
|
||||
arguments:
|
||||
$assetDirectory: '%env(default:kant.asset_dir:ASSET_DIRECTORY)%'
|
||||
App\Content\MediaMirror:
|
||||
arguments:
|
||||
$directory: '%env(default:kant.media_mirror_dir:MEDIA_MIRROR_DIRECTORY)%'
|
||||
$mediaDirectory: '%env(projectdir:MEDIA_DIRECTORY)%'
|
||||
# Versand und Protokoll (ADR 121). MAIL_LOG nur fuer Tests, damit sie nicht ins echte var/log
|
||||
# schreiben.
|
||||
App\Ops\MailCheck:
|
||||
@@ -209,5 +216,7 @@ services:
|
||||
# Frameworks nimmt. Hier und nicht in config/packages/test, weil diese Datei danach geladen wird.
|
||||
when@test:
|
||||
parameters:
|
||||
# Tests schreiben nicht nach public/. Wer das Abbild prüft, setzt MEDIA_MIRROR_DIRECTORY.
|
||||
kant.media_mirror_dir: ''
|
||||
kant.mail_log: '%kernel.cache_dir%/mail.log'
|
||||
kant.mail_dsn: 'smtp://cms.test:587'
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
Stand: 10.11.2026. Arbeitstitel KantSites; Produktname offen.
|
||||
|
||||
## ADR 129 – Bilder: Stufen 720 und 1280, Abbild für den Webserver (10.11.2026)
|
||||
|
||||
**Kontext:** Die Komplettanalyse maß auf kant-it.de rund 250 ms bis zum ersten Byte eines Bildes gegen rund 100 ms für eine statische Datei, weil jedes Bild durch die Anwendung lief (ADR 009). Zwischen den Stufen 480 und 960 lag außerdem genau der Bedarf eines Telefons (412 px × 1,75 = 721 px); Becker kam damit mobil auf einen LCP von 2,6 s.
|
||||
|
||||
**Entscheidung:** (1) `MediaLibrary::WIDTHS` ist `[480, 720, 960, 1280, 1600, 2400]`; eine Stufe knapp unter der eigenen Breite entfällt (`steps()`). Vorhandene Bilder bekommen die neuen Stufen über die Aufgabe „Bildgrößen ergänzen“ (`image-variants`, alle 15 Minuten, portionsweise) aus ihrer größten Variante – das Original wird nicht aufbewahrt. Bestehende Varianten und ihre Adressen bleiben. (2) Die Seite löst `props.sources` eines Bildes beim Ausliefern aus der Mediathek auf (`PageController::currentImageSources`), statt die im Dokument gespeicherte Liste zu drucken: Nachgereichte Stufen erreichen jede gespeicherte Seite ohne Dokumentmigration. (3) Hintergrundbilder nehmen auf dem Telefon die Stufe 720 statt 960; die Vorabladung löst das Bild je Gerät mit derselben Vererbung und Sichtbarkeit auf wie die Regel. (4) `MediaMirror` legt eine Variante beim ersten Abruf über PHP unter `public/media/<kennung>/<breite>.webp` ab (harter Verweis, sonst Kopie); eine Regel in `public/.htaccess` liefert sie danach ohne PHP aus. `var/media` bleibt die Quelle für Sicherung, Paket und Wiederherstellung; „Aufräumen“ und der Paketimport entfernen aus dem Abbild, was es dort nicht mehr gibt.
|
||||
|
||||
**Folgen:** Keine Migration, kein Schemawechsel, die Adressen der Bilder bleiben. Das hebt den Teil von ADR 009 auf, nach dem Bilder nur über die Anwendung ausgeliefert werden: Was im Abbild liegt, war auch vorher für jeden abrufbar, der die Adresse kennt (die Auslieferung prüft keine Anmeldung). Lässt sich `public/media` nicht schreiben oder greift die Regel bei einem Hoster nicht, bleibt alles beim Weg über PHP. Der eingebaute PHP-Server **ohne** Router antwortet mit 404, sobald zu einer Kennung ein Ordner im Abbild liegt; `bin/dev-router.php` bildet die Regel nach, und die Prüfskripte nutzen ein eigenes Abbild (`MEDIA_MIRROR_DIRECTORY`). Die WebP-Qualität bleibt 82. Nachgereichte Stufen sind aus einer WebP-Variante gerechnet, nicht aus dem Original.
|
||||
|
||||
## ADR 128 – Lesen ist nachsichtig, Schreiben streng; Kennung statt Versionsspalte (10.11.2026)
|
||||
|
||||
**Kontext:** Die Komplettanalyse vom 10.11.2026 fand drei Stellen, an denen ein gespeicherter Stand zur Falle wurde: Seiten wurden bei jedem Lesen voll geprüft, samt eigenem CSS – eine später verschärfte Regel ließ sie mit 500 antworten und im Editor nicht mehr öffnen; Einstellungen und Design überschrieb der zweite von zwei Admins still; Sicherungen galten als unversehrt, auch wenn Mediendateien fehlten.
|
||||
|
||||
@@ -24,6 +24,16 @@ RewriteBase /
|
||||
#RewriteCond %{HTTP:X-Forwarded-Proto} !=https
|
||||
#RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
|
||||
|
||||
# Bilder der Mediathek ohne PHP ausliefern (ADR 129).
|
||||
#
|
||||
# Ein Bild heisst /media/<kennung>/<breite>. Liegt die Variante im Abbild public/media/ - dort legt
|
||||
# die Anwendung sie beim ersten Abruf ab -, beantwortet der Webserver die Anfrage selbst: kein
|
||||
# PHP-Prozess, keine Datenbank. Fehlt sie, greift die Regel nicht, und alles laeuft wie bisher
|
||||
# ueber index.php. Die Pruefung haengt an REQUEST_FILENAME wie die Regel darunter; nach dem
|
||||
# Umschreiben endet die Adresse auf .webp und passt nicht noch einmal.
|
||||
RewriteCond %{REQUEST_FILENAME}.webp -f
|
||||
RewriteRule ^media/[a-f0-9]{32}/[0-9]{1,4}$ %{REQUEST_URI}.webp [L]
|
||||
|
||||
RewriteRule ^index\.php$ - [L]
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteRule ^ index.php [L]
|
||||
@@ -49,6 +59,10 @@ RewriteRule ^ index.php [L]
|
||||
# Adresse. Sie duerfen deshalb ein Jahr bleiben. Stylesheets und Skripte tragen die Fassung als
|
||||
# Parameter, also ebenfalls ein Jahr. HTML wird nie zwischengespeichert - eine veroeffentlichte
|
||||
# Aenderung soll beim naechsten Aufruf sichtbar sein; die Seiten senden dafuer selbst no-store.
|
||||
# Aeltere Server kennen die Endung nicht und lieferten die Bilder des Abbilds als Text aus.
|
||||
<IfModule mod_mime.c>
|
||||
AddType image/webp .webp
|
||||
</IfModule>
|
||||
<IfModule mod_expires.c>
|
||||
ExpiresActive On
|
||||
ExpiresByType image/webp "access plus 1 year"
|
||||
@@ -68,6 +82,10 @@ RewriteRule ^ index.php [L]
|
||||
<FilesMatch "\.woff2$">
|
||||
Header set Access-Control-Allow-Origin "*"
|
||||
</FilesMatch>
|
||||
# Dieselbe Zeile, die die Anwendung an ein Bild haengt, wenn sie es selbst ausliefert.
|
||||
<FilesMatch "\.webp$">
|
||||
Header set X-Content-Type-Options "nosniff"
|
||||
</FilesMatch>
|
||||
# Was der Server ueber sich selbst erzaehlt, geht niemanden etwas an: die genaue PHP-Fassung
|
||||
# ist fuer einen Besucher wertlos und fuer einen Scanner eine Einkaufsliste.
|
||||
Header always unset X-Powered-By
|
||||
|
||||
@@ -8,7 +8,7 @@ import net from 'node:net';
|
||||
|
||||
const php = process.env.PHP_BINARY || 'php';
|
||||
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, MEDIA_MIRROR_DIRECTORY: fixture.mediaDirectory + '-abbild' };
|
||||
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
|
||||
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
|
||||
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
|
||||
|
||||
@@ -8,12 +8,12 @@ import net from 'node:net';
|
||||
|
||||
const php = process.env.PHP_BINARY || 'php';
|
||||
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8' }));
|
||||
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env: { ...process.env, DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory } });
|
||||
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env: { ...process.env, DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, MEDIA_MIRROR_DIRECTORY: fixture.mediaDirectory + '-abbild' } });
|
||||
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
|
||||
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
const server = spawn(php, ['-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', 'bin/dev-router.php'], {
|
||||
windowsHide: true, stdio: 'ignore', env: { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, SITE_ORIGIN: origin },
|
||||
windowsHide: true, stdio: 'ignore', env: { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, MEDIA_MIRROR_DIRECTORY: fixture.mediaDirectory + '-abbild', SITE_ORIGIN: origin },
|
||||
});
|
||||
await mkdir('reports/admin-quality', { recursive: true });
|
||||
let browser, page;
|
||||
|
||||
@@ -7,7 +7,7 @@ import net from 'node:net';
|
||||
|
||||
const php = process.env.PHP_BINARY || 'php';
|
||||
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, MEDIA_MIRROR_DIRECTORY: fixture.mediaDirectory + '-abbild' };
|
||||
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
|
||||
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
|
||||
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
|
||||
|
||||
@@ -11,7 +11,7 @@ import net from 'node:net';
|
||||
|
||||
const php = process.env.PHP_BINARY || 'php';
|
||||
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, MEDIA_MIRROR_DIRECTORY: fixture.mediaDirectory + '-abbild' };
|
||||
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
|
||||
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
|
||||
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
|
||||
|
||||
@@ -8,7 +8,7 @@ import net from 'node:net';
|
||||
|
||||
const php = process.env.PHP_BINARY || 'php';
|
||||
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, MEDIA_MIRROR_DIRECTORY: fixture.mediaDirectory + '-abbild' };
|
||||
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
|
||||
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
|
||||
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
|
||||
|
||||
@@ -16,7 +16,7 @@ await new Promise(resolve => probe.close(resolve));
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
const server = spawn(php, ['-d', 'upload_max_filesize=8M', '-d', 'post_max_size=10M', '-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public'], {
|
||||
windowsHide: true,
|
||||
env: { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, SITE_ORIGIN: origin },
|
||||
env: { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, MEDIA_MIRROR_DIRECTORY: fixture.mediaDirectory + '-abbild', SITE_ORIGIN: origin },
|
||||
stdio: 'ignore',
|
||||
});
|
||||
server.on('error', error => { throw error; });
|
||||
|
||||
@@ -8,7 +8,7 @@ import { mediaProps, upgradeDocument } from '../public/assets/editor-model.js';
|
||||
|
||||
const php = process.env.PHP_BINARY || 'php';
|
||||
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, MEDIA_MIRROR_DIRECTORY: fixture.mediaDirectory + '-abbild' };
|
||||
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
|
||||
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
|
||||
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
|
||||
|
||||
@@ -9,7 +9,7 @@ import { mediaProps } from '../public/assets/editor-model.js';
|
||||
|
||||
const php = process.env.PHP_BINARY || 'php';
|
||||
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, MEDIA_MIRROR_DIRECTORY: fixture.mediaDirectory + '-abbild' };
|
||||
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
|
||||
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
|
||||
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
|
||||
|
||||
@@ -9,7 +9,7 @@ import { mediaProps } from '../public/assets/editor-model.js';
|
||||
|
||||
const php = process.env.PHP_BINARY || 'php';
|
||||
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, MEDIA_MIRROR_DIRECTORY: fixture.mediaDirectory + '-abbild' };
|
||||
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
|
||||
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
|
||||
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
|
||||
|
||||
@@ -8,7 +8,7 @@ import net from 'node:net';
|
||||
|
||||
const php = process.env.PHP_BINARY || 'php';
|
||||
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
|
||||
const environment = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
|
||||
const environment = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, MEDIA_MIRROR_DIRECTORY: fixture.mediaDirectory + '-abbild' };
|
||||
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env: environment });
|
||||
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
|
||||
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
|
||||
|
||||
@@ -12,7 +12,7 @@ import net from 'node:net';
|
||||
|
||||
const php = process.env.PHP_BINARY || 'php';
|
||||
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, MEDIA_MIRROR_DIRECTORY: fixture.mediaDirectory + '-abbild' };
|
||||
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
|
||||
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
|
||||
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
|
||||
|
||||
@@ -9,7 +9,7 @@ const php = process.env.PHP_BINARY || 'php';
|
||||
const hash = async () => createHash('sha256').update(await readFile('var/kantit.db')).digest('hex');
|
||||
const before = await hash();
|
||||
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, MEDIA_MIRROR_DIRECTORY: fixture.mediaDirectory + '-abbild' };
|
||||
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { env, windowsHide: true, stdio: 'ignore' });
|
||||
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
|
||||
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
|
||||
|
||||
@@ -7,7 +7,7 @@ import net from 'node:net';
|
||||
|
||||
const php = process.env.PHP_BINARY || 'php';
|
||||
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, MEDIA_MIRROR_DIRECTORY: fixture.mediaDirectory + '-abbild' };
|
||||
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
|
||||
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
|
||||
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
|
||||
|
||||
@@ -8,7 +8,7 @@ import net from 'node:net';
|
||||
|
||||
const php = process.env.PHP_BINARY || 'php';
|
||||
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, MEDIA_MIRROR_DIRECTORY: fixture.mediaDirectory + '-abbild' };
|
||||
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
|
||||
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
|
||||
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
|
||||
|
||||
@@ -8,7 +8,7 @@ import net from 'node:net';
|
||||
|
||||
const php = process.env.PHP_BINARY || 'php';
|
||||
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, MEDIA_MIRROR_DIRECTORY: fixture.mediaDirectory + '-abbild' };
|
||||
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
|
||||
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
|
||||
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
|
||||
|
||||
@@ -11,8 +11,34 @@ final class MediaLibrary
|
||||
/** Enough to fill a screen, few enough that the browser does not decode a hundred images. */
|
||||
public const PER_PAGE = 24;
|
||||
|
||||
/** The widths every upload is rendered to, in WebP. A source narrower than a step is skipped. */
|
||||
public const WIDTHS = [480, 960, 1600, 2400];
|
||||
/**
|
||||
* The widths every upload is rendered to, in WebP. A source narrower than a step is skipped.
|
||||
*
|
||||
* 720 und 1280 kamen mit 1.42.0 dazu: Zwischen 480 und 960 lag genau der Bereich, den ein
|
||||
* Telefon braucht (412 px Breite mal 1,75 sind 721 px) - es lud deshalb die 960er Fassung mit
|
||||
* fast doppelt so vielen Bildpunkten. Vorhandene Bilder bekommen die neuen Stufen über
|
||||
* fillVariants() nachgereicht.
|
||||
*/
|
||||
public const WIDTHS = [480, 720, 960, 1280, 1600, 2400];
|
||||
|
||||
/** WebP-Qualität aller Varianten. */
|
||||
private const QUALITY = 82;
|
||||
|
||||
/**
|
||||
* Die Breiten, in denen ein Bild dieser Breite vorliegt: jede Stufe darunter und es selbst.
|
||||
*
|
||||
* Eine Stufe knapp unter der eigenen Breite entfällt - neben einem 760 px breiten Bild ist
|
||||
* eine Fassung mit 720 px eine zweite Datei ohne Nutzen.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
public static function steps(int $width): array
|
||||
{
|
||||
$steps = array_filter(self::WIDTHS, static fn (int $step): bool => $step <= $width * 0.9);
|
||||
$steps[] = min($width, max(self::WIDTHS));
|
||||
|
||||
return array_values(array_unique($steps));
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Bilder, die in dieser Anfrage schon gesucht wurden.
|
||||
@@ -165,7 +191,7 @@ final class MediaLibrary
|
||||
// Four widths, and never larger than the original: 480 for a phone, 960 for a tablet
|
||||
// or a column, 1600 for a full width section, 2400 for a full bleed image on a large
|
||||
// display. A smaller source simply produces fewer variants.
|
||||
foreach (array_unique(array_map(static fn (int $step): int => min($step, $width), self::WIDTHS)) as $targetWidth) {
|
||||
foreach (self::steps($width) as $targetWidth) {
|
||||
$targetHeight = max(1, (int) round($height * $targetWidth / $width));
|
||||
$target = imagecreatetruecolor($targetWidth, $targetHeight);
|
||||
try {
|
||||
@@ -174,7 +200,7 @@ final class MediaLibrary
|
||||
imagecopyresampled($target, $source, 0, 0, 0, 0, $targetWidth, $targetHeight, $width, $height);
|
||||
$path = $this->directory.'/'.$id.'-'.$targetWidth.'.webp';
|
||||
$paths[] = $path;
|
||||
if (!imagewebp($target, $path, 82) || !is_file($path) || !filesize($path)) {
|
||||
if (!imagewebp($target, $path, self::QUALITY) || !is_file($path) || !filesize($path)) {
|
||||
throw new \RuntimeException('Bildvariante konnte nicht gespeichert werden.');
|
||||
}
|
||||
$variants[] = ['src' => '/media/'.$id.'/'.$targetWidth, 'width' => $targetWidth, 'height' => $targetHeight, 'bytes' => filesize($path)];
|
||||
@@ -201,6 +227,84 @@ final class MediaLibrary
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reicht vorhandenen Bildern die Stufen nach, die es bei ihrem Upload noch nicht gab.
|
||||
*
|
||||
* Das Original wird nicht aufbewahrt; gerechnet wird aus der größten vorhandenen Variante.
|
||||
* Bestehende Varianten bleiben unangetastet, ihre Adressen stehen in gespeicherten Seiten.
|
||||
* Die Schreibversion des Bildes ändert sich nicht - ein offenes Formular mit Titel und
|
||||
* Alternativtext soll daran nicht scheitern.
|
||||
*
|
||||
* Arbeitet portionsweise, weil ein Lauf an einem PHP-Prozess mit fremdem Zeitlimit hängt.
|
||||
*
|
||||
* @return array{done: int, left: int} ergänzte Bilder und solche, die noch warten
|
||||
*/
|
||||
public function fillVariants(int $limit = 8, float $seconds = 8.0): array
|
||||
{
|
||||
$start = microtime(true);
|
||||
$done = 0;
|
||||
$left = 0;
|
||||
foreach ($this->db->fetchAllAssociative('SELECT id, variants FROM media ORDER BY created_at DESC, id DESC') as $row) {
|
||||
$variants = json_decode($row['variants'], true, 512, JSON_THROW_ON_ERROR);
|
||||
if (!is_array($variants) || [] === $variants) {
|
||||
continue;
|
||||
}
|
||||
usort($variants, static fn (array $a, array $b): int => $a['width'] <=> $b['width']);
|
||||
$largest = end($variants);
|
||||
$missing = array_diff(self::steps((int) $largest['width']), array_column($variants, 'width'));
|
||||
$file = $this->directory.'/'.$row['id'].'-'.$largest['width'].'.webp';
|
||||
if ([] === $missing || !is_file($file)) {
|
||||
continue;
|
||||
}
|
||||
if ($done >= $limit || microtime(true) - $start > $seconds || !function_exists('imagecreatefromwebp')) {
|
||||
++$left;
|
||||
continue;
|
||||
}
|
||||
$memoryLimit = ini_parse_quantity((string) ini_get('memory_limit'));
|
||||
if ($memoryLimit > 0 && memory_get_usage(true) + $largest['width'] * $largest['height'] * 12 + 16777216 > $memoryLimit) {
|
||||
// Passt nicht in den Arbeitsspeicher dieses Hostings: Das Bild behält seine Stufen.
|
||||
continue;
|
||||
}
|
||||
$source = @imagecreatefromwebp($file);
|
||||
if (!$source) {
|
||||
continue;
|
||||
}
|
||||
$written = [];
|
||||
try {
|
||||
foreach ($missing as $width) {
|
||||
$height = max(1, (int) round($largest['height'] * $width / $largest['width']));
|
||||
$target = imagecreatetruecolor($width, $height);
|
||||
try {
|
||||
imagealphablending($target, false);
|
||||
imagesavealpha($target, true);
|
||||
imagecopyresampled($target, $source, 0, 0, 0, 0, $width, $height, (int) $largest['width'], (int) $largest['height']);
|
||||
$path = $this->directory.'/'.$row['id'].'-'.$width.'.webp';
|
||||
$written[] = $path;
|
||||
if (!imagewebp($target, $path, self::QUALITY) || !is_file($path) || !filesize($path)) {
|
||||
throw new \RuntimeException('Bildvariante konnte nicht gespeichert werden.');
|
||||
}
|
||||
$variants[] = ['src' => '/media/'.$row['id'].'/'.$width, 'width' => $width, 'height' => $height, 'bytes' => filesize($path)];
|
||||
} finally {
|
||||
imagedestroy($target);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $error) {
|
||||
foreach ($written as $path) {
|
||||
@unlink($path);
|
||||
}
|
||||
throw $error;
|
||||
} finally {
|
||||
imagedestroy($source);
|
||||
}
|
||||
usort($variants, static fn (array $a, array $b): int => $a['width'] <=> $b['width']);
|
||||
$this->db->executeStatement('UPDATE media SET variants = ? WHERE id = ?', [json_encode($variants, JSON_THROW_ON_ERROR), $row['id']]);
|
||||
++$done;
|
||||
}
|
||||
$this->gemerkt = [];
|
||||
|
||||
return ['done' => $done, 'left' => $left];
|
||||
}
|
||||
|
||||
public function update(string $id, array $data): array
|
||||
{
|
||||
if (!is_string($data['title'] ?? null) || '' === trim($data['title']) || strlen($data['title']) > 160 || !is_string($data['alt'] ?? null) || strlen($data['alt']) > 300 || !is_bool($data['archived'] ?? null) || !is_int($data['version'] ?? null)) {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Content;
|
||||
|
||||
/**
|
||||
* Legt Bildvarianten dorthin, wo der Webserver sie ohne PHP ausliefern kann.
|
||||
*
|
||||
* Jedes Bild einer Seite war eine Anfrage an die Anwendung: Kernel starten, Datenbank fragen,
|
||||
* Datei durchreichen. Gemessen auf kant-it.de (10.11.2026) rund 250 ms bis zum ersten Byte, eine
|
||||
* statische Datei daneben rund 100 ms - und jede dieser Anfragen belegt einen der wenigen
|
||||
* PHP-Prozesse, die ein geteiltes Hosting gleichzeitig hergibt.
|
||||
*
|
||||
* Der Medienordner unter var/ bleibt die Quelle; Sicherung, Website-Paket und Wiederherstellung
|
||||
* kennen nur ihn. Dieser Ordner ist ein Abbild, das sich von selbst füllt: Beim ersten Abruf über
|
||||
* PHP landet die Variante hier, alle weiteren beantwortet der Webserver (Regel in
|
||||
* public/.htaccess). Fehlt das Abbild oder lässt es sich nicht schreiben, bleibt alles beim Alten.
|
||||
*
|
||||
* Die Adressen der Bilder ändern sich nicht. Was hier liegt, war auch vorher für jeden abrufbar,
|
||||
* der die Adresse kennt - die Auslieferung über PHP prüft keine Anmeldung.
|
||||
*/
|
||||
final class MediaMirror
|
||||
{
|
||||
/** @param string $directory das Abbild, üblich public/media; leer schaltet es ab */
|
||||
public function __construct(private string $directory, private string $mediaDirectory)
|
||||
{
|
||||
}
|
||||
|
||||
public function enabled(): bool
|
||||
{
|
||||
return '' !== $this->directory;
|
||||
}
|
||||
|
||||
/** Legt eine Variante ab. Scheitert still: das Abbild ist eine Abkürzung, keine Voraussetzung. */
|
||||
public function publish(string $id, int $width, string $source): void
|
||||
{
|
||||
if (!$this->enabled() || 1 !== preg_match('/^[a-f0-9]{32}$/D', $id) || $width < 1 || $width > 9999) {
|
||||
return;
|
||||
}
|
||||
$folder = $this->directory.'/'.$id;
|
||||
$target = $folder.'/'.$width.'.webp';
|
||||
if (is_file($target)) {
|
||||
return;
|
||||
}
|
||||
if (!is_dir($folder) && !@mkdir($folder, 0o775, true) && !is_dir($folder)) {
|
||||
return;
|
||||
}
|
||||
// Ein harter Verweis kostet keinen zweiten Platz auf der Platte. Wo das Dateisystem ihn
|
||||
// nicht kennt oder der Medienordner woanders liegt, wird kopiert - über eine Zwischendatei,
|
||||
// damit der Webserver nie ein halbes Bild ausliefert.
|
||||
if (@link($source, $target)) {
|
||||
return;
|
||||
}
|
||||
$teil = $target.'.'.bin2hex(random_bytes(4)).'.tmp';
|
||||
if (@copy($source, $teil) && filesize($source) === filesize($teil) && @rename($teil, $target)) {
|
||||
return;
|
||||
}
|
||||
@unlink($teil);
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt aus dem Abbild, was es im Medienordner nicht mehr gibt - nach dem Import eines
|
||||
* Website-Pakets etwa. Ohne das lieferte der Webserver Bilder weiter aus, die die Anwendung
|
||||
* selbst nicht mehr kennt.
|
||||
*
|
||||
* @return int entfernte Dateien
|
||||
*/
|
||||
public function prune(): int
|
||||
{
|
||||
if (!$this->enabled() || !is_dir($this->directory)) {
|
||||
return 0;
|
||||
}
|
||||
$removed = 0;
|
||||
foreach (scandir($this->directory) ?: [] as $id) {
|
||||
if (1 !== preg_match('/^[a-f0-9]{32}$/D', $id) || !is_dir($this->directory.'/'.$id)) {
|
||||
continue;
|
||||
}
|
||||
foreach (scandir($this->directory.'/'.$id) ?: [] as $name) {
|
||||
if ('.' === $name || '..' === $name) {
|
||||
continue;
|
||||
}
|
||||
$width = 1 === preg_match('/^(\d{1,4})\.webp$/D', $name, $treffer) ? $treffer[1] : null;
|
||||
if (null === $width || !is_file($this->mediaDirectory.'/'.$id.'-'.$width.'.webp')) {
|
||||
$removed += (int) @unlink($this->directory.'/'.$id.'/'.$name);
|
||||
}
|
||||
}
|
||||
// Gelingt nur, wenn der Ordner leer ist - genau dann soll er weg.
|
||||
@rmdir($this->directory.'/'.$id);
|
||||
}
|
||||
|
||||
return $removed;
|
||||
}
|
||||
}
|
||||
@@ -239,26 +239,37 @@ final class PostRepository
|
||||
[$pageId, 'category'],
|
||||
);
|
||||
// Dieselbe Sortierung wie im Archiv, damit „vorheriges" dort dasselbe heißt wie hier.
|
||||
$all = $this->published(1, '' === $termId ? null : $termId, 500, $locale, $language)['posts'];
|
||||
// present() gibt bewusst keine Kennung heraus - in eine Liste gehören Titel und Adresse.
|
||||
// Verglichen wird deshalb über die Adresse, die innerhalb einer Website eindeutig ist.
|
||||
$own = $this->settings->publicPath((string) $this->db->fetchOne('SELECT path FROM page WHERE id = ?', [$pageId]));
|
||||
$index = null;
|
||||
foreach ($all as $position => $post) {
|
||||
if ($post['href'] === $own) {
|
||||
$index = $position;
|
||||
break;
|
||||
}
|
||||
// Geholt wird erst nur die Reihenfolge - Kennungen, keine Dokumente - und danach genau
|
||||
// das, was gezeigt wird: die zwei Nachbarn. Vorher lud jeder Aufruf eines Beitrags bis zu
|
||||
// 500 veröffentlichte Fassungen mit vollem Dokument, um zwei Titel zu lesen, und ab dem
|
||||
// 501. Beitrag einer Kategorie gab es gar keine Nachbarn mehr.
|
||||
$where = "p.website_id = ? AND p.locale = ? AND p.type = 'post' AND p.published_revision_id IS NOT NULL AND p.deleted_at IS NULL";
|
||||
$params = ['default', $locale];
|
||||
$join = '';
|
||||
if ('' !== $termId) {
|
||||
$join = ' JOIN page_term pt ON pt.page_id = p.id AND pt.term_id = ?';
|
||||
array_unshift($params, $termId);
|
||||
}
|
||||
if (null === $index) {
|
||||
$order = $this->db->fetchFirstColumn('SELECT p.id FROM page p'.$join.' WHERE '.$where.' ORDER BY p.published_at IS NULL, p.published_at DESC, p.path ASC', $params);
|
||||
$index = array_search($pageId, $order, true);
|
||||
if (false === $index) {
|
||||
return ['previous' => null, 'next' => null];
|
||||
}
|
||||
// „Vorheriges" ist im Archiv der neuere Beitrag, weil dort das Neueste oben steht.
|
||||
$wanted = array_filter(['previous' => $order[$index - 1] ?? null, 'next' => $order[$index + 1] ?? null]);
|
||||
if ([] === $wanted) {
|
||||
return ['previous' => null, 'next' => null];
|
||||
}
|
||||
$rows = $this->db->fetchAllAssociativeIndexed(
|
||||
'SELECT p.id AS kennung, p.id, p.path, p.excerpt, p.author, p.featured_media_id, p.published_at, r.document FROM page p'
|
||||
.' JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id WHERE p.id IN ('.implode(',', array_fill(0, count($wanted), '?')).')',
|
||||
array_values($wanted),
|
||||
);
|
||||
$images = $this->media->findMany(array_map(static fn (array $row) => (string) $row['featured_media_id'], $rows));
|
||||
$terms = $this->termsForMany(array_keys($rows), $language);
|
||||
$post = fn (?string $id): ?array => null !== $id && isset($rows[$id]) ? $this->present($rows[$id], $images, $terms[$id] ?? ['category' => [], 'tag' => []], $language) : null;
|
||||
|
||||
return [
|
||||
// „Vorheriges" ist im Archiv der neuere Beitrag, weil dort das Neueste oben steht.
|
||||
'previous' => $all[$index - 1] ?? null,
|
||||
'next' => $all[$index + 1] ?? null,
|
||||
];
|
||||
return ['previous' => $post($wanted['previous'] ?? null), 'next' => $post($wanted['next'] ?? null)];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -488,7 +488,11 @@ final class StyleCompiler
|
||||
*/
|
||||
public static function variantFor(array $variants, string $device): array
|
||||
{
|
||||
$wanted = ['mobile' => 960, 'tablet' => 1600, 'desktop' => 4000][$device] ?? 4000;
|
||||
// 720 für das Telefon seit 1.42.0: 412 px Breite mal 1,75 sind 721 px. Vorher 960 - mit
|
||||
// fast doppelt so vielen Bildpunkten war der Hintergrund des Heros das, worauf das größte
|
||||
// Element der Seite am längsten wartete. Ein Bild, dem diese Stufe noch fehlt,
|
||||
// bekommt die nächstgrößere, also wie bisher 960.
|
||||
$wanted = ['mobile' => 720, 'tablet' => 1600, 'desktop' => 4000][$device] ?? 4000;
|
||||
$chosen = $variants[0];
|
||||
foreach ($variants as $variant) {
|
||||
if ($variant['width'] <= $wanted || $chosen['width'] < $wanted) {
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Controller;
|
||||
|
||||
use App\Content\DocumentLibrary;
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Content\MediaMirror;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Content\VideoLibrary;
|
||||
use App\Domain\StaleWrite;
|
||||
@@ -18,7 +19,7 @@ use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
|
||||
|
||||
final class MediaController extends AbstractController
|
||||
{
|
||||
public function __construct(private MediaLibrary $media, private CsrfTokenManagerInterface $csrf, private SiteSettings $siteSettings, private DocumentLibrary $documents, private VideoLibrary $videos)
|
||||
public function __construct(private MediaLibrary $media, private CsrfTokenManagerInterface $csrf, private SiteSettings $siteSettings, private DocumentLibrary $documents, private VideoLibrary $videos, private MediaMirror $mirror)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -380,6 +381,9 @@ final class MediaController extends AbstractController
|
||||
public function serve(string $id, int $width): Response
|
||||
{
|
||||
$path = $this->media->file($id, $width) ?? throw $this->createNotFoundException();
|
||||
// Diese Anfrage kam noch bei PHP an; die nächste nach demselben Bild beantwortet der
|
||||
// Webserver selbst.
|
||||
$this->mirror->publish($id, $width, $path);
|
||||
|
||||
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'"]);
|
||||
}
|
||||
|
||||
@@ -323,6 +323,10 @@ final class PageController extends AbstractController
|
||||
$areas['footer'] = $this->partials->expandRepeatedLinks($areas['footer'], $sections, $seen, $pristine);
|
||||
}
|
||||
}
|
||||
// Bilder nennen die Größen, die die Mediathek heute hat - nicht die vom Tag, an dem das
|
||||
// Bild in die Seite kam. So erreichen nachgereichte Stufen jede gespeicherte Seite, ohne
|
||||
// dass ein Dokument umgeschrieben wird.
|
||||
[$page['document'], $areas, $sections] = $this->currentImageSources($page['document'], $areas, $sections);
|
||||
$documents = [$page['document'], ...array_values($areas), ...array_values($sections)];
|
||||
$needsSlider = (bool) array_filter($documents, fn (array $doc): bool => $this->hasType($doc, 'slider'));
|
||||
$needsMotion = (bool) array_filter($documents, fn (array $doc): bool => $this->hasEntrance($doc));
|
||||
@@ -1042,6 +1046,59 @@ final class PageController extends AbstractController
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt bei jedem Bild aus der Mediathek die Liste der Größen auf ihren heutigen Stand.
|
||||
*
|
||||
* Angefasst wird nur ein Bild, das seine Kennung trägt und dessen Adresse zu ihr gehört; die
|
||||
* Adresse und die Maße des Bildes selbst bleiben, wie sie gespeichert sind.
|
||||
*
|
||||
* @param array<string, array> $areas
|
||||
* @param array<string, array> $sections
|
||||
*
|
||||
* @return array{0: array, 1: array<string, array>, 2: array<string, array>}
|
||||
*/
|
||||
private function currentImageSources(array $document, array $areas, array $sections): array
|
||||
{
|
||||
$ids = [];
|
||||
$collect = static function (array $blocks) use (&$collect, &$ids): void {
|
||||
foreach ($blocks as $block) {
|
||||
$id = $block['props']['mediaId'] ?? null;
|
||||
if ('image' === ($block['type'] ?? null) && is_string($id) && isset($block['props']['sources'])) {
|
||||
$ids[$id] = true;
|
||||
}
|
||||
$collect($block['children'] ?? []);
|
||||
}
|
||||
};
|
||||
foreach ([$document, ...array_values($areas), ...array_values($sections)] as $one) {
|
||||
$collect($one['blocks'] ?? []);
|
||||
}
|
||||
if ([] === $ids) {
|
||||
return [$document, $areas, $sections];
|
||||
}
|
||||
$media = $this->media->findMany(array_keys($ids));
|
||||
$apply = static function (array $blocks) use (&$apply, $media): array {
|
||||
foreach ($blocks as $index => $block) {
|
||||
$id = $block['props']['mediaId'] ?? null;
|
||||
if ('image' === ($block['type'] ?? null) && is_string($id) && isset($block['props']['sources'], $media[$id])
|
||||
&& str_starts_with((string) ($block['props']['src'] ?? ''), '/media/'.$id.'/') && [] !== $media[$id]['variants']) {
|
||||
$blocks[$index]['props']['sources'] = array_map(static fn (array $v): array => ['src' => $v['src'], 'width' => $v['width']], $media[$id]['variants']);
|
||||
}
|
||||
if (isset($block['children'])) {
|
||||
$blocks[$index]['children'] = $apply($block['children']);
|
||||
}
|
||||
}
|
||||
|
||||
return $blocks;
|
||||
};
|
||||
$one = static function (array $doc) use ($apply): array {
|
||||
$doc['blocks'] = $apply($doc['blocks'] ?? []);
|
||||
|
||||
return $doc;
|
||||
};
|
||||
|
||||
return [$one($document), array_map($one, $areas), array_map($one, $sections)];
|
||||
}
|
||||
|
||||
/**
|
||||
* The first image of the page, in the order it is rendered.
|
||||
*
|
||||
@@ -1069,23 +1126,32 @@ final class PageController extends AbstractController
|
||||
// obere. Sonst der erste Abschnitt des Dokuments.
|
||||
foreach ([$header, $document] as $kandidat) {
|
||||
$block = ($kandidat['blocks'] ?? [])[0] ?? null;
|
||||
$id = $block['style']['desktop']['backgroundImage'] ?? null;
|
||||
if (!is_string($id) || '' === $id) {
|
||||
continue;
|
||||
}
|
||||
$item = $this->media->find($id);
|
||||
if (null === $item || [] === ($item['variants'] ?? [])) {
|
||||
continue;
|
||||
}
|
||||
// Je Gerät das Bild, das die Regel dort wirklich nimmt: Tablet erbt vom Desktop, das
|
||||
// Telefon vom Tablet - dieselbe Vererbung wie im Compiler. Vorher zählte nur der
|
||||
// Desktop-Wert: Ein Telefon mit eigenem oder abgeschaltetem Hintergrund lud dann mit
|
||||
// hoher Priorität ein Bild, das es nie zeigt, gegen sein eigentliches größtes Element.
|
||||
$sichtbar = ['all' => [], 'desktop' => ['tablet', 'mobile'], 'tablet' => ['desktop', 'mobile'], 'mobile' => ['desktop', 'tablet'], 'hide-desktop' => ['desktop'], 'hide-tablet' => ['tablet'], 'hide-mobile' => ['mobile'], 'hidden' => ['desktop', 'tablet', 'mobile']];
|
||||
$aus = $sichtbar[$block['layout']['visibility'] ?? 'all'] ?? [];
|
||||
$verweise = [];
|
||||
$geerbt = null;
|
||||
foreach (StyleCompiler::PRELOAD_DEVICES as $device => $media) {
|
||||
$id = $block['style'][$device]['backgroundImage'] ?? $geerbt;
|
||||
$geerbt = $id;
|
||||
if (!is_string($id) || '' === $id || 'none' === $id || in_array($device, $aus, true)) {
|
||||
continue;
|
||||
}
|
||||
$item = $this->media->find($id);
|
||||
if (null === $item || [] === ($item['variants'] ?? [])) {
|
||||
continue;
|
||||
}
|
||||
$verweise[] = [
|
||||
'src' => StyleCompiler::variantFor($item['variants'], $device)['src'],
|
||||
'media' => $media,
|
||||
];
|
||||
}
|
||||
|
||||
return $verweise;
|
||||
if ([] !== $verweise) {
|
||||
return $verweise;
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Ops;
|
||||
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Content\MediaMirror;
|
||||
use App\Domain\InvalidDocument;
|
||||
use App\Domain\InvalidPage;
|
||||
use Doctrine\DBAL\Connection;
|
||||
@@ -49,6 +50,7 @@ final class SitePackage
|
||||
private Updater $updater,
|
||||
private string $root,
|
||||
private string $mediaDirectory,
|
||||
private ?MediaMirror $mirror = null,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -231,6 +233,8 @@ final class SitePackage
|
||||
return $fail();
|
||||
}
|
||||
$step('Medien ersetzen', true, sprintf('%d Datei(en) übernommen.', $moved));
|
||||
// Was der Webserver noch aus dem Abbild lieferte, gehört zur Website von vorher.
|
||||
$this->mirror?->prune();
|
||||
} finally {
|
||||
if (!$inWartung) {
|
||||
$this->updater->maintenance(false);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Ops\Tasks;
|
||||
|
||||
use App\Content\MediaMirror;
|
||||
use App\Ops\ArchiveUpdate;
|
||||
use App\Ops\Scheduler;
|
||||
use App\Ops\Updater;
|
||||
@@ -26,7 +27,7 @@ final class CleanupTask implements Task
|
||||
/** So viele Zeilen bleiben in den Protokollen stehen. */
|
||||
private const KEEP_LINES = 200;
|
||||
|
||||
public function __construct(private string $projectDir)
|
||||
public function __construct(private string $projectDir, private ?MediaMirror $mirror = null)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -57,12 +58,16 @@ final class CleanupTask implements Task
|
||||
{
|
||||
$limit = time() - self::KEEP_DAYS * 86400;
|
||||
|
||||
return sprintf(
|
||||
$bericht = sprintf(
|
||||
'%d Sitzungsdatei(en), %d Update-Ordner entfernt, %d Protokoll(e) gekürzt.',
|
||||
$this->sessions($limit),
|
||||
$this->updateFolders($limit),
|
||||
$this->logs(),
|
||||
);
|
||||
// Bildvarianten im öffentlichen Abbild, deren Bild es nicht mehr gibt.
|
||||
$verwaist = $this->mirror?->prune() ?? 0;
|
||||
|
||||
return 0 === $verwaist ? $bericht : $bericht.sprintf(' %d verwaiste Bilddatei(en) aus public/media entfernt.', $verwaist);
|
||||
}
|
||||
|
||||
/** Sitzungsdateien. Der Garbage Collector von PHP ist auf vielen Hostings abgeschaltet. */
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Ops\Tasks;
|
||||
|
||||
use App\Content\MediaLibrary;
|
||||
|
||||
/**
|
||||
* Reicht vorhandenen Bildern Größen nach, die es bei ihrem Upload noch nicht gab.
|
||||
*
|
||||
* Mit 1.42.0 kamen die Stufen 720 und 1280 dazu. Neue Uploads bekommen sie sofort; für alles,
|
||||
* was schon in der Mediathek liegt, rechnet diese Aufgabe sie portionsweise aus der größten
|
||||
* vorhandenen Variante. Ist nichts mehr offen, kostet ein Lauf eine Abfrage.
|
||||
*/
|
||||
final class ImageVariantsTask implements Task
|
||||
{
|
||||
public function __construct(private MediaLibrary $media)
|
||||
{
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'image-variants';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Bildgrößen ergänzen';
|
||||
}
|
||||
|
||||
public function explanation(): string
|
||||
{
|
||||
return 'Ohne sie laden Telefone bei älteren Bildern eine größere Fassung als nötig. Angezeigt wird trotzdem alles.';
|
||||
}
|
||||
|
||||
public function interval(): int
|
||||
{
|
||||
return 900;
|
||||
}
|
||||
|
||||
public function run(): string
|
||||
{
|
||||
$ergebnis = $this->media->fillVariants();
|
||||
if (0 === $ergebnis['done'] && 0 === $ergebnis['left']) {
|
||||
return 'Alle Bilder haben alle Größen.';
|
||||
}
|
||||
|
||||
return sprintf('%d Bild(er) ergänzt, %d warten auf den nächsten Lauf.', $ergebnis['done'], $ergebnis['left']);
|
||||
}
|
||||
}
|
||||
+111
-5
@@ -1757,7 +1757,7 @@ final class CmsTest extends TestCase
|
||||
preg_match('/@media \(max-width:767px\)\{#'.$id.'\{([^}]*)}/', $html, $mobil);
|
||||
preg_match('/#'.$id.'\{([^}]*)}/', $html, $desktop);
|
||||
self::assertNotEmpty($mobil, 'Für kleine Geräte muss eine eigene Regel entstehen.');
|
||||
self::assertStringContainsString('/960', $mobil[1], 'Das Telefon bekommt die kleine Variante.');
|
||||
self::assertStringContainsString('/720', $mobil[1], 'Das Telefon bekommt die kleine Variante.');
|
||||
self::assertStringContainsString('/2000', $desktop[1], 'Der Schreibtisch die große.');
|
||||
// Die Abdunkelung gehört zur selben Regel - sonst stünde heller Text plötzlich auf
|
||||
// einem ungedämpften Bild.
|
||||
@@ -1770,7 +1770,7 @@ final class CmsTest extends TestCase
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, $version), $version + 1);
|
||||
preg_match('/@media \(max-width:767px\)\{#'.$id.'\{([^}]*)}/', $this->request('/')->getContent(), $eigen);
|
||||
self::assertStringContainsString('background-color:#ffffff', $eigen[1]);
|
||||
self::assertStringContainsString('/960', $eigen[1], 'Das Bild fehlt dort nicht, nur weil eine andere Eigenschaft gesetzt ist.');
|
||||
self::assertStringContainsString('/720', $eigen[1], 'Das Bild fehlt dort nicht, nur weil eine andere Eigenschaft gesetzt ist.');
|
||||
}
|
||||
|
||||
public function testTheBigPictureLoadsFirstAndSmallOnesAskForSmallVariants(): void
|
||||
@@ -3828,6 +3828,97 @@ display: flex', '#b1{display:flex;color:red}'];
|
||||
(new DocumentValidator())->validate($document);
|
||||
}
|
||||
|
||||
public function testAnImageServedOnceLandsWhereTheWebServerDeliversItWithoutPhp(): void
|
||||
{
|
||||
$media = new MediaLibrary($this->db, $this->path.'-media');
|
||||
$file = tempnam(sys_get_temp_dir(), 'kant-upload-');
|
||||
$image = imagecreatetruecolor(600, 400);
|
||||
imagepng($image, $file);
|
||||
imagedestroy($image);
|
||||
$item = $media->upload(new UploadedFile($file, 'bild.png', test: true));
|
||||
@unlink($file);
|
||||
$abbild = $this->path.'-abbild';
|
||||
$ziel = $abbild.'/'.$item['id'].'/480.webp';
|
||||
$quelle = $this->path.'-media/'.$item['id'].'-480.webp';
|
||||
|
||||
// Ohne Abbild (so laufen alle anderen Tests) bleibt es bei der Auslieferung über PHP.
|
||||
self::assertSame(200, $this->request('/media/'.$item['id'].'/480')->getStatusCode());
|
||||
self::assertDirectoryDoesNotExist($abbild);
|
||||
|
||||
$_SERVER['MEDIA_MIRROR_DIRECTORY'] = $_ENV['MEDIA_MIRROR_DIRECTORY'] = $abbild;
|
||||
try {
|
||||
$antwort = $this->request('/media/'.$item['id'].'/480');
|
||||
self::assertSame(200, $antwort->getStatusCode());
|
||||
self::assertFileEquals($quelle, $ziel, 'Die Variante liegt unter der Adresse, die public/.htaccess prüft.');
|
||||
// Eine Breite, die es nicht gibt, legt nichts an.
|
||||
self::assertSame(404, $this->request('/media/'.$item['id'].'/999')->getStatusCode());
|
||||
self::assertFileDoesNotExist($abbild.'/'.$item['id'].'/999.webp');
|
||||
} finally {
|
||||
unset($_SERVER['MEDIA_MIRROR_DIRECTORY'], $_ENV['MEDIA_MIRROR_DIRECTORY']);
|
||||
}
|
||||
|
||||
// Verschwindet das Bild aus dem Medienordner (Paketimport), räumt das Abbild es weg -
|
||||
// sonst lieferte der Webserver weiter aus, was die Anwendung nicht mehr kennt.
|
||||
$mirror = new \App\Content\MediaMirror($abbild, $this->path.'-media');
|
||||
self::assertSame(0, $mirror->prune());
|
||||
unlink($quelle);
|
||||
file_put_contents($abbild.'/'.$item['id'].'/fremd.txt', 'gehört nicht hierher');
|
||||
self::assertSame(2, $mirror->prune());
|
||||
self::assertDirectoryDoesNotExist($abbild.'/'.$item['id']);
|
||||
@rmdir($abbild);
|
||||
self::assertSame(0, (new \App\Content\MediaMirror('', $this->path.'-media'))->prune());
|
||||
}
|
||||
|
||||
public function testAnOlderImageGetsTheNewWidthsAndStoredPagesUseThem(): void
|
||||
{
|
||||
$media = new MediaLibrary($this->db, $this->path.'-media');
|
||||
$file = tempnam(sys_get_temp_dir(), 'kant-upload-');
|
||||
$limit = ini_get('memory_limit');
|
||||
ini_set('memory_limit', '512M');
|
||||
try {
|
||||
$image = imagecreatetruecolor(1700, 1000);
|
||||
imagejpeg($image, $file, 92);
|
||||
imagedestroy($image);
|
||||
$item = $media->upload(new UploadedFile($file, 'alt.jpg', test: true));
|
||||
// So sah ein Bild vor 1.42.0 aus: ohne 720 und 1280, in der Mediathek wie in der Seite.
|
||||
$alt = array_values(array_filter($item['variants'], static fn (array $v): bool => !in_array($v['width'], [720, 1280], true)));
|
||||
foreach ([720, 1280] as $width) {
|
||||
unlink($this->path.'-media/'.$item['id'].'-'.$width.'.webp');
|
||||
}
|
||||
$this->db->update('media', ['variants' => json_encode($alt)], ['id' => $item['id']]);
|
||||
$document = DemoContent::starter('Startseite');
|
||||
$document['schemaVersion'] = 18;
|
||||
$document['blocks'] = [['id' => 'oben', 'type' => 'section', 'props' => [], 'children' => [
|
||||
['id' => 'titel', 'type' => 'heading', 'props' => ['text' => 'Startseite', 'level' => 1]],
|
||||
['id' => 'bild', 'type' => 'image', 'props' => ['mediaId' => $item['id'], 'src' => '/media/'.$item['id'].'/1700', 'alt' => 'Ein Bild', 'decorative' => false, 'width' => 1700, 'height' => 1000, 'sources' => array_map(static fn (array $v): array => ['src' => $v['src'], 'width' => $v['width']], $alt)]],
|
||||
]]];
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||||
self::assertStringNotContainsString('720w', $this->request('/')->getContent());
|
||||
|
||||
$media = new MediaLibrary($this->db, $this->path.'-media');
|
||||
self::assertSame(['done' => 1, 'left' => 0], $media->fillVariants());
|
||||
$neu = $media->find($item['id']);
|
||||
self::assertSame([480, 720, 960, 1280, 1700], array_column($neu['variants'], 'width'));
|
||||
self::assertSame($item['version'], $neu['version'], 'Die Schreibversion bleibt: ein offenes Formular zum Bild scheitert daran nicht.');
|
||||
foreach ([720 => 424, 1280 => 753] as $width => $height) {
|
||||
$size = getimagesize($this->path.'-media/'.$item['id'].'-'.$width.'.webp');
|
||||
self::assertSame([$width, $height, 'image/webp'], [$size[0], $size[1], $size['mime']]);
|
||||
}
|
||||
// Ein zweiter Lauf hat nichts mehr zu tun.
|
||||
self::assertSame(['done' => 0, 'left' => 0], $media->fillVariants());
|
||||
|
||||
// Die gespeicherte Seite nennt die neuen Größen, ohne dass ihr Dokument angefasst wurde -
|
||||
// im Bild wie in seiner Vorabladung.
|
||||
$html = $this->request('/')->getContent();
|
||||
self::assertMatchesRegularExpression('#<link rel="preload" as="image"[^>]+imagesrcset="[^"]*/720 720w[^"]*/1280 1280w#', $html);
|
||||
self::assertMatchesRegularExpression('#<img[^>]+srcset="[^"]*/720 720w[^"]*/1280 1280w#', $html);
|
||||
self::assertStringNotContainsString('720', (string) $this->db->fetchOne('SELECT document FROM revision ORDER BY seq DESC LIMIT 1'));
|
||||
} finally {
|
||||
ini_set('memory_limit', $limit);
|
||||
@unlink($file);
|
||||
}
|
||||
}
|
||||
|
||||
public function testAnUploadBecomesWebpInSeveralWidths(): void
|
||||
{
|
||||
$media = new MediaLibrary($this->db, $this->path.'-media');
|
||||
@@ -3845,7 +3936,7 @@ display: flex', '#b1{display:flex;color:red}'];
|
||||
$item = $media->upload(new UploadedFile($file, 'foto.jpg', test: true));
|
||||
|
||||
// Four widths, all WebP, none larger than the source.
|
||||
self::assertSame([480, 960, 1600, 2400], array_column($item['variants'], 'width'));
|
||||
self::assertSame([480, 720, 960, 1280, 1600, 2400], array_column($item['variants'], 'width'));
|
||||
foreach ($item['variants'] as $variant) {
|
||||
$path = $this->path.'-media/'.$item['id'].'-'.$variant['width'].'.webp';
|
||||
self::assertSame('image/webp', getimagesize($path)['mime'], 'Jede Variante ist WebP.');
|
||||
@@ -3886,7 +3977,7 @@ display: flex', '#b1{display:flex;color:red}'];
|
||||
|
||||
// CSS has no srcset, but the rules are written per device - so the device decides.
|
||||
self::assertStringContainsString('#hero{background-image:url("/media/'.$item['id'].'/2000")}', $css);
|
||||
self::assertStringContainsString('@media (max-width:767px){#hero{background-image:url("/media/'.$item['id'].'/960")}}', $css);
|
||||
self::assertStringContainsString('@media (max-width:767px){#hero{background-image:url("/media/'.$item['id'].'/720")}}', $css);
|
||||
} finally {
|
||||
@unlink($file);
|
||||
}
|
||||
@@ -3983,7 +4074,7 @@ display: flex', '#b1{display:flex;color:red}'];
|
||||
array_column($treffer, 2),
|
||||
);
|
||||
self::assertSame(
|
||||
['/media/'.$item['id'].'/2400', '/media/'.$item['id'].'/1600', '/media/'.$item['id'].'/960'],
|
||||
['/media/'.$item['id'].'/2400', '/media/'.$item['id'].'/1600', '/media/'.$item['id'].'/720'],
|
||||
array_column($treffer, 1),
|
||||
'Jedes Gerät bekommt die Variante, die es auch ohne Vorabholen bekäme.',
|
||||
);
|
||||
@@ -4003,6 +4094,21 @@ display: flex', '#b1{display:flex;color:red}'];
|
||||
$mitBild,
|
||||
$sparsam,
|
||||
));
|
||||
|
||||
// Das Telefon ohne Hintergrund, das Tablet ausgeblendet: vorab geholt wird nur, was
|
||||
// die Regel des jeweiligen Geräts auch zeigt. Vorher zählte allein der Desktop-Wert.
|
||||
$mit['schemaVersion'] = max(22, $mit['schemaVersion']);
|
||||
$mit['blocks'][0]['style']['mobile']['backgroundImage'] = 'none';
|
||||
$entwurf = $this->pages->saveDraft('page-1', $mit, $version($this->db));
|
||||
$this->pages->publish('page-1', $entwurf, $version($this->db));
|
||||
preg_match_all('/<link rel="preload" as="image" href="([^"]+)" media="([^"]+)"[^>]*>/', (string) $this->request('/')->getContent(), $treffer, PREG_SET_ORDER);
|
||||
self::assertSame(['(min-width:1025px)', '(min-width:768px) and (max-width:1024px)'], array_column($treffer, 2), 'Das Telefon lädt kein Bild vor, das es nicht zeigt.');
|
||||
|
||||
$mit['blocks'][0]['layout']['visibility'] = 'hide-tablet';
|
||||
$entwurf = $this->pages->saveDraft('page-1', $mit, $version($this->db));
|
||||
$this->pages->publish('page-1', $entwurf, $version($this->db));
|
||||
preg_match_all('/<link rel="preload" as="image" href="([^"]+)" media="([^"]+)"[^>]*>/', (string) $this->request('/')->getContent(), $treffer, PREG_SET_ORDER);
|
||||
self::assertSame(['(min-width:1025px)'], array_column($treffer, 2));
|
||||
} finally {
|
||||
@unlink($datei);
|
||||
ini_set('memory_limit', (string) $limit);
|
||||
|
||||
@@ -954,7 +954,7 @@ final class EditorSecurityTest extends WebTestCase
|
||||
$item = json_decode($client->getResponse()->getContent(), true)['item'];
|
||||
// Four steps now; the last one is capped at the width of the source (1800 here), so a
|
||||
// picture is never blown up beyond what was uploaded.
|
||||
self::assertSame([480, 960, 1600, 1800], array_column($item['variants'], 'width'));
|
||||
self::assertSame([480, 720, 960, 1280, 1600, 1800], array_column($item['variants'], 'width'));
|
||||
foreach ($item['variants'] as $variant) {
|
||||
$file = $this->path.'-media/'.$item['id'].'-'.$variant['width'].'.webp';
|
||||
self::assertSame('image/webp', getimagesize($file)['mime']);
|
||||
|
||||
@@ -78,7 +78,7 @@ final class TaskScreenTest extends WebTestCase
|
||||
self::assertStringContainsString('Ohne Besuch und ohne geöffnete Verwaltung passiert nichts.', $html);
|
||||
self::assertStringContainsString('/cms-cron/'.Scheduler::cronToken(self::SECRET), $html);
|
||||
// Und der Plan steht danach in der Datenbank - eine Zeile je Aufgabe, nicht mehr.
|
||||
self::assertSame(6, (int) $this->db->fetchOne('SELECT COUNT(*) FROM scheduled_task'));
|
||||
self::assertSame(7, (int) $this->db->fetchOne('SELECT COUNT(*) FROM scheduled_task'));
|
||||
}
|
||||
|
||||
public function testStartingWorkNeedsAnAdministratorAndAValidForm(): void
|
||||
|
||||
Reference in new issue
Block a user