Upload PDFs to the library, link them from any link target, show them in a viewer

PDFs go through the same upload field as images, are checked by their content and kept
unchanged in their own table. Every link field can pick one, and a page that links a PDF
opens it in a dialog with the browser's own viewer; touch devices and browsers without a
viewer get a new tab instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-22 07:56:29 +02:00
1 parent 04927b9563
commit e8a9dcdf5c
20 files changed
+667 -30

No files matched your search

+56
View File
@@ -5,6 +5,7 @@ namespace App\Tests;
use App\Content\Consent;
use App\Content\CustomCode;
use App\Content\DemoContent;
use App\Content\DocumentLibrary;
use App\Content\DocumentValidator;
use App\Content\FontLibrary;
use App\Content\FormRepository;
@@ -1153,6 +1154,61 @@ final class CmsTest extends TestCase
self::assertStringContainsString("script-src 'none'", (string) $zweite->headers->get('Content-Security-Policy'));
}
public function testAPdfFromTheLibraryIsALinkTargetAndOpensInTheViewer(): void
{
// Umut: „PDFs hochladen und als Button verlinken - und ein Inline-PDF-Viewer wie das
// Bild-Popup." Das PDF bleibt, wie es kam; geprüft wird, dass es eines ist.
$documents = new DocumentLibrary($this->db, $this->path.'-media');
$file = tempnam(sys_get_temp_dir(), 'kant-pdf-');
try {
file_put_contents($file, '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>');
try {
$documents->upload(new UploadedFile($file, 'preisliste.pdf', test: true));
self::fail('Eine Datei, die kein PDF ist, darf nicht als PDF durchgehen.');
} catch (\InvalidArgumentException) {
}
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM document'));
file_put_contents($file, "%PDF-1.4\n1 0 obj << /Type /Catalog >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n");
$item = $documents->upload(new UploadedFile($file, 'Preisliste 2026.pdf', test: true));
} finally {
@unlink($file);
}
self::assertSame('Preisliste 2026', $item['title']);
self::assertSame('/media/'.$item['id'].'.pdf', $item['src']);
// Ausgeliefert als PDF, zum Anzeigen, mit dem Titel als Dateinamen, und einbetten darf es
// nur die eigene Website.
$antwort = $this->request($item['src']);
self::assertSame(200, $antwort->getStatusCode());
self::assertSame('application/pdf', $antwort->headers->get('Content-Type'));
self::assertSame('nosniff', $antwort->headers->get('X-Content-Type-Options'));
self::assertSame("frame-ancestors 'self'", $antwort->headers->get('Content-Security-Policy'));
self::assertStringStartsWith('inline;', (string) $antwort->headers->get('Content-Disposition'));
self::assertStringContainsString('Preisliste 2026.pdf', (string) $antwort->headers->get('Content-Disposition'));
self::assertSame(404, $this->request('/media/'.str_repeat('a', 32).'.pdf')->getStatusCode());
// Ein Button zeigt auf das PDF - ein gewöhnlicher lokaler Pfad, ohne neues Schema.
$document = DemoContent::starter('Startseite');
$document['blocks'][] = ['id' => 'preise', 'type' => 'button', 'props' => ['text' => 'Preisliste', 'href' => $item['src']]];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$seite = $this->request('/');
$html = $seite->getContent();
self::assertStringContainsString('href="'.$item['src'].'"', $html);
self::assertStringContainsString('<script src="/assets/lightbox.js?v=', $html);
$csp = (string) $seite->headers->get('Content-Security-Policy');
self::assertStringContainsString("frame-src 'self'", $csp);
self::assertStringContainsString("script-src 'self'", $csp);
// Ohne Link auf ein PDF bleibt es beim alten: kein Skript, kein Rahmen.
$ohne = DemoContent::starter('Startseite');
$version = $this->pages->findForEditor('page-1')['version'];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $ohne, $version), $version + 1);
$zweite = $this->request('/');
self::assertStringNotContainsString('lightbox.js', $zweite->getContent());
self::assertStringContainsString("frame-src 'none'", (string) $zweite->headers->get('Content-Security-Policy'));
}
public function testLegalLinksAppearOnlyWhereAnAuthorPlacesThem(): void
{
// Die hinterlegten Adressen erzeugen keine zusätzliche Leiste unter dem Footer.
+42 -1
View File
@@ -738,6 +738,47 @@ final class EditorSecurityTest extends WebTestCase
}
}
public function testPdfsGoThroughTheMediaUploadAndTheEditorPicker(): void
{
$client = $this->login('editor');
$crawler = $client->request('GET', '/admin/media');
$token = $crawler->filter('form[action="/admin/media/upload"] input[name="_csrf_token"]')->attr('value');
$path = tempnam(sys_get_temp_dir(), 'kant-form-pdf-');
try {
file_put_contents($path, "%PDF-1.7\n%%EOF\n");
// Dasselbe Feld wie für Bilder: der Inhalt entscheidet, wohin die Datei geht.
$client->request('POST', '/admin/media/upload', ['_csrf_token' => $token], ['files' => [new UploadedFile($path, 'Flyer.pdf', test: true)]]);
self::assertResponseRedirects('/admin/media');
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM media'));
self::assertSame('Flyer', $this->db->fetchOne('SELECT title FROM document'));
$crawler = $client->followRedirect();
self::assertStringContainsString('1 Datei(en) hochgeladen', $crawler->filter('.notice.ok')->text());
self::assertCount(1, $crawler->filter('section[aria-labelledby="documents-heading"] .row'));
// Die Auswahl im Editor: Liste mit Token, Hochladen nur mit Token.
$client->request('GET', '/admin/api/dokumente');
self::assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
self::assertSame('Flyer', $data['items'][0]['title']);
$client->request('POST', '/admin/api/dokumente/upload', files: ['file' => new UploadedFile($path, 'zweites.pdf', test: true)]);
self::assertResponseStatusCodeSame(403);
file_put_contents($path, '<?php echo 1;');
$client->request('POST', '/admin/api/dokumente/upload', files: ['file' => new UploadedFile($path, 'getarnt.pdf', test: true)], server: ['HTTP_X_CSRF_TOKEN' => $data['csrfToken']]);
self::assertResponseStatusCodeSame(422);
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM document'));
// Archivieren nimmt es aus der Auswahl-Übersicht, die Datei bleibt erreichbar.
$id = $data['items'][0]['id'];
$client->request('POST', '/admin/api/dokumente/'.$id, server: ['CONTENT_TYPE' => 'application/json', 'HTTP_X_CSRF_TOKEN' => $data['csrfToken']], content: json_encode(['title' => 'Flyer 2026', 'archived' => true, 'version' => 1]));
self::assertResponseIsSuccessful();
self::assertCount(0, $client->request('GET', '/admin/media')->filter('section[aria-labelledby="documents-heading"] .row'));
$client->request('GET', '/media/'.$id.'.pdf');
self::assertResponseIsSuccessful();
} finally {
unlink($path);
}
}
public function testMediaPreviewReadsSmallAndArchivedImagesWithoutListingTheLibrary(): void
{
$id = str_repeat('a', 32);
@@ -785,7 +826,7 @@ final class EditorSecurityTest extends WebTestCase
$crawler = $client->followRedirect();
self::assertCount(2, $crawler->filter('.library-tile'));
self::assertCount(1, $crawler->filter('.notice.ok'));
self::assertStringContainsString('2 Bild(er) hochgeladen', $crawler->filter('.notice.ok')->text());
self::assertStringContainsString('2 Datei(en) hochgeladen', $crawler->filter('.notice.ok')->text());
$id = (string) $this->db->fetchOne('SELECT id FROM media LIMIT 1');
$client->request('POST', '/admin/media/'.$id, ['_csrf_token' => $token, 'title' => 'Bergblick', 'alt' => 'Grüne Berglandschaft', 'archived' => '1', 'version' => 1]);
+2
View File
@@ -21,6 +21,7 @@ use DoctrineMigrations\Version20260913000100;
use DoctrineMigrations\Version20260914000100;
use DoctrineMigrations\Version20260915000100;
use DoctrineMigrations\Version20260916000100;
use DoctrineMigrations\Version20260917000100;
use Psr\Log\NullLogger;
/**
@@ -53,6 +54,7 @@ final class TestSchema
Version20260914000100::class,
Version20260915000100::class,
Version20260916000100::class,
Version20260917000100::class,
] as $migration) {
(new $migration($db, new NullLogger()))->up($schema);
}