diff --git a/app/Activity/ActivityType.php b/app/Activity/ActivityType.php index 09b2ae73c56..5ec9b9cf0dc 100644 --- a/app/Activity/ActivityType.php +++ b/app/Activity/ActivityType.php @@ -67,6 +67,10 @@ class ActivityType const WEBHOOK_UPDATE = 'webhook_update'; const WEBHOOK_DELETE = 'webhook_delete'; + const IMPORT_CREATE = 'import_create'; + const IMPORT_RUN = 'import_run'; + const IMPORT_DELETE = 'import_delete'; + /** * Get all the possible values. */ diff --git a/app/Entities/Models/Chapter.php b/app/Entities/Models/Chapter.php index c926aaa647a..088d199da67 100644 --- a/app/Entities/Models/Chapter.php +++ b/app/Entities/Models/Chapter.php @@ -60,6 +60,7 @@ public function defaultTemplate(): BelongsTo /** * Get the visible pages in this chapter. + * @returns Collection */ public function getVisiblePages(): Collection { diff --git a/app/Entities/Repos/PageRepo.php b/app/Entities/Repos/PageRepo.php index 1bc15392cec..68b1c398f80 100644 --- a/app/Entities/Repos/PageRepo.php +++ b/app/Entities/Repos/PageRepo.php @@ -87,6 +87,17 @@ public function publishDraft(Page $draft, array $input): Page return $draft; } + /** + * Directly update the content for the given page from the provided input. + * Used for direct content access in a way that performs required changes + * (Search index & reference regen) without performing an official update. + */ + public function setContentFromInput(Page $page, array $input): void + { + $this->updateTemplateStatusAndContentFromInput($page, $input); + $this->baseRepo->update($page, []); + } + /** * Update a page in the system. */ @@ -121,7 +132,7 @@ public function update(Page $page, array $input): Page return $page; } - protected function updateTemplateStatusAndContentFromInput(Page $page, array $input) + protected function updateTemplateStatusAndContentFromInput(Page $page, array $input): void { if (isset($input['template']) && userCan('templates-manage')) { $page->template = ($input['template'] === 'true'); diff --git a/app/Entities/Tools/Cloner.php b/app/Entities/Tools/Cloner.php index 2030b050c4b..2be6083e3dd 100644 --- a/app/Entities/Tools/Cloner.php +++ b/app/Entities/Tools/Cloner.php @@ -18,17 +18,12 @@ class Cloner { - protected PageRepo $pageRepo; - protected ChapterRepo $chapterRepo; - protected BookRepo $bookRepo; - protected ImageService $imageService; - - public function __construct(PageRepo $pageRepo, ChapterRepo $chapterRepo, BookRepo $bookRepo, ImageService $imageService) - { - $this->pageRepo = $pageRepo; - $this->chapterRepo = $chapterRepo; - $this->bookRepo = $bookRepo; - $this->imageService = $imageService; + public function __construct( + protected PageRepo $pageRepo, + protected ChapterRepo $chapterRepo, + protected BookRepo $bookRepo, + protected ImageService $imageService, + ) { } /** diff --git a/app/Exceptions/ZipExportException.php b/app/Exceptions/ZipExportException.php new file mode 100644 index 00000000000..b2c811e0b33 --- /dev/null +++ b/app/Exceptions/ZipExportException.php @@ -0,0 +1,7 @@ +errors); + parent::__construct($message); + } +} diff --git a/app/Exceptions/ZipValidationException.php b/app/Exceptions/ZipValidationException.php new file mode 100644 index 00000000000..aaaee792ef0 --- /dev/null +++ b/app/Exceptions/ZipValidationException.php @@ -0,0 +1,12 @@ +download()->directly($textContent, $bookSlug . '.md'); } + + /** + * Export a book to a contained ZIP export file. + * @throws NotFoundException + */ + public function zip(string $bookSlug, ZipExportBuilder $builder) + { + $book = $this->queries->findVisibleBySlugOrFail($bookSlug); + $zip = $builder->buildForBook($book); + + return $this->download()->streamedDirectly(fopen($zip, 'r'), $bookSlug . '.zip', filesize($zip)); + } } diff --git a/app/Entities/Controllers/ChapterExportApiController.php b/app/Exports/Controllers/ChapterExportApiController.php similarity index 95% rename from app/Entities/Controllers/ChapterExportApiController.php rename to app/Exports/Controllers/ChapterExportApiController.php index ceb2522b211..9914e2b7fbe 100644 --- a/app/Entities/Controllers/ChapterExportApiController.php +++ b/app/Exports/Controllers/ChapterExportApiController.php @@ -1,9 +1,9 @@ download()->directly($chapterText, $chapterSlug . '.md'); } + + /** + * Export a book to a contained ZIP export file. + * @throws NotFoundException + */ + public function zip(string $bookSlug, string $chapterSlug, ZipExportBuilder $builder) + { + $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug); + $zip = $builder->buildForChapter($chapter); + + return $this->download()->streamedDirectly(fopen($zip, 'r'), $chapterSlug . '.zip', filesize($zip)); + } } diff --git a/app/Exports/Controllers/ImportController.php b/app/Exports/Controllers/ImportController.php new file mode 100644 index 00000000000..b938dac8e29 --- /dev/null +++ b/app/Exports/Controllers/ImportController.php @@ -0,0 +1,110 @@ +middleware('can:content-import'); + } + + /** + * Show the view to start a new import, and also list out the existing + * in progress imports that are visible to the user. + */ + public function start() + { + $imports = $this->imports->getVisibleImports(); + + $this->setPageTitle(trans('entities.import')); + + return view('exports.import', [ + 'imports' => $imports, + 'zipErrors' => session()->pull('validation_errors') ?? [], + ]); + } + + /** + * Upload, validate and store an import file. + */ + public function upload(Request $request) + { + $this->validate($request, [ + 'file' => ['required', ...AttachmentService::getFileValidationRules()] + ]); + + $file = $request->file('file'); + try { + $import = $this->imports->storeFromUpload($file); + } catch (ZipValidationException $exception) { + return redirect('/import')->with('validation_errors', $exception->errors); + } + + return redirect($import->getUrl()); + } + + /** + * Show a pending import, with a form to allow progressing + * with the import process. + */ + public function show(int $id) + { + $import = $this->imports->findVisible($id); + + $this->setPageTitle(trans('entities.import_continue')); + + return view('exports.import-show', [ + 'import' => $import, + 'data' => $import->decodeMetadata(), + ]); + } + + /** + * Run the import process against an uploaded import ZIP. + */ + public function run(int $id, Request $request) + { + $import = $this->imports->findVisible($id); + $parent = null; + + if ($import->type === 'page' || $import->type === 'chapter') { + session()->setPreviousUrl($import->getUrl()); + $data = $this->validate($request, [ + 'parent' => ['required', 'string'], + ]); + $parent = $data['parent']; + } + + try { + $entity = $this->imports->runImport($import, $parent); + } catch (ZipImportException $exception) { + session()->flush(); + $this->showErrorNotification(trans('errors.import_zip_failed_notification')); + return redirect($import->getUrl())->with('import_errors', $exception->errors); + } + + return redirect($entity->getUrl()); + } + + /** + * Delete an active pending import from the filesystem and database. + */ + public function delete(int $id) + { + $import = $this->imports->findVisible($id); + $this->imports->deleteImport($import); + + return redirect('/import'); + } +} diff --git a/app/Entities/Controllers/PageExportApiController.php b/app/Exports/Controllers/PageExportApiController.php similarity index 95% rename from app/Entities/Controllers/PageExportApiController.php rename to app/Exports/Controllers/PageExportApiController.php index 693760bc8e7..c6e20b615d2 100644 --- a/app/Entities/Controllers/PageExportApiController.php +++ b/app/Exports/Controllers/PageExportApiController.php @@ -1,9 +1,9 @@ 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)); + } } diff --git a/app/Entities/Tools/ExportFormatter.php b/app/Exports/ExportFormatter.php similarity index 98% rename from app/Entities/Tools/ExportFormatter.php rename to app/Exports/ExportFormatter.php index beddfe8e6e0..4f78830b075 100644 --- a/app/Entities/Tools/ExportFormatter.php +++ b/app/Exports/ExportFormatter.php @@ -1,11 +1,13 @@ size / 1000000, 2); + return "{$mb} MB"; + } + + /** + * Get the URL to view/continue this import. + */ + public function getUrl(string $path = ''): string + { + $path = ltrim($path, '/'); + return url("/import/{$this->id}" . ($path ? '/' . $path : '')); + } + + public function logDescriptor(): string + { + return "({$this->id}) {$this->name}"; + } + + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function decodeMetadata(): ZipExportBook|ZipExportChapter|ZipExportPage|null + { + $metadataArray = json_decode($this->metadata, true); + return match ($this->type) { + 'book' => ZipExportBook::fromArray($metadataArray), + 'chapter' => ZipExportChapter::fromArray($metadataArray), + 'page' => ZipExportPage::fromArray($metadataArray), + default => null, + }; + } +} diff --git a/app/Exports/ImportRepo.php b/app/Exports/ImportRepo.php new file mode 100644 index 00000000000..f72386c47bc --- /dev/null +++ b/app/Exports/ImportRepo.php @@ -0,0 +1,137 @@ + + */ + public function getVisibleImports(): Collection + { + $query = Import::query(); + + if (!userCan('settings-manage')) { + $query->where('created_by', user()->id); + } + + return $query->get(); + } + + public function findVisible(int $id): Import + { + $query = Import::query(); + + if (!userCan('settings-manage')) { + $query->where('created_by', user()->id); + } + + return $query->findOrFail($id); + } + + /** + * @throws FileUploadException + * @throws ZipValidationException + * @throws ZipExportException + */ + public function storeFromUpload(UploadedFile $file): Import + { + $zipPath = $file->getRealPath(); + $reader = new ZipExportReader($zipPath); + + $errors = (new ZipExportValidator($reader))->validate(); + if ($errors) { + throw new ZipValidationException($errors); + } + + $exportModel = $reader->decodeDataToExportModel(); + + $import = new Import(); + $import->type = match (get_class($exportModel)) { + ZipExportPage::class => 'page', + ZipExportChapter::class => 'chapter', + ZipExportBook::class => 'book', + }; + + $import->name = $exportModel->name; + $import->created_by = user()->id; + $import->size = filesize($zipPath); + + $exportModel->metadataOnly(); + $import->metadata = json_encode($exportModel); + + $path = $this->storage->uploadFile( + $file, + 'uploads/files/imports/', + '', + 'zip' + ); + + $import->path = $path; + $import->save(); + + Activity::add(ActivityType::IMPORT_CREATE, $import); + + return $import; + } + + /** + * @throws ZipImportException + */ + public function runImport(Import $import, ?string $parent = null): Entity + { + $parentModel = null; + if ($import->type === 'page' || $import->type === 'chapter') { + $parentModel = $parent ? $this->entityQueries->findVisibleByStringIdentifier($parent) : null; + } + + DB::beginTransaction(); + try { + $model = $this->importer->run($import, $parentModel); + } catch (ZipImportException $e) { + DB::rollBack(); + $this->importer->revertStoredFiles(); + throw $e; + } + + DB::commit(); + $this->deleteImport($import); + Activity::add(ActivityType::IMPORT_RUN, $import); + + return $model; + } + + public function deleteImport(Import $import): void + { + $this->storage->delete($import->path); + $import->delete(); + + Activity::add(ActivityType::IMPORT_DELETE, $import); + } +} diff --git a/app/Entities/Tools/PdfGenerator.php b/app/Exports/PdfGenerator.php similarity index 99% rename from app/Entities/Tools/PdfGenerator.php rename to app/Exports/PdfGenerator.php index 79cd1b02f7f..0524e063fb2 100644 --- a/app/Entities/Tools/PdfGenerator.php +++ b/app/Exports/PdfGenerator.php @@ -1,10 +1,10 @@ link = $this->file = null; + } + + public static function fromModel(Attachment $model, ZipExportFiles $files): self + { + $instance = new self(); + $instance->id = $model->id; + $instance->name = $model->name; + + if ($model->external) { + $instance->link = $model->path; + } else { + $instance->file = $files->referenceForAttachment($model); + } + + return $instance; + } + + public static function fromModelArray(array $attachmentArray, ZipExportFiles $files): array + { + return array_values(array_map(function (Attachment $attachment) use ($files) { + return self::fromModel($attachment, $files); + }, $attachmentArray)); + } + + public static function validate(ZipValidationHelper $context, array $data): array + { + $rules = [ + 'id' => ['nullable', 'int', $context->uniqueIdRule('attachment')], + 'name' => ['required', 'string', 'min:1'], + 'link' => ['required_without:file', 'nullable', 'string'], + 'file' => ['required_without:link', 'nullable', 'string', $context->fileReferenceRule()], + ]; + + return $context->validateData($data, $rules); + } + + public static function fromArray(array $data): self + { + $model = new self(); + + $model->id = $data['id'] ?? null; + $model->name = $data['name']; + $model->link = $data['link'] ?? null; + $model->file = $data['file'] ?? null; + + return $model; + } +} diff --git a/app/Exports/ZipExports/Models/ZipExportBook.php b/app/Exports/ZipExports/Models/ZipExportBook.php new file mode 100644 index 00000000000..4f641d25bd7 --- /dev/null +++ b/app/Exports/ZipExports/Models/ZipExportBook.php @@ -0,0 +1,118 @@ +description_html = $this->cover = null; + + foreach ($this->chapters as $chapter) { + $chapter->metadataOnly(); + } + foreach ($this->pages as $page) { + $page->metadataOnly(); + } + foreach ($this->tags as $tag) { + $tag->metadataOnly(); + } + } + + public function children(): array + { + $children = [ + ...$this->pages, + ...$this->chapters, + ]; + + usort($children, function ($a, $b) { + return ($a->priority ?? 0) - ($b->priority ?? 0); + }); + + return $children; + } + + public static function fromModel(Book $model, ZipExportFiles $files): self + { + $instance = new self(); + $instance->id = $model->id; + $instance->name = $model->name; + $instance->description_html = $model->descriptionHtml(); + + if ($model->cover) { + $instance->cover = $files->referenceForImage($model->cover); + } + + $instance->tags = ZipExportTag::fromModelArray($model->tags()->get()->all()); + + $chapters = []; + $pages = []; + + $children = $model->getDirectVisibleChildren()->all(); + foreach ($children as $child) { + if ($child instanceof Chapter) { + $chapters[] = $child; + } else if ($child instanceof Page) { + $pages[] = $child; + } + } + + $instance->pages = ZipExportPage::fromModelArray($pages, $files); + $instance->chapters = ZipExportChapter::fromModelArray($chapters, $files); + + return $instance; + } + + public static function validate(ZipValidationHelper $context, array $data): array + { + $rules = [ + 'id' => ['nullable', 'int', $context->uniqueIdRule('book')], + 'name' => ['required', 'string', 'min:1'], + 'description_html' => ['nullable', 'string'], + 'cover' => ['nullable', 'string', $context->fileReferenceRule()], + 'tags' => ['array'], + 'pages' => ['array'], + 'chapters' => ['array'], + ]; + + $errors = $context->validateData($data, $rules); + $errors['tags'] = $context->validateRelations($data['tags'] ?? [], ZipExportTag::class); + $errors['pages'] = $context->validateRelations($data['pages'] ?? [], ZipExportPage::class); + $errors['chapters'] = $context->validateRelations($data['chapters'] ?? [], ZipExportChapter::class); + + return $errors; + } + + public static function fromArray(array $data): self + { + $model = new self(); + + $model->id = $data['id'] ?? null; + $model->name = $data['name']; + $model->description_html = $data['description_html'] ?? null; + $model->cover = $data['cover'] ?? null; + $model->tags = ZipExportTag::fromManyArray($data['tags'] ?? []); + $model->pages = ZipExportPage::fromManyArray($data['pages'] ?? []); + $model->chapters = ZipExportChapter::fromManyArray($data['chapters'] ?? []); + + return $model; + } +} diff --git a/app/Exports/ZipExports/Models/ZipExportChapter.php b/app/Exports/ZipExports/Models/ZipExportChapter.php new file mode 100644 index 00000000000..bf2dc78f8de --- /dev/null +++ b/app/Exports/ZipExports/Models/ZipExportChapter.php @@ -0,0 +1,95 @@ +description_html = null; + + foreach ($this->pages as $page) { + $page->metadataOnly(); + } + foreach ($this->tags as $tag) { + $tag->metadataOnly(); + } + } + + public function children(): array + { + return $this->pages; + } + + public static function fromModel(Chapter $model, ZipExportFiles $files): self + { + $instance = new self(); + $instance->id = $model->id; + $instance->name = $model->name; + $instance->description_html = $model->descriptionHtml(); + $instance->priority = $model->priority; + $instance->tags = ZipExportTag::fromModelArray($model->tags()->get()->all()); + + $pages = $model->getVisiblePages()->filter(fn (Page $page) => !$page->draft)->all(); + $instance->pages = ZipExportPage::fromModelArray($pages, $files); + + return $instance; + } + + /** + * @param Chapter[] $chapterArray + * @return self[] + */ + public static function fromModelArray(array $chapterArray, ZipExportFiles $files): array + { + return array_values(array_map(function (Chapter $chapter) use ($files) { + return self::fromModel($chapter, $files); + }, $chapterArray)); + } + + public static function validate(ZipValidationHelper $context, array $data): array + { + $rules = [ + 'id' => ['nullable', 'int', $context->uniqueIdRule('chapter')], + 'name' => ['required', 'string', 'min:1'], + 'description_html' => ['nullable', 'string'], + 'priority' => ['nullable', 'int'], + 'tags' => ['array'], + 'pages' => ['array'], + ]; + + $errors = $context->validateData($data, $rules); + $errors['tags'] = $context->validateRelations($data['tags'] ?? [], ZipExportTag::class); + $errors['pages'] = $context->validateRelations($data['pages'] ?? [], ZipExportPage::class); + + return $errors; + } + + public static function fromArray(array $data): self + { + $model = new self(); + + $model->id = $data['id'] ?? null; + $model->name = $data['name']; + $model->description_html = $data['description_html'] ?? null; + $model->priority = isset($data['priority']) ? intval($data['priority']) : null; + $model->tags = ZipExportTag::fromManyArray($data['tags'] ?? []); + $model->pages = ZipExportPage::fromManyArray($data['pages'] ?? []); + + return $model; + } +} diff --git a/app/Exports/ZipExports/Models/ZipExportImage.php b/app/Exports/ZipExports/Models/ZipExportImage.php new file mode 100644 index 00000000000..e0e7d11986d --- /dev/null +++ b/app/Exports/ZipExports/Models/ZipExportImage.php @@ -0,0 +1,57 @@ +id = $model->id; + $instance->name = $model->name; + $instance->type = $model->type; + $instance->file = $files->referenceForImage($model); + + return $instance; + } + + public function metadataOnly(): void + { + // + } + + public static function validate(ZipValidationHelper $context, array $data): array + { + $acceptedImageTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp']; + $rules = [ + 'id' => ['nullable', 'int', $context->uniqueIdRule('image')], + 'name' => ['required', 'string', 'min:1'], + 'file' => ['required', 'string', $context->fileReferenceRule($acceptedImageTypes)], + 'type' => ['required', 'string', Rule::in(['gallery', 'drawio'])], + ]; + + return $context->validateData($data, $rules); + } + + public static function fromArray(array $data): self + { + $model = new self(); + + $model->id = $data['id'] ?? null; + $model->name = $data['name']; + $model->file = $data['file']; + $model->type = $data['type']; + + return $model; + } +} diff --git a/app/Exports/ZipExports/Models/ZipExportModel.php b/app/Exports/ZipExports/Models/ZipExportModel.php new file mode 100644 index 00000000000..d3a8c35674b --- /dev/null +++ b/app/Exports/ZipExports/Models/ZipExportModel.php @@ -0,0 +1,57 @@ +__invoke($this); + return array_filter($publicProps, fn ($value) => $value !== null); + } + + /** + * Validate the given array of data intended for this model. + * Return an array of validation errors messages. + * Child items can be considered in the validation result by returning a keyed + * item in the array for its own validation messages. + */ + abstract public static function validate(ZipValidationHelper $context, array $data): array; + + /** + * Decode the array of data into this export model. + */ + abstract public static function fromArray(array $data): self; + + /** + * Decode an array of array data into an array of export models. + * @param array[] $data + * @return self[] + */ + public static function fromManyArray(array $data): array + { + $results = []; + foreach ($data as $item) { + $results[] = static::fromArray($item); + } + return $results; + } + + /** + * Remove additional content in this model to reduce it down + * to just essential id/name values for identification. + * + * The result of this may be something that does not pass validation, but is + * simple for the purpose of creating a contents. + */ + abstract public function metadataOnly(): void; +} diff --git a/app/Exports/ZipExports/Models/ZipExportPage.php b/app/Exports/ZipExports/Models/ZipExportPage.php new file mode 100644 index 00000000000..097443df02b --- /dev/null +++ b/app/Exports/ZipExports/Models/ZipExportPage.php @@ -0,0 +1,104 @@ +html = $this->markdown = null; + + foreach ($this->attachments as $attachment) { + $attachment->metadataOnly(); + } + foreach ($this->images as $image) { + $image->metadataOnly(); + } + foreach ($this->tags as $tag) { + $tag->metadataOnly(); + } + } + + public static function fromModel(Page $model, ZipExportFiles $files): self + { + $instance = new self(); + $instance->id = $model->id; + $instance->name = $model->name; + $instance->html = (new PageContent($model))->render(); + $instance->priority = $model->priority; + + if (!empty($model->markdown)) { + $instance->markdown = $model->markdown; + } + + $instance->tags = ZipExportTag::fromModelArray($model->tags()->get()->all()); + $instance->attachments = ZipExportAttachment::fromModelArray($model->attachments()->get()->all(), $files); + + return $instance; + } + + /** + * @param Page[] $pageArray + * @return self[] + */ + public static function fromModelArray(array $pageArray, ZipExportFiles $files): array + { + return array_values(array_map(function (Page $page) use ($files) { + return self::fromModel($page, $files); + }, $pageArray)); + } + + public static function validate(ZipValidationHelper $context, array $data): array + { + $rules = [ + 'id' => ['nullable', 'int', $context->uniqueIdRule('page')], + 'name' => ['required', 'string', 'min:1'], + 'html' => ['nullable', 'string'], + 'markdown' => ['nullable', 'string'], + 'priority' => ['nullable', 'int'], + 'attachments' => ['array'], + 'images' => ['array'], + 'tags' => ['array'], + ]; + + $errors = $context->validateData($data, $rules); + $errors['attachments'] = $context->validateRelations($data['attachments'] ?? [], ZipExportAttachment::class); + $errors['images'] = $context->validateRelations($data['images'] ?? [], ZipExportImage::class); + $errors['tags'] = $context->validateRelations($data['tags'] ?? [], ZipExportTag::class); + + return $errors; + } + + public static function fromArray(array $data): self + { + $model = new self(); + + $model->id = $data['id'] ?? null; + $model->name = $data['name']; + $model->html = $data['html'] ?? null; + $model->markdown = $data['markdown'] ?? null; + $model->priority = isset($data['priority']) ? intval($data['priority']) : null; + $model->attachments = ZipExportAttachment::fromManyArray($data['attachments'] ?? []); + $model->images = ZipExportImage::fromManyArray($data['images'] ?? []); + $model->tags = ZipExportTag::fromManyArray($data['tags'] ?? []); + + return $model; + } +} diff --git a/app/Exports/ZipExports/Models/ZipExportTag.php b/app/Exports/ZipExports/Models/ZipExportTag.php new file mode 100644 index 00000000000..6b4720fca7e --- /dev/null +++ b/app/Exports/ZipExports/Models/ZipExportTag.php @@ -0,0 +1,51 @@ +value = null; + } + + public static function fromModel(Tag $model): self + { + $instance = new self(); + $instance->name = $model->name; + $instance->value = $model->value; + + return $instance; + } + + public static function fromModelArray(array $tagArray): array + { + return array_values(array_map(self::fromModel(...), $tagArray)); + } + + public static function validate(ZipValidationHelper $context, array $data): array + { + $rules = [ + 'name' => ['required', 'string', 'min:1'], + 'value' => ['nullable', 'string'], + ]; + + return $context->validateData($data, $rules); + } + + public static function fromArray(array $data): self + { + $model = new self(); + + $model->name = $data['name']; + $model->value = $data['value'] ?? null; + + return $model; + } +} diff --git a/app/Exports/ZipExports/ZipExportBuilder.php b/app/Exports/ZipExports/ZipExportBuilder.php new file mode 100644 index 00000000000..4c5c638f591 --- /dev/null +++ b/app/Exports/ZipExports/ZipExportBuilder.php @@ -0,0 +1,100 @@ +files); + $this->data['page'] = $exportPage; + + $this->references->addPage($exportPage); + + return $this->build(); + } + + /** + * @throws ZipExportException + */ + public function buildForChapter(Chapter $chapter): string + { + $exportChapter = ZipExportChapter::fromModel($chapter, $this->files); + $this->data['chapter'] = $exportChapter; + + $this->references->addChapter($exportChapter); + + return $this->build(); + } + + /** + * @throws ZipExportException + */ + public function buildForBook(Book $book): string + { + $exportBook = ZipExportBook::fromModel($book, $this->files); + $this->data['book'] = $exportBook; + + $this->references->addBook($exportBook); + + return $this->build(); + } + + /** + * @throws ZipExportException + */ + protected function build(): string + { + $this->references->buildReferences($this->files); + + $this->data['exported_at'] = date(DATE_ATOM); + $this->data['instance'] = [ + 'id' => setting('instance-id', ''), + 'version' => trim(file_get_contents(base_path('version'))), + ]; + + $zipFile = tempnam(sys_get_temp_dir(), 'bszip-'); + $zip = new ZipArchive(); + $opened = $zip->open($zipFile, ZipArchive::CREATE); + if ($opened !== true) { + throw new ZipExportException('Failed to create zip file for export.'); + } + + $zip->addFromString('data.json', json_encode($this->data)); + $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; + } +} diff --git a/app/Exports/ZipExports/ZipExportFiles.php b/app/Exports/ZipExports/ZipExportFiles.php new file mode 100644 index 00000000000..8f0a6bd4031 --- /dev/null +++ b/app/Exports/ZipExports/ZipExportFiles.php @@ -0,0 +1,107 @@ + + */ + protected array $attachmentRefsById = []; + + /** + * References for images by image ID. + * @var array + */ + protected array $imageRefsById = []; + + public function __construct( + protected AttachmentService $attachmentService, + protected ImageService $imageService, + ) { + } + + /** + * 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]; + } + + $existingFiles = $this->getAllFileNames(); + do { + $fileName = Str::random(20) . '.' . $attachment->extension; + } while (in_array($fileName, $existingFiles)); + + $this->attachmentRefsById[$attachment->id] = $fileName; + + return $fileName; + } + + /** + * Gain a reference to the given image instance. + * This is expected to be an image that the user has visibility of, + * no permission/access checks are performed here. + */ + public function referenceForImage(Image $image): string + { + if (isset($this->imageRefsById[$image->id])) { + return $this->imageRefsById[$image->id]; + } + + $existingFiles = $this->getAllFileNames(); + $extension = pathinfo($image->path, PATHINFO_EXTENSION); + do { + $fileName = Str::random(20) . '.' . $extension; + } while (in_array($fileName, $existingFiles)); + + $this->imageRefsById[$image->id] = $fileName; + + return $fileName; + } + + protected function getAllFileNames(): array + { + return array_merge( + array_values($this->attachmentRefsById), + array_values($this->imageRefsById), + ); + } + + /** + * 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); + } + + foreach ($this->imageRefsById as $imageId => $ref) { + $image = Image::query()->find($imageId); + $stream = $this->imageService->getImageStream($image); + $tmpFile = tempnam(sys_get_temp_dir(), 'bszipimage-'); + $tmpFileStream = fopen($tmpFile, 'w'); + stream_copy_to_stream($stream, $tmpFileStream); + $callback($tmpFile, $ref); + } + } +} diff --git a/app/Exports/ZipExports/ZipExportReader.php b/app/Exports/ZipExports/ZipExportReader.php new file mode 100644 index 00000000000..c3d5c23cfec --- /dev/null +++ b/app/Exports/ZipExports/ZipExportReader.php @@ -0,0 +1,111 @@ +zip = new ZipArchive(); + } + + /** + * @throws ZipExportException + */ + protected function open(): void + { + if ($this->open) { + return; + } + + // Validate file exists + if (!file_exists($this->zipPath) || !is_readable($this->zipPath)) { + throw new ZipExportException(trans('errors.import_zip_cant_read')); + } + + // Validate file is valid zip + $opened = $this->zip->open($this->zipPath, ZipArchive::RDONLY); + if ($opened !== true) { + throw new ZipExportException(trans('errors.import_zip_cant_read')); + } + + $this->open = true; + } + + public function close(): void + { + if ($this->open) { + $this->zip->close(); + $this->open = false; + } + } + + /** + * @throws ZipExportException + */ + public function readData(): array + { + $this->open(); + + // Validate json data exists, including metadata + $jsonData = $this->zip->getFromName('data.json') ?: ''; + $importData = json_decode($jsonData, true); + if (!$importData) { + throw new ZipExportException(trans('errors.import_zip_cant_decode_data')); + } + + return $importData; + } + + public function fileExists(string $fileName): bool + { + return $this->zip->statName("files/{$fileName}") !== false; + } + + /** + * @return false|resource + */ + public function streamFile(string $fileName) + { + return $this->zip->getStream("files/{$fileName}"); + } + + /** + * Sniff the mime type from the file of given name. + */ + public function sniffFileMime(string $fileName): string + { + $stream = $this->streamFile($fileName); + $sniffContent = fread($stream, 2000); + + return (new WebSafeMimeSniffer())->sniff($sniffContent); + } + + /** + * @throws ZipExportException + */ + public function decodeDataToExportModel(): ZipExportBook|ZipExportChapter|ZipExportPage + { + $data = $this->readData(); + if (isset($data['book'])) { + return ZipExportBook::fromArray($data['book']); + } else if (isset($data['chapter'])) { + return ZipExportChapter::fromArray($data['chapter']); + } else if (isset($data['page'])) { + return ZipExportPage::fromArray($data['page']); + } + + throw new ZipExportException("Could not identify content in ZIP file data."); + } +} diff --git a/app/Exports/ZipExports/ZipExportReferences.php b/app/Exports/ZipExports/ZipExportReferences.php new file mode 100644 index 00000000000..bf5e02133ef --- /dev/null +++ b/app/Exports/ZipExports/ZipExportReferences.php @@ -0,0 +1,159 @@ +id) { + $this->pages[$page->id] = $page; + } + + foreach ($page->attachments as $attachment) { + if ($attachment->id) { + $this->attachments[$attachment->id] = $attachment; + } + } + } + + public function addChapter(ZipExportChapter $chapter): void + { + if ($chapter->id) { + $this->chapters[$chapter->id] = $chapter; + } + + foreach ($chapter->pages as $page) { + $this->addPage($page); + } + } + + public function addBook(ZipExportBook $book): void + { + if ($book->id) { + $this->books[$book->id] = $book; + } + + foreach ($book->pages as $page) { + $this->addPage($page); + } + + foreach ($book->chapters as $chapter) { + $this->addChapter($chapter); + } + } + + public function buildReferences(ZipExportFiles $files): void + { + $createHandler = function (ZipExportModel $zipModel) use ($files) { + return function (Model $model) use ($files, $zipModel) { + return $this->handleModelReference($model, $zipModel, $files); + }; + }; + + // Parse page content first + foreach ($this->pages as $page) { + $handler = $createHandler($page); + $page->html = $this->parser->parseLinks($page->html ?? '', $handler); + if ($page->markdown) { + $page->markdown = $this->parser->parseLinks($page->markdown, $handler); + } + } + + // Parse chapter description HTML + foreach ($this->chapters as $chapter) { + if ($chapter->description_html) { + $handler = $createHandler($chapter); + $chapter->description_html = $this->parser->parseLinks($chapter->description_html, $handler); + } + } + + // Parse book description HTML + foreach ($this->books as $book) { + if ($book->description_html) { + $handler = $createHandler($book); + $book->description_html = $this->parser->parseLinks($book->description_html, $handler); + } + } + } + + protected function handleModelReference(Model $model, ZipExportModel $exportModel, ZipExportFiles $files): ?string + { + // Handle attachment references + // No permission check needed here since they would only already exist in this + // reference context if already allowed via their entity access. + if ($model instanceof Attachment) { + if (isset($this->attachments[$model->id])) { + return "[[bsexport:attachment:{$model->id}]]"; + } + return null; + } + + // Handle image references + if ($model instanceof Image) { + // Only handle gallery and drawio images + if ($model->type !== 'gallery' && $model->type !== 'drawio') { + return null; + } + + // Handle simple links outside of page content + if (!($exportModel instanceof ZipExportPage) && isset($this->images[$model->id])) { + return "[[bsexport:image:{$model->id}]]"; + } + + // Find and include images if in visibility + $page = $model->getPage(); + if ($page && userCan('view', $page)) { + if (!isset($this->images[$model->id])) { + $exportImage = ZipExportImage::fromModel($model, $files); + $this->images[$model->id] = $exportImage; + $exportModel->images[] = $exportImage; + } + return "[[bsexport:image:{$model->id}]]"; + } + return null; + } + + // Handle entity references + if ($model instanceof Book && isset($this->books[$model->id])) { + return "[[bsexport:book:{$model->id}]]"; + } else if ($model instanceof Chapter && isset($this->chapters[$model->id])) { + return "[[bsexport:chapter:{$model->id}]]"; + } else if ($model instanceof Page && isset($this->pages[$model->id])) { + return "[[bsexport:page:{$model->id}]]"; + } + + return null; + } +} diff --git a/app/Exports/ZipExports/ZipExportValidator.php b/app/Exports/ZipExports/ZipExportValidator.php new file mode 100644 index 00000000000..889804f2013 --- /dev/null +++ b/app/Exports/ZipExports/ZipExportValidator.php @@ -0,0 +1,57 @@ +reader->readData(); + } catch (ZipExportException $exception) { + return ['format' => $exception->getMessage()]; + } + + $helper = new ZipValidationHelper($this->reader); + + if (isset($importData['book'])) { + $modelErrors = ZipExportBook::validate($helper, $importData['book']); + $keyPrefix = 'book'; + } else if (isset($importData['chapter'])) { + $modelErrors = ZipExportChapter::validate($helper, $importData['chapter']); + $keyPrefix = 'chapter'; + } else if (isset($importData['page'])) { + $modelErrors = ZipExportPage::validate($helper, $importData['page']); + $keyPrefix = 'page'; + } else { + return ['format' => trans('errors.import_zip_no_data')]; + } + + return $this->flattenModelErrors($modelErrors, $keyPrefix); + } + + protected function flattenModelErrors(array $errors, string $keyPrefix): array + { + $flattened = []; + + foreach ($errors as $key => $error) { + if (is_array($error)) { + $flattened = array_merge($flattened, $this->flattenModelErrors($error, $keyPrefix . '.' . $key)); + } else { + $flattened[$keyPrefix . '.' . $key] = $error; + } + } + + return $flattened; + } +} diff --git a/app/Exports/ZipExports/ZipFileReferenceRule.php b/app/Exports/ZipExports/ZipFileReferenceRule.php new file mode 100644 index 00000000000..90e78c060b0 --- /dev/null +++ b/app/Exports/ZipExports/ZipFileReferenceRule.php @@ -0,0 +1,37 @@ +context->zipReader->fileExists($value)) { + $fail('validation.zip_file')->translate(); + } + + if (!empty($this->acceptedMimes)) { + $fileMime = $this->context->zipReader->sniffFileMime($value); + if (!in_array($fileMime, $this->acceptedMimes)) { + $fail('validation.zip_file_mime')->translate([ + 'attribute' => $attribute, + 'validTypes' => implode(',', $this->acceptedMimes), + 'foundType' => $fileMime + ]); + } + } + } +} diff --git a/app/Exports/ZipExports/ZipImportReferences.php b/app/Exports/ZipExports/ZipImportReferences.php new file mode 100644 index 00000000000..da0581df6f5 --- /dev/null +++ b/app/Exports/ZipExports/ZipImportReferences.php @@ -0,0 +1,161 @@ + */ + protected array $referenceMap = []; + + /** @var array */ + protected array $zipExportPageMap = []; + /** @var array */ + protected array $zipExportChapterMap = []; + /** @var array */ + protected array $zipExportBookMap = []; + + public function __construct( + protected ZipReferenceParser $parser, + protected BaseRepo $baseRepo, + protected PageRepo $pageRepo, + protected ImageResizer $imageResizer, + ) { + } + + protected function addReference(string $type, Model $model, ?int $importId): void + { + if ($importId) { + $key = $type . ':' . $importId; + $this->referenceMap[$key] = $model; + } + } + + public function addPage(Page $page, ZipExportPage $exportPage): void + { + $this->pages[] = $page; + $this->zipExportPageMap[$page->id] = $exportPage; + $this->addReference('page', $page, $exportPage->id); + } + + public function addChapter(Chapter $chapter, ZipExportChapter $exportChapter): void + { + $this->chapters[] = $chapter; + $this->zipExportChapterMap[$chapter->id] = $exportChapter; + $this->addReference('chapter', $chapter, $exportChapter->id); + } + + public function addBook(Book $book, ZipExportBook $exportBook): void + { + $this->books[] = $book; + $this->zipExportBookMap[$book->id] = $exportBook; + $this->addReference('book', $book, $exportBook->id); + } + + public function addAttachment(Attachment $attachment, ?int $importId): void + { + $this->attachments[] = $attachment; + $this->addReference('attachment', $attachment, $importId); + } + + public function addImage(Image $image, ?int $importId): void + { + $this->images[] = $image; + $this->addReference('image', $image, $importId); + } + + protected function handleReference(string $type, int $id): ?string + { + $key = $type . ':' . $id; + $model = $this->referenceMap[$key] ?? null; + if ($model instanceof Entity) { + return $model->getUrl(); + } else if ($model instanceof Image) { + if ($model->type === 'gallery') { + $this->imageResizer->loadGalleryThumbnailsForImage($model, false); + return $model->thumbs['display'] ?? $model->url; + } + + return $model->url; + } else if ($model instanceof Attachment) { + return $model->getUrl(false); + } + + return null; + } + + public function replaceReferences(): void + { + foreach ($this->books as $book) { + $exportBook = $this->zipExportBookMap[$book->id]; + $content = $exportBook->description_html ?? ''; + $parsed = $this->parser->parseReferences($content, $this->handleReference(...)); + + $this->baseRepo->update($book, [ + 'description_html' => $parsed, + ]); + } + + foreach ($this->chapters as $chapter) { + $exportChapter = $this->zipExportChapterMap[$chapter->id]; + $content = $exportChapter->description_html ?? ''; + $parsed = $this->parser->parseReferences($content, $this->handleReference(...)); + + $this->baseRepo->update($chapter, [ + 'description_html' => $parsed, + ]); + } + + foreach ($this->pages as $page) { + $exportPage = $this->zipExportPageMap[$page->id]; + $contentType = $exportPage->markdown ? 'markdown' : 'html'; + $content = $exportPage->markdown ?: ($exportPage->html ?: ''); + $parsed = $this->parser->parseReferences($content, $this->handleReference(...)); + + $this->pageRepo->setContentFromInput($page, [ + $contentType => $parsed, + ]); + } + } + + + /** + * @return Image[] + */ + public function images(): array + { + return $this->images; + } + + /** + * @return Attachment[] + */ + public function attachments(): array + { + return $this->attachments; + } +} diff --git a/app/Exports/ZipExports/ZipImportRunner.php b/app/Exports/ZipExports/ZipImportRunner.php new file mode 100644 index 00000000000..d25a1621f6e --- /dev/null +++ b/app/Exports/ZipExports/ZipImportRunner.php @@ -0,0 +1,364 @@ +getZipPath($import); + $reader = new ZipExportReader($zipPath); + + $errors = (new ZipExportValidator($reader))->validate(); + if ($errors) { + throw new ZipImportException([ + trans('errors.import_validation_failed'), + ...$errors, + ]); + } + + try { + $exportModel = $reader->decodeDataToExportModel(); + } catch (ZipExportException $e) { + throw new ZipImportException([$e->getMessage()]); + } + + // Validate parent type + if ($exportModel instanceof ZipExportBook && ($parent !== null)) { + throw new ZipImportException(["Must not have a parent set for a Book import."]); + } else if ($exportModel instanceof ZipExportChapter && !($parent instanceof Book)) { + throw new ZipImportException(["Parent book required for chapter import."]); + } else if ($exportModel instanceof ZipExportPage && !($parent instanceof Book || $parent instanceof Chapter)) { + throw new ZipImportException(["Parent book or chapter required for page import."]); + } + + $this->ensurePermissionsPermitImport($exportModel, $parent); + + if ($exportModel instanceof ZipExportBook) { + $entity = $this->importBook($exportModel, $reader); + } else if ($exportModel instanceof ZipExportChapter) { + $entity = $this->importChapter($exportModel, $parent, $reader); + } else if ($exportModel instanceof ZipExportPage) { + $entity = $this->importPage($exportModel, $parent, $reader); + } else { + throw new ZipImportException(['No importable data found in import data.']); + } + + $this->references->replaceReferences(); + + $reader->close(); + $this->cleanup(); + + return $entity; + } + + /** + * Revert any files which have been stored during this import process. + * Considers files only, and avoids the database under the + * assumption that the database may already have been + * reverted as part of a transaction rollback. + */ + public function revertStoredFiles(): void + { + foreach ($this->references->images() as $image) { + $this->imageService->destroyFileAtPath($image->type, $image->path); + } + + foreach ($this->references->attachments() as $attachment) { + if (!$attachment->external) { + $this->attachmentService->deleteFileInStorage($attachment); + } + } + + $this->cleanup(); + } + + protected function cleanup(): void + { + foreach ($this->tempFilesToCleanup as $file) { + unlink($file); + } + + $this->tempFilesToCleanup = []; + } + + protected function importBook(ZipExportBook $exportBook, ZipExportReader $reader): Book + { + $book = $this->bookRepo->create([ + 'name' => $exportBook->name, + 'description_html' => $exportBook->description_html ?? '', + 'image' => $exportBook->cover ? $this->zipFileToUploadedFile($exportBook->cover, $reader) : null, + 'tags' => $this->exportTagsToInputArray($exportBook->tags ?? []), + ]); + + if ($book->cover) { + $this->references->addImage($book->cover, null); + } + + $children = [ + ...$exportBook->chapters, + ...$exportBook->pages, + ]; + + usort($children, function (ZipExportPage|ZipExportChapter $a, ZipExportPage|ZipExportChapter $b) { + return ($a->priority ?? 0) - ($b->priority ?? 0); + }); + + foreach ($children as $child) { + if ($child instanceof ZipExportChapter) { + $this->importChapter($child, $book, $reader); + } else if ($child instanceof ZipExportPage) { + $this->importPage($child, $book, $reader); + } + } + + $this->references->addBook($book, $exportBook); + + return $book; + } + + protected function importChapter(ZipExportChapter $exportChapter, Book $parent, ZipExportReader $reader): Chapter + { + $chapter = $this->chapterRepo->create([ + 'name' => $exportChapter->name, + 'description_html' => $exportChapter->description_html ?? '', + 'tags' => $this->exportTagsToInputArray($exportChapter->tags ?? []), + ], $parent); + + $exportPages = $exportChapter->pages; + usort($exportPages, function (ZipExportPage $a, ZipExportPage $b) { + return ($a->priority ?? 0) - ($b->priority ?? 0); + }); + + foreach ($exportPages as $exportPage) { + $this->importPage($exportPage, $chapter, $reader); + } + + $this->references->addChapter($chapter, $exportChapter); + + return $chapter; + } + + protected function importPage(ZipExportPage $exportPage, Book|Chapter $parent, ZipExportReader $reader): Page + { + $page = $this->pageRepo->getNewDraftPage($parent); + + foreach ($exportPage->attachments as $exportAttachment) { + $this->importAttachment($exportAttachment, $page, $reader); + } + + foreach ($exportPage->images as $exportImage) { + $this->importImage($exportImage, $page, $reader); + } + + $this->pageRepo->publishDraft($page, [ + 'name' => $exportPage->name, + 'markdown' => $exportPage->markdown, + 'html' => $exportPage->html, + 'tags' => $this->exportTagsToInputArray($exportPage->tags ?? []), + ]); + + $this->references->addPage($page, $exportPage); + + return $page; + } + + protected function importAttachment(ZipExportAttachment $exportAttachment, Page $page, ZipExportReader $reader): Attachment + { + if ($exportAttachment->file) { + $file = $this->zipFileToUploadedFile($exportAttachment->file, $reader); + $attachment = $this->attachmentService->saveNewUpload($file, $page->id); + $attachment->name = $exportAttachment->name; + $attachment->save(); + } else { + $attachment = $this->attachmentService->saveNewFromLink( + $exportAttachment->name, + $exportAttachment->link ?? '', + $page->id, + ); + } + + $this->references->addAttachment($attachment, $exportAttachment->id); + + return $attachment; + } + + protected function importImage(ZipExportImage $exportImage, Page $page, ZipExportReader $reader): Image + { + $mime = $reader->sniffFileMime($exportImage->file); + $extension = explode('/', $mime)[1]; + + $file = $this->zipFileToUploadedFile($exportImage->file, $reader); + $image = $this->imageService->saveNewFromUpload( + $file, + $exportImage->type, + $page->id, + null, + null, + true, + $exportImage->name . '.' . $extension, + ); + + $image->name = $exportImage->name; + $image->save(); + + $this->references->addImage($image, $exportImage->id); + + return $image; + } + + protected function exportTagsToInputArray(array $exportTags): array + { + $tags = []; + + /** @var ZipExportTag $tag */ + foreach ($exportTags as $tag) { + $tags[] = ['name' => $tag->name, 'value' => $tag->value ?? '']; + } + + return $tags; + } + + protected function zipFileToUploadedFile(string $fileName, ZipExportReader $reader): UploadedFile + { + $tempPath = tempnam(sys_get_temp_dir(), 'bszipextract'); + $fileStream = $reader->streamFile($fileName); + $tempStream = fopen($tempPath, 'wb'); + stream_copy_to_stream($fileStream, $tempStream); + fclose($tempStream); + + $this->tempFilesToCleanup[] = $tempPath; + + return new UploadedFile($tempPath, $fileName); + } + + /** + * @throws ZipImportException + */ + protected function ensurePermissionsPermitImport(ZipExportPage|ZipExportChapter|ZipExportBook $exportModel, Book|Chapter|null $parent = null): void + { + $errors = []; + + $chapters = []; + $pages = []; + $images = []; + $attachments = []; + + if ($exportModel instanceof ZipExportBook) { + if (!userCan('book-create-all')) { + $errors[] = trans('errors.import_perms_books'); + } + array_push($pages, ...$exportModel->pages); + array_push($chapters, ...$exportModel->chapters); + } else if ($exportModel instanceof ZipExportChapter) { + $chapters[] = $exportModel; + } else if ($exportModel instanceof ZipExportPage) { + $pages[] = $exportModel; + } + + foreach ($chapters as $chapter) { + array_push($pages, ...$chapter->pages); + } + + if (count($chapters) > 0) { + $permission = 'chapter-create' . ($parent ? '' : '-all'); + if (!userCan($permission, $parent)) { + $errors[] = trans('errors.import_perms_chapters'); + } + } + + foreach ($pages as $page) { + array_push($attachments, ...$page->attachments); + array_push($images, ...$page->images); + } + + if (count($pages) > 0) { + if ($parent) { + if (!userCan('page-create', $parent)) { + $errors[] = trans('errors.import_perms_pages'); + } + } else { + $hasPermission = userCan('page-create-all') || userCan('page-create-own'); + if (!$hasPermission) { + $errors[] = trans('errors.import_perms_pages'); + } + } + } + + if (count($images) > 0) { + if (!userCan('image-create-all')) { + $errors[] = trans('errors.import_perms_images'); + } + } + + if (count($attachments) > 0) { + if (!userCan('attachment-create-all')) { + $errors[] = trans('errors.import_perms_attachments'); + } + } + + if (count($errors)) { + throw new ZipImportException($errors); + } + } + + protected function getZipPath(Import $import): string + { + if (!$this->storage->isRemote()) { + return $this->storage->getSystemPath($import->path); + } + + $tempFilePath = tempnam(sys_get_temp_dir(), 'bszip-import-'); + $tempFile = fopen($tempFilePath, 'wb'); + $stream = $this->storage->getReadStream($import->path); + stream_copy_to_stream($stream, $tempFile); + fclose($tempFile); + + $this->tempFilesToCleanup[] = $tempFilePath; + + return $tempFilePath; + } +} diff --git a/app/Exports/ZipExports/ZipReferenceParser.php b/app/Exports/ZipExports/ZipReferenceParser.php new file mode 100644 index 00000000000..a6560e3f289 --- /dev/null +++ b/app/Exports/ZipExports/ZipReferenceParser.php @@ -0,0 +1,140 @@ +getLinkRegex(); + $matches = []; + preg_match_all($linkRegex, $content, $matches); + + if (count($matches) < 2) { + return $content; + } + + foreach ($matches[1] as $link) { + $model = $this->linkToModel($link); + if ($model) { + $result = $handler($model); + if ($result !== null) { + $content = str_replace($link, $result, $content); + } + } + } + + return $content; + } + + /** + * Parse and replace references in the given content. + * Calls the handler for each reference detected and replaces the link + * with the handler return value if provided. + * Returns the resulting content string with references replaced. + * @param callable(string $type, int $id):(string|null) $handler + */ + public function parseReferences(string $content, callable $handler): string + { + $referenceRegex = '/\[\[bsexport:([a-z]+):(\d+)]]/'; + $matches = []; + preg_match_all($referenceRegex, $content, $matches); + + if (count($matches) < 3) { + return $content; + } + + for ($i = 0; $i < count($matches[0]); $i++) { + $referenceText = $matches[0][$i]; + $type = strtolower($matches[1][$i]); + $id = intval($matches[2][$i]); + $result = $handler($type, $id); + if ($result !== null) { + $content = str_replace($referenceText, $result, $content); + } + } + + return $content; + } + + + /** + * Attempt to resolve the given link to a model using the instance model resolvers. + */ + protected function linkToModel(string $link): ?Model + { + foreach ($this->getModelResolvers() as $resolver) { + $model = $resolver->resolve($link); + if (!is_null($model)) { + return $model; + } + } + + return null; + } + + protected function getModelResolvers(): array + { + if (isset($this->modelResolvers)) { + return $this->modelResolvers; + } + + $this->modelResolvers = [ + new PagePermalinkModelResolver($this->queries->pages), + new PageLinkModelResolver($this->queries->pages), + new ChapterLinkModelResolver($this->queries->chapters), + new BookLinkModelResolver($this->queries->books), + new ImageModelResolver(), + new AttachmentModelResolver(), + ]; + + return $this->modelResolvers; + } + + /** + * Build the regex to identify links we should handle in content. + */ + protected function getLinkRegex(): string + { + $urls = [rtrim(url('/'), '/')]; + $imageUrl = rtrim(ImageStorage::getPublicUrl('/'), '/'); + if ($urls[0] !== $imageUrl) { + $urls[] = $imageUrl; + } + + + $urlBaseRegex = implode('|', array_map(function ($url) { + return preg_quote($url, '/'); + }, $urls)); + + return "/(({$urlBaseRegex}).*?)[\\t\\n\\f>\"'=?#()]/"; + } +} diff --git a/app/Exports/ZipExports/ZipUniqueIdRule.php b/app/Exports/ZipExports/ZipUniqueIdRule.php new file mode 100644 index 00000000000..ea2b2539296 --- /dev/null +++ b/app/Exports/ZipExports/ZipUniqueIdRule.php @@ -0,0 +1,26 @@ +context->hasIdBeenUsed($this->modelType, $value)) { + $fail('validation.zip_unique')->translate(['attribute' => $attribute]); + } + } +} diff --git a/app/Exports/ZipExports/ZipValidationHelper.php b/app/Exports/ZipExports/ZipValidationHelper.php new file mode 100644 index 00000000000..fd9cd784472 --- /dev/null +++ b/app/Exports/ZipExports/ZipValidationHelper.php @@ -0,0 +1,77 @@ +:". Example: "book:2") + * which we can use to check uniqueness. + * @var array + */ + protected array $validatedIds = []; + + public function __construct( + public ZipExportReader $zipReader, + ) { + $this->validationFactory = app(Factory::class); + } + + public function validateData(array $data, array $rules): array + { + $messages = $this->validationFactory->make($data, $rules)->errors()->messages(); + + foreach ($messages as $key => $message) { + $messages[$key] = implode("\n", $message); + } + + return $messages; + } + + public function fileReferenceRule(array $acceptedMimes = []): ZipFileReferenceRule + { + return new ZipFileReferenceRule($this, $acceptedMimes); + } + + public function uniqueIdRule(string $type): ZipUniqueIdRule + { + return new ZipUniqueIdRule($this, $type); + } + + public function hasIdBeenUsed(string $type, mixed $id): bool + { + $key = $type . ':' . $id; + if (isset($this->validatedIds[$key])) { + return true; + } + + $this->validatedIds[$key] = true; + + return false; + } + + /** + * Validate an array of relation data arrays that are expected + * to be for the given ZipExportModel. + * @param class-string $model + */ + public function validateRelations(array $relations, string $model): array + { + $results = []; + + foreach ($relations as $key => $relationData) { + if (is_array($relationData)) { + $results[$key] = $model::validate($this, $relationData); + } else { + $results[$key] = [trans('validation.zip_model_expected', ['type' => gettype($relationData)])]; + } + } + + return $results; + } +} diff --git a/app/Http/Controller.php b/app/Http/Controller.php index 8facf5dab3c..090cf523ad2 100644 --- a/app/Http/Controller.php +++ b/app/Http/Controller.php @@ -152,10 +152,8 @@ protected function showErrorNotification(string $message): void /** * Log an activity in the system. - * - * @param string|Loggable $detail */ - protected function logActivity(string $type, $detail = ''): void + protected function logActivity(string $type, string|Loggable $detail = ''): void { Activity::add($type, $detail); } diff --git a/app/References/ModelResolvers/AttachmentModelResolver.php b/app/References/ModelResolvers/AttachmentModelResolver.php new file mode 100644 index 00000000000..e870d515bbe --- /dev/null +++ b/app/References/ModelResolvers/AttachmentModelResolver.php @@ -0,0 +1,22 @@ +find($id); + } +} diff --git a/app/References/ModelResolvers/ImageModelResolver.php b/app/References/ModelResolvers/ImageModelResolver.php new file mode 100644 index 00000000000..2c6c9fecd74 --- /dev/null +++ b/app/References/ModelResolvers/ImageModelResolver.php @@ -0,0 +1,58 @@ +getUrlPattern(); + $matches = []; + $match = preg_match($pattern, $link, $matches); + if (!$match) { + return null; + } + + $path = $matches[2]; + + // Strip thumbnail element from path if existing + $originalPathSplit = array_filter(explode('/', $path), function (string $part) { + $resizedDir = (str_starts_with($part, 'thumbs-') || str_starts_with($part, 'scaled-')); + $missingExtension = !str_contains($part, '.'); + + return !($resizedDir && $missingExtension); + }); + + // Build a database-format image path and search for the image entry + $fullPath = '/uploads/images/' . ltrim(implode('/', $originalPathSplit), '/'); + + return Image::query()->where('path', '=', $fullPath)->first(); + } + + /** + * Get the regex pattern to identify image URLs. + * Caches the pattern since it requires looking up to settings/config. + */ + protected function getUrlPattern(): string + { + if ($this->pattern) { + return $this->pattern; + } + + $urls = [url('/uploads/images')]; + $baseImageUrl = ImageStorage::getPublicUrl('/uploads/images'); + if ($baseImageUrl !== $urls[0]) { + $urls[] = $baseImageUrl; + } + + $imageUrlRegex = implode('|', array_map(fn ($url) => preg_quote($url, '/'), $urls)); + $this->pattern = '/^(' . $imageUrlRegex . ')\/(.+)/'; + + return $this->pattern; + } +} diff --git a/app/Uploads/AttachmentService.php b/app/Uploads/AttachmentService.php index bd319fbd795..033f2334104 100644 --- a/app/Uploads/AttachmentService.php +++ b/app/Uploads/AttachmentService.php @@ -4,62 +4,13 @@ use BookStack\Exceptions\FileUploadException; use Exception; -use Illuminate\Contracts\Filesystem\Filesystem as Storage; -use Illuminate\Filesystem\FilesystemManager; -use Illuminate\Support\Facades\Log; -use Illuminate\Support\Str; -use League\Flysystem\WhitespacePathNormalizer; use Symfony\Component\HttpFoundation\File\UploadedFile; class AttachmentService { - protected FilesystemManager $fileSystem; - - /** - * AttachmentService constructor. - */ - public function __construct(FilesystemManager $fileSystem) - { - $this->fileSystem = $fileSystem; - } - - /** - * Get the storage that will be used for storing files. - */ - protected function getStorageDisk(): Storage - { - return $this->fileSystem->disk($this->getStorageDiskName()); - } - - /** - * Get the name of the storage disk to use. - */ - protected function getStorageDiskName(): string - { - $storageType = config('filesystems.attachments'); - - // Change to our secure-attachment disk if any of the local options - // are used to prevent escaping that location. - if ($storageType === 'local' || $storageType === 'local_secure' || $storageType === 'local_secure_restricted') { - $storageType = 'local_secure_attachments'; - } - - return $storageType; - } - - /** - * Change the originally provided path to fit any disk-specific requirements. - * This also ensures the path is kept to the expected root folders. - */ - protected function adjustPathForStorageDisk(string $path): string - { - $path = (new WhitespacePathNormalizer())->normalizePath(str_replace('uploads/files/', '', $path)); - - if ($this->getStorageDiskName() === 'local_secure_attachments') { - return $path; - } - - return 'uploads/files/' . $path; + public function __construct( + protected FileStorage $storage, + ) { } /** @@ -69,7 +20,7 @@ protected function adjustPathForStorageDisk(string $path): string */ public function streamAttachmentFromStorage(Attachment $attachment) { - return $this->getStorageDisk()->readStream($this->adjustPathForStorageDisk($attachment->path)); + return $this->storage->getReadStream($attachment->path); } /** @@ -77,7 +28,7 @@ public function streamAttachmentFromStorage(Attachment $attachment) */ public function getAttachmentFileSize(Attachment $attachment): int { - return $this->getStorageDisk()->size($this->adjustPathForStorageDisk($attachment->path)); + return $this->storage->getSize($attachment->path); } /** @@ -200,15 +151,9 @@ public function deleteFile(Attachment $attachment) * Delete a file from the filesystem it sits on. * Cleans any empty leftover folders. */ - protected function deleteFileInStorage(Attachment $attachment) + public function deleteFileInStorage(Attachment $attachment): void { - $storage = $this->getStorageDisk(); - $dirPath = $this->adjustPathForStorageDisk(dirname($attachment->path)); - - $storage->delete($this->adjustPathForStorageDisk($attachment->path)); - if (count($storage->allFiles($dirPath)) === 0) { - $storage->deleteDirectory($dirPath); - } + $this->storage->delete($attachment->path); } /** @@ -218,32 +163,20 @@ protected function deleteFileInStorage(Attachment $attachment) */ protected function putFileInStorage(UploadedFile $uploadedFile): string { - $storage = $this->getStorageDisk(); $basePath = 'uploads/files/' . date('Y-m-M') . '/'; - $uploadFileName = Str::random(16) . '-' . $uploadedFile->getClientOriginalExtension(); - while ($storage->exists($this->adjustPathForStorageDisk($basePath . $uploadFileName))) { - $uploadFileName = Str::random(3) . $uploadFileName; - } - - $attachmentStream = fopen($uploadedFile->getRealPath(), 'r'); - $attachmentPath = $basePath . $uploadFileName; - - try { - $storage->writeStream($this->adjustPathForStorageDisk($attachmentPath), $attachmentStream); - } catch (Exception $e) { - Log::error('Error when attempting file upload:' . $e->getMessage()); - - throw new FileUploadException(trans('errors.path_not_writable', ['filePath' => $attachmentPath])); - } - - return $attachmentPath; + return $this->storage->uploadFile( + $uploadedFile, + $basePath, + $uploadedFile->getClientOriginalExtension(), + '' + ); } /** * Get the file validation rules for attachments. */ - public function getFileValidationRules(): array + public static function getFileValidationRules(): array { return ['file', 'max:' . (config('app.upload_limit') * 1000)]; } diff --git a/app/Uploads/FileStorage.php b/app/Uploads/FileStorage.php new file mode 100644 index 00000000000..e6ac368d000 --- /dev/null +++ b/app/Uploads/FileStorage.php @@ -0,0 +1,132 @@ +getStorageDisk()->readStream($this->adjustPathForStorageDisk($path)); + } + + public function getSize(string $path): int + { + return $this->getStorageDisk()->size($this->adjustPathForStorageDisk($path)); + } + + public function delete(string $path, bool $removeEmptyDir = false): void + { + $storage = $this->getStorageDisk(); + $adjustedPath = $this->adjustPathForStorageDisk($path); + $dir = dirname($adjustedPath); + + $storage->delete($adjustedPath); + if ($removeEmptyDir && count($storage->allFiles($dir)) === 0) { + $storage->deleteDirectory($dir); + } + } + + /** + * @throws FileUploadException + */ + public function uploadFile(UploadedFile $file, string $subDirectory, string $suffix, string $extension): string + { + $storage = $this->getStorageDisk(); + $basePath = trim($subDirectory, '/') . '/'; + + $uploadFileName = Str::random(16) . ($suffix ? "-{$suffix}" : '') . ($extension ? ".{$extension}" : ''); + while ($storage->exists($this->adjustPathForStorageDisk($basePath . $uploadFileName))) { + $uploadFileName = Str::random(3) . $uploadFileName; + } + + $fileStream = fopen($file->getRealPath(), 'r'); + $filePath = $basePath . $uploadFileName; + + try { + $storage->writeStream($this->adjustPathForStorageDisk($filePath), $fileStream); + } catch (Exception $e) { + Log::error('Error when attempting file upload:' . $e->getMessage()); + + throw new FileUploadException(trans('errors.path_not_writable', ['filePath' => $filePath])); + } + + return $filePath; + } + + /** + * Check whether the configured storage is remote from the host of this app. + */ + public function isRemote(): bool + { + return $this->getStorageDiskName() === 's3'; + } + + /** + * Get the actual path on system for the given relative file path. + */ + public function getSystemPath(string $filePath): string + { + if ($this->isRemote()) { + return ''; + } + + return storage_path('uploads/files/' . ltrim($this->adjustPathForStorageDisk($filePath), '/')); + } + + /** + * Get the storage that will be used for storing files. + */ + protected function getStorageDisk(): Storage + { + return $this->fileSystem->disk($this->getStorageDiskName()); + } + + /** + * Get the name of the storage disk to use. + */ + protected function getStorageDiskName(): string + { + $storageType = trim(strtolower(config('filesystems.attachments'))); + + // Change to our secure-attachment disk if any of the local options + // are used to prevent escaping that location. + if ($storageType === 'local' || $storageType === 'local_secure' || $storageType === 'local_secure_restricted') { + $storageType = 'local_secure_attachments'; + } + + return $storageType; + } + + /** + * Change the originally provided path to fit any disk-specific requirements. + * This also ensures the path is kept to the expected root folders. + */ + protected function adjustPathForStorageDisk(string $path): string + { + $path = (new WhitespacePathNormalizer())->normalizePath(str_replace('uploads/files/', '', $path)); + + if ($this->getStorageDiskName() === 'local_secure_attachments') { + return $path; + } + + return 'uploads/files/' . $path; + } +} diff --git a/app/Uploads/ImageService.php b/app/Uploads/ImageService.php index 8d8da61ec18..038e6aa417c 100644 --- a/app/Uploads/ImageService.php +++ b/app/Uploads/ImageService.php @@ -33,9 +33,10 @@ public function saveNewFromUpload( int $uploadedTo = 0, int $resizeWidth = null, int $resizeHeight = null, - bool $keepRatio = true + bool $keepRatio = true, + string $imageName = '', ): Image { - $imageName = $uploadedFile->getClientOriginalName(); + $imageName = $imageName ?: $uploadedFile->getClientOriginalName(); $imageData = file_get_contents($uploadedFile->getRealPath()); if ($resizeWidth !== null || $resizeHeight !== null) { @@ -133,6 +134,19 @@ public function getImageData(Image $image): string return $disk->get($image->path); } + /** + * Get the raw data content from an image. + * + * @throws Exception + * @returns ?resource + */ + public function getImageStream(Image $image): mixed + { + $disk = $this->storage->getDisk(); + + return $disk->stream($image->path); + } + /** * Destroy an image along with its revisions, thumbnails and remaining folders. * @@ -140,11 +154,19 @@ public function getImageData(Image $image): string */ public function destroy(Image $image): void { - $disk = $this->storage->getDisk($image->type); - $disk->destroyAllMatchingNameFromPath($image->path); + $this->destroyFileAtPath($image->type, $image->path); $image->delete(); } + /** + * Destroy the underlying image file at the given path. + */ + public function destroyFileAtPath(string $type, string $path): void + { + $disk = $this->storage->getDisk($type); + $disk->destroyAllMatchingNameFromPath($path); + } + /** * Delete gallery and drawings that are not within HTML content of pages or page revisions. * Checks based off of only the image name. diff --git a/app/Uploads/ImageStorage.php b/app/Uploads/ImageStorage.php index dc4abc0f281..ddaa26a9400 100644 --- a/app/Uploads/ImageStorage.php +++ b/app/Uploads/ImageStorage.php @@ -110,10 +110,20 @@ public function urlToPath(string $url): ?string } /** - * Gets a public facing url for an image by checking relevant environment variables. + * Gets a public facing url for an image or location at the given path. + */ + public static function getPublicUrl(string $filePath): string + { + return static::getPublicBaseUrl() . '/' . ltrim($filePath, '/'); + } + + /** + * Get the public base URL used for images. + * Will not include any path element of the image file, just the base part + * from where the path is then expected to start from. * If s3-style store is in use it will default to guessing a public bucket URL. */ - public function getPublicUrl(string $filePath): string + protected static function getPublicBaseUrl(): string { $storageUrl = config('filesystems.url'); @@ -131,6 +141,6 @@ public function getPublicUrl(string $filePath): string $basePath = $storageUrl ?: url('/'); - return rtrim($basePath, '/') . $filePath; + return rtrim($basePath, '/'); } } diff --git a/app/Uploads/ImageStorageDisk.php b/app/Uploads/ImageStorageDisk.php index 798b72abdbf..8df702e0d94 100644 --- a/app/Uploads/ImageStorageDisk.php +++ b/app/Uploads/ImageStorageDisk.php @@ -55,6 +55,15 @@ public function get(string $path): ?string return $this->filesystem->get($this->adjustPathForDisk($path)); } + /** + * Get a stream to the file at the given path. + * @returns ?resource + */ + public function stream(string $path): mixed + { + return $this->filesystem->readStream($this->adjustPathForDisk($path)); + } + /** * Save the given image data at the given path. Can choose to set * the image as public which will update its visibility after saving. diff --git a/composer.json b/composer.json index 5c54774f1e2..3680a2c6aa2 100644 --- a/composer.json +++ b/composer.json @@ -16,6 +16,7 @@ "ext-json": "*", "ext-mbstring": "*", "ext-xml": "*", + "ext-zip": "*", "bacon/bacon-qr-code": "^3.0", "doctrine/dbal": "^3.5", "dompdf/dompdf": "^3.0", diff --git a/database/factories/Exports/ImportFactory.php b/database/factories/Exports/ImportFactory.php new file mode 100644 index 00000000000..5d0b4f89299 --- /dev/null +++ b/database/factories/Exports/ImportFactory.php @@ -0,0 +1,31 @@ + 'uploads/files/imports/' . Str::random(10) . '.zip', + 'name' => $this->faker->words(3, true), + 'type' => 'book', + 'metadata' => '{"name": "My book"}', + 'created_at' => User::factory(), + ]; + } +} diff --git a/database/migrations/2021_08_28_161743_add_export_role_permission.php b/database/migrations/2021_08_28_161743_add_export_role_permission.php index 21f45aa0691..99416f9fcf6 100644 --- a/database/migrations/2021_08_28_161743_add_export_role_permission.php +++ b/database/migrations/2021_08_28_161743_add_export_role_permission.php @@ -11,8 +11,7 @@ */ public function up(): void { - // Create new templates-manage permission and assign to admin role - $roles = DB::table('roles')->get('id'); + // Create new content-export permission $permissionId = DB::table('role_permissions')->insertGetId([ 'name' => 'content-export', 'display_name' => 'Export Content', @@ -20,6 +19,7 @@ public function up(): void 'updated_at' => Carbon::now()->toDateTimeString(), ]); + $roles = DB::table('roles')->get('id'); $permissionRoles = $roles->map(function ($role) use ($permissionId) { return [ 'role_id' => $role->id, @@ -27,6 +27,7 @@ public function up(): void ]; })->values()->toArray(); + // Assign to all existing roles in the system DB::table('permission_role')->insert($permissionRoles); } @@ -40,6 +41,6 @@ public function down(): void ->where('name', '=', 'content-export')->first(); DB::table('permission_role')->where('permission_id', '=', $contentExportPermission->id)->delete(); - DB::table('role_permissions')->where('id', '=', 'content-export')->delete(); + DB::table('role_permissions')->where('id', '=', $contentExportPermission->id)->delete(); } }; diff --git a/database/migrations/2024_10_29_114420_add_import_role_permission.php b/database/migrations/2024_10_29_114420_add_import_role_permission.php new file mode 100644 index 00000000000..17bbe4cff26 --- /dev/null +++ b/database/migrations/2024_10_29_114420_add_import_role_permission.php @@ -0,0 +1,61 @@ +insertGetId([ + 'name' => 'content-import', + 'display_name' => 'Import Content', + 'created_at' => Carbon::now()->toDateTimeString(), + 'updated_at' => Carbon::now()->toDateTimeString(), + ]); + + // Get existing admin-level role ids + $settingManagePermission = DB::table('role_permissions') + ->where('name', '=', 'settings-manage')->first(); + + if (!$settingManagePermission) { + return; + } + + $adminRoleIds = DB::table('permission_role') + ->where('permission_id', '=', $settingManagePermission->id) + ->pluck('role_id')->all(); + + // Assign the new permission to all existing admins + $newPermissionRoles = array_values(array_map(function ($roleId) use ($permissionId) { + return [ + 'role_id' => $roleId, + 'permission_id' => $permissionId, + ]; + }, $adminRoleIds)); + + DB::table('permission_role')->insert($newPermissionRoles); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // Remove content-import permission + $importPermission = DB::table('role_permissions') + ->where('name', '=', 'content-import')->first(); + + if (!$importPermission) { + return; + } + + DB::table('permission_role')->where('permission_id', '=', $importPermission->id)->delete(); + DB::table('role_permissions')->where('id', '=', $importPermission->id)->delete(); + } +}; diff --git a/database/migrations/2024_11_02_160700_create_imports_table.php b/database/migrations/2024_11_02_160700_create_imports_table.php new file mode 100644 index 00000000000..0784591b8e3 --- /dev/null +++ b/database/migrations/2024_11_02_160700_create_imports_table.php @@ -0,0 +1,33 @@ +increments('id'); + $table->string('name'); + $table->string('path'); + $table->integer('size'); + $table->string('type'); + $table->longText('metadata'); + $table->integer('created_by')->index(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('imports'); + } +}; diff --git a/database/migrations/2024_11_27_171039_add_instance_id_setting.php b/database/migrations/2024_11_27_171039_add_instance_id_setting.php new file mode 100644 index 00000000000..ee1e90d0303 --- /dev/null +++ b/database/migrations/2024_11_27_171039_add_instance_id_setting.php @@ -0,0 +1,30 @@ +insert([ + 'setting_key' => 'instance-id', + 'value' => Str::uuid(), + 'created_at' => Carbon::now(), + 'updated_at' => Carbon::now(), + 'type' => 'string', + ]); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + DB::table('settings')->where('setting_key', '=', 'instance-id')->delete(); + } +}; diff --git a/dev/docs/portable-zip-file-format.md b/dev/docs/portable-zip-file-format.md new file mode 100644 index 00000000000..754cb4d3e94 --- /dev/null +++ b/dev/docs/portable-zip-file-format.md @@ -0,0 +1,160 @@ +# Portable ZIP File Format + +BookStack provides exports in a "Portable ZIP" which allows the portable transfer, storage, import & export of BookStack content. +This document details the format used, and is intended for our own internal development use in addition to detailing the format for potential external use-cases (readers, apps, import for other platforms etc...). + +**Note:** This is not a BookStack backup format! This format misses much of the data that would be needed to re-create/restore a BookStack instance. There are existing better alternative options for this use-case. + +## Stability + +Following the goals & ideals of BookStack, stability is very important. We aim for this defined format to be stable and forwards compatible, to prevent breakages in use-case due to changes. Here are the general rules we follow in regard to stability & changes: + +- New features & properties may be added with any release. +- Where reasonably possible, we will attempt to avoid modifications/removals of existing features/properties. +- Where potentially breaking changes do have to be made, these will be noted in BookStack release/update notes. + +The addition of new features/properties alone are not considered as a breaking change to the format. Breaking changes are considered as such where they could impact common/expected use of the existing properties and features we document, they are not considered based upon user assumptions or any possible breakage. +For example if your application, using the format, breaks because we added a new property while you hard-coded your application to use the third property (instead of a property name), then that's on you. + +## Format Outline + +The format is intended to be very simple, readable and based on open standards that could be easily read/handled in most common programming languages. +The below outlines the structure of the format: + +- **ZIP archive container** + - **data.json** - Export data. + - **files/** - Directory containing referenced files. + - *file-a* + - *file-b* + - *...* + +## References + +Some properties in the export data JSON are indicated as `String reference`, and these are direct references to a file name within the `files/` directory of the ZIP. For example, the below book cover is directly referencing a `files/4a5m4a.jpg` within the ZIP which would be expected to exist. + +```json +{ + "book": { + "cover": "4a5m4a.jpg" + } +} +``` + +Within HTML and markdown content, you may require references across to other items within the export content. +This can be done using the following format: + +``` +[[bsexport::]] +``` + +References are to the `id` for data objects. +Here's an example of each type of such reference that could be used: + +``` +[[bsexport:image:22]] +[[bsexport:attachment:55]] +[[bsexport:page:40]] +[[bsexport:chapter:2]] +[[bsexport:book:8]] +``` + +## HTML & Markdown Content + +BookStack commonly stores & utilises content in the HTML format. +Properties that expect or provided HTML will either be named `html` or contain `html` in the property name. +While BookStack supports a range of HTML, not all HTML content will be supported by BookStack and be assured to work as desired across all BookStack features. +The HTML supported by BookStack is not yet formally documented, but you can inspect to what the WYSIWYG editor produces as a basis. +Generally, top-level elements should keep to common block formats (p, blockquote, h1, h2 etc...) with no nesting or custom structure apart from common inline elements. +Some areas of BookStack where HTML is used, like book & chapter descriptions, will strictly limit/filter HTML tag & attributes to an allow-list. + +For markdown content, in BookStack we target [the commonmark spec](https://commonmark.org/) with the addition of tables & task-lists. +HTML within markdown is supported but not all HTML is assured to work as advised above. + +### Content Security + +If you're consuming HTML or markdown within an export please consider that the content is not assured to be safe, even if provided directly by a BookStack instance. It's best to treat such content as potentially unsafe. +By default, BookStack performs some basic filtering to remove scripts among other potentially dangerous elements but this is not foolproof. BookStack itself relies on additional security mechanisms such as [CSP](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) to help prevent a range of exploits. + +## Export Data - `data.json` + +The `data.json` file is a JSON format file which contains all structured data for the export. The properties are as follows: + +- `instance` - [Instance](#instance) Object, optional, details of the export source instance. +- `exported_at` - String, optional, full ISO 8601 datetime of when the export was created. +- `book` - [Book](#book) Object, optional, book export data. +- `chapter` - [Chapter](#chapter) Object, optional, chapter export data. +- `page` - [Page](#page) Object, optional, page export data. + +Either `book`, `chapter` or `page` will exist depending on export type. You'd want to check for each to check what kind of export this is, and if it's an export you can handle. It's possible that other options are added in the future (`books` for a range of books for example) so it'd be wise to specifically check for properties that can be handled, otherwise error to indicate lack of support. + +## Data Objects + +The below details the objects & their properties used in Application Data. + +#### Instance + +These details are informational regarding the exporting BookStack instance from where an export was created from. + +- `id` - String, required, unique identifier for the BookStack instance. +- `version` - String, required, BookStack version of the export source instance. + +#### Book + +- `id` - Number, optional, original ID for the book from exported system. +- `name` - String, required, name/title of the book. +- `description_html` - String, optional, HTML description content. +- `cover` - String reference, optional, reference to book cover image. +- `chapters` - [Chapter](#chapter) array, optional, chapters within this book. +- `pages` - [Page](#page) array, optional, direct child pages for this book. +- `tags` - [Tag](#tag) array, optional, tags assigned to this book. + +The `pages` are not all pages within the book, just those that are direct children (not in a chapter). To build an ordered mixed chapter/page list for the book, as what you'd see in BookStack, you'd need to combine `chapters` and `pages` together and sort by their `priority` value (low to high). + +#### Chapter + +- `id` - Number, optional, original ID for the chapter from exported system. +- `name` - String, required, name/title of the chapter. +- `description_html` - String, optional, HTML description content. +- `priority` - Number, optional, integer order for when shown within a book (shown low to high). +- `pages` - [Page](#page) array, optional, pages within this chapter. +- `tags` - [Tag](#tag) array, optional, tags assigned to this chapter. + +#### Page + +- `id` - Number, optional, original ID for the page from exported system. +- `name` - String, required, name/title of the page. +- `html` - String, optional, page HTML content. +- `markdown` - String, optional, user markdown content for this page. +- `priority` - Number, optional, integer order for when shown within a book (shown low to high). +- `attachments` - [Attachment](#attachment) array, optional, attachments uploaded to this page. +- `images` - [Image](#image) array, optional, images used in this page. +- `tags` - [Tag](#tag) array, optional, tags assigned to this page. + +To define the page content, either `markdown` or `html` should be provided. Ideally these should be limited to the range of markdown and HTML which BookStack supports. See the ["HTML & Markdown Content"](#html--markdown-content) section. + +The page editor type, and edit content will be determined by what content is provided. If non-empty `markdown` is provided, the page will be assumed as a markdown editor page (where permissions allow) and the HTML will be rendered from the markdown content. Otherwise, the provided `html` will be used as editor & display content. + +#### Image + +- `id` - Number, optional, original ID for the page from exported system. +- `name` - String, required, name of image. +- `file` - String reference, required, reference to image file. +- `type` - String, required, must be 'gallery' or 'drawio' + +File must be an image type accepted by BookStack (png, jpg, gif, webp). +Images of type 'drawio' are expected to be png with draw.io drawing data +embedded within it. + +#### Attachment + +- `id` - Number, optional, original ID for the attachment from exported system. +- `name` - String, required, name of attachment. +- `link` - String, semi-optional, URL of attachment. +- `file` - String reference, semi-optional, reference to attachment file. + +Either `link` or `file` must be present, as that will determine the type of attachment. + +#### Tag + +- `name` - String, required, name of the tag. +- `value` - String, optional, value of the tag (can be empty). \ No newline at end of file diff --git a/lang/en/activities.php b/lang/en/activities.php index 092398ef0e1..7c3454d41ca 100644 --- a/lang/en/activities.php +++ b/lang/en/activities.php @@ -84,6 +84,14 @@ 'webhook_delete' => 'deleted webhook', 'webhook_delete_notification' => 'Webhook successfully deleted', + // Imports + 'import_create' => 'created import', + 'import_create_notification' => 'Import successfully uploaded', + 'import_run' => 'updated import', + 'import_run_notification' => 'Content successfully imported', + 'import_delete' => 'deleted import', + 'import_delete_notification' => 'Import successfully deleted', + // Users 'user_create' => 'created user', 'user_create_notification' => 'User successfully created', diff --git a/lang/en/entities.php b/lang/en/entities.php index 35e6f050bb8..26a563a7eb5 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -39,9 +39,30 @@ 'export_pdf' => 'PDF File', 'export_text' => 'Plain Text File', 'export_md' => 'Markdown File', + 'export_zip' => 'Portable ZIP', '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_select' => 'Select a template page', + 'import' => 'Import', + 'import_validate' => 'Validate Import', + 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', + 'import_zip_select' => 'Select ZIP file to upload', + 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', + 'import_pending' => 'Pending Imports', + 'import_pending_none' => 'No imports have been started.', + 'import_continue' => 'Continue Import', + 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', + 'import_details' => 'Import Details', + 'import_run' => 'Run Import', + 'import_size' => ':size Import ZIP Size', + 'import_uploaded_at' => 'Uploaded :relativeTime', + 'import_uploaded_by' => 'Uploaded by', + 'import_location' => 'Import Location', + 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', + 'import_delete_confirm' => 'Are you sure you want to delete this import?', + 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Permissions', diff --git a/lang/en/errors.php b/lang/en/errors.php index 9c40aa9ed33..9d738379648 100644 --- a/lang/en/errors.php +++ b/lang/en/errors.php @@ -105,6 +105,18 @@ 'app_down' => ':appName is down right now', 'back_soon' => 'It will be back up soon.', + // Import + 'import_zip_cant_read' => 'Could not read ZIP file.', + 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', + 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_validation_failed' => 'Import ZIP failed to validate with errors:', + 'import_zip_failed_notification' => 'Failed to import ZIP file.', + 'import_perms_books' => 'You are lacking the required permissions to create books.', + 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', + 'import_perms_pages' => 'You are lacking the required permissions to create pages.', + 'import_perms_images' => 'You are lacking the required permissions to create images.', + 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + // API errors 'api_no_authorization_found' => 'No authorization token found on the request', 'api_bad_authorization_format' => 'An authorization token was found on the request but the format appeared incorrect', diff --git a/lang/en/settings.php b/lang/en/settings.php index 5427cb9419e..c0b6b692a57 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -162,6 +162,7 @@ 'role_access_api' => 'Access system API', 'role_manage_settings' => 'Manage app settings', 'role_export_content' => 'Export content', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Change page editor', 'role_notifications' => 'Receive & manage notifications', 'role_asset' => 'Asset Permissions', diff --git a/lang/en/validation.php b/lang/en/validation.php index 2a676c7c4cc..d9b982d1e23 100644 --- a/lang/en/validation.php +++ b/lang/en/validation.php @@ -105,6 +105,11 @@ 'url' => 'The :attribute format is invalid.', 'uploaded' => 'The file could not be uploaded. The server may not accept files of this size.', + 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', + 'zip_model_expected' => 'Data object expected but ":type" found.', + 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/resources/js/components/index.js b/resources/js/components/index.js index 8ad5e14cb2e..24e60bd97f2 100644 --- a/resources/js/components/index.js +++ b/resources/js/components/index.js @@ -30,6 +30,7 @@ export {HeaderMobileToggle} from './header-mobile-toggle'; export {ImageManager} from './image-manager'; export {ImagePicker} from './image-picker'; export {ListSortControl} from './list-sort-control'; +export {LoadingButton} from './loading-button.ts'; export {MarkdownEditor} from './markdown-editor'; export {NewUserPassword} from './new-user-password'; export {Notification} from './notification'; diff --git a/resources/js/components/loading-button.ts b/resources/js/components/loading-button.ts new file mode 100644 index 00000000000..a793d30a235 --- /dev/null +++ b/resources/js/components/loading-button.ts @@ -0,0 +1,38 @@ +import {Component} from "./component.js"; +import {showLoading} from "../services/dom"; +import {el} from "../wysiwyg/utils/dom"; + +/** + * Loading button. + * Shows a loading indicator and disables the button when the button is clicked, + * or when the form attached to the button is submitted. + */ +export class LoadingButton extends Component { + + protected button!: HTMLButtonElement; + protected loadingEl: HTMLDivElement|null = null; + + setup() { + this.button = this.$el as HTMLButtonElement; + const form = this.button.form; + + const action = () => { + setTimeout(() => this.showLoadingState(), 10) + }; + + this.button.addEventListener('click', action); + if (form) { + form.addEventListener('submit', action); + } + } + + showLoadingState() { + this.button.disabled = true; + + if (!this.loadingEl) { + this.loadingEl = el('div', {class: 'inline block'}) as HTMLDivElement; + showLoading(this.loadingEl); + this.button.after(this.loadingEl); + } + } +} \ No newline at end of file diff --git a/resources/js/components/page-comments.js b/resources/js/components/page-comments.js index 1d6abfe2044..63900888aca 100644 --- a/resources/js/components/page-comments.js +++ b/resources/js/components/page-comments.js @@ -93,7 +93,6 @@ export class PageComments extends Component { updateCount() { const count = this.getCommentCount(); - console.log('update count', count, this.container); this.commentsTitle.textContent = window.$trans.choice(this.countText, count, {count}); } diff --git a/resources/sass/_forms.scss b/resources/sass/_forms.scss index 67df4171499..1c679aaa0dd 100644 --- a/resources/sass/_forms.scss +++ b/resources/sass/_forms.scss @@ -545,6 +545,43 @@ input[type=color] { outline: 1px solid var(--color-primary); } +.custom-simple-file-input { + max-width: 100%; + border: 1px solid; + @include lightDark(border-color, #DDD, #666); + border-radius: 4px; + padding: $-s $-m; +} +.custom-simple-file-input::file-selector-button { + background-color: transparent; + text-decoration: none; + font-size: 0.8rem; + line-height: 1.4em; + padding: $-xs $-s; + border: 1px solid; + font-weight: 400; + outline: 0; + border-radius: 4px; + cursor: pointer; + margin-right: $-m; + @include lightDark(color, #666, #AAA); + @include lightDark(border-color, #CCC, #666); + &:hover, &:focus, &:active { + @include lightDark(color, #444, #BBB); + border: 1px solid #CCC; + box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.1); + background-color: #F2F2F2; + @include lightDark(background-color, #f8f8f8, #444); + filter: none; + } + &:active { + border-color: #BBB; + background-color: #DDD; + color: #666; + box-shadow: inset 0 0 2px rgba(0, 0, 0, 0.1); + } +} + input.shortcut-input { width: auto; max-width: 120px; diff --git a/resources/sass/styles.scss b/resources/sass/styles.scss index 636367e3aeb..2106f86e625 100644 --- a/resources/sass/styles.scss +++ b/resources/sass/styles.scss @@ -106,6 +106,10 @@ $loadingSize: 10px; } } +.inline.block .loading-container { + margin: $-xs $-s; +} + .skip-to-content-link { position: fixed; top: -52px; @@ -138,6 +142,11 @@ $loadingSize: 10px; font-size: 16px; padding: $-s $-m; } + input[type="text"]:focus { + outline: 1px solid var(--color-primary); + border-radius: 3px 3px 0 0; + outline-offset: -1px; + } .entity-list { overflow-y: scroll; height: 400px; @@ -171,6 +180,19 @@ $loadingSize: 10px; font-size: 14px; } } + &.small { + .entity-list-item { + padding: $-xs $-m; + } + .entity-list, .loading { + height: 300px; + } + input[type="text"] { + font-size: 13px; + padding: $-xs $-m; + height: auto; + } + } } .fullscreen { @@ -230,4 +252,9 @@ $loadingSize: 10px; transform: rotate(180deg); } } +} + +.import-item { + border-inline-start: 2px solid currentColor; + padding-inline-start: $-xs; } \ No newline at end of file diff --git a/resources/views/books/index.blade.php b/resources/views/books/index.blade.php index 0b407a8609a..418c0fea8d1 100644 --- a/resources/views/books/index.blade.php +++ b/resources/views/books/index.blade.php @@ -49,6 +49,13 @@ @icon('tag') {{ trans('entities.tags_view_tags') }} + + @if(userCan('content-import')) + + @icon('upload') + {{ trans('entities.import') }} + + @endif diff --git a/resources/views/entities/export-menu.blade.php b/resources/views/entities/export-menu.blade.php index a55ab56d199..e58c842ba42 100644 --- a/resources/views/entities/export-menu.blade.php +++ b/resources/views/entities/export-menu.blade.php @@ -18,6 +18,7 @@ class="icon-list-item"
  • {{ trans('entities.export_pdf') }}.pdf
  • {{ trans('entities.export_text') }}.txt
  • {{ trans('entities.export_md') }}.md
  • +
  • {{ trans('entities.export_zip') }}.zip
  • diff --git a/resources/views/entities/selector.blade.php b/resources/views/entities/selector.blade.php index c1280cfb2f7..0cdf4376cc7 100644 --- a/resources/views/entities/selector.blade.php +++ b/resources/views/entities/selector.blade.php @@ -1,3 +1,11 @@ +{{-- +$name - string +$autofocus - boolean, optional +$entityTypes - string, optional +$entityPermission - string, optional +$selectorEndpoint - string, optional +$selectorSize - string, optional (compact) +--}}
    + +
    +

    {{ trans('entities.import_continue') }}

    +

    {{ trans('entities.import_continue_desc') }}

    + + @if(session()->has('import_errors')) +
    + +

    {{ trans('entities.import_errors_desc') }}

    + @foreach(session()->get('import_errors') ?? [] as $error) +

    {{ $error }}

    + @endforeach +
    +
    + @endif + +
    + +
    +
    + @include('exports.parts.import-item', ['type' => $import->type, 'model' => $data]) +
    +
    +
    {{ trans('entities.import_size', ['size' => $import->getSizeString()]) }}
    +
    {{ trans('entities.import_uploaded_at', ['relativeTime' => $import->created_at->diffForHumans()]) }}
    + @if($import->createdBy) +
    + {{ trans('entities.import_uploaded_by') }} + {{ $import->createdBy->name }} +
    + @endif +
    +
    +
    + +
    + {{ csrf_field() }} + + @if($import->type === 'page' || $import->type === 'chapter') +
    + +

    {{ trans('entities.import_location_desc') }}

    + @if($errors->has('parent')) +
    + @include('form.errors', ['name' => 'parent']) +
    + @endif + @include('entities.selector', [ + 'name' => 'parent', + 'entityTypes' => $import->type === 'page' ? 'chapter,book' : 'book', + 'entityPermission' => "{$import->type}-create", + 'selectorSize' => 'compact small', + ]) + @endif + +
    + {{ trans('common.cancel') }} +
    + + +
    + +
    +
    +
    +
    + +
    + {{ method_field('DELETE') }} + {{ csrf_field() }} +
    + +@stop diff --git a/resources/views/exports/import.blade.php b/resources/views/exports/import.blade.php new file mode 100644 index 00000000000..be9de4c0e91 --- /dev/null +++ b/resources/views/exports/import.blade.php @@ -0,0 +1,56 @@ +@extends('layouts.simple') + +@section('body') + +
    + +
    +

    {{ trans('entities.import') }}

    +
    + {{ csrf_field() }} +
    +

    {{ trans('entities.import_desc') }}

    +
    +
    + + + @include('form.errors', ['name' => 'file']) +
    +
    +
    + + @if(count($zipErrors) > 0) +

    {{ trans('entities.import_zip_validation_errors') }}

    +
      + @foreach($zipErrors as $key => $error) +
    • [{{ $key }}]: {{ $error }}
    • + @endforeach +
    + @endif + +
    + {{ trans('common.cancel') }} + +
    +
    +
    + +
    +

    {{ trans('entities.import_pending') }}

    + @if(count($imports) === 0) +

    {{ trans('entities.import_pending_none') }}

    + @else +
    + @foreach($imports as $import) + @include('exports.parts.import', ['import' => $import]) + @endforeach +
    + @endif +
    +
    + +@stop diff --git a/resources/views/exports/parts/import-item.blade.php b/resources/views/exports/parts/import-item.blade.php new file mode 100644 index 00000000000..5da4b21405d --- /dev/null +++ b/resources/views/exports/parts/import-item.blade.php @@ -0,0 +1,28 @@ +{{-- +$type - string +$model - object +--}} +
    +

    @icon($type){{ $model->name }}

    +
    +
    + @if($model->attachments ?? []) + @icon('attach'){{ count($model->attachments) }} + @endif + @if($model->images ?? []) + @icon('image'){{ count($model->images) }} + @endif + @if($model->tags ?? []) + @icon('tag'){{ count($model->tags) }} + @endif +
    + @if(method_exists($model, 'children')) + @foreach($model->children() as $child) + @include('exports.parts.import-item', [ + 'type' => ($child instanceof \BookStack\Exports\ZipExports\Models\ZipExportPage) ? 'page' : 'chapter', + 'model' => $child + ]) + @endforeach + @endif +
    +
    \ No newline at end of file diff --git a/resources/views/exports/parts/import.blade.php b/resources/views/exports/parts/import.blade.php new file mode 100644 index 00000000000..2f7659c469e --- /dev/null +++ b/resources/views/exports/parts/import.blade.php @@ -0,0 +1,10 @@ +
    + +
    +
    {{ $import->getSizeString() }}
    +
    @icon('time'){{ $import->created_at->diffForHumans() }}
    +
    +
    \ No newline at end of file diff --git a/resources/views/form/errors.blade.php b/resources/views/form/errors.blade.php index 03cd4be88f0..72d41ee56c7 100644 --- a/resources/views/form/errors.blade.php +++ b/resources/views/form/errors.blade.php @@ -1,3 +1,6 @@ +{{-- +$name - string +--}} @if($errors->has($name))
    {{ $errors->first($name) }}
    @endif \ No newline at end of file diff --git a/resources/views/settings/roles/parts/form.blade.php b/resources/views/settings/roles/parts/form.blade.php index 9fa76f2bfd7..a77b80e4c69 100644 --- a/resources/views/settings/roles/parts/form.blade.php +++ b/resources/views/settings/roles/parts/form.blade.php @@ -37,6 +37,7 @@
    @include('settings.roles.parts.checkbox', ['permission' => 'templates-manage', 'label' => trans('settings.role_manage_page_templates')])
    @include('settings.roles.parts.checkbox', ['permission' => 'access-api', 'label' => trans('settings.role_access_api')])
    @include('settings.roles.parts.checkbox', ['permission' => 'content-export', 'label' => trans('settings.role_export_content')])
    +
    @include('settings.roles.parts.checkbox', ['permission' => 'content-import', 'label' => trans('settings.role_import_content')])
    @include('settings.roles.parts.checkbox', ['permission' => 'editor-change', 'label' => trans('settings.role_editor_change')])
    @include('settings.roles.parts.checkbox', ['permission' => 'receive-notifications', 'label' => trans('settings.role_notifications')])
    diff --git a/routes/api.php b/routes/api.php index c0919d3247b..71036485597 100644 --- a/routes/api.php +++ b/routes/api.php @@ -9,6 +9,7 @@ use BookStack\Activity\Controllers\AuditLogApiController; use BookStack\Api\ApiDocsController; use BookStack\Entities\Controllers as EntityControllers; +use BookStack\Exports\Controllers as ExportControllers; use BookStack\Permissions\ContentPermissionApiController; use BookStack\Search\SearchApiController; use BookStack\Uploads\Controllers\AttachmentApiController; @@ -31,21 +32,20 @@ Route::put('books/{id}', [EntityControllers\BookApiController::class, 'update']); Route::delete('books/{id}', [EntityControllers\BookApiController::class, 'delete']); -Route::get('books/{id}/export/html', [EntityControllers\BookExportApiController::class, 'exportHtml']); -Route::get('books/{id}/export/pdf', [EntityControllers\BookExportApiController::class, 'exportPdf']); -Route::get('books/{id}/export/plaintext', [EntityControllers\BookExportApiController::class, 'exportPlainText']); -Route::get('books/{id}/export/markdown', [EntityControllers\BookExportApiController::class, 'exportMarkdown']); +Route::get('books/{id}/export/html', [ExportControllers\BookExportApiController::class, 'exportHtml']); +Route::get('books/{id}/export/pdf', [ExportControllers\BookExportApiController::class, 'exportPdf']); +Route::get('books/{id}/export/plaintext', [ExportControllers\BookExportApiController::class, 'exportPlainText']); +Route::get('books/{id}/export/markdown', [ExportControllers\BookExportApiController::class, 'exportMarkdown']); Route::get('chapters', [EntityControllers\ChapterApiController::class, 'list']); Route::post('chapters', [EntityControllers\ChapterApiController::class, 'create']); Route::get('chapters/{id}', [EntityControllers\ChapterApiController::class, 'read']); Route::put('chapters/{id}', [EntityControllers\ChapterApiController::class, 'update']); Route::delete('chapters/{id}', [EntityControllers\ChapterApiController::class, 'delete']); - -Route::get('chapters/{id}/export/html', [EntityControllers\ChapterExportApiController::class, 'exportHtml']); -Route::get('chapters/{id}/export/pdf', [EntityControllers\ChapterExportApiController::class, 'exportPdf']); -Route::get('chapters/{id}/export/plaintext', [EntityControllers\ChapterExportApiController::class, 'exportPlainText']); -Route::get('chapters/{id}/export/markdown', [EntityControllers\ChapterExportApiController::class, 'exportMarkdown']); +Route::get('chapters/{id}/export/html', [ExportControllers\ChapterExportApiController::class, 'exportHtml']); +Route::get('chapters/{id}/export/pdf', [ExportControllers\ChapterExportApiController::class, 'exportPdf']); +Route::get('chapters/{id}/export/plaintext', [ExportControllers\ChapterExportApiController::class, 'exportPlainText']); +Route::get('chapters/{id}/export/markdown', [ExportControllers\ChapterExportApiController::class, 'exportMarkdown']); Route::get('pages', [EntityControllers\PageApiController::class, 'list']); Route::post('pages', [EntityControllers\PageApiController::class, 'create']); @@ -53,10 +53,10 @@ Route::put('pages/{id}', [EntityControllers\PageApiController::class, 'update']); Route::delete('pages/{id}', [EntityControllers\PageApiController::class, 'delete']); -Route::get('pages/{id}/export/html', [EntityControllers\PageExportApiController::class, 'exportHtml']); -Route::get('pages/{id}/export/pdf', [EntityControllers\PageExportApiController::class, 'exportPdf']); -Route::get('pages/{id}/export/plaintext', [EntityControllers\PageExportApiController::class, 'exportPlainText']); -Route::get('pages/{id}/export/markdown', [EntityControllers\PageExportApiController::class, 'exportMarkdown']); +Route::get('pages/{id}/export/html', [ExportControllers\PageExportApiController::class, 'exportHtml']); +Route::get('pages/{id}/export/pdf', [ExportControllers\PageExportApiController::class, 'exportPdf']); +Route::get('pages/{id}/export/plaintext', [ExportControllers\PageExportApiController::class, 'exportPlainText']); +Route::get('pages/{id}/export/markdown', [ExportControllers\PageExportApiController::class, 'exportMarkdown']); Route::get('image-gallery', [ImageGalleryApiController::class, 'list']); Route::post('image-gallery', [ImageGalleryApiController::class, 'create']); diff --git a/routes/web.php b/routes/web.php index 81b938f32ec..85f83352859 100644 --- a/routes/web.php +++ b/routes/web.php @@ -7,6 +7,7 @@ use BookStack\App\HomeController; use BookStack\App\MetaController; use BookStack\Entities\Controllers as EntityControllers; +use BookStack\Exports\Controllers as ExportControllers; use BookStack\Http\Middleware\VerifyCsrfToken; use BookStack\Permissions\PermissionsController; use BookStack\References\ReferenceController; @@ -74,11 +75,11 @@ Route::get('/books/{bookSlug}/sort', [EntityControllers\BookSortController::class, 'show']); Route::put('/books/{bookSlug}/sort', [EntityControllers\BookSortController::class, 'update']); Route::get('/books/{slug}/references', [ReferenceController::class, 'book']); - Route::get('/books/{bookSlug}/export/html', [EntityControllers\BookExportController::class, 'html']); - Route::get('/books/{bookSlug}/export/pdf', [EntityControllers\BookExportController::class, 'pdf']); - Route::get('/books/{bookSlug}/export/markdown', [EntityControllers\BookExportController::class, 'markdown']); - Route::get('/books/{bookSlug}/export/zip', [EntityControllers\BookExportController::class, 'zip']); - Route::get('/books/{bookSlug}/export/plaintext', [EntityControllers\BookExportController::class, 'plainText']); + Route::get('/books/{bookSlug}/export/html', [ExportControllers\BookExportController::class, 'html']); + Route::get('/books/{bookSlug}/export/pdf', [ExportControllers\BookExportController::class, 'pdf']); + Route::get('/books/{bookSlug}/export/markdown', [ExportControllers\BookExportController::class, 'markdown']); + Route::get('/books/{bookSlug}/export/zip', [ExportControllers\BookExportController::class, 'zip']); + Route::get('/books/{bookSlug}/export/plaintext', [ExportControllers\BookExportController::class, 'plainText']); // Pages Route::get('/books/{bookSlug}/create-page', [EntityControllers\PageController::class, 'create']); @@ -86,10 +87,11 @@ Route::get('/books/{bookSlug}/draft/{pageId}', [EntityControllers\PageController::class, 'editDraft']); Route::post('/books/{bookSlug}/draft/{pageId}', [EntityControllers\PageController::class, 'store']); Route::get('/books/{bookSlug}/page/{pageSlug}', [EntityControllers\PageController::class, 'show']); - Route::get('/books/{bookSlug}/page/{pageSlug}/export/pdf', [EntityControllers\PageExportController::class, 'pdf']); - Route::get('/books/{bookSlug}/page/{pageSlug}/export/html', [EntityControllers\PageExportController::class, 'html']); - Route::get('/books/{bookSlug}/page/{pageSlug}/export/markdown', [EntityControllers\PageExportController::class, 'markdown']); - Route::get('/books/{bookSlug}/page/{pageSlug}/export/plaintext', [EntityControllers\PageExportController::class, 'plainText']); + Route::get('/books/{bookSlug}/page/{pageSlug}/export/pdf', [ExportControllers\PageExportController::class, 'pdf']); + 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/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}/move', [EntityControllers\PageController::class, 'showMove']); Route::put('/books/{bookSlug}/page/{pageSlug}/move', [EntityControllers\PageController::class, 'move']); @@ -126,10 +128,11 @@ Route::get('/books/{bookSlug}/chapter/{chapterSlug}/edit', [EntityControllers\ChapterController::class, 'edit']); Route::post('/books/{bookSlug}/chapter/{chapterSlug}/convert-to-book', [EntityControllers\ChapterController::class, 'convertToBook']); Route::get('/books/{bookSlug}/chapter/{chapterSlug}/permissions', [PermissionsController::class, 'showForChapter']); - Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/pdf', [EntityControllers\ChapterExportController::class, 'pdf']); - Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/html', [EntityControllers\ChapterExportController::class, 'html']); - Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/markdown', [EntityControllers\ChapterExportController::class, 'markdown']); - Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/plaintext', [EntityControllers\ChapterExportController::class, 'plainText']); + Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/pdf', [ExportControllers\ChapterExportController::class, 'pdf']); + Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/html', [ExportControllers\ChapterExportController::class, 'html']); + Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/markdown', [ExportControllers\ChapterExportController::class, 'markdown']); + Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/plaintext', [ExportControllers\ChapterExportController::class, 'plainText']); + Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/zip', [ExportControllers\ChapterExportController::class, 'zip']); Route::put('/books/{bookSlug}/chapter/{chapterSlug}/permissions', [PermissionsController::class, 'updateForChapter']); Route::get('/books/{bookSlug}/chapter/{chapterSlug}/references', [ReferenceController::class, 'chapter']); Route::get('/books/{bookSlug}/chapter/{chapterSlug}/delete', [EntityControllers\ChapterController::class, 'showDelete']); @@ -203,6 +206,13 @@ // Watching Route::put('/watching/update', [ActivityControllers\WatchController::class, 'update']); + // Importing + Route::get('/import', [ExportControllers\ImportController::class, 'start']); + Route::post('/import', [ExportControllers\ImportController::class, 'upload']); + Route::get('/import/{id}', [ExportControllers\ImportController::class, 'show']); + Route::post('/import/{id}', [ExportControllers\ImportController::class, 'run']); + Route::delete('/import/{id}', [ExportControllers\ImportController::class, 'delete']); + // Other Pages Route::get('/', [HomeController::class, 'index']); Route::get('/home', [HomeController::class, 'index']); diff --git a/tests/Entity/ExportTest.php b/tests/Entity/ExportTest.php deleted file mode 100644 index 7aafa3b7927..00000000000 --- a/tests/Entity/ExportTest.php +++ /dev/null @@ -1,569 +0,0 @@ -entities->page(); - $this->asEditor(); - - $resp = $this->get($page->getUrl('/export/plaintext')); - $resp->assertStatus(200); - $resp->assertSee($page->name); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.txt"'); - } - - public function test_page_pdf_export() - { - $page = $this->entities->page(); - $this->asEditor(); - - $resp = $this->get($page->getUrl('/export/pdf')); - $resp->assertStatus(200); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.pdf"'); - } - - public function test_page_html_export() - { - $page = $this->entities->page(); - $this->asEditor(); - - $resp = $this->get($page->getUrl('/export/html')); - $resp->assertStatus(200); - $resp->assertSee($page->name); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.html"'); - } - - public function test_book_text_export() - { - $book = $this->entities->bookHasChaptersAndPages(); - $directPage = $book->directPages()->first(); - $chapter = $book->chapters()->first(); - $chapterPage = $chapter->pages()->first(); - $this->entities->updatePage($directPage, ['html' => '

    My awesome page

    ']); - $this->entities->updatePage($chapterPage, ['html' => '

    My little nested page

    ']); - $this->asEditor(); - - $resp = $this->get($book->getUrl('/export/plaintext')); - $resp->assertStatus(200); - $resp->assertSee($book->name); - $resp->assertSee($chapterPage->name); - $resp->assertSee($chapter->name); - $resp->assertSee($directPage->name); - $resp->assertSee('My awesome page'); - $resp->assertSee('My little nested page'); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.txt"'); - } - - public function test_book_text_export_format() - { - $entities = $this->entities->createChainBelongingToUser($this->users->viewer()); - $this->entities->updatePage($entities['page'], ['html' => '

    My great page

    Full of great stuff

    ', 'name' => 'My wonderful page!']); - $entities['chapter']->name = 'Export chapter'; - $entities['chapter']->description = "A test chapter to be exported\nIt has loads of info within"; - $entities['book']->name = 'Export Book'; - $entities['book']->description = "This is a book with stuff to export"; - $entities['chapter']->save(); - $entities['book']->save(); - - $resp = $this->asEditor()->get($entities['book']->getUrl('/export/plaintext')); - - $expected = "Export Book\nThis is a book with stuff to export\n\nExport chapter\nA test chapter to be exported\nIt has loads of info within\n\n"; - $expected .= "My wonderful page!\nMy great page Full of great stuff"; - $resp->assertSee($expected); - } - - public function test_book_pdf_export() - { - $page = $this->entities->page(); - $book = $page->book; - $this->asEditor(); - - $resp = $this->get($book->getUrl('/export/pdf')); - $resp->assertStatus(200); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.pdf"'); - } - - public function test_book_html_export() - { - $page = $this->entities->page(); - $book = $page->book; - $this->asEditor(); - - $resp = $this->get($book->getUrl('/export/html')); - $resp->assertStatus(200); - $resp->assertSee($book->name); - $resp->assertSee($page->name); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.html"'); - } - - public function test_book_html_export_shows_html_descriptions() - { - $book = $this->entities->bookHasChaptersAndPages(); - $chapter = $book->chapters()->first(); - $book->description_html = '

    A description with HTML within!

    '; - $chapter->description_html = '

    A chapter description with HTML within!

    '; - $book->save(); - $chapter->save(); - - $resp = $this->asEditor()->get($book->getUrl('/export/html')); - $resp->assertSee($book->description_html, false); - $resp->assertSee($chapter->description_html, false); - } - - public function test_chapter_text_export() - { - $chapter = $this->entities->chapter(); - $page = $chapter->pages[0]; - $this->entities->updatePage($page, ['html' => '

    This is content within the page!

    ']); - $this->asEditor(); - - $resp = $this->get($chapter->getUrl('/export/plaintext')); - $resp->assertStatus(200); - $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"'); - } - - public function test_chapter_text_export_format() - { - $entities = $this->entities->createChainBelongingToUser($this->users->viewer()); - $this->entities->updatePage($entities['page'], ['html' => '

    My great page

    Full of great stuff

    ', 'name' => 'My wonderful page!']); - $entities['chapter']->name = 'Export chapter'; - $entities['chapter']->description = "A test chapter to be exported\nIt has loads of info within"; - $entities['chapter']->save(); - - $resp = $this->asEditor()->get($entities['book']->getUrl('/export/plaintext')); - - $expected = "Export chapter\nA test chapter to be exported\nIt has loads of info within\n\n"; - $expected .= "My wonderful page!\nMy great page Full of great stuff"; - $resp->assertSee($expected); - } - - public function test_chapter_pdf_export() - { - $chapter = $this->entities->chapter(); - $this->asEditor(); - - $resp = $this->get($chapter->getUrl('/export/pdf')); - $resp->assertStatus(200); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.pdf"'); - } - - public function test_chapter_html_export() - { - $chapter = $this->entities->chapter(); - $page = $chapter->pages[0]; - $this->asEditor(); - - $resp = $this->get($chapter->getUrl('/export/html')); - $resp->assertStatus(200); - $resp->assertSee($chapter->name); - $resp->assertSee($page->name); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.html"'); - } - - public function test_chapter_html_export_shows_html_descriptions() - { - $chapter = $this->entities->chapter(); - $chapter->description_html = '

    A description with HTML within!

    '; - $chapter->save(); - - $resp = $this->asEditor()->get($chapter->getUrl('/export/html')); - $resp->assertSee($chapter->description_html, false); - } - - public function test_page_html_export_contains_custom_head_if_set() - { - $page = $this->entities->page(); - - $customHeadContent = ''; - $this->setSettings(['app-custom-head' => $customHeadContent]); - - $resp = $this->asEditor()->get($page->getUrl('/export/html')); - $resp->assertSee($customHeadContent, false); - } - - public function test_page_html_export_does_not_break_with_only_comments_in_custom_head() - { - $page = $this->entities->page(); - - $customHeadContent = ''; - $this->setSettings(['app-custom-head' => $customHeadContent]); - - $resp = $this->asEditor()->get($page->getUrl('/export/html')); - $resp->assertStatus(200); - $resp->assertSee($customHeadContent, false); - } - - public function test_page_html_export_use_absolute_dates() - { - $page = $this->entities->page(); - - $resp = $this->asEditor()->get($page->getUrl('/export/html')); - $resp->assertSee($page->created_at->isoFormat('D MMMM Y HH:mm:ss')); - $resp->assertDontSee($page->created_at->diffForHumans()); - $resp->assertSee($page->updated_at->isoFormat('D MMMM Y HH:mm:ss')); - $resp->assertDontSee($page->updated_at->diffForHumans()); - } - - public function test_page_export_does_not_include_user_or_revision_links() - { - $page = $this->entities->page(); - - $resp = $this->asEditor()->get($page->getUrl('/export/html')); - $resp->assertDontSee($page->getUrl('/revisions')); - $resp->assertDontSee($page->createdBy->getProfileUrl()); - $resp->assertSee($page->createdBy->name); - } - - public function test_page_export_sets_right_data_type_for_svg_embeds() - { - $page = $this->entities->page(); - Storage::disk('local')->makeDirectory('uploads/images/gallery'); - Storage::disk('local')->put('uploads/images/gallery/svg_test.svg', ''); - $page->html = ''; - $page->save(); - - $this->asEditor(); - $resp = $this->get($page->getUrl('/export/html')); - Storage::disk('local')->delete('uploads/images/gallery/svg_test.svg'); - - $resp->assertStatus(200); - $resp->assertSee(''; - $page->save(); - - $resp = $this->asEditor()->get($page->getUrl('/export/html')); - Storage::disk('local')->delete('uploads/images/gallery/svg_test.svg'); - Storage::disk('local')->delete('uploads/images/gallery/svg_test2.svg'); - - $resp->assertDontSee('http://localhost/uploads/images/gallery/svg_test'); - } - - public function test_page_export_contained_html_image_fetches_only_run_when_url_points_to_image_upload_folder() - { - $page = $this->entities->page(); - $page->html = '' - . '' - . ''; - $storageDisk = Storage::disk('local'); - $storageDisk->makeDirectory('uploads/images/gallery'); - $storageDisk->put('uploads/images/gallery/svg_test.svg', 'good'); - $storageDisk->put('uploads/svg_test.svg', 'bad'); - $page->save(); - - $resp = $this->asEditor()->get($page->getUrl('/export/html')); - - $storageDisk->delete('uploads/images/gallery/svg_test.svg'); - $storageDisk->delete('uploads/svg_test.svg'); - - $resp->assertDontSee('http://localhost/uploads/images/gallery/svg_test.svg', false); - $resp->assertSee('http://localhost/uploads/svg_test.svg'); - $resp->assertSee('src="/uploads/svg_test.svg"', false); - } - - public function test_page_export_contained_html_does_not_allow_upward_traversal_with_local() - { - $contents = file_get_contents(public_path('.htaccess')); - config()->set('filesystems.images', 'local'); - - $page = $this->entities->page(); - $page->html = ''; - $page->save(); - - $resp = $this->asEditor()->get($page->getUrl('/export/html')); - $resp->assertDontSee(base64_encode($contents)); - } - - public function test_page_export_contained_html_does_not_allow_upward_traversal_with_local_secure() - { - $testFilePath = storage_path('logs/test.txt'); - config()->set('filesystems.images', 'local_secure'); - file_put_contents($testFilePath, 'I am a cat'); - - $page = $this->entities->page(); - $page->html = ''; - $page->save(); - - $resp = $this->asEditor()->get($page->getUrl('/export/html')); - $resp->assertDontSee(base64_encode('I am a cat')); - unlink($testFilePath); - } - - public function test_exports_removes_scripts_from_custom_head() - { - $entities = [ - Page::query()->first(), Chapter::query()->first(), Book::query()->first(), - ]; - setting()->put('app-custom-head', ''); - - foreach ($entities as $entity) { - $resp = $this->asEditor()->get($entity->getUrl('/export/html')); - $resp->assertDontSee('window.donkey'); - $resp->assertDontSee('assertSee('.my-test-class { color: red; }'); - } - } - - public function test_page_export_with_deleted_creator_and_updater() - { - $user = $this->users->viewer(['name' => 'ExportWizardTheFifth']); - $page = $this->entities->page(); - $page->created_by = $user->id; - $page->updated_by = $user->id; - $page->save(); - - $resp = $this->asEditor()->get($page->getUrl('/export/html')); - $resp->assertSee('ExportWizardTheFifth'); - - $user->delete(); - $resp = $this->get($page->getUrl('/export/html')); - $resp->assertStatus(200); - $resp->assertDontSee('ExportWizardTheFifth'); - } - - public function test_page_pdf_export_converts_iframes_to_links() - { - $page = Page::query()->first()->forceFill([ - 'html' => '', - ]); - $page->save(); - - $pdfHtml = ''; - $mockPdfGenerator = $this->mock(PdfGenerator::class); - $mockPdfGenerator->shouldReceive('fromHtml') - ->with(\Mockery::capture($pdfHtml)) - ->andReturn(''); - $mockPdfGenerator->shouldReceive('getActiveEngine')->andReturn(PdfGenerator::ENGINE_DOMPDF); - - $this->asEditor()->get($page->getUrl('/export/pdf')); - $this->assertStringNotContainsString('iframe>', $pdfHtml); - $this->assertStringContainsString('

    https://www.youtube.com/embed/ShqUjt33uOs

    ', $pdfHtml); - } - - public function test_page_pdf_export_opens_details_blocks() - { - $page = $this->entities->page()->forceFill([ - 'html' => '
    Hello

    Content!

    ', - ]); - $page->save(); - - $pdfHtml = ''; - $mockPdfGenerator = $this->mock(PdfGenerator::class); - $mockPdfGenerator->shouldReceive('fromHtml') - ->with(\Mockery::capture($pdfHtml)) - ->andReturn(''); - $mockPdfGenerator->shouldReceive('getActiveEngine')->andReturn(PdfGenerator::ENGINE_DOMPDF); - - $this->asEditor()->get($page->getUrl('/export/pdf')); - $this->assertStringContainsString('
    entities->page(); - - $resp = $this->asEditor()->get($page->getUrl('/export/markdown')); - $resp->assertStatus(200); - $resp->assertSee($page->name); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.md"'); - } - - public function test_page_markdown_export_uses_existing_markdown_if_apparent() - { - $page = $this->entities->page()->forceFill([ - 'markdown' => '# A header', - 'html' => '

    Dogcat

    ', - ]); - $page->save(); - - $resp = $this->asEditor()->get($page->getUrl('/export/markdown')); - $resp->assertSee('A header'); - $resp->assertDontSee('Dogcat'); - } - - public function test_page_markdown_export_converts_html_where_no_markdown() - { - $page = $this->entities->page()->forceFill([ - 'markdown' => '', - 'html' => '

    Dogcat

    Some bold text

    ', - ]); - $page->save(); - - $resp = $this->asEditor()->get($page->getUrl('/export/markdown')); - $resp->assertSee("# Dogcat\n\nSome **bold** text"); - } - - public function test_chapter_markdown_export() - { - $chapter = $this->entities->chapter(); - $page = $chapter->pages()->first(); - $resp = $this->asEditor()->get($chapter->getUrl('/export/markdown')); - - $resp->assertSee('# ' . $chapter->name); - $resp->assertSee('# ' . $page->name); - } - - public function test_book_markdown_export() - { - $book = Book::query()->whereHas('pages')->whereHas('chapters')->first(); - $chapter = $book->chapters()->first(); - $page = $chapter->pages()->first(); - $resp = $this->asEditor()->get($book->getUrl('/export/markdown')); - - $resp->assertSee('# ' . $book->name); - $resp->assertSee('# ' . $chapter->name); - $resp->assertSee('# ' . $page->name); - } - - public function test_book_markdown_export_concats_immediate_pages_with_newlines() - { - /** @var Book $book */ - $book = Book::query()->whereHas('pages')->first(); - - $this->asEditor()->get($book->getUrl('/create-page')); - $this->get($book->getUrl('/create-page')); - - [$pageA, $pageB] = $book->pages()->where('chapter_id', '=', 0)->get(); - $pageA->html = '

    hello tester

    '; - $pageA->save(); - $pageB->name = 'The second page in this test'; - $pageB->save(); - - $resp = $this->get($book->getUrl('/export/markdown')); - $resp->assertDontSee('hello tester# The second page in this test'); - $resp->assertSee("hello tester\n\n# The second page in this test"); - } - - public function test_export_option_only_visible_and_accessible_with_permission() - { - $book = Book::query()->whereHas('pages')->whereHas('chapters')->first(); - $chapter = $book->chapters()->first(); - $page = $chapter->pages()->first(); - $entities = [$book, $chapter, $page]; - $user = $this->users->viewer(); - $this->actingAs($user); - - foreach ($entities as $entity) { - $resp = $this->get($entity->getUrl()); - $resp->assertSee('/export/pdf'); - } - - $this->permissions->removeUserRolePermissions($user, ['content-export']); - - foreach ($entities as $entity) { - $resp = $this->get($entity->getUrl()); - $resp->assertDontSee('/export/pdf'); - $resp = $this->get($entity->getUrl('/export/pdf')); - $this->assertPermissionError($resp); - } - } - - public function test_wkhtmltopdf_only_used_when_allow_untrusted_is_true() - { - $page = $this->entities->page(); - - config()->set('exports.snappy.pdf_binary', '/abc123'); - config()->set('app.allow_untrusted_server_fetching', false); - - $resp = $this->asEditor()->get($page->getUrl('/export/pdf')); - $resp->assertStatus(200); // Sucessful response with invalid snappy binary indicates dompdf usage. - - config()->set('app.allow_untrusted_server_fetching', true); - $resp = $this->get($page->getUrl('/export/pdf')); - $resp->assertStatus(500); // Bad response indicates wkhtml usage - } - - public function test_pdf_command_option_used_if_set() - { - $page = $this->entities->page(); - $command = 'cp {input_html_path} {output_pdf_path}'; - config()->set('exports.pdf_command', $command); - - $resp = $this->asEditor()->get($page->getUrl('/export/pdf')); - $download = $resp->getContent(); - - $this->assertStringContainsString(e($page->name), $download); - $this->assertStringContainsString('set('exports.pdf_command', $command); - - $this->assertThrows(function () use ($page) { - $this->withoutExceptionHandling()->asEditor()->get($page->getUrl('/export/pdf')); - }, PdfExportException::class); - } - - public function test_pdf_command_option_errors_if_command_returns_error_status() - { - $page = $this->entities->page(); - $command = 'exit 1'; - config()->set('exports.pdf_command', $command); - - $this->assertThrows(function () use ($page) { - $this->withoutExceptionHandling()->asEditor()->get($page->getUrl('/export/pdf')); - }, PdfExportException::class); - } - - public function test_pdf_command_timout_option_limits_export_time() - { - $page = $this->entities->page(); - $command = 'php -r \'sleep(4);\''; - config()->set('exports.pdf_command', $command); - config()->set('exports.pdf_command_timeout', 1); - - $this->assertThrows(function () use ($page) { - $start = time(); - $this->withoutExceptionHandling()->asEditor()->get($page->getUrl('/export/pdf')); - - $this->assertTrue(time() < ($start + 3)); - }, PdfExportException::class, - "PDF Export via command failed due to timeout at 1 second(s)"); - } - - public function test_html_exports_contain_csp_meta_tag() - { - $entities = [ - $this->entities->page(), - $this->entities->book(), - $this->entities->chapter(), - ]; - - foreach ($entities as $entity) { - $resp = $this->asEditor()->get($entity->getUrl('/export/html')); - $this->withHtml($resp)->assertElementExists('head meta[http-equiv="Content-Security-Policy"][content*="script-src "]'); - } - } - - public function test_html_exports_contain_body_classes_for_export_identification() - { - $page = $this->entities->page(); - - $resp = $this->asEditor()->get($page->getUrl('/export/html')); - $this->withHtml($resp)->assertElementExists('body.export.export-format-html.export-engine-none'); - } -} diff --git a/tests/Exports/ExportUiTest.php b/tests/Exports/ExportUiTest.php new file mode 100644 index 00000000000..77b26ad8929 --- /dev/null +++ b/tests/Exports/ExportUiTest.php @@ -0,0 +1,33 @@ +whereHas('pages')->whereHas('chapters')->first(); + $chapter = $book->chapters()->first(); + $page = $chapter->pages()->first(); + $entities = [$book, $chapter, $page]; + $user = $this->users->viewer(); + $this->actingAs($user); + + foreach ($entities as $entity) { + $resp = $this->get($entity->getUrl()); + $resp->assertSee('/export/pdf'); + } + + $this->permissions->removeUserRolePermissions($user, ['content-export']); + + foreach ($entities as $entity) { + $resp = $this->get($entity->getUrl()); + $resp->assertDontSee('/export/pdf'); + $resp = $this->get($entity->getUrl('/export/pdf')); + $this->assertPermissionError($resp); + } + } +} diff --git a/tests/Exports/HtmlExportTest.php b/tests/Exports/HtmlExportTest.php new file mode 100644 index 00000000000..069cf280167 --- /dev/null +++ b/tests/Exports/HtmlExportTest.php @@ -0,0 +1,253 @@ +entities->page(); + $this->asEditor(); + + $resp = $this->get($page->getUrl('/export/html')); + $resp->assertStatus(200); + $resp->assertSee($page->name); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.html"'); + } + + public function test_book_html_export() + { + $page = $this->entities->page(); + $book = $page->book; + $this->asEditor(); + + $resp = $this->get($book->getUrl('/export/html')); + $resp->assertStatus(200); + $resp->assertSee($book->name); + $resp->assertSee($page->name); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.html"'); + } + + public function test_book_html_export_shows_html_descriptions() + { + $book = $this->entities->bookHasChaptersAndPages(); + $chapter = $book->chapters()->first(); + $book->description_html = '

    A description with HTML within!

    '; + $chapter->description_html = '

    A chapter description with HTML within!

    '; + $book->save(); + $chapter->save(); + + $resp = $this->asEditor()->get($book->getUrl('/export/html')); + $resp->assertSee($book->description_html, false); + $resp->assertSee($chapter->description_html, false); + } + + public function test_chapter_html_export() + { + $chapter = $this->entities->chapter(); + $page = $chapter->pages[0]; + $this->asEditor(); + + $resp = $this->get($chapter->getUrl('/export/html')); + $resp->assertStatus(200); + $resp->assertSee($chapter->name); + $resp->assertSee($page->name); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.html"'); + } + + public function test_chapter_html_export_shows_html_descriptions() + { + $chapter = $this->entities->chapter(); + $chapter->description_html = '

    A description with HTML within!

    '; + $chapter->save(); + + $resp = $this->asEditor()->get($chapter->getUrl('/export/html')); + $resp->assertSee($chapter->description_html, false); + } + + public function test_page_html_export_contains_custom_head_if_set() + { + $page = $this->entities->page(); + + $customHeadContent = ''; + $this->setSettings(['app-custom-head' => $customHeadContent]); + + $resp = $this->asEditor()->get($page->getUrl('/export/html')); + $resp->assertSee($customHeadContent, false); + } + + public function test_page_html_export_does_not_break_with_only_comments_in_custom_head() + { + $page = $this->entities->page(); + + $customHeadContent = ''; + $this->setSettings(['app-custom-head' => $customHeadContent]); + + $resp = $this->asEditor()->get($page->getUrl('/export/html')); + $resp->assertStatus(200); + $resp->assertSee($customHeadContent, false); + } + + public function test_page_html_export_use_absolute_dates() + { + $page = $this->entities->page(); + + $resp = $this->asEditor()->get($page->getUrl('/export/html')); + $resp->assertSee($page->created_at->isoFormat('D MMMM Y HH:mm:ss')); + $resp->assertDontSee($page->created_at->diffForHumans()); + $resp->assertSee($page->updated_at->isoFormat('D MMMM Y HH:mm:ss')); + $resp->assertDontSee($page->updated_at->diffForHumans()); + } + + public function test_page_export_does_not_include_user_or_revision_links() + { + $page = $this->entities->page(); + + $resp = $this->asEditor()->get($page->getUrl('/export/html')); + $resp->assertDontSee($page->getUrl('/revisions')); + $resp->assertDontSee($page->createdBy->getProfileUrl()); + $resp->assertSee($page->createdBy->name); + } + + public function test_page_export_sets_right_data_type_for_svg_embeds() + { + $page = $this->entities->page(); + Storage::disk('local')->makeDirectory('uploads/images/gallery'); + Storage::disk('local')->put('uploads/images/gallery/svg_test.svg', ''); + $page->html = ''; + $page->save(); + + $this->asEditor(); + $resp = $this->get($page->getUrl('/export/html')); + Storage::disk('local')->delete('uploads/images/gallery/svg_test.svg'); + + $resp->assertStatus(200); + $resp->assertSee(''; + $page->save(); + + $resp = $this->asEditor()->get($page->getUrl('/export/html')); + Storage::disk('local')->delete('uploads/images/gallery/svg_test.svg'); + Storage::disk('local')->delete('uploads/images/gallery/svg_test2.svg'); + + $resp->assertDontSee('http://localhost/uploads/images/gallery/svg_test'); + } + + public function test_page_export_contained_html_image_fetches_only_run_when_url_points_to_image_upload_folder() + { + $page = $this->entities->page(); + $page->html = '' + . '' + . ''; + $storageDisk = Storage::disk('local'); + $storageDisk->makeDirectory('uploads/images/gallery'); + $storageDisk->put('uploads/images/gallery/svg_test.svg', 'good'); + $storageDisk->put('uploads/svg_test.svg', 'bad'); + $page->save(); + + $resp = $this->asEditor()->get($page->getUrl('/export/html')); + + $storageDisk->delete('uploads/images/gallery/svg_test.svg'); + $storageDisk->delete('uploads/svg_test.svg'); + + $resp->assertDontSee('http://localhost/uploads/images/gallery/svg_test.svg', false); + $resp->assertSee('http://localhost/uploads/svg_test.svg'); + $resp->assertSee('src="/uploads/svg_test.svg"', false); + } + + public function test_page_export_contained_html_does_not_allow_upward_traversal_with_local() + { + $contents = file_get_contents(public_path('.htaccess')); + config()->set('filesystems.images', 'local'); + + $page = $this->entities->page(); + $page->html = ''; + $page->save(); + + $resp = $this->asEditor()->get($page->getUrl('/export/html')); + $resp->assertDontSee(base64_encode($contents)); + } + + public function test_page_export_contained_html_does_not_allow_upward_traversal_with_local_secure() + { + $testFilePath = storage_path('logs/test.txt'); + config()->set('filesystems.images', 'local_secure'); + file_put_contents($testFilePath, 'I am a cat'); + + $page = $this->entities->page(); + $page->html = ''; + $page->save(); + + $resp = $this->asEditor()->get($page->getUrl('/export/html')); + $resp->assertDontSee(base64_encode('I am a cat')); + unlink($testFilePath); + } + + public function test_exports_removes_scripts_from_custom_head() + { + $entities = [ + Page::query()->first(), Chapter::query()->first(), Book::query()->first(), + ]; + setting()->put('app-custom-head', ''); + + foreach ($entities as $entity) { + $resp = $this->asEditor()->get($entity->getUrl('/export/html')); + $resp->assertDontSee('window.donkey'); + $resp->assertDontSee('assertSee('.my-test-class { color: red; }'); + } + } + + public function test_page_export_with_deleted_creator_and_updater() + { + $user = $this->users->viewer(['name' => 'ExportWizardTheFifth']); + $page = $this->entities->page(); + $page->created_by = $user->id; + $page->updated_by = $user->id; + $page->save(); + + $resp = $this->asEditor()->get($page->getUrl('/export/html')); + $resp->assertSee('ExportWizardTheFifth'); + + $user->delete(); + $resp = $this->get($page->getUrl('/export/html')); + $resp->assertStatus(200); + $resp->assertDontSee('ExportWizardTheFifth'); + } + + public function test_html_exports_contain_csp_meta_tag() + { + $entities = [ + $this->entities->page(), + $this->entities->book(), + $this->entities->chapter(), + ]; + + foreach ($entities as $entity) { + $resp = $this->asEditor()->get($entity->getUrl('/export/html')); + $this->withHtml($resp)->assertElementExists('head meta[http-equiv="Content-Security-Policy"][content*="script-src "]'); + } + } + + public function test_html_exports_contain_body_classes_for_export_identification() + { + $page = $this->entities->page(); + + $resp = $this->asEditor()->get($page->getUrl('/export/html')); + $this->withHtml($resp)->assertElementExists('body.export.export-format-html.export-engine-none'); + } +} diff --git a/tests/Exports/MarkdownExportTest.php b/tests/Exports/MarkdownExportTest.php new file mode 100644 index 00000000000..05ebbc68d75 --- /dev/null +++ b/tests/Exports/MarkdownExportTest.php @@ -0,0 +1,85 @@ +entities->page(); + + $resp = $this->asEditor()->get($page->getUrl('/export/markdown')); + $resp->assertStatus(200); + $resp->assertSee($page->name); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.md"'); + } + + public function test_page_markdown_export_uses_existing_markdown_if_apparent() + { + $page = $this->entities->page()->forceFill([ + 'markdown' => '# A header', + 'html' => '

    Dogcat

    ', + ]); + $page->save(); + + $resp = $this->asEditor()->get($page->getUrl('/export/markdown')); + $resp->assertSee('A header'); + $resp->assertDontSee('Dogcat'); + } + + public function test_page_markdown_export_converts_html_where_no_markdown() + { + $page = $this->entities->page()->forceFill([ + 'markdown' => '', + 'html' => '

    Dogcat

    Some bold text

    ', + ]); + $page->save(); + + $resp = $this->asEditor()->get($page->getUrl('/export/markdown')); + $resp->assertSee("# Dogcat\n\nSome **bold** text"); + } + + public function test_chapter_markdown_export() + { + $chapter = $this->entities->chapter(); + $page = $chapter->pages()->first(); + $resp = $this->asEditor()->get($chapter->getUrl('/export/markdown')); + + $resp->assertSee('# ' . $chapter->name); + $resp->assertSee('# ' . $page->name); + } + + public function test_book_markdown_export() + { + $book = Book::query()->whereHas('pages')->whereHas('chapters')->first(); + $chapter = $book->chapters()->first(); + $page = $chapter->pages()->first(); + $resp = $this->asEditor()->get($book->getUrl('/export/markdown')); + + $resp->assertSee('# ' . $book->name); + $resp->assertSee('# ' . $chapter->name); + $resp->assertSee('# ' . $page->name); + } + + public function test_book_markdown_export_concats_immediate_pages_with_newlines() + { + /** @var Book $book */ + $book = Book::query()->whereHas('pages')->first(); + + $this->asEditor()->get($book->getUrl('/create-page')); + $this->get($book->getUrl('/create-page')); + + [$pageA, $pageB] = $book->pages()->where('chapter_id', '=', 0)->get(); + $pageA->html = '

    hello tester

    '; + $pageA->save(); + $pageB->name = 'The second page in this test'; + $pageB->save(); + + $resp = $this->get($book->getUrl('/export/markdown')); + $resp->assertDontSee('hello tester# The second page in this test'); + $resp->assertSee("hello tester\n\n# The second page in this test"); + } +} diff --git a/tests/Exports/PdfExportTest.php b/tests/Exports/PdfExportTest.php new file mode 100644 index 00000000000..9d85c69e23a --- /dev/null +++ b/tests/Exports/PdfExportTest.php @@ -0,0 +1,146 @@ +entities->page(); + $this->asEditor(); + + $resp = $this->get($page->getUrl('/export/pdf')); + $resp->assertStatus(200); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.pdf"'); + } + + public function test_book_pdf_export() + { + $page = $this->entities->page(); + $book = $page->book; + $this->asEditor(); + + $resp = $this->get($book->getUrl('/export/pdf')); + $resp->assertStatus(200); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.pdf"'); + } + + public function test_chapter_pdf_export() + { + $chapter = $this->entities->chapter(); + $this->asEditor(); + + $resp = $this->get($chapter->getUrl('/export/pdf')); + $resp->assertStatus(200); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.pdf"'); + } + + + public function test_page_pdf_export_converts_iframes_to_links() + { + $page = Page::query()->first()->forceFill([ + 'html' => '', + ]); + $page->save(); + + $pdfHtml = ''; + $mockPdfGenerator = $this->mock(PdfGenerator::class); + $mockPdfGenerator->shouldReceive('fromHtml') + ->with(\Mockery::capture($pdfHtml)) + ->andReturn(''); + $mockPdfGenerator->shouldReceive('getActiveEngine')->andReturn(PdfGenerator::ENGINE_DOMPDF); + + $this->asEditor()->get($page->getUrl('/export/pdf')); + $this->assertStringNotContainsString('iframe>', $pdfHtml); + $this->assertStringContainsString('

    https://www.youtube.com/embed/ShqUjt33uOs

    ', $pdfHtml); + } + + public function test_page_pdf_export_opens_details_blocks() + { + $page = $this->entities->page()->forceFill([ + 'html' => '
    Hello

    Content!

    ', + ]); + $page->save(); + + $pdfHtml = ''; + $mockPdfGenerator = $this->mock(PdfGenerator::class); + $mockPdfGenerator->shouldReceive('fromHtml') + ->with(\Mockery::capture($pdfHtml)) + ->andReturn(''); + $mockPdfGenerator->shouldReceive('getActiveEngine')->andReturn(PdfGenerator::ENGINE_DOMPDF); + + $this->asEditor()->get($page->getUrl('/export/pdf')); + $this->assertStringContainsString('
    entities->page(); + + config()->set('exports.snappy.pdf_binary', '/abc123'); + config()->set('app.allow_untrusted_server_fetching', false); + + $resp = $this->asEditor()->get($page->getUrl('/export/pdf')); + $resp->assertStatus(200); // Sucessful response with invalid snappy binary indicates dompdf usage. + + config()->set('app.allow_untrusted_server_fetching', true); + $resp = $this->get($page->getUrl('/export/pdf')); + $resp->assertStatus(500); // Bad response indicates wkhtml usage + } + + public function test_pdf_command_option_used_if_set() + { + $page = $this->entities->page(); + $command = 'cp {input_html_path} {output_pdf_path}'; + config()->set('exports.pdf_command', $command); + + $resp = $this->asEditor()->get($page->getUrl('/export/pdf')); + $download = $resp->getContent(); + + $this->assertStringContainsString(e($page->name), $download); + $this->assertStringContainsString('set('exports.pdf_command', $command); + + $this->assertThrows(function () use ($page) { + $this->withoutExceptionHandling()->asEditor()->get($page->getUrl('/export/pdf')); + }, PdfExportException::class); + } + + public function test_pdf_command_option_errors_if_command_returns_error_status() + { + $page = $this->entities->page(); + $command = 'exit 1'; + config()->set('exports.pdf_command', $command); + + $this->assertThrows(function () use ($page) { + $this->withoutExceptionHandling()->asEditor()->get($page->getUrl('/export/pdf')); + }, PdfExportException::class); + } + + public function test_pdf_command_timout_option_limits_export_time() + { + $page = $this->entities->page(); + $command = 'php -r \'sleep(4);\''; + config()->set('exports.pdf_command', $command); + config()->set('exports.pdf_command_timeout', 1); + + $this->assertThrows(function () use ($page) { + $start = time(); + $this->withoutExceptionHandling()->asEditor()->get($page->getUrl('/export/pdf')); + + $this->assertTrue(time() < ($start + 3)); + }, PdfExportException::class, + "PDF Export via command failed due to timeout at 1 second(s)"); + } +} diff --git a/tests/Exports/TextExportTest.php b/tests/Exports/TextExportTest.php new file mode 100644 index 00000000000..c593a6585cb --- /dev/null +++ b/tests/Exports/TextExportTest.php @@ -0,0 +1,88 @@ +entities->page(); + $this->asEditor(); + + $resp = $this->get($page->getUrl('/export/plaintext')); + $resp->assertStatus(200); + $resp->assertSee($page->name); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.txt"'); + } + + public function test_book_text_export() + { + $book = $this->entities->bookHasChaptersAndPages(); + $directPage = $book->directPages()->first(); + $chapter = $book->chapters()->first(); + $chapterPage = $chapter->pages()->first(); + $this->entities->updatePage($directPage, ['html' => '

    My awesome page

    ']); + $this->entities->updatePage($chapterPage, ['html' => '

    My little nested page

    ']); + $this->asEditor(); + + $resp = $this->get($book->getUrl('/export/plaintext')); + $resp->assertStatus(200); + $resp->assertSee($book->name); + $resp->assertSee($chapterPage->name); + $resp->assertSee($chapter->name); + $resp->assertSee($directPage->name); + $resp->assertSee('My awesome page'); + $resp->assertSee('My little nested page'); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.txt"'); + } + + public function test_book_text_export_format() + { + $entities = $this->entities->createChainBelongingToUser($this->users->viewer()); + $this->entities->updatePage($entities['page'], ['html' => '

    My great page

    Full of great stuff

    ', 'name' => 'My wonderful page!']); + $entities['chapter']->name = 'Export chapter'; + $entities['chapter']->description = "A test chapter to be exported\nIt has loads of info within"; + $entities['book']->name = 'Export Book'; + $entities['book']->description = "This is a book with stuff to export"; + $entities['chapter']->save(); + $entities['book']->save(); + + $resp = $this->asEditor()->get($entities['book']->getUrl('/export/plaintext')); + + $expected = "Export Book\nThis is a book with stuff to export\n\nExport chapter\nA test chapter to be exported\nIt has loads of info within\n\n"; + $expected .= "My wonderful page!\nMy great page Full of great stuff"; + $resp->assertSee($expected); + } + + public function test_chapter_text_export() + { + $chapter = $this->entities->chapter(); + $page = $chapter->pages[0]; + $this->entities->updatePage($page, ['html' => '

    This is content within the page!

    ']); + $this->asEditor(); + + $resp = $this->get($chapter->getUrl('/export/plaintext')); + $resp->assertStatus(200); + $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"'); + } + + public function test_chapter_text_export_format() + { + $entities = $this->entities->createChainBelongingToUser($this->users->viewer()); + $this->entities->updatePage($entities['page'], ['html' => '

    My great page

    Full of great stuff

    ', 'name' => 'My wonderful page!']); + $entities['chapter']->name = 'Export chapter'; + $entities['chapter']->description = "A test chapter to be exported\nIt has loads of info within"; + $entities['chapter']->save(); + + $resp = $this->asEditor()->get($entities['book']->getUrl('/export/plaintext')); + + $expected = "Export chapter\nA test chapter to be exported\nIt has loads of info within\n\n"; + $expected .= "My wonderful page!\nMy great page Full of great stuff"; + $resp->assertSee($expected); + } +} diff --git a/tests/Exports/ZipExportTest.php b/tests/Exports/ZipExportTest.php new file mode 100644 index 00000000000..ebe07d052bc --- /dev/null +++ b/tests/Exports/ZipExportTest.php @@ -0,0 +1,403 @@ +entities->page(); + $response = $this->asEditor()->get($page->getUrl("/export/zip")); + + $zipData = $response->streamedContent(); + $zipFile = tempnam(sys_get_temp_dir(), 'bstesta-'); + file_put_contents($zipFile, $zipData); + $zip = new ZipArchive(); + $zip->open($zipFile, ZipArchive::RDONLY); + + $this->assertNotFalse($zip->locateName('data.json')); + $this->assertNotFalse($zip->locateName('files/')); + + $data = json_decode($zip->getFromName('data.json'), true); + $this->assertIsArray($data); + $this->assertGreaterThan(0, count($data)); + + $zip->close(); + unlink($zipFile); + } + + public function test_export_metadata() + { + $page = $this->entities->page(); + $zipResp = $this->asEditor()->get($page->getUrl("/export/zip")); + $zip = $this->extractZipResponse($zipResp); + + $this->assertEquals($page->id, $zip->data['page']['id'] ?? null); + $this->assertArrayNotHasKey('book', $zip->data); + $this->assertArrayNotHasKey('chapter', $zip->data); + + $now = time(); + $date = Carbon::parse($zip->data['exported_at'])->unix(); + $this->assertLessThan($now + 2, $date); + $this->assertGreaterThan($now - 2, $date); + + $version = trim(file_get_contents(base_path('version'))); + $this->assertEquals($version, $zip->data['instance']['version']); + + $zipInstanceId = $zip->data['instance']['id']; + $instanceId = setting('instance-id'); + $this->assertNotEmpty($instanceId); + $this->assertEquals($instanceId, $zipInstanceId); + } + + public function test_page_export() + { + $page = $this->entities->page(); + $zipResp = $this->asEditor()->get($page->getUrl("/export/zip")); + $zip = $this->extractZipResponse($zipResp); + + $pageData = $zip->data['page']; + $this->assertEquals([ + 'id' => $page->id, + 'name' => $page->name, + 'html' => (new PageContent($page))->render(), + 'priority' => $page->priority, + 'attachments' => [], + 'images' => [], + 'tags' => [], + ], $pageData); + } + + public function test_page_export_with_markdown() + { + $page = $this->entities->page(); + $markdown = "# My page\n\nwritten in markdown for export\n"; + $page->markdown = $markdown; + $page->save(); + + $zipResp = $this->asEditor()->get($page->getUrl("/export/zip")); + $zip = $this->extractZipResponse($zipResp); + + $pageData = $zip->data['page']; + $this->assertEquals($markdown, $pageData['markdown']); + $this->assertNotEmpty($pageData['html']); + } + + public function test_page_export_with_tags() + { + $page = $this->entities->page(); + $page->tags()->saveMany([ + new Tag(['name' => 'Exporty', 'value' => 'Content', 'order' => 1]), + new Tag(['name' => 'Another', 'value' => '', 'order' => 2]), + ]); + + $zipResp = $this->asEditor()->get($page->getUrl("/export/zip")); + $zip = $this->extractZipResponse($zipResp); + + $pageData = $zip->data['page']; + $this->assertEquals([ + [ + 'name' => 'Exporty', + 'value' => 'Content', + ], + [ + 'name' => 'Another', + 'value' => '', + ] + ], $pageData['tags']); + } + + public function test_page_export_with_images() + { + $this->asEditor(); + $page = $this->entities->page(); + $result = $this->files->uploadGalleryImageToPage($this, $page); + $displayThumb = $result['response']->thumbs->gallery ?? ''; + $page->html = '

    My image

    '; + $page->save(); + $image = Image::findOrFail($result['response']->id); + + $zipResp = $this->asEditor()->get($page->getUrl("/export/zip")); + $zip = $this->extractZipResponse($zipResp); + $pageData = $zip->data['page']; + + $this->assertCount(1, $pageData['images']); + $imageData = $pageData['images'][0]; + $this->assertEquals($image->id, $imageData['id']); + $this->assertEquals($image->name, $imageData['name']); + $this->assertEquals('gallery', $imageData['type']); + $this->assertNotEmpty($imageData['file']); + + $filePath = $zip->extractPath("files/{$imageData['file']}"); + $this->assertFileExists($filePath); + $this->assertEquals(file_get_contents(public_path($image->path)), file_get_contents($filePath)); + + $this->assertEquals('

    My image

    ', $pageData['html']); + } + + public function test_page_export_file_attachments() + { + $contents = 'My great attachment content!'; + + $page = $this->entities->page(); + $this->asAdmin(); + $attachment = $this->files->uploadAttachmentDataToPage($this, $page, 'PageAttachmentExport.txt', $contents, 'text/plain'); + + $zipResp = $this->get($page->getUrl("/export/zip")); + $zip = $this->extractZipResponse($zipResp); + + $pageData = $zip->data['page']; + $this->assertCount(1, $pageData['attachments']); + + $attachmentData = $pageData['attachments'][0]; + $this->assertEquals('PageAttachmentExport.txt', $attachmentData['name']); + $this->assertEquals($attachment->id, $attachmentData['id']); + $this->assertArrayNotHasKey('link', $attachmentData); + $this->assertNotEmpty($attachmentData['file']); + + $fileRef = $attachmentData['file']; + $filePath = $zip->extractPath("/files/$fileRef"); + $this->assertFileExists($filePath); + $this->assertEquals($contents, file_get_contents($filePath)); + } + + public function test_page_export_link_attachments() + { + $page = $this->entities->page(); + $this->asEditor(); + $attachment = Attachment::factory()->create([ + 'name' => 'My link attachment for export', + 'path' => 'https://example.com/cats', + 'external' => true, + 'uploaded_to' => $page->id, + 'order' => 1, + ]); + + $zipResp = $this->get($page->getUrl("/export/zip")); + $zip = $this->extractZipResponse($zipResp); + + $pageData = $zip->data['page']; + $this->assertCount(1, $pageData['attachments']); + + $attachmentData = $pageData['attachments'][0]; + $this->assertEquals('My link attachment for export', $attachmentData['name']); + $this->assertEquals($attachment->id, $attachmentData['id']); + $this->assertEquals('https://example.com/cats', $attachmentData['link']); + $this->assertArrayNotHasKey('file', $attachmentData); + } + + public function test_book_export() + { + $book = $this->entities->book(); + $book->tags()->saveMany(Tag::factory()->count(2)->make()); + + $zipResp = $this->asEditor()->get($book->getUrl("/export/zip")); + $zip = $this->extractZipResponse($zipResp); + $this->assertArrayHasKey('book', $zip->data); + + $bookData = $zip->data['book']; + $this->assertEquals($book->id, $bookData['id']); + $this->assertEquals($book->name, $bookData['name']); + $this->assertEquals($book->descriptionHtml(), $bookData['description_html']); + $this->assertCount(2, $bookData['tags']); + $this->assertCount($book->directPages()->count(), $bookData['pages']); + $this->assertCount($book->chapters()->count(), $bookData['chapters']); + $this->assertArrayNotHasKey('cover', $bookData); + } + + public function test_book_export_with_cover_image() + { + $book = $this->entities->book(); + $bookRepo = $this->app->make(BookRepo::class); + $coverImageFile = $this->files->uploadedImage('cover.png'); + $bookRepo->updateCoverImage($book, $coverImageFile); + $coverImage = $book->cover()->first(); + + $zipResp = $this->asEditor()->get($book->getUrl("/export/zip")); + $zip = $this->extractZipResponse($zipResp); + + $this->assertArrayHasKey('cover', $zip->data['book']); + $coverRef = $zip->data['book']['cover']; + $coverPath = $zip->extractPath("/files/$coverRef"); + $this->assertFileExists($coverPath); + $this->assertEquals(file_get_contents(public_path($coverImage->path)), file_get_contents($coverPath)); + } + + public function test_chapter_export() + { + $chapter = $this->entities->chapter(); + $chapter->tags()->saveMany(Tag::factory()->count(2)->make()); + + $zipResp = $this->asEditor()->get($chapter->getUrl("/export/zip")); + $zip = $this->extractZipResponse($zipResp); + $this->assertArrayHasKey('chapter', $zip->data); + + $chapterData = $zip->data['chapter']; + $this->assertEquals($chapter->id, $chapterData['id']); + $this->assertEquals($chapter->name, $chapterData['name']); + $this->assertEquals($chapter->descriptionHtml(), $chapterData['description_html']); + $this->assertCount(2, $chapterData['tags']); + $this->assertEquals($chapter->priority, $chapterData['priority']); + $this->assertCount($chapter->pages()->count(), $chapterData['pages']); + } + + + public function test_cross_reference_links_are_converted() + { + $book = $this->entities->bookHasChaptersAndPages(); + $chapter = $book->chapters()->first(); + $page = $chapter->pages()->first(); + + $book->description_html = '

    Link to chapter

    '; + $book->save(); + $chapter->description_html = '

    Link to page

    '; + $chapter->save(); + $page->html = '

    Link to book

    '; + $page->save(); + + $zipResp = $this->asEditor()->get($book->getUrl("/export/zip")); + $zip = $this->extractZipResponse($zipResp); + $bookData = $zip->data['book']; + $chapterData = $bookData['chapters'][0]; + $pageData = $chapterData['pages'][0]; + + $this->assertStringContainsString('href="[[bsexport:chapter:' . $chapter->id . ']]"', $bookData['description_html']); + $this->assertStringContainsString('href="[[bsexport:page:' . $page->id . ']]#section2"', $chapterData['description_html']); + $this->assertStringContainsString('href="[[bsexport:book:' . $book->id . ']]?view=true"', $pageData['html']); + } + + public function test_book_and_chapter_description_links_to_images_in_pages_are_converted() + { + $book = $this->entities->bookHasChaptersAndPages(); + $chapter = $book->chapters()->first(); + $page = $chapter->pages()->first(); + + $this->asEditor(); + $this->files->uploadGalleryImageToPage($this, $page); + /** @var Image $image */ + $image = Image::query()->where('type', '=', 'gallery') + ->where('uploaded_to', '=', $page->id)->first(); + + $book->description_html = '

    Link to image

    '; + $book->save(); + $chapter->description_html = '

    Link to image

    '; + $chapter->save(); + + $zipResp = $this->get($book->getUrl("/export/zip")); + $zip = $this->extractZipResponse($zipResp); + $bookData = $zip->data['book']; + $chapterData = $bookData['chapters'][0]; + + $this->assertStringContainsString('href="[[bsexport:image:' . $image->id . ']]"', $bookData['description_html']); + $this->assertStringContainsString('href="[[bsexport:image:' . $image->id . ']]"', $chapterData['description_html']); + } + + public function test_image_links_are_handled_when_using_external_storage_url() + { + $page = $this->entities->page(); + + $this->asEditor(); + $this->files->uploadGalleryImageToPage($this, $page); + /** @var Image $image */ + $image = Image::query()->where('type', '=', 'gallery') + ->where('uploaded_to', '=', $page->id)->first(); + + config()->set('filesystems.url', 'https://i.example.com/content'); + + $storageUrl = 'https://i.example.com/content/' . ltrim($image->path, '/'); + $page->html = '

    Original URLStorage URL

    '; + $page->save(); + + $zipResp = $this->get($page->getUrl("/export/zip")); + $zip = $this->extractZipResponse($zipResp); + $pageData = $zip->data['page']; + + $ref = '[[bsexport:image:' . $image->id . ']]'; + $this->assertStringContainsString("Original URLStorage URL", $pageData['html']); + } + + public function test_cross_reference_links_external_to_export_are_not_converted() + { + $page = $this->entities->page(); + $page->html = '

    Link to book

    '; + $page->save(); + + $zipResp = $this->asEditor()->get($page->getUrl("/export/zip")); + $zip = $this->extractZipResponse($zipResp); + $pageData = $zip->data['page']; + + $this->assertStringContainsString('href="' . $page->book->getUrl() . '"', $pageData['html']); + } + + public function test_attachments_links_are_converted() + { + $page = $this->entities->page(); + $attachment = Attachment::factory()->create([ + 'name' => 'My link attachment for export reference', + 'path' => 'https://example.com/cats/ref', + 'external' => true, + 'uploaded_to' => $page->id, + 'order' => 1, + ]); + + $page->html = '

    id}") . '?open=true">Link to attachment

    '; + $page->save(); + + $zipResp = $this->asEditor()->get($page->getUrl("/export/zip")); + $zip = $this->extractZipResponse($zipResp); + $pageData = $zip->data['page']; + + $this->assertStringContainsString('href="[[bsexport:attachment:' . $attachment->id . ']]?open=true"', $pageData['html']); + } + + public function test_links_in_markdown_are_parsed() + { + $chapter = $this->entities->chapterHasPages(); + $page = $chapter->pages()->first(); + + $page->markdown = "[Link to chapter]({$chapter->getUrl()})"; + $page->save(); + + $zipResp = $this->asEditor()->get($chapter->getUrl("/export/zip")); + $zip = $this->extractZipResponse($zipResp); + $pageData = $zip->data['chapter']['pages'][0]; + + $this->assertStringContainsString("[Link to chapter]([[bsexport:chapter:{$chapter->id}]])", $pageData['markdown']); + } + + protected function extractZipResponse(TestResponse $response): ZipResultData + { + $zipData = $response->streamedContent(); + $zipFile = tempnam(sys_get_temp_dir(), 'bstest-'); + + file_put_contents($zipFile, $zipData); + $extractDir = tempnam(sys_get_temp_dir(), 'bstestextracted-'); + if (file_exists($extractDir)) { + unlink($extractDir); + } + mkdir($extractDir); + + $zip = new ZipArchive(); + $zip->open($zipFile, ZipArchive::RDONLY); + $zip->extractTo($extractDir); + + $dataJson = file_get_contents($extractDir . DIRECTORY_SEPARATOR . "data.json"); + $data = json_decode($dataJson, true); + + return new ZipResultData( + $zipFile, + $extractDir, + $data, + ); + } +} diff --git a/tests/Exports/ZipExportValidatorTest.php b/tests/Exports/ZipExportValidatorTest.php new file mode 100644 index 00000000000..c453ef294d4 --- /dev/null +++ b/tests/Exports/ZipExportValidatorTest.php @@ -0,0 +1,93 @@ +filesToRemove as $file) { + unlink($file); + } + + parent::tearDown(); + } + + protected function getValidatorForData(array $zipData, array $files = []): ZipExportValidator + { + $upload = ZipTestHelper::zipUploadFromData($zipData, $files); + $path = $upload->getRealPath(); + $this->filesToRemove[] = $path; + $reader = new ZipExportReader($path); + return new ZipExportValidator($reader); + } + + public function test_ids_have_to_be_unique() + { + $validator = $this->getValidatorForData([ + 'book' => [ + 'id' => 4, + 'name' => 'My book', + 'pages' => [ + [ + 'id' => 4, + 'name' => 'My page', + 'markdown' => 'hello', + 'attachments' => [ + ['id' => 4, 'name' => 'Attachment A', 'link' => 'https://example.com'], + ['id' => 4, 'name' => 'Attachment B', 'link' => 'https://example.com'] + ], + 'images' => [ + ['id' => 4, 'name' => 'Image A', 'type' => 'gallery', 'file' => 'cat'], + ['id' => 4, 'name' => 'Image b', 'type' => 'gallery', 'file' => 'cat'], + ], + ], + ['id' => 4, 'name' => 'My page', 'markdown' => 'hello'], + ], + 'chapters' => [ + ['id' => 4, 'name' => 'Chapter 1'], + ['id' => 4, 'name' => 'Chapter 2'] + ] + ] + ], ['cat' => $this->files->testFilePath('test-image.png')]); + + $results = $validator->validate(); + $this->assertCount(4, $results); + + $expectedMessage = 'The id must be unique for the object type within the ZIP.'; + $this->assertEquals($expectedMessage, $results['book.pages.0.attachments.1.id']); + $this->assertEquals($expectedMessage, $results['book.pages.0.images.1.id']); + $this->assertEquals($expectedMessage, $results['book.pages.1.id']); + $this->assertEquals($expectedMessage, $results['book.chapters.1.id']); + } + + public function test_image_files_need_to_be_a_valid_detected_image_file() + { + $validator = $this->getValidatorForData([ + 'page' => [ + 'id' => 4, + 'name' => 'My page', + 'markdown' => 'hello', + 'images' => [ + ['id' => 4, 'name' => 'Image A', 'type' => 'gallery', 'file' => 'cat'], + ], + ] + ], ['cat' => $this->files->testFilePath('test-file.txt')]); + + $results = $validator->validate(); + $this->assertCount(1, $results); + + $this->assertEquals('The file needs to reference a file of type image/png,image/jpeg,image/gif,image/webp, found text/plain.', $results['page.images.0.file']); + } +} diff --git a/tests/Exports/ZipImportRunnerTest.php b/tests/Exports/ZipImportRunnerTest.php new file mode 100644 index 00000000000..d3af6df76a6 --- /dev/null +++ b/tests/Exports/ZipImportRunnerTest.php @@ -0,0 +1,396 @@ +runner = app()->make(ZipImportRunner::class); + } + + public function test_book_import() + { + $testImagePath = $this->files->testFilePath('test-image.png'); + $testFilePath = $this->files->testFilePath('test-file.txt'); + $import = ZipTestHelper::importFromData([], [ + 'book' => [ + 'id' => 5, + 'name' => 'Import test', + 'cover' => 'book_cover_image', + 'description_html' => '

    Link to chapter page

    ', + 'tags' => [ + ['name' => 'Animal', 'value' => 'Cat'], + ['name' => 'Category', 'value' => 'Test'], + ], + 'chapters' => [ + [ + 'id' => 6, + 'name' => 'Chapter A', + 'description_html' => '

    Link to book

    ', + 'priority' => 1, + 'tags' => [ + ['name' => 'Reviewed'], + ['name' => 'Category', 'value' => 'Test Chapter'], + ], + 'pages' => [ + [ + 'id' => 3, + 'name' => 'Page A', + 'priority' => 6, + 'html' => ' +

    Link to self

    +

    Link to cat image

    +

    Link to text attachment

    ', + 'tags' => [ + ['name' => 'Unreviewed'], + ], + 'attachments' => [ + [ + 'id' => 4, + 'name' => 'Text attachment', + 'file' => 'file_attachment' + ], + [ + 'name' => 'Cats', + 'link' => 'https://example.com/cats', + ] + ], + 'images' => [ + [ + 'id' => 1, + 'name' => 'Cat', + 'type' => 'gallery', + 'file' => 'cat_image' + ], + [ + 'id' => 2, + 'name' => 'Dog Drawing', + 'type' => 'drawio', + 'file' => 'dog_image' + ] + ], + ], + ], + ], + [ + 'name' => 'Chapter child B', + 'priority' => 5, + ] + ], + 'pages' => [ + [ + 'name' => 'Page C', + 'markdown' => '[Link to text]([[bsexport:attachment:4]]?scale=big)', + 'priority' => 3, + ] + ], + ], + ], [ + 'book_cover_image' => $testImagePath, + 'file_attachment' => $testFilePath, + 'cat_image' => $testImagePath, + 'dog_image' => $testImagePath, + ]); + + $this->asAdmin(); + /** @var Book $book */ + $book = $this->runner->run($import); + + // Book checks + $this->assertEquals('Import test', $book->name); + $this->assertFileExists(public_path($book->cover->path)); + $this->assertCount(2, $book->tags); + $this->assertEquals('Cat', $book->tags()->first()->value); + $this->assertCount(2, $book->chapters); + $this->assertEquals(1, $book->directPages()->count()); + + // Chapter checks + $chapterA = $book->chapters()->where('name', 'Chapter A')->first(); + $this->assertCount(2, $chapterA->tags); + $firstChapterTag = $chapterA->tags()->first(); + $this->assertEquals('Reviewed', $firstChapterTag->name); + $this->assertEquals('', $firstChapterTag->value); + $this->assertCount(1, $chapterA->pages); + + // Page checks + /** @var Page $pageA */ + $pageA = $chapterA->pages->first(); + $this->assertEquals('Page A', $pageA->name); + $this->assertCount(1, $pageA->tags); + $firstPageTag = $pageA->tags()->first(); + $this->assertEquals('Unreviewed', $firstPageTag->name); + $this->assertCount(2, $pageA->attachments); + $firstAttachment = $pageA->attachments->first(); + $this->assertEquals('Text attachment', $firstAttachment->name); + $this->assertFileEquals($testFilePath, storage_path($firstAttachment->path)); + $this->assertFalse($firstAttachment->external); + $secondAttachment = $pageA->attachments->last(); + $this->assertEquals('Cats', $secondAttachment->name); + $this->assertEquals('https://example.com/cats', $secondAttachment->path); + $this->assertTrue($secondAttachment->external); + $pageAImages = Image::where('uploaded_to', '=', $pageA->id)->whereIn('type', ['gallery', 'drawio'])->get(); + $this->assertCount(2, $pageAImages); + $this->assertEquals('Cat', $pageAImages[0]->name); + $this->assertEquals('gallery', $pageAImages[0]->type); + $this->assertFileEquals($testImagePath, public_path($pageAImages[0]->path)); + $this->assertEquals('Dog Drawing', $pageAImages[1]->name); + $this->assertEquals('drawio', $pageAImages[1]->type); + + // Book order check + $children = $book->getDirectVisibleChildren()->values()->all(); + $this->assertEquals($children[0]->name, 'Chapter A'); + $this->assertEquals($children[1]->name, 'Page C'); + $this->assertEquals($children[2]->name, 'Chapter child B'); + + // Reference checks + $textAttachmentUrl = $firstAttachment->getUrl(); + $this->assertStringContainsString($pageA->getUrl(), $book->description_html); + $this->assertStringContainsString($book->getUrl(), $chapterA->description_html); + $this->assertStringContainsString($pageA->getUrl(), $pageA->html); + $this->assertStringContainsString($pageAImages[0]->getThumb(1680, null, true), $pageA->html); + $this->assertStringContainsString($firstAttachment->getUrl(), $pageA->html); + + // Reference in converted markdown + $pageC = $children[1]; + $this->assertStringContainsString("href=\"{$textAttachmentUrl}?scale=big\"", $pageC->html); + + ZipTestHelper::deleteZipForImport($import); + } + + public function test_chapter_import() + { + $testImagePath = $this->files->testFilePath('test-image.png'); + $testFilePath = $this->files->testFilePath('test-file.txt'); + $parent = $this->entities->book(); + + $import = ZipTestHelper::importFromData([], [ + 'chapter' => [ + 'id' => 6, + 'name' => 'Chapter A', + 'description_html' => '

    Link to page

    ', + 'priority' => 1, + 'tags' => [ + ['name' => 'Reviewed', 'value' => '2024'], + ], + 'pages' => [ + [ + 'id' => 3, + 'name' => 'Page A', + 'priority' => 6, + 'html' => '

    Link to chapter

    +

    Link to dog drawing

    +

    Link to text attachment

    ', + 'tags' => [ + ['name' => 'Unreviewed'], + ], + 'attachments' => [ + [ + 'id' => 4, + 'name' => 'Text attachment', + 'file' => 'file_attachment' + ] + ], + 'images' => [ + [ + 'id' => 2, + 'name' => 'Dog Drawing', + 'type' => 'drawio', + 'file' => 'dog_image' + ] + ], + ], + [ + 'name' => 'Page B', + 'markdown' => '[Link to page A]([[bsexport:page:3]])', + 'priority' => 9, + ], + ], + ], + ], [ + 'file_attachment' => $testFilePath, + 'dog_image' => $testImagePath, + ]); + + $this->asAdmin(); + /** @var Chapter $chapter */ + $chapter = $this->runner->run($import, $parent); + + // Chapter checks + $this->assertEquals('Chapter A', $chapter->name); + $this->assertEquals($parent->id, $chapter->book_id); + $this->assertCount(1, $chapter->tags); + $firstChapterTag = $chapter->tags()->first(); + $this->assertEquals('Reviewed', $firstChapterTag->name); + $this->assertEquals('2024', $firstChapterTag->value); + $this->assertCount(2, $chapter->pages); + + // Page checks + /** @var Page $pageA */ + $pageA = $chapter->pages->first(); + $this->assertEquals('Page A', $pageA->name); + $this->assertCount(1, $pageA->tags); + $this->assertCount(1, $pageA->attachments); + $pageAImages = Image::where('uploaded_to', '=', $pageA->id)->whereIn('type', ['gallery', 'drawio'])->get(); + $this->assertCount(1, $pageAImages); + + // Reference checks + $attachment = $pageA->attachments->first(); + $this->assertStringContainsString($pageA->getUrl(), $chapter->description_html); + $this->assertStringContainsString($chapter->getUrl(), $pageA->html); + $this->assertStringContainsString($pageAImages[0]->url, $pageA->html); + $this->assertStringContainsString($attachment->getUrl(), $pageA->html); + + ZipTestHelper::deleteZipForImport($import); + } + + public function test_page_import() + { + $testImagePath = $this->files->testFilePath('test-image.png'); + $testFilePath = $this->files->testFilePath('test-file.txt'); + $parent = $this->entities->chapter(); + + $import = ZipTestHelper::importFromData([], [ + 'page' => [ + 'id' => 3, + 'name' => 'Page A', + 'priority' => 6, + 'html' => '

    Link to self

    +

    Link to dog drawing

    +

    Link to text attachment

    ', + 'tags' => [ + ['name' => 'Unreviewed'], + ], + 'attachments' => [ + [ + 'id' => 4, + 'name' => 'Text attachment', + 'file' => 'file_attachment' + ] + ], + 'images' => [ + [ + 'id' => 2, + 'name' => 'Dog Drawing', + 'type' => 'drawio', + 'file' => 'dog_image' + ] + ], + ], + ], [ + 'file_attachment' => $testFilePath, + 'dog_image' => $testImagePath, + ]); + + $this->asAdmin(); + /** @var Page $page */ + $page = $this->runner->run($import, $parent); + + // Page checks + $this->assertEquals('Page A', $page->name); + $this->assertCount(1, $page->tags); + $this->assertCount(1, $page->attachments); + $pageImages = Image::where('uploaded_to', '=', $page->id)->whereIn('type', ['gallery', 'drawio'])->get(); + $this->assertCount(1, $pageImages); + $this->assertFileEquals($testImagePath, public_path($pageImages[0]->path)); + + // Reference checks + $this->assertStringContainsString($page->getUrl(), $page->html); + $this->assertStringContainsString($pageImages[0]->url, $page->html); + $this->assertStringContainsString($page->attachments->first()->getUrl(), $page->html); + + ZipTestHelper::deleteZipForImport($import); + } + + public function test_revert_cleans_up_uploaded_files() + { + $testImagePath = $this->files->testFilePath('test-image.png'); + $testFilePath = $this->files->testFilePath('test-file.txt'); + $parent = $this->entities->chapter(); + + $import = ZipTestHelper::importFromData([], [ + 'page' => [ + 'name' => 'Page A', + 'html' => '

    Hello

    ', + 'attachments' => [ + [ + 'name' => 'Text attachment', + 'file' => 'file_attachment' + ] + ], + 'images' => [ + [ + 'name' => 'Dog Image', + 'type' => 'gallery', + 'file' => 'dog_image' + ] + ], + ], + ], [ + 'file_attachment' => $testFilePath, + 'dog_image' => $testImagePath, + ]); + + $this->asAdmin(); + /** @var Page $page */ + $page = $this->runner->run($import, $parent); + + $attachment = $page->attachments->first(); + $image = Image::query()->where('uploaded_to', '=', $page->id)->where('type', '=', 'gallery')->first(); + + $this->assertFileExists(public_path($image->path)); + $this->assertFileExists(storage_path($attachment->path)); + + $this->runner->revertStoredFiles(); + + $this->assertFileDoesNotExist(public_path($image->path)); + $this->assertFileDoesNotExist(storage_path($attachment->path)); + + ZipTestHelper::deleteZipForImport($import); + } + + public function test_imported_images_have_their_detected_extension_added() + { + $testImagePath = $this->files->testFilePath('test-image.png'); + $parent = $this->entities->chapter(); + + $import = ZipTestHelper::importFromData([], [ + 'page' => [ + 'name' => 'Page A', + 'html' => '

    hello

    ', + 'images' => [ + [ + 'id' => 2, + 'name' => 'Cat', + 'type' => 'gallery', + 'file' => 'cat_image' + ] + ], + ], + ], [ + 'cat_image' => $testImagePath, + ]); + + $this->asAdmin(); + /** @var Page $page */ + $page = $this->runner->run($import, $parent); + + $pageImages = Image::where('uploaded_to', '=', $page->id)->whereIn('type', ['gallery', 'drawio'])->get(); + + $this->assertCount(1, $pageImages); + $this->assertStringEndsWith('.png', $pageImages[0]->url); + $this->assertStringEndsWith('.png', $pageImages[0]->path); + + ZipTestHelper::deleteZipForImport($import); + } +} diff --git a/tests/Exports/ZipImportTest.php b/tests/Exports/ZipImportTest.php new file mode 100644 index 00000000000..ad0e6b24137 --- /dev/null +++ b/tests/Exports/ZipImportTest.php @@ -0,0 +1,396 @@ +asAdmin()->get('/import'); + $resp->assertSee('Import'); + $this->withHtml($resp)->assertElementExists('form input[type="file"][name="file"]'); + } + + public function test_permissions_needed_for_import_page() + { + $user = $this->users->viewer(); + $this->actingAs($user); + + $resp = $this->get('/books'); + $this->withHtml($resp)->assertLinkNotExists(url('/import')); + $resp = $this->get('/import'); + $resp->assertRedirect('/'); + + $this->permissions->grantUserRolePermissions($user, ['content-import']); + + $resp = $this->get('/books'); + $this->withHtml($resp)->assertLinkExists(url('/import')); + $resp = $this->get('/import'); + $resp->assertOk(); + $resp->assertSeeText('Select ZIP file to upload'); + } + + public function test_import_page_pending_import_visibility_limited() + { + $user = $this->users->viewer(); + $admin = $this->users->admin(); + $userImport = Import::factory()->create(['name' => 'MySuperUserImport', 'created_by' => $user->id]); + $adminImport = Import::factory()->create(['name' => 'MySuperAdminImport', 'created_by' => $admin->id]); + $this->permissions->grantUserRolePermissions($user, ['content-import']); + + $resp = $this->actingAs($user)->get('/import'); + $resp->assertSeeText('MySuperUserImport'); + $resp->assertDontSeeText('MySuperAdminImport'); + + $this->permissions->grantUserRolePermissions($user, ['settings-manage']); + + $resp = $this->actingAs($user)->get('/import'); + $resp->assertSeeText('MySuperUserImport'); + $resp->assertSeeText('MySuperAdminImport'); + } + + public function test_zip_read_errors_are_shown_on_validation() + { + $invalidUpload = $this->files->uploadedImage('image.zip'); + + $this->asAdmin(); + $resp = $this->runImportFromFile($invalidUpload); + $resp->assertRedirect('/import'); + + $resp = $this->followRedirects($resp); + $resp->assertSeeText('Could not read ZIP file'); + } + + public function test_error_shown_if_missing_data() + { + $zipFile = tempnam(sys_get_temp_dir(), 'bstest-'); + $zip = new ZipArchive(); + $zip->open($zipFile, ZipArchive::CREATE); + $zip->addFromString('beans', 'cat'); + $zip->close(); + + $this->asAdmin(); + $upload = new UploadedFile($zipFile, 'upload.zip', 'application/zip', null, true); + $resp = $this->runImportFromFile($upload); + $resp->assertRedirect('/import'); + + $resp = $this->followRedirects($resp); + $resp->assertSeeText('Could not find and decode ZIP data.json content.'); + } + + public function test_error_shown_if_no_importable_key() + { + $this->asAdmin(); + $resp = $this->runImportFromFile(ZipTestHelper::zipUploadFromData([ + 'instance' => [] + ])); + + $resp->assertRedirect('/import'); + $resp = $this->followRedirects($resp); + $resp->assertSeeText('ZIP file data has no expected book, chapter or page content.'); + } + + public function test_zip_data_validation_messages_shown() + { + $this->asAdmin(); + $resp = $this->runImportFromFile(ZipTestHelper::zipUploadFromData([ + 'book' => [ + 'id' => 4, + 'pages' => [ + 'cat', + [ + 'name' => 'My inner page', + 'tags' => [ + [ + 'value' => 5 + ] + ], + ] + ], + ] + ])); + + $resp->assertRedirect('/import'); + $resp = $this->followRedirects($resp); + + $resp->assertSeeText('[book.name]: The name field is required.'); + $resp->assertSeeText('[book.pages.0.0]: Data object expected but "string" found.'); + $resp->assertSeeText('[book.pages.1.tags.0.name]: The name field is required.'); + $resp->assertSeeText('[book.pages.1.tags.0.value]: The value must be a string.'); + } + + public function test_import_upload_success() + { + $admin = $this->users->admin(); + $this->actingAs($admin); + $data = [ + 'book' => [ + 'name' => 'My great book name', + 'chapters' => [ + [ + 'name' => 'my chapter', + 'pages' => [ + [ + 'name' => 'my chapter page', + ] + ] + ] + ], + 'pages' => [ + [ + 'name' => 'My page', + ] + ], + ], + ]; + + $resp = $this->runImportFromFile(ZipTestHelper::zipUploadFromData($data)); + + $this->assertDatabaseHas('imports', [ + 'name' => 'My great book name', + 'type' => 'book', + 'created_by' => $admin->id, + ]); + + /** @var Import $import */ + $import = Import::query()->latest()->first(); + $resp->assertRedirect("/import/{$import->id}"); + $this->assertFileExists(storage_path($import->path)); + $this->assertActivityExists(ActivityType::IMPORT_CREATE); + + ZipTestHelper::deleteZipForImport($import); + } + + public function test_import_show_page() + { + $exportBook = new ZipExportBook(); + $exportBook->name = 'My exported book'; + $exportChapter = new ZipExportChapter(); + $exportChapter->name = 'My exported chapter'; + $exportPage = new ZipExportPage(); + $exportPage->name = 'My exported page'; + $exportBook->chapters = [$exportChapter]; + $exportChapter->pages = [$exportPage]; + + $import = Import::factory()->create([ + 'name' => 'MySuperAdminImport', + 'metadata' => json_encode($exportBook) + ]); + + $resp = $this->asAdmin()->get("/import/{$import->id}"); + $resp->assertOk(); + $resp->assertSeeText('My exported book'); + $resp->assertSeeText('My exported chapter'); + $resp->assertSeeText('My exported page'); + } + + public function test_import_show_page_access_limited() + { + $user = $this->users->viewer(); + $admin = $this->users->admin(); + $userImport = Import::factory()->create(['name' => 'MySuperUserImport', 'created_by' => $user->id]); + $adminImport = Import::factory()->create(['name' => 'MySuperAdminImport', 'created_by' => $admin->id]); + $this->actingAs($user); + + $this->get("/import/{$userImport->id}")->assertRedirect('/'); + $this->get("/import/{$adminImport->id}")->assertRedirect('/'); + + $this->permissions->grantUserRolePermissions($user, ['content-import']); + + $this->get("/import/{$userImport->id}")->assertOk(); + $this->get("/import/{$adminImport->id}")->assertStatus(404); + + $this->permissions->grantUserRolePermissions($user, ['settings-manage']); + + $this->get("/import/{$userImport->id}")->assertOk(); + $this->get("/import/{$adminImport->id}")->assertOk(); + } + + public function test_import_delete() + { + $this->asAdmin(); + $this->runImportFromFile(ZipTestHelper::zipUploadFromData([ + 'book' => [ + 'name' => 'My great book name' + ], + ])); + + /** @var Import $import */ + $import = Import::query()->latest()->first(); + $this->assertDatabaseHas('imports', [ + 'id' => $import->id, + 'name' => 'My great book name' + ]); + $this->assertFileExists(storage_path($import->path)); + + $resp = $this->delete("/import/{$import->id}"); + + $resp->assertRedirect('/import'); + $this->assertActivityExists(ActivityType::IMPORT_DELETE); + $this->assertDatabaseMissing('imports', [ + 'id' => $import->id, + ]); + $this->assertFileDoesNotExist(storage_path($import->path)); + } + + public function test_import_delete_access_limited() + { + $user = $this->users->viewer(); + $admin = $this->users->admin(); + $userImport = Import::factory()->create(['name' => 'MySuperUserImport', 'created_by' => $user->id]); + $adminImport = Import::factory()->create(['name' => 'MySuperAdminImport', 'created_by' => $admin->id]); + $this->actingAs($user); + + $this->delete("/import/{$userImport->id}")->assertRedirect('/'); + $this->delete("/import/{$adminImport->id}")->assertRedirect('/'); + + $this->permissions->grantUserRolePermissions($user, ['content-import']); + + $this->delete("/import/{$userImport->id}")->assertRedirect('/import'); + $this->delete("/import/{$adminImport->id}")->assertStatus(404); + + $this->permissions->grantUserRolePermissions($user, ['settings-manage']); + + $this->delete("/import/{$adminImport->id}")->assertRedirect('/import'); + } + + public function test_run_simple_success_scenario() + { + $import = ZipTestHelper::importFromData([], [ + 'book' => [ + 'name' => 'My imported book', + 'pages' => [ + [ + 'name' => 'My imported book page', + 'html' => '

    Hello there from child page!

    ' + ] + ], + ] + ]); + + $resp = $this->asAdmin()->post("/import/{$import->id}"); + $book = Book::query()->where('name', '=', 'My imported book')->latest()->first(); + $resp->assertRedirect($book->getUrl()); + + $resp = $this->followRedirects($resp); + $resp->assertSee('My imported book page'); + $resp->assertSee('Hello there from child page!'); + + $this->assertDatabaseMissing('imports', ['id' => $import->id]); + $this->assertFileDoesNotExist(storage_path($import->path)); + $this->assertActivityExists(ActivityType::IMPORT_RUN, null, $import->logDescriptor()); + } + + public function test_import_run_access_limited() + { + $user = $this->users->editor(); + $admin = $this->users->admin(); + $userImport = Import::factory()->create(['name' => 'MySuperUserImport', 'created_by' => $user->id]); + $adminImport = Import::factory()->create(['name' => 'MySuperAdminImport', 'created_by' => $admin->id]); + $this->actingAs($user); + + $this->post("/import/{$userImport->id}")->assertRedirect('/'); + $this->post("/import/{$adminImport->id}")->assertRedirect('/'); + + $this->permissions->grantUserRolePermissions($user, ['content-import']); + + $this->post("/import/{$userImport->id}")->assertRedirect($userImport->getUrl()); // Getting validation response instead of access issue response + $this->post("/import/{$adminImport->id}")->assertStatus(404); + + $this->permissions->grantUserRolePermissions($user, ['settings-manage']); + + $this->post("/import/{$adminImport->id}")->assertRedirect($adminImport->getUrl()); // Getting validation response instead of access issue response + } + + public function test_run_revalidates_content() + { + $import = ZipTestHelper::importFromData([], [ + 'book' => [ + 'id' => 'abc', + ] + ]); + + $resp = $this->asAdmin()->post("/import/{$import->id}"); + $resp->assertRedirect($import->getUrl()); + + $resp = $this->followRedirects($resp); + $resp->assertSeeText('The name field is required.'); + $resp->assertSeeText('The id must be an integer.'); + + ZipTestHelper::deleteZipForImport($import); + } + + public function test_run_checks_permissions_on_import() + { + $viewer = $this->users->viewer(); + $this->permissions->grantUserRolePermissions($viewer, ['content-import']); + $import = ZipTestHelper::importFromData(['created_by' => $viewer->id], [ + 'book' => ['name' => 'My import book'], + ]); + + $resp = $this->asViewer()->post("/import/{$import->id}"); + $resp->assertRedirect($import->getUrl()); + + $resp = $this->followRedirects($resp); + $resp->assertSeeText('You are lacking the required permissions to create books.'); + + ZipTestHelper::deleteZipForImport($import); + } + + public function test_run_requires_parent_for_chapter_and_page_imports() + { + $book = $this->entities->book(); + $pageImport = ZipTestHelper::importFromData([], [ + 'page' => ['name' => 'My page', 'html' => '

    page test!

    '], + ]); + $chapterImport = ZipTestHelper::importFromData([], [ + 'chapter' => ['name' => 'My chapter'], + ]); + + $resp = $this->asAdmin()->post("/import/{$pageImport->id}"); + $resp->assertRedirect($pageImport->getUrl()); + $this->followRedirects($resp)->assertSee('The parent field is required.'); + + $resp = $this->asAdmin()->post("/import/{$pageImport->id}", ['parent' => "book:{$book->id}"]); + $resp->assertRedirectContains($book->getUrl()); + + $resp = $this->asAdmin()->post("/import/{$chapterImport->id}"); + $resp->assertRedirect($chapterImport->getUrl()); + $this->followRedirects($resp)->assertSee('The parent field is required.'); + + $resp = $this->asAdmin()->post("/import/{$chapterImport->id}", ['parent' => "book:{$book->id}"]); + $resp->assertRedirectContains($book->getUrl()); + } + + public function test_run_validates_correct_parent_type() + { + $chapter = $this->entities->chapter(); + $import = ZipTestHelper::importFromData([], [ + 'chapter' => ['name' => 'My chapter'], + ]); + + $resp = $this->asAdmin()->post("/import/{$import->id}", ['parent' => "chapter:{$chapter->id}"]); + $resp->assertRedirect($import->getUrl()); + + $resp = $this->followRedirects($resp); + $resp->assertSee('Parent book required for chapter import.'); + + ZipTestHelper::deleteZipForImport($import); + } + + protected function runImportFromFile(UploadedFile $file): TestResponse + { + return $this->call('POST', '/import', [], [], ['file' => $file]); + } +} diff --git a/tests/Exports/ZipResultData.php b/tests/Exports/ZipResultData.php new file mode 100644 index 00000000000..7725004c7be --- /dev/null +++ b/tests/Exports/ZipResultData.php @@ -0,0 +1,22 @@ +extractedDirPath . DIRECTORY_SEPARATOR . ltrim($relPath, DIRECTORY_SEPARATOR); + } +} diff --git a/tests/Exports/ZipTestHelper.php b/tests/Exports/ZipTestHelper.php new file mode 100644 index 00000000000..d830d8eb6bf --- /dev/null +++ b/tests/Exports/ZipTestHelper.php @@ -0,0 +1,59 @@ +create($importData); + $zip = static::zipUploadFromData($zipData, $files); + $targetPath = storage_path($import->path); + $targetDir = dirname($targetPath); + + if (!file_exists($targetDir)) { + mkdir($targetDir); + } + + rename($zip->getRealPath(), $targetPath); + + return $import; + } + + public static function deleteZipForImport(Import $import): void + { + $path = storage_path($import->path); + if (file_exists($path)) { + unlink($path); + } + } + + public static function zipUploadFromData(array $data, array $files = []): UploadedFile + { + $zipFile = tempnam(sys_get_temp_dir(), 'bstest-'); + + $zip = new ZipArchive(); + $zip->open($zipFile, ZipArchive::CREATE); + $zip->addFromString('data.json', json_encode($data)); + + foreach ($files as $name => $file) { + $zip->addFile($file, "files/$name"); + } + + $zip->close(); + + return new UploadedFile($zipFile, 'upload.zip', 'application/zip', null, true); + } +}