ZIP Export: Expanded page & added base attachment handling
Some checks failed
analyse-php / build (push) Has been cancelled
lint-php / build (push) Has been cancelled
test-migrations / build (8.1) (push) Has been cancelled
test-migrations / build (8.2) (push) Has been cancelled
test-migrations / build (8.3) (push) Has been cancelled
test-php / build (8.1) (push) Has been cancelled
test-php / build (8.2) (push) Has been cancelled
test-php / build (8.3) (push) Has been cancelled

This commit is contained in:
Dan Brown 2024-10-19 15:41:07 +01:00
parent bf0262d7d1
commit 21ccfa97dd
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
8 changed files with 154 additions and 12 deletions

View File

@ -6,6 +6,7 @@ use BookStack\Entities\Queries\PageQueries;
use BookStack\Entities\Tools\PageContent; use BookStack\Entities\Tools\PageContent;
use BookStack\Exceptions\NotFoundException; use BookStack\Exceptions\NotFoundException;
use BookStack\Exports\ExportFormatter; use BookStack\Exports\ExportFormatter;
use BookStack\Exports\ZipExportBuilder;
use BookStack\Http\Controller; use BookStack\Http\Controller;
use Throwable; use Throwable;
@ -74,4 +75,16 @@ class PageExportController extends Controller
return $this->download()->directly($pageText, $pageSlug . '.md'); return $this->download()->directly($pageText, $pageSlug . '.md');
} }
/**
* Export a page to a contained ZIP export file.
* @throws NotFoundException
*/
public function zip(string $bookSlug, string $pageSlug, ZipExportBuilder $builder)
{
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$zip = $builder->buildForPage($page);
return $this->download()->streamedDirectly(fopen($zip, 'r'), $pageSlug . '.zip', filesize($zip));
}
} }

View File

