Merge branch 'sec_chapter_export' into development

This commit is contained in:
Dan Brown
2026-03-17 10:41:51 +00:00
8 changed files with 94 additions and 30 deletions

View File

@@ -323,7 +323,7 @@ class ExportFormatter
$text .= $description . "\n\n";
}
foreach ($chapter->pages as $page) {
foreach ($chapter->getVisiblePages() as $page) {
$text .= $this->pageToMarkdown($page) . "\n\n";
}

View File

@@ -102,12 +102,15 @@ class DownloadResponseFactory
protected function getHeaders(string $fileName, int $fileSize, string $mime = 'application/octet-stream'): array
{
$disposition = ($mime === 'application/octet-stream') ? 'attachment' : 'inline';
$downloadName = str_replace('"', '', $fileName);
$downloadName = str_replace(['"', '/', '\\', '$'], '', $fileName);
$downloadName = preg_replace('/[\x00-\x1F\x7F]/', '', $downloadName);
$encodedDownloadName = rawurlencode($downloadName);
return [
'Content-Type' => $mime,
'Content-Length' => $fileSize,
'Content-Disposition' => "{$disposition}; filename=\"{$downloadName}\"",
'Content-Disposition' => "{$disposition}; filename*=UTF-8''{$encodedDownloadName}",
'X-Content-Type-Options' => 'nosniff',
];
}

View File

@@ -19,7 +19,7 @@ class ExportsApiTest extends TestCase
$resp = $this->get("/api/books/{$book->id}/export/html");
$resp->assertStatus(200);
$resp->assertSee($book->name);
$resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.html"');
$resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $book->slug . '.html');
}
public function test_book_plain_text_endpoint()
@@ -30,7 +30,7 @@ class ExportsApiTest extends TestCase
$resp = $this->get("/api/books/{$book->id}/export/plaintext");
$resp->assertStatus(200);
$resp->assertSee($book->name);
$resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.txt"');
$resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $book->slug . '.txt');
}
public function test_book_pdf_endpoint()
@@ -40,7 +40,7 @@ class ExportsApiTest extends TestCase
$resp = $this->get("/api/books/{$book->id}/export/pdf");
$resp->assertStatus(200);
$resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.pdf"');
$resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $book->slug . '.pdf');
}
public function test_book_markdown_endpoint()
@@ -50,7 +50,7 @@ class ExportsApiTest extends TestCase
$resp = $this->get("/api/books/{$book->id}/export/markdown");
$resp->assertStatus(200);
$resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.md"');
$resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $book->slug . '.md');
$resp->assertSee('# ' . $book->name);
$resp->assertSee('# ' . $book->pages()->first()->name);
$resp->assertSee('# ' . $book->chapters()->first()->name);
@@ -63,7 +63,7 @@ class ExportsApiTest extends TestCase
$resp = $this->get("/api/books/{$book->id}/export/zip");
$resp->assertStatus(200);
$resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.zip"');
$resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $book->slug . '.zip');
$zip = ZipTestHelper::extractFromZipResponse($resp);
$this->assertArrayHasKey('book', $zip->data);
@@ -77,7 +77,7 @@ class ExportsApiTest extends TestCase
$resp = $this->get("/api/chapters/{$chapter->id}/export/html");
$resp->assertStatus(200);
$resp->assertSee($chapter->name);
$resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.html"');
$resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $chapter->slug . '.html');
}
public function test_chapter_plain_text_endpoint()
@@ -88,7 +88,7 @@ class ExportsApiTest extends TestCase
$resp = $this->get("/api/chapters/{$chapter->id}/export/plaintext");
$resp->assertStatus(200);
$resp->assertSee($chapter->name);
$resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.txt"');
$resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $chapter->slug . '.txt');
}
public function test_chapter_pdf_endpoint()
@@ -98,7 +98,7 @@ class ExportsApiTest extends TestCase
$resp = $this->get("/api/chapters/{$chapter->id}/export/pdf");
$resp->assertStatus(200);
$resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.pdf"');
$resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $chapter->slug . '.pdf');
}
public function test_chapter_markdown_endpoint()
@@ -108,7 +108,7 @@ class ExportsApiTest extends TestCase
$resp = $this->get("/api/chapters/{$chapter->id}/export/markdown");
$resp->assertStatus(200);
$resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.md"');
$resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $chapter->slug . '.md');
$resp->assertSee('# ' . $chapter->name);
$resp->assertSee('# ' . $chapter->pages()->first()->name);
}
@@ -120,7 +120,7 @@ class ExportsApiTest extends TestCase
$resp = $this->get("/api/chapters/{$chapter->id}/export/zip");
$resp->assertStatus(200);
$resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.zip"');
$resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $chapter->slug . '.zip');
$zip = ZipTestHelper::extractFromZipResponse($resp);
$this->assertArrayHasKey('chapter', $zip->data);
@@ -134,7 +134,7 @@ class ExportsApiTest extends TestCase
$resp = $this->get("/api/pages/{$page->id}/export/html");
$resp->assertStatus(200);
$resp->assertSee($page->name);
$resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.html"');
$resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $page->slug . '.html');
}
public function test_page_plain_text_endpoint()
@@ -145,7 +145,7 @@ class ExportsApiTest extends TestCase
$resp = $this->get("/api/pages/{$page->id}/export/plaintext");
$resp->assertStatus(200);
$resp->assertSee($page->name);
$resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.txt"');
$resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $page->slug . '.txt');
}
public function test_page_pdf_endpoint()
@@ -155,7 +155,7 @@ class ExportsApiTest extends TestCase
$resp = $this->get("/api/pages/{$page->id}/export/pdf");
$resp->assertStatus(200);
$resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.pdf"');
$resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $page->slug . '.pdf');
}
public function test_page_markdown_endpoint()
@@ -166,7 +166,7 @@ class ExportsApiTest extends TestCase
$resp = $this->get("/api/pages/{$page->id}/export/markdown");
$resp->assertStatus(200);
$resp->assertSee('# ' . $page->name);
$resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.md"');
$resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $page->slug . '.md');
}
public function test_page_zip_endpoint()
@@ -176,7 +176,7 @@ class ExportsApiTest extends TestCase
$resp = $this->get("/api/pages/{$page->id}/export/zip");
$resp->assertStatus(200);
$resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.zip"');
$resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $page->slug . '.zip');
$zip = ZipTestHelper::extractFromZipResponse($resp);
$this->assertArrayHasKey('page', $zip->data);

