]> BookStack Code Mirror - bookstack/blob - tests/Entity/ExportTest.php
Skip intermediate login page with single provider
[bookstack] / tests / Entity / ExportTest.php
1 <?php
2
3 namespace Tests\Entity;
4
5 use BookStack\Auth\Role;
6 use BookStack\Entities\Models\Book;
7 use BookStack\Entities\Models\Chapter;
8 use BookStack\Entities\Models\Page;
9 use BookStack\Entities\Tools\PdfGenerator;
10 use Illuminate\Support\Facades\Storage;
11 use Illuminate\Support\Str;
12 use Tests\TestCase;
13
14 class ExportTest extends TestCase
15 {
16     public function test_page_text_export()
17     {
18         $page = Page::query()->first();
19         $this->asEditor();
20
21         $resp = $this->get($page->getUrl('/export/plaintext'));
22         $resp->assertStatus(200);
23         $resp->assertSee($page->name);
24         $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.txt"');
25     }
26
27     public function test_page_pdf_export()
28     {
29         $page = Page::query()->first();
30         $this->asEditor();
31
32         $resp = $this->get($page->getUrl('/export/pdf'));
33         $resp->assertStatus(200);
34         $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.pdf"');
35     }
36
37     public function test_page_html_export()
38     {
39         $page = Page::query()->first();
40         $this->asEditor();
41
42         $resp = $this->get($page->getUrl('/export/html'));
43         $resp->assertStatus(200);
44         $resp->assertSee($page->name);
45         $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.html"');
46     }
47
48     public function test_book_text_export()
49     {
50         $page = Page::query()->first();
51         $book = $page->book;
52         $this->asEditor();
53
54         $resp = $this->get($book->getUrl('/export/plaintext'));
55         $resp->assertStatus(200);
56         $resp->assertSee($book->name);
57         $resp->assertSee($page->name);
58         $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.txt"');
59     }
60
61     public function test_book_pdf_export()
62     {
63         $page = Page::query()->first();
64         $book = $page->book;
65         $this->asEditor();
66
67         $resp = $this->get($book->getUrl('/export/pdf'));
68         $resp->assertStatus(200);
69         $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.pdf"');
70     }
71
72     public function test_book_html_export()
73     {
74         $page = Page::query()->first();
75         $book = $page->book;
76         $this->asEditor();
77
78         $resp = $this->get($book->getUrl('/export/html'));
79         $resp->assertStatus(200);
80         $resp->assertSee($book->name);
81         $resp->assertSee($page->name);
82         $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.html"');
83     }
84
85     public function test_book_html_export_shows_chapter_descriptions()
86     {
87         $chapterDesc = 'My custom test chapter description ' . Str::random(12);
88         $chapter = Chapter::query()->first();
89         $chapter->description = $chapterDesc;
90         $chapter->save();
91
92         $book = $chapter->book;
93         $this->asEditor();
94
95         $resp = $this->get($book->getUrl('/export/html'));
96         $resp->assertSee($chapterDesc);
97     }
98
99     public function test_chapter_text_export()
100     {
101         $chapter = Chapter::query()->first();
102         $page = $chapter->pages[0];
103         $this->asEditor();
104
105         $resp = $this->get($chapter->getUrl('/export/plaintext'));
106         $resp->assertStatus(200);
107         $resp->assertSee($chapter->name);
108         $resp->assertSee($page->name);
109         $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.txt"');
110     }
111
112     public function test_chapter_pdf_export()
113     {
114         $chapter = Chapter::query()->first();
115         $this->asEditor();
116
117         $resp = $this->get($chapter->getUrl('/export/pdf'));
118         $resp->assertStatus(200);
119         $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.pdf"');
120     }
121
122     public function test_chapter_html_export()
123     {
124         $chapter = Chapter::query()->first();
125         $page = $chapter->pages[0];
126         $this->asEditor();
127
128         $resp = $this->get($chapter->getUrl('/export/html'));
129         $resp->assertStatus(200);
130         $resp->assertSee($chapter->name);
131         $resp->assertSee($page->name);
132         $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.html"');
133     }
134
135     public function test_page_html_export_contains_custom_head_if_set()
136     {
137         $page = Page::query()->first();
138
139         $customHeadContent = '<style>p{color: red;}</style>';
140         $this->setSettings(['app-custom-head' => $customHeadContent]);
141
142         $resp = $this->asEditor()->get($page->getUrl('/export/html'));
143         $resp->assertSee($customHeadContent, false);
144     }
145
146     public function test_page_html_export_does_not_break_with_only_comments_in_custom_head()
147     {
148         $page = Page::query()->first();
149
150         $customHeadContent = '<!-- A comment -->';
151         $this->setSettings(['app-custom-head' => $customHeadContent]);
152
153         $resp = $this->asEditor()->get($page->getUrl('/export/html'));
154         $resp->assertStatus(200);
155         $resp->assertSee($customHeadContent, false);
156     }
157
158     public function test_page_html_export_use_absolute_dates()
159     {
160         $page = Page::query()->first();
161
162         $resp = $this->asEditor()->get($page->getUrl('/export/html'));
163         $resp->assertSee($page->created_at->formatLocalized('%e %B %Y %H:%M:%S'));
164         $resp->assertDontSee($page->created_at->diffForHumans());
165         $resp->assertSee($page->updated_at->formatLocalized('%e %B %Y %H:%M:%S'));
166         $resp->assertDontSee($page->updated_at->diffForHumans());
167     }
168
169     public function test_page_export_does_not_include_user_or_revision_links()
170     {
171         $page = Page::query()->first();
172
173         $resp = $this->asEditor()->get($page->getUrl('/export/html'));
174         $resp->assertDontSee($page->getUrl('/revisions'));
175         $resp->assertDontSee($page->createdBy->getProfileUrl());
176         $resp->assertSee($page->createdBy->name);
177     }
178
179     public function test_page_export_sets_right_data_type_for_svg_embeds()
180     {
181         $page = Page::query()->first();
182         Storage::disk('local')->makeDirectory('uploads/images/gallery');
183         Storage::disk('local')->put('uploads/images/gallery/svg_test.svg', '<svg></svg>');
184         $page->html = '<img src="http://localhost/uploads/images/gallery/svg_test.svg">';
185         $page->save();
186
187         $this->asEditor();
188         $resp = $this->get($page->getUrl('/export/html'));
189         Storage::disk('local')->delete('uploads/images/gallery/svg_test.svg');
190
191         $resp->assertStatus(200);
192         $resp->assertSee('<img src="data:image/svg+xml;base64', false);
193     }
194
195     public function test_page_image_containment_works_on_multiple_images_within_a_single_line()
196     {
197         $page = Page::query()->first();
198         Storage::disk('local')->makeDirectory('uploads/images/gallery');
199         Storage::disk('local')->put('uploads/images/gallery/svg_test.svg', '<svg></svg>');
200         Storage::disk('local')->put('uploads/images/gallery/svg_test2.svg', '<svg></svg>');
201         $page->html = '<img src="http://localhost/uploads/images/gallery/svg_test.svg" class="a"><img src="http://localhost/uploads/images/gallery/svg_test2.svg" class="b">';
202         $page->save();
203
204         $resp = $this->asEditor()->get($page->getUrl('/export/html'));
205         Storage::disk('local')->delete('uploads/images/gallery/svg_test.svg');
206         Storage::disk('local')->delete('uploads/images/gallery/svg_test2.svg');
207
208         $resp->assertDontSee('http://localhost/uploads/images/gallery/svg_test');
209     }
210
211     public function test_page_export_contained_html_image_fetches_only_run_when_url_points_to_image_upload_folder()
212     {
213         $page = Page::query()->first();
214         $page->html = '<img src="http://localhost/uploads/images/gallery/svg_test.svg"/>'
215             . '<img src="http://localhost/uploads/svg_test.svg"/>'
216             . '<img src="/uploads/svg_test.svg"/>';
217         $storageDisk = Storage::disk('local');
218         $storageDisk->makeDirectory('uploads/images/gallery');
219         $storageDisk->put('uploads/images/gallery/svg_test.svg', '<svg>good</svg>');
220         $storageDisk->put('uploads/svg_test.svg', '<svg>bad</svg>');
221         $page->save();
222
223         $resp = $this->asEditor()->get($page->getUrl('/export/html'));
224
225         $storageDisk->delete('uploads/images/gallery/svg_test.svg');
226         $storageDisk->delete('uploads/svg_test.svg');
227
228         $resp->assertDontSee('http://localhost/uploads/images/gallery/svg_test.svg', false);
229         $resp->assertSee('http://localhost/uploads/svg_test.svg');
230         $resp->assertSee('src="/uploads/svg_test.svg"', false);
231     }
232
233     public function test_page_export_contained_html_does_not_allow_upward_traversal_with_local()
234     {
235         $contents = file_get_contents(public_path('.htaccess'));
236         config()->set('filesystems.images', 'local');
237
238         $page = Page::query()->first();
239         $page->html = '<img src="http://localhost/uploads/images/../../.htaccess"/>';
240         $page->save();
241
242         $resp = $this->asEditor()->get($page->getUrl('/export/html'));
243         $resp->assertDontSee(base64_encode($contents));
244     }
245
246     public function test_page_export_contained_html_does_not_allow_upward_traversal_with_local_secure()
247     {
248         $testFilePath = storage_path('logs/test.txt');
249         config()->set('filesystems.images', 'local_secure');
250         file_put_contents($testFilePath, 'I am a cat');
251
252         $page = Page::query()->first();
253         $page->html = '<img src="http://localhost/uploads/images/../../logs/test.txt"/>';
254         $page->save();
255
256         $resp = $this->asEditor()->get($page->getUrl('/export/html'));
257         $resp->assertDontSee(base64_encode('I am a cat'));
258         unlink($testFilePath);
259     }
260
261     public function test_exports_removes_scripts_from_custom_head()
262     {
263         $entities = [
264             Page::query()->first(), Chapter::query()->first(), Book::query()->first(),
265         ];
266         setting()->put('app-custom-head', '<script>window.donkey = "cat";</script><style>.my-test-class { color: red; }</style>');
267
268         foreach ($entities as $entity) {
269             $resp = $this->asEditor()->get($entity->getUrl('/export/html'));
270             $resp->assertDontSee('window.donkey');
271             $resp->assertDontSee('<script', false);
272             $resp->assertSee('.my-test-class { color: red; }');
273         }
274     }
275
276     public function test_page_export_with_deleted_creator_and_updater()
277     {
278         $user = $this->getViewer(['name' => 'ExportWizardTheFifth']);
279         $page = Page::query()->first();
280         $page->created_by = $user->id;
281         $page->updated_by = $user->id;
282         $page->save();
283
284         $resp = $this->asEditor()->get($page->getUrl('/export/html'));
285         $resp->assertSee('ExportWizardTheFifth');
286
287         $user->delete();
288         $resp = $this->get($page->getUrl('/export/html'));
289         $resp->assertStatus(200);
290         $resp->assertDontSee('ExportWizardTheFifth');
291     }
292
293     public function test_page_pdf_export_converts_iframes_to_links()
294     {
295         $page = Page::query()->first()->forceFill([
296             'html'     => '<iframe width="560" height="315" src="//www.youtube.com/embed/ShqUjt33uOs"></iframe>',
297         ]);
298         $page->save();
299
300         $pdfHtml = '';
301         $mockPdfGenerator = $this->mock(PdfGenerator::class);
302         $mockPdfGenerator->shouldReceive('fromHtml')
303             ->with(\Mockery::capture($pdfHtml))
304             ->andReturn('');
305         $mockPdfGenerator->shouldReceive('getActiveEngine')->andReturn(PdfGenerator::ENGINE_DOMPDF);
306
307         $this->asEditor()->get($page->getUrl('/export/pdf'));
308         $this->assertStringNotContainsString('iframe>', $pdfHtml);
309         $this->assertStringContainsString('<p><a href="https://www.youtube.com/embed/ShqUjt33uOs">https://www.youtube.com/embed/ShqUjt33uOs</a></p>', $pdfHtml);
310     }
311
312     public function test_page_pdf_export_opens_details_blocks()
313     {
314         $page = Page::query()->first()->forceFill([
315             'html'     => '<details><summary>Hello</summary><p>Content!</p></details>',
316         ]);
317         $page->save();
318
319         $pdfHtml = '';
320         $mockPdfGenerator = $this->mock(PdfGenerator::class);
321         $mockPdfGenerator->shouldReceive('fromHtml')
322             ->with(\Mockery::capture($pdfHtml))
323             ->andReturn('');
324         $mockPdfGenerator->shouldReceive('getActiveEngine')->andReturn(PdfGenerator::ENGINE_DOMPDF);
325
326         $this->asEditor()->get($page->getUrl('/export/pdf'));
327         $this->assertStringContainsString('<details open="open"', $pdfHtml);
328     }
329
330     public function test_page_markdown_export()
331     {
332         $page = Page::query()->first();
333
334         $resp = $this->asEditor()->get($page->getUrl('/export/markdown'));
335         $resp->assertStatus(200);
336         $resp->assertSee($page->name);
337         $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.md"');
338     }
339
340     public function test_page_markdown_export_uses_existing_markdown_if_apparent()
341     {
342         $page = Page::query()->first()->forceFill([
343             'markdown' => '# A header',
344             'html'     => '<h1>Dogcat</h1>',
345         ]);
346         $page->save();
347
348         $resp = $this->asEditor()->get($page->getUrl('/export/markdown'));
349         $resp->assertSee('A header');
350         $resp->assertDontSee('Dogcat');
351     }
352
353     public function test_page_markdown_export_converts_html_where_no_markdown()
354     {
355         $page = Page::query()->first()->forceFill([
356             'markdown' => '',
357             'html'     => '<h1>Dogcat</h1><p>Some <strong>bold</strong> text</p>',
358         ]);
359         $page->save();
360
361         $resp = $this->asEditor()->get($page->getUrl('/export/markdown'));
362         $resp->assertSee("# Dogcat\n\nSome **bold** text");
363     }
364
365     public function test_chapter_markdown_export()
366     {
367         $chapter = Chapter::query()->first();
368         $page = $chapter->pages()->first();
369         $resp = $this->asEditor()->get($chapter->getUrl('/export/markdown'));
370
371         $resp->assertSee('# ' . $chapter->name);
372         $resp->assertSee('# ' . $page->name);
373     }
374
375     public function test_book_markdown_export()
376     {
377         $book = Book::query()->whereHas('pages')->whereHas('chapters')->first();
378         $chapter = $book->chapters()->first();
379         $page = $chapter->pages()->first();
380         $resp = $this->asEditor()->get($book->getUrl('/export/markdown'));
381
382         $resp->assertSee('# ' . $book->name);
383         $resp->assertSee('# ' . $chapter->name);
384         $resp->assertSee('# ' . $page->name);
385     }
386
387     public function test_book_markdown_export_concats_immediate_pages_with_newlines()
388     {
389         /** @var Book $book */
390         $book = Book::query()->whereHas('pages')->first();
391
392         $this->asEditor()->get($book->getUrl('/create-page'));
393         $this->get($book->getUrl('/create-page'));
394
395         [$pageA, $pageB] = $book->pages()->where('chapter_id', '=', 0)->get();
396         $pageA->html = '<p>hello tester</p>';
397         $pageA->save();
398         $pageB->name = 'The second page in this test';
399         $pageB->save();
400
401         $resp = $this->get($book->getUrl('/export/markdown'));
402         $resp->assertDontSee('hello tester# The second page in this test');
403         $resp->assertSee("hello tester\n\n# The second page in this test");
404     }
405
406     public function test_export_option_only_visible_and_accessible_with_permission()
407     {
408         $book = Book::query()->whereHas('pages')->whereHas('chapters')->first();
409         $chapter = $book->chapters()->first();
410         $page = $chapter->pages()->first();
411         $entities = [$book, $chapter, $page];
412         $user = $this->getViewer();
413         $this->actingAs($user);
414
415         foreach ($entities as $entity) {
416             $resp = $this->get($entity->getUrl());
417             $resp->assertSee('/export/pdf');
418         }
419
420         /** @var Role $role */
421         $this->removePermissionFromUser($user, 'content-export');
422
423         foreach ($entities as $entity) {
424             $resp = $this->get($entity->getUrl());
425             $resp->assertDontSee('/export/pdf');
426             $resp = $this->get($entity->getUrl('/export/pdf'));
427             $this->assertPermissionError($resp);
428         }
429     }
430
431     public function test_wkhtmltopdf_only_used_when_allow_untrusted_is_true()
432     {
433         /** @var Page $page */
434         $page = Page::query()->first();
435
436         config()->set('snappy.pdf.binary', '/abc123');
437         config()->set('app.allow_untrusted_server_fetching', false);
438
439         $resp = $this->asEditor()->get($page->getUrl('/export/pdf'));
440         $resp->assertStatus(200); // Sucessful response with invalid snappy binary indicates dompdf usage.
441
442         config()->set('app.allow_untrusted_server_fetching', true);
443         $resp = $this->get($page->getUrl('/export/pdf'));
444         $resp->assertStatus(500); // Bad response indicates wkhtml usage
445     }
446
447     public function test_html_exports_contain_csp_meta_tag()
448     {
449         $entities = [
450             Page::query()->first(),
451             Book::query()->first(),
452             Chapter::query()->first(),
453         ];
454
455         foreach ($entities as $entity) {
456             $resp = $this->asEditor()->get($entity->getUrl('/export/html'));
457             $resp->assertElementExists('head meta[http-equiv="Content-Security-Policy"][content*="script-src "]');
458         }
459     }
460 }
Morty Proxy This is a proxified and sanitized view of the page, visit original site.