@ -2,24 +2,70 @@
namespace BookStack\Exports; namespace BookStack\Exports;
use BookStack\Activity\Models\Tag;
use BookStack\Entities\Models\Page; use BookStack\Entities\Models\Page;
use BookStack\Exceptions\ZipExportException; use BookStack\Exceptions\ZipExportException;
use BookStack\Uploads\Attachment;
use ZipArchive; use ZipArchive;
class ZipExportBuilder class ZipExportBuilder
{ {
protected array $data = []; protected array $data = [];
public function __construct(
protected ZipExportFiles $files
) {
}
/** /**
* @throws ZipExportException * @throws ZipExportException
*/ */
public function buildForPage(Page $page): string public function buildForPage(Page $page): string
{ {
$this->data['page'] = [ $this->data['page'] = $this->convertPage($page);
'id' => $page->id, return $this->build();
}
protected function convertPage(Page $page): array
{
$tags = array_map($this->convertTag(...), $page->tags()->get()->all());
$attachments = array_map($this->convertAttachment(...), $page->attachments()->get()->all());
return [
'id' => $page->id,
'name' => $page->name,
'html' => '', // TODO
'markdown' => '', // TODO
'priority' => $page->priority,
'attachments' => $attachments,
'images' => [], // TODO
'tags' => $tags,
];
}
protected function convertAttachment(Attachment $attachment): array
{
$data = [
'name' => $attachment->name,
'order' => $attachment->order,
]; ];
return $this->build(); if ($attachment->external) {
$data['link'] = $attachment->path;
} else {
$data['file'] = $this->files->referenceForAttachment($attachment);
}
return $data;
}
protected function convertTag(Tag $tag): array
{
return [
'name' => $tag->name,
'value' => $tag->value,
'order' => $tag->order,
];
} }
/** /**
@ -29,7 +75,7 @@ class ZipExportBuilder
{ {
$this->data['exported_at'] = date(DATE_ATOM); $this->data['exported_at'] = date(DATE_ATOM);
$this->data['instance'] = [ $this->data['instance'] = [
'version' => trim(file_get_contents(base_path('version'))), 'version' => trim(file_get_contents(base_path('version'))),
'id_ciphertext' => encrypt('bookstack'), 'id_ciphertext' => encrypt('bookstack'),
]; ];
@ -43,6 +89,18 @@ class ZipExportBuilder
$zip->addFromString('data.json', json_encode($this->data)); $zip->addFromString('data.json', json_encode($this->data));
$zip->addEmptyDir('files'); $zip->addEmptyDir('files');
$toRemove = [];
$this->files->extractEach(function ($filePath, $fileRef) use ($zip, &$toRemove) {
$zip->addFile($filePath, "files/$fileRef");
$toRemove[] = $filePath;
});
$zip->close();
foreach ($toRemove as $file) {
unlink($file);
}
return $zipFile; return $zipFile;
} }
} }

View File

@ -0,0 +1,58 @@
<?php
namespace BookStack\Exports;
use BookStack\Uploads\Attachment;
use BookStack\Uploads\AttachmentService;
use Illuminate\Support\Str;
class ZipExportFiles
{
/**
* References for attachments by attachment ID.
* @var array<int, string>
*/
protected array $attachmentRefsById = [];
public function __construct(
protected AttachmentService $attachmentService,
) {
}
/**
* Gain a reference to the given attachment instance.
* This is expected to be a file-based attachment that the user
* has visibility of, no permission/access checks are performed here.
*/
public function referenceForAttachment(Attachment $attachment): string
{
if (isset($this->attachmentRefsById[$attachment->id])) {
return $this->attachmentRefsById[$attachment->id];
}
do {
$fileName = Str::random(20) . '.' . $attachment->extension;
} while (in_array($fileName, $this->attachmentRefsById));
$this->attachmentRefsById[$attachment->id] = $fileName;
return $fileName;
}
/**
* Extract each of the ZIP export tracked files.
* Calls the given callback for each tracked file, passing a temporary
* file reference of the file contents, and the zip-local tracked reference.
*/
public function extractEach(callable $callback): void
{
foreach ($this->attachmentRefsById as $attachmentId => $ref) {
$attachment = Attachment::query()->find($attachmentId);
$stream = $this->attachmentService->streamAttachmentFromStorage($attachment);
$tmpFile = tempnam(sys_get_temp_dir(), 'bszipfile-');
$tmpFileStream = fopen($tmpFile, 'w');
stream_copy_to_stream($stream, $tmpFileStream);
$callback($tmpFile, $ref);
}
}
}

View File

@ -13,14 +13,9 @@ use Symfony\Component\HttpFoundation\File\UploadedFile;
class AttachmentService class AttachmentService
{ {
protected FilesystemManager $fileSystem; public function __construct(
protected FilesystemManager $fileSystem
/** ) {
* AttachmentService constructor.
*/
public function __construct(FilesystemManager $fileSystem)
{
$this->fileSystem = $fileSystem;
} }
/** /**

View File

@ -39,6 +39,7 @@ return [
'export_pdf' => 'PDF File', 'export_pdf' => 'PDF File',
'export_text' => 'Plain Text File', 'export_text' => 'Plain Text File',
'export_md' => 'Markdown File', 'export_md' => 'Markdown File',
'export_zip' => 'Portable ZIP',
'default_template' => 'Default Page Template', 'default_template' => 'Default Page Template',
'default_template_explain' => 'Assign a page template that will be used as the default content for all pages created within this item. Keep in mind this will only be used if the page creator has view access to the chosen template page.', 'default_template_explain' => 'Assign a page template that will be used as the default content for all pages created within this item. Keep in mind this will only be used if the page creator has view access to the chosen template page.',
'default_template_select' => 'Select a template page', 'default_template_select' => 'Select a template page',

View File

@ -18,6 +18,7 @@
<li><a href="{{ $entity->getUrl('/export/pdf') }}" target="_blank" class="label-item"><span>{{ trans('entities.export_pdf') }}</span><span>.pdf</span></a></li> <li><a href="{{ $entity->getUrl('/export/pdf') }}" target="_blank" class="label-item"><span>{{ trans('entities.export_pdf') }}</span><span>.pdf</span></a></li>
<li><a href="{{ $entity->getUrl('/export/plaintext') }}" target="_blank" class="label-item"><span>{{ trans('entities.export_text') }}</span><span>.txt</span></a></li> <li><a href="{{ $entity->getUrl('/export/plaintext') }}" target="_blank" class="label-item"><span>{{ trans('entities.export_text') }}</span><span>.txt</span></a></li>
<li><a href="{{ $entity->getUrl('/export/markdown') }}" target="_blank" class="label-item"><span>{{ trans('entities.export_md') }}</span><span>.md</span></a></li> <li><a href="{{ $entity->getUrl('/export/markdown') }}" target="_blank" class="label-item"><span>{{ trans('entities.export_md') }}</span><span>.md</span></a></li>
<li><a href="{{ $entity->getUrl('/export/zip') }}" target="_blank" class="label-item"><span>{{ trans('entities.export_zip') }}</span><span>.zip</span></a></li>
</ul> </ul>
</div> </div>

View File

@ -91,6 +91,7 @@ Route::middleware('auth')->group(function () {
Route::get('/books/{bookSlug}/page/{pageSlug}/export/html', [ExportControllers\PageExportController::class, 'html']); Route::get('/books/{bookSlug}/page/{pageSlug}/export/html', [ExportControllers\PageExportController::class, 'html']);
Route::get('/books/{bookSlug}/page/{pageSlug}/export/markdown', [ExportControllers\PageExportController::class, 'markdown']); Route::get('/books/{bookSlug}/page/{pageSlug}/export/markdown', [ExportControllers\PageExportController::class, 'markdown']);
Route::get('/books/{bookSlug}/page/{pageSlug}/export/plaintext', [ExportControllers\PageExportController::class, 'plainText']); Route::get('/books/{bookSlug}/page/{pageSlug}/export/plaintext', [ExportControllers\PageExportController::class, 'plainText']);
Route::get('/books/{bookSlug}/page/{pageSlug}/export/zip', [ExportControllers\PageExportController::class, 'zip']);
Route::get('/books/{bookSlug}/page/{pageSlug}/edit', [EntityControllers\PageController::class, 'edit']); Route::get('/books/{bookSlug}/page/{pageSlug}/edit', [EntityControllers\PageController::class, 'edit']);
Route::get('/books/{bookSlug}/page/{pageSlug}/move', [EntityControllers\PageController::class, 'showMove']); Route::get('/books/{bookSlug}/page/{pageSlug}/move', [EntityControllers\PageController::class, 'showMove']);
Route::put('/books/{bookSlug}/page/{pageSlug}/move', [EntityControllers\PageController::class, 'move']); Route::put('/books/{bookSlug}/page/{pageSlug}/move', [EntityControllers\PageController::class, 'move']);

View File

@ -0,0 +1,15 @@
<?php
namespace Tests\Exports;
use BookStack\Entities\Models\Book;
use Tests\TestCase;
class ZipExportTest extends TestCase
{
public function test_page_export()
{
$page = $this->entities->page();
// TODO
}
}