View File

@@ -18,7 +18,7 @@ class HtmlExportTest extends TestCase
$resp = $this->get($page->getUrl('/export/html'));
$resp->assertStatus(200);
$resp->assertSee($page->name);
$resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.html"');
$resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $page->slug . '.html');
}
public function test_book_html_export()
@@ -31,7 +31,7 @@ class HtmlExportTest extends TestCase
$resp->assertStatus(200);
$resp->assertSee($book->name);
$resp->assertSee($page->name);
$resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.html"');
$resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $book->slug . '.html');
}
public function test_book_html_export_shows_html_descriptions()
@@ -58,7 +58,7 @@ class HtmlExportTest extends TestCase
$resp->assertStatus(200);
$resp->assertSee($chapter->name);
$resp->assertSee($page->name);
$resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.html"');
$resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $chapter->slug . '.html');
}
public function test_chapter_html_export_shows_html_descriptions()

View File

@@ -14,7 +14,7 @@ class MarkdownExportTest extends TestCase
$resp = $this->asEditor()->get($page->getUrl('/export/markdown'));
$resp->assertStatus(200);
$resp->assertSee($page->name);
$resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.md"');
$resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $page->slug . '.md');
}
public function test_page_markdown_export_uses_existing_markdown_if_apparent()
@@ -56,6 +56,20 @@ class MarkdownExportTest extends TestCase
$resp->assertSee('My **chapter** description');
}
public function test_chapter_markdown_export_pages_are_permission_controlled()
{
$chapter = $this->entities->chapterHasPages();
$page = $chapter->pages()->first();
$page->name = 'MyPageWhichShouldNotBeFound';
$page->save();
$this->permissions->disableEntityInheritedPermissions($page);
$resp = $this->asEditor()->get($chapter->getUrl('/export/markdown'));
$resp->assertSee('# ' . $chapter->name);
$resp->assertDontSee('MyPageWhichShouldNotBeFound');
}
public function test_book_markdown_export()
{
$book = Book::query()->whereHas('pages')->whereHas('chapters')->first();
@@ -76,6 +90,38 @@ class MarkdownExportTest extends TestCase
$resp->assertSee('My **chapter** description');
}
public function test_book_markdown_export_chapters_are_permission_controlled()
{
$book = $this->entities->bookHasChaptersAndPages();
$chapter = $book->chapters()->first();
$page = $chapter->pages()->first();
$page->name = 'MyPageWhichShouldNotBeFound';
$page->save();
$chapter->name = 'MyChapterWhichShouldNotBeFound';
$chapter->save();
$this->permissions->disableEntityInheritedPermissions($chapter);
$resp = $this->asEditor()->get($book->getUrl('/export/markdown'));
$resp->assertSee('# ' . $book->name);
$resp->assertDontSee('MyChapterWhichShouldNotBeFound');
$resp->assertDontSee('MyPageWhichShouldNotBeFound');
}
public function test_book_markdown_export_direct_pages_are_permission_controlled()
{
$book = $this->entities->bookHasChaptersAndPages();
$page = $book->directPages()->first();
$page->name = 'MyPageWhichShouldNotBeFound';
$page->save();
$this->permissions->disableEntityInheritedPermissions($page);
$resp = $this->asEditor()->get($book->getUrl('/export/markdown'));
$resp->assertSee('# ' . $book->name);
$resp->assertDontSee('MyPageWhichShouldNotBeFound');
}
public function test_book_markdown_export_concats_immediate_pages_with_newlines()
{
/** @var Book $book */

View File

@@ -17,7 +17,7 @@ class PdfExportTest extends TestCase
$resp = $this->get($page->getUrl('/export/pdf'));
$resp->assertStatus(200);
$resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.pdf"');
$resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $page->slug . '.pdf');
}
public function test_book_pdf_export()
@@ -28,7 +28,7 @@ class PdfExportTest extends TestCase
$resp = $this->get($book->getUrl('/export/pdf'));
$resp->assertStatus(200);
$resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.pdf"');
$resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $book->slug . '.pdf');
}
public function test_chapter_pdf_export()
@@ -38,7 +38,7 @@ class PdfExportTest extends TestCase
$resp = $this->get($chapter->getUrl('/export/pdf'));
$resp->assertStatus(200);
$resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.pdf"');
$resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $chapter->slug . '.pdf');
}

