Add videos: library, block and section background

Videos join the media library as their own table (schema v28), with a
video block and a looping background on sections. video.js starts loops
only while visible, offers a pause button and leaves the still image for
reduced motion and data saving.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWYm8kjAWmZaB2QB2xuBhU
This commit is contained in:
kantarciandClaude Opus 5.5 committed 2026-11-05 05:23:17 +00:00
1 parent 592e3d5342
commit 76613b9f8e
25 files changed
+674 -20

No files matched your search

+90
View File
@@ -24,6 +24,7 @@ use App\Content\StyleCompiler;
use App\Content\StyleSchema;
use App\Content\Theme;
use App\Content\Turnstile;
use App\Content\VideoLibrary;
use App\Domain\InvalidDocument;
use App\Domain\InvalidMenu;
use App\Domain\InvalidPage;
@@ -1399,6 +1400,95 @@ final class CmsTest extends TestCase
self::assertStringContainsString('<script src="/assets/lightbox.js?v=', $html);
}
public function testAVideoFromTheLibraryPlaysAsBlockAndAsSectionBackground(): void
{
// Umut: „Baust du video embedding gleich ein" - der Hero von kant-it.de soll ein Video
// bekommen, ohne dass dafür Dateien von Hand auf den Server müssen.
$videos = new VideoLibrary($this->db, $this->path.'-media');
$file = tempnam(sys_get_temp_dir(), 'kant-video-');
try {
file_put_contents($file, '<html><script>alert(1)</script></html>');
try {
$videos->upload(new UploadedFile($file, 'film.mp4', test: true));
self::fail('Eine Datei, die kein Video ist, darf nicht als Video durchgehen.');
} catch (\InvalidArgumentException) {
}
file_put_contents($file, "\0\0\0\x14ftypqt \0\0\0\0qt ".str_repeat("\0", 64));
try {
$videos->upload(new UploadedFile($file, 'film.mov', test: true));
self::fail('MOV spielen nicht alle Browser ab und wird deshalb abgewiesen.');
} catch (\InvalidArgumentException) {
}
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM video'));
file_put_contents($file, "\0\0\0\x18ftypmp42\0\0\0\0mp42isom".str_repeat('x', 2000));
$item = $videos->upload(new UploadedFile($file, 'Hero Loop.mp4', test: true));
file_put_contents($file, "\x1A\x45\xDF\xA3\x9F\x42\x86\x81\x01\x42\x82\x84webm".str_repeat('y', 2000));
$webm = $videos->upload(new UploadedFile($file, 'hero.bin', test: true));
} finally {
@unlink($file);
}
self::assertSame('Hero Loop', $item['title']);
self::assertSame('/media/'.$item['id'].'.mp4', $item['src']);
self::assertSame('/media/'.$webm['id'].'.webm', $webm['src']);
$antwort = $this->request($item['src']);
self::assertSame(200, $antwort->getStatusCode());
self::assertSame('video/mp4', $antwort->headers->get('Content-Type'));
self::assertSame('nosniff', $antwort->headers->get('X-Content-Type-Options'));
self::assertSame("default-src 'none'", $antwort->headers->get('Content-Security-Policy'));
self::assertSame('bytes', $antwort->headers->get('Accept-Ranges'));
// Dieselbe Kennung unter der falschen Endung gibt es nicht.
self::assertSame(404, $this->request('/media/'.$item['id'].'.webm')->getStatusCode());
self::assertSame(404, $this->request('/media/'.str_repeat('a', 32).'.mp4')->getStatusCode());
$document = DemoContent::starter('Startseite');
$document['schemaVersion'] = DocumentValidator::SCHEMA_VERSION;
$document['blocks'][0]['props'] = ['backgroundVideo' => $item['src'], 'backgroundVideoWebm' => $webm['src']];
$document['blocks'][] = ['id' => 'film', 'type' => 'video', 'props' => ['src' => $item['src'], 'mode' => 'player', 'title' => 'Unser Betrieb', 'poster' => '/media/'.str_repeat('b', 32).'/1600']];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$seite = $this->request('/');
$html = $seite->getContent();
self::assertStringContainsString('has-video', $html);
self::assertMatchesRegularExpression('#<video class="section-video"[^>]* muted loop playsinline preload="none"[^>]*data-video-loop#', $html);
// Das WebM steht zuerst: der Browser nimmt die erste Quelle, die er abspielen kann.
self::assertLessThan(strpos($html, 'src="'.$item['src'].'"'), strpos($html, 'src="'.$webm['src'].'"'));
self::assertStringContainsString('class="video-toggle"', $html);
self::assertStringContainsString('aria-label="Unser Betrieb"', $html);
self::assertMatchesRegularExpression('#<video class="video-media" controls playsinline preload="metadata" poster=#', $html);
self::assertStringContainsString('<script type="module" src="/assets/video.js?v=', $html);
self::assertStringContainsString("script-src 'self'", (string) $seite->headers->get('Content-Security-Policy'));
// Ein Spieler allein braucht kein Skript: die Seite bleibt bei script-src 'none'.
$spieler = DemoContent::starter('Startseite');
$spieler['schemaVersion'] = DocumentValidator::SCHEMA_VERSION;
$spieler['blocks'][] = ['id' => 'film', 'type' => 'video', 'props' => ['src' => $item['src'], 'mode' => 'player', 'title' => '']];
$version = $this->pages->findForEditor('page-1')['version'];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $spieler, $version), $version + 1);
$ohne = $this->request('/');
self::assertStringNotContainsString('video.js', $ohne->getContent());
self::assertStringContainsString("script-src 'none'", (string) $ohne->headers->get('Content-Security-Policy'));
// Fremde Adressen, zu altes Schema und unbekannte Betriebsarten weist das Dokument ab.
$pruefer = new DocumentValidator();
foreach ([
['video', ['src' => 'https://example.com/film.mp4'], DocumentValidator::SCHEMA_VERSION],
['video', ['src' => '/media/../secret.mp4'], DocumentValidator::SCHEMA_VERSION],
['video', ['src' => $item['src'], 'mode' => 'autoplay'], DocumentValidator::SCHEMA_VERSION],
['video', ['src' => $item['src']], 27],
] as [$type, $props, $schema]) {
$falsch = DemoContent::starter('Startseite');
$falsch['schemaVersion'] = $schema;
$falsch['blocks'][] = ['id' => 'film', 'type' => $type, 'props' => $props];
try {
$pruefer->validate($falsch);
self::fail('Abgewiesen werden muss: '.json_encode($props));
} catch (InvalidDocument) {
$this->addToAssertionCount(1);
}
}
}
public function testAPdfFromTheLibraryIsALinkTargetAndOpensInTheViewer(): void
{
// Umut: „PDFs hochladen und als Button verlinken - und ein Inline-PDF-Viewer wie das