View File

@@ -14,7 +14,7 @@ class TextExportTest extends TestCase
$resp = $this->get($page->getUrl('/export/plaintext'));
$resp->assertStatus(200);
$resp->assertSee($page->name);
$resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.txt"');
$resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $page->slug . '.txt');
}
public function test_book_text_export()
@@ -35,7 +35,7 @@ class TextExportTest extends TestCase
$resp->assertSee($directPage->name);
$resp->assertSee('My awesome page');
$resp->assertSee('My little nested page');
$resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.txt"');
$resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $book->slug . '.txt');
}
public function test_book_text_export_format()
@@ -68,7 +68,7 @@ class TextExportTest extends TestCase
$resp->assertSee($chapter->name);
$resp->assertSee($page->name);
$resp->assertSee('This is content within the page!');
$resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.txt"');
$resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $chapter->slug . '.txt');
}
public function test_chapter_text_export_format()

View File

@@ -324,7 +324,7 @@ class AttachmentTest extends TestCase
$attachmentGet = $this->get($attachment->getUrl(true));
// http-foundation/Response does some 'fixing' of responses to add charsets to text responses.
$attachmentGet->assertHeader('Content-Type', 'text/plain; charset=utf-8');
$attachmentGet->assertHeader('Content-Disposition', 'inline; filename="upload_test_file.txt"');
$attachmentGet->assertHeader('Content-Disposition', 'inline; filename*=UTF-8\'\'upload_test_file.txt');
$attachmentGet->assertHeader('X-Content-Type-Options', 'nosniff');
$this->files->deleteAllAttachmentFiles();
@@ -340,7 +340,22 @@ class AttachmentTest extends TestCase
$attachmentGet = $this->get($attachment->getUrl(true));
// http-foundation/Response does some 'fixing' of responses to add charsets to text responses.
$attachmentGet->assertHeader('Content-Type', 'text/plain; charset=utf-8');
$attachmentGet->assertHeader('Content-Disposition', 'inline; filename="test_file.html"');
$attachmentGet->assertHeader('Content-Disposition', 'inline; filename*=UTF-8\'\'test_file.html');
$this->files->deleteAllAttachmentFiles();
}
public function test_file_access_name_in_content_disposition_header_is_sanitized()
{
$page = $this->entities->page();
$this->asAdmin();
$attachment = $this->files->uploadAttachmentDataToPage($this, $page, 'test_file.html', '<html></html><p>testing</p>', 'text/html');
$attachment->name = "my\\_/super\n_fu\$n_\tfile";
$attachment->save();
$attachmentGet = $this->get($attachment->getUrl(true));
$attachmentGet->assertHeader('Content-Disposition', 'inline; filename*=UTF-8\'\'my_super_fun_file.html');
$this->files->deleteAllAttachmentFiles();
}