From c61ce8dee4ea474b16564c3d3337bac25fba1f46 Mon Sep 17 00:00:00 2001 From: "nchoudhary@logicmines.in" Date: Fri, 25 Apr 2025 12:45:09 +0530 Subject: [PATCH 1/7] Implement functionality to export a book, along with its pages and chapters, as a ZIP file. --- .../Controllers/BookExportApiController.php | 18 +++++++++++++++++- .../Controllers/ChapterExportApiController.php | 12 +++++++++++- .../Controllers/PageExportApiController.php | 14 +++++++++++++- 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/app/Exports/Controllers/BookExportApiController.php b/app/Exports/Controllers/BookExportApiController.php index 164946b0c78..431afef143d 100644 --- a/app/Exports/Controllers/BookExportApiController.php +++ b/app/Exports/Controllers/BookExportApiController.php @@ -4,6 +4,7 @@ use BookStack\Entities\Queries\BookQueries; use BookStack\Exports\ExportFormatter; +use BookStack\Exports\ZipExports\ZipExportBuilder; use BookStack\Http\ApiController; use Throwable; @@ -63,4 +64,19 @@ public function exportMarkdown(int $id) return $this->download()->directly($markdown, $book->slug . '.md'); } -} + + + /** + * Export a book to a contained ZIP export file. + * @throws NotFoundException + */ + public function exportZip(int $id, ZipExportBuilder $builder) + { + $book = $this->queries->findVisibleByIdOrFail($id); + $bookName= $book->getShortName(); + + $zip = $builder->buildForBook($book); + + return $this->download()->streamedFileDirectly($zip, $bookName . '.zip', filesize($zip), true); + } +} \ No newline at end of file diff --git a/app/Exports/Controllers/ChapterExportApiController.php b/app/Exports/Controllers/ChapterExportApiController.php index 9914e2b7fbe..58df4c9b087 100644 --- a/app/Exports/Controllers/ChapterExportApiController.php +++ b/app/Exports/Controllers/ChapterExportApiController.php @@ -4,6 +4,7 @@ use BookStack\Entities\Queries\ChapterQueries; use BookStack\Exports\ExportFormatter; +use BookStack\Exports\ZipExports\ZipExportBuilder; use BookStack\Http\ApiController; use Throwable; @@ -63,4 +64,13 @@ public function exportMarkdown(int $id) return $this->download()->directly($markdown, $chapter->slug . '.md'); } -} + + public function exportZip(int $id, ZipExportBuilder $builder) + { + $chapter = $this->queries->findVisibleByIdOrFail($id); + $chapterName= $chapter->getShortName(); + $zip = $builder->buildForChapter($chapter); + + return $this->download()->streamedFileDirectly($zip, $chapterName . '.zip', filesize($zip), true); + } +} \ No newline at end of file diff --git a/app/Exports/Controllers/PageExportApiController.php b/app/Exports/Controllers/PageExportApiController.php index c6e20b615d2..ef564da3e5c 100644 --- a/app/Exports/Controllers/PageExportApiController.php +++ b/app/Exports/Controllers/PageExportApiController.php @@ -4,6 +4,7 @@ use BookStack\Entities\Queries\PageQueries; use BookStack\Exports\ExportFormatter; +use BookStack\Exports\ZipExports\ZipExportBuilder; use BookStack\Http\ApiController; use Throwable; @@ -63,4 +64,15 @@ public function exportMarkdown(int $id) return $this->download()->directly($markdown, $page->slug . '.md'); } -} + + + + public function exportZip(int $id, ZipExportBuilder $builder) + { + $page = $this->queries->findVisibleByIdOrFail($id); + $pageSlug = $page->slug; + $zip = $builder->buildForPage($page); + + return $this->download()->streamedFileDirectly($zip, $pageSlug . '.zip', filesize($zip), true); + } +} \ No newline at end of file From 5fa728f28a0c4f09649da7b653dd66260e49a596 Mon Sep 17 00:00:00 2001 From: "nchoudhary@logicmines.in" Date: Fri, 25 Apr 2025 12:48:34 +0530 Subject: [PATCH 2/7] Develop functionality to import ZIP files. Create an API controller and define a route entry for handling the import process. Implement logic to read the list of files within the ZIP, process the directory structure, and automatically create associated pages, chapters, and books based on the ZIP file's contents. --- .../Controllers/ImportApiController.php | 121 ++++++++++++++++++ routes/api.php | 6 + 2 files changed, 127 insertions(+) create mode 100644 app/Exports/Controllers/ImportApiController.php diff --git a/app/Exports/Controllers/ImportApiController.php b/app/Exports/Controllers/ImportApiController.php new file mode 100644 index 00000000000..682d340b3b4 --- /dev/null +++ b/app/Exports/Controllers/ImportApiController.php @@ -0,0 +1,121 @@ +middleware('can:content-import'); + } + + /** + * List existing imports visible to the user. + */ + public function list(): JsonResponse + { + $imports = $this->imports->getVisibleImports(); + + return response()->json([ + 'status' => 'success', + 'imports' => $imports, + ]); + } + + /** + * Upload, validate and store an import file. + */ + public function upload(Request $request): JsonResponse + { + $this->validate($request, [ + 'file' => ['required', ...AttachmentService::getFileValidationRules()] + ]); + + $file = $request->file('file'); + + try { + $import = $this->imports->storeFromUpload($file); + } catch (ZipValidationException $exception) { + return response()->json([ + 'status' => 'error', + 'message' => 'Validation failed', + 'errors' => $exception->errors, + ], 422); + } + + return response()->json([ + 'status' => 'success', + 'import' => $import, + ], 201); + } + + /** + * Show details of a pending import. + */ + public function read(int $id): JsonResponse + { + $import = $this->imports->findVisible($id); + + return response()->json([ + 'status' => 'success', + 'import' => $import, + 'data' => $import->decodeMetadata(), + ]); + } + + /** + * Run the import process. + */ + public function create(int $id, Request $request): JsonResponse + { + $import = $this->imports->findVisible($id); + $parent = null; + + if ($import->type === 'page' || $import->type === 'chapter') { + $data = $this->validate($request, [ + 'parent' => ['required', 'string'], + ]); + $parent = $data['parent']; + } + + try { + $entity = $this->imports->runImport($import, $parent); + } catch (ZipImportException $exception) { + return response()->json([ + 'status' => 'error', + 'message' => 'Import failed', + 'errors' => $exception->errors, + ], 500); + } + + return response()->json([ + 'status' => 'success', + 'entity' => $entity, + ]); + } + + /** + * Delete a pending import. + */ + public function delete(int $id): JsonResponse + { + $import = $this->imports->findVisible($id); + $this->imports->deleteImport($import); + + return response()->json([ + 'status' => 'success', + 'message' => 'Import deleted successfully', + ]); + } +} \ No newline at end of file diff --git a/routes/api.php b/routes/api.php index 71036485597..bd00ea4b098 100644 --- a/routes/api.php +++ b/routes/api.php @@ -92,3 +92,9 @@ Route::put('content-permissions/{contentType}/{contentId}', [ContentPermissionApiController::class, 'update']); Route::get('audit-log', [AuditLogApiController::class, 'list']); + +Route::get('import', [ExportControllers\ImportApiController::class, 'list']); +Route::post('import', [ExportControllers\ImportApiController::class, 'upload']); +Route::get('import/{id}', [ExportControllers\ImportApiController::class, 'read']); +Route::post('import/{id}/create', [ExportControllers\ImportApiController::class, 'create']); +Route::delete('import/{id}', [ExportControllers\ImportApiController::class, 'destroy']); \ No newline at end of file From 64da80cbf4611f0e6a1700e6e2fa399389ee50da Mon Sep 17 00:00:00 2001 From: "nchoudhary@logicmines.in" Date: Fri, 25 Apr 2025 13:00:06 +0530 Subject: [PATCH 3/7] added routes for zip export --- routes/api.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/routes/api.php b/routes/api.php index bd00ea4b098..7bc7d7d44c1 100644 --- a/routes/api.php +++ b/routes/api.php @@ -36,6 +36,7 @@ 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('books/{id}/export/zip', [ExportControllers\BookExportApiController::class, 'exportZip']); Route::get('chapters', [EntityControllers\ChapterApiController::class, 'list']); Route::post('chapters', [EntityControllers\ChapterApiController::class, 'create']); @@ -46,6 +47,7 @@ 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('chapters/{id}/export/zip', [ExportControllers\ChapterExportApiController::class, 'exportZip']); Route::get('pages', [EntityControllers\PageApiController::class, 'list']); Route::post('pages', [EntityControllers\PageApiController::class, 'create']); @@ -57,6 +59,7 @@ 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('pages/{id}/export/zip', [ExportControllers\PageExportApiController::class, 'exportZip']); Route::get('image-gallery', [ImageGalleryApiController::class, 'list']); Route::post('image-gallery', [ImageGalleryApiController::class, 'create']); From d15eb129b0a372181ec77e9f7c3897e80a525613 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 18 Jul 2025 09:54:49 +0100 Subject: [PATCH 4/7] API: Initial review pass of zip import/export endpoints Review of #5592 --- .../Controllers/BookExportApiController.php | 8 +- .../ChapterExportApiController.php | 5 +- .../Controllers/ImportApiController.php | 92 +++++++++---------- .../Controllers/PageExportApiController.php | 7 +- routes/api.php | 12 +-- 5 files changed, 55 insertions(+), 69 deletions(-) diff --git a/app/Exports/Controllers/BookExportApiController.php b/app/Exports/Controllers/BookExportApiController.php index 431afef143d..e2d0addc30c 100644 --- a/app/Exports/Controllers/BookExportApiController.php +++ b/app/Exports/Controllers/BookExportApiController.php @@ -65,18 +65,14 @@ public function exportMarkdown(int $id) return $this->download()->directly($markdown, $book->slug . '.md'); } - /** * Export a book to a contained ZIP export file. - * @throws NotFoundException */ public function exportZip(int $id, ZipExportBuilder $builder) { $book = $this->queries->findVisibleByIdOrFail($id); - $bookName= $book->getShortName(); - $zip = $builder->buildForBook($book); - return $this->download()->streamedFileDirectly($zip, $bookName . '.zip', filesize($zip), true); + return $this->download()->streamedFileDirectly($zip, $book->slug . '.zip', true); } -} \ No newline at end of file +} diff --git a/app/Exports/Controllers/ChapterExportApiController.php b/app/Exports/Controllers/ChapterExportApiController.php index 58df4c9b087..66e2276b5ce 100644 --- a/app/Exports/Controllers/ChapterExportApiController.php +++ b/app/Exports/Controllers/ChapterExportApiController.php @@ -68,9 +68,8 @@ public function exportMarkdown(int $id) public function exportZip(int $id, ZipExportBuilder $builder) { $chapter = $this->queries->findVisibleByIdOrFail($id); - $chapterName= $chapter->getShortName(); $zip = $builder->buildForChapter($chapter); - return $this->download()->streamedFileDirectly($zip, $chapterName . '.zip', filesize($zip), true); + return $this->download()->streamedFileDirectly($zip, $chapter->slug . '.zip', true); } -} \ No newline at end of file +} diff --git a/app/Exports/Controllers/ImportApiController.php b/app/Exports/Controllers/ImportApiController.php index 682d340b3b4..13bc9d83ea5 100644 --- a/app/Exports/Controllers/ImportApiController.php +++ b/app/Exports/Controllers/ImportApiController.php @@ -7,12 +7,13 @@ use BookStack\Exceptions\ZipImportException; use BookStack\Exceptions\ZipValidationException; use BookStack\Exports\ImportRepo; -use BookStack\Http\Controller; +use BookStack\Http\ApiController; use BookStack\Uploads\AttachmentService; use Illuminate\Http\Request; use Illuminate\Http\JsonResponse; +use Illuminate\Http\Response; -class ImportApiController extends Controller +class ImportApiController extends ApiController { public function __construct( protected ImportRepo $imports, @@ -21,101 +22,94 @@ public function __construct( } /** - * List existing imports visible to the user. + * List existing ZIP imports visible to the user. */ public function list(): JsonResponse { - $imports = $this->imports->getVisibleImports(); + $imports = $this->imports->getVisibleImports()->all(); - return response()->json([ - 'status' => 'success', - 'imports' => $imports, - ]); + return response()->json($imports); } /** - * Upload, validate and store an import file. + * Upload, validate and store a ZIP import file. + * This does not run the import. That is performed via a separate endpoint. */ public function upload(Request $request): JsonResponse { - $this->validate($request, [ - 'file' => ['required', ...AttachmentService::getFileValidationRules()] - ]); + $this->validate($request, $this->rules()['upload']); $file = $request->file('file'); try { $import = $this->imports->storeFromUpload($file); } catch (ZipValidationException $exception) { - return response()->json([ - 'status' => 'error', - 'message' => 'Validation failed', - 'errors' => $exception->errors, - ], 422); + $message = "ZIP upload failed with the following validation errors: \n" . implode("\n", $exception->errors); + return $this->jsonError($message, 422); } - return response()->json([ - 'status' => 'success', - 'import' => $import, - ], 201); + return response()->json($import); } /** - * Show details of a pending import. + * Read details of a pending ZIP import. */ public function read(int $id): JsonResponse { $import = $this->imports->findVisible($id); - return response()->json([ - 'status' => 'success', - 'import' => $import, - 'data' => $import->decodeMetadata(), - ]); + return response()->json($import); } /** - * Run the import process. + * Run the import process for an uploaded ZIP import. + * The parent_id and parent_type parameters are required when the import type is 'chapter' or 'page'. + * On success, returns the imported item. */ - public function create(int $id, Request $request): JsonResponse + public function run(int $id, Request $request): JsonResponse { $import = $this->imports->findVisible($id); $parent = null; + $rules = $this->rules()['run']; if ($import->type === 'page' || $import->type === 'chapter') { - $data = $this->validate($request, [ - 'parent' => ['required', 'string'], - ]); - $parent = $data['parent']; + $rules['parent_type'][] = 'required'; + $rules['parent_id'][] = 'required'; + $data = $this->validate($request, $rules); + $parent = "{$data['parent_type']}:{$data['parent_id']}"; } try { $entity = $this->imports->runImport($import, $parent); } catch (ZipImportException $exception) { - return response()->json([ - 'status' => 'error', - 'message' => 'Import failed', - 'errors' => $exception->errors, - ], 500); + $message = "ZIP import failed with the following errors: \n" . implode("\n", $exception->errors); + return $this->jsonError($message); } - return response()->json([ - 'status' => 'success', - 'entity' => $entity, - ]); + return response()->json($entity); } /** - * Delete a pending import. + * Delete a pending ZIP import. */ - public function delete(int $id): JsonResponse + public function delete(int $id): Response { $import = $this->imports->findVisible($id); $this->imports->deleteImport($import); - return response()->json([ - 'status' => 'success', - 'message' => 'Import deleted successfully', - ]); + return response('', 204); } -} \ No newline at end of file + + protected function rules(): array + { + return [ + 'upload' => [ + 'file' => ['required', ...AttachmentService::getFileValidationRules()], + ], + 'run' => [ + 'parent_type' => ['string', 'in:book,chapter'], + 'parent_id' => ['int'], + ], + ]; + } +} diff --git a/app/Exports/Controllers/PageExportApiController.php b/app/Exports/Controllers/PageExportApiController.php index ef564da3e5c..d6412614c45 100644 --- a/app/Exports/Controllers/PageExportApiController.php +++ b/app/Exports/Controllers/PageExportApiController.php @@ -65,14 +65,11 @@ public function exportMarkdown(int $id) return $this->download()->directly($markdown, $page->slug . '.md'); } - - public function exportZip(int $id, ZipExportBuilder $builder) { $page = $this->queries->findVisibleByIdOrFail($id); - $pageSlug = $page->slug; $zip = $builder->buildForPage($page); - return $this->download()->streamedFileDirectly($zip, $pageSlug . '.zip', filesize($zip), true); + return $this->download()->streamedFileDirectly($zip, $page->slug . '.zip', true); } -} \ No newline at end of file +} diff --git a/routes/api.php b/routes/api.php index 5bdf5361130..efb7b258c6f 100644 --- a/routes/api.php +++ b/routes/api.php @@ -88,6 +88,12 @@ Route::put('roles/{id}', [RoleApiController::class, 'update']); Route::delete('roles/{id}', [RoleApiController::class, 'delete']); +Route::get('import', [ExportControllers\ImportApiController::class, 'list']); +Route::post('import', [ExportControllers\ImportApiController::class, 'upload']); +Route::get('import/{id}', [ExportControllers\ImportApiController::class, 'read']); +Route::post('import/{id}', [ExportControllers\ImportApiController::class, 'run']); +Route::delete('import/{id}', [ExportControllers\ImportApiController::class, 'delete']); + Route::get('recycle-bin', [EntityControllers\RecycleBinApiController::class, 'list']); Route::put('recycle-bin/{deletionId}', [EntityControllers\RecycleBinApiController::class, 'restore']); Route::delete('recycle-bin/{deletionId}', [EntityControllers\RecycleBinApiController::class, 'destroy']); @@ -98,9 +104,3 @@ Route::get('audit-log', [AuditLogApiController::class, 'list']); Route::get('system', [SystemApiController::class, 'read']); - -Route::get('import', [ExportControllers\ImportApiController::class, 'list']); -Route::post('import', [ExportControllers\ImportApiController::class, 'upload']); -Route::get('import/{id}', [ExportControllers\ImportApiController::class, 'read']); -Route::post('import/{id}/create', [ExportControllers\ImportApiController::class, 'create']); -Route::delete('import/{id}', [ExportControllers\ImportApiController::class, 'destroy']); \ No newline at end of file From d55684531f56fc1f029aeb473b9bccee8edc841e Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 18 Jul 2025 10:58:10 +0100 Subject: [PATCH 5/7] API: Added zip export tests, reorganised tests Extracted an extra method into helper for reuse. --- tests/Api/BooksApiTest.php | 58 --------- tests/Api/ChaptersApiTest.php | 57 --------- tests/Api/ExportsApiTest.php | 210 ++++++++++++++++++++++++++++++++ tests/Api/PagesApiTest.php | 56 --------- tests/Exports/ZipExportTest.php | 64 +++------- tests/Exports/ZipTestHelper.php | 27 ++++ 6 files changed, 256 insertions(+), 216 deletions(-) create mode 100644 tests/Api/ExportsApiTest.php diff --git a/tests/Api/BooksApiTest.php b/tests/Api/BooksApiTest.php index 084cb59bd5c..22ccfb482c9 100644 --- a/tests/Api/BooksApiTest.php +++ b/tests/Api/BooksApiTest.php @@ -287,62 +287,4 @@ public function test_delete_endpoint() $resp->assertStatus(204); $this->assertActivityExists('book_delete'); } - - public function test_export_html_endpoint() - { - $this->actingAsApiEditor(); - $book = $this->entities->book(); - - $resp = $this->get($this->baseEndpoint . "/{$book->id}/export/html"); - $resp->assertStatus(200); - $resp->assertSee($book->name); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.html"'); - } - - public function test_export_plain_text_endpoint() - { - $this->actingAsApiEditor(); - $book = $this->entities->book(); - - $resp = $this->get($this->baseEndpoint . "/{$book->id}/export/plaintext"); - $resp->assertStatus(200); - $resp->assertSee($book->name); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.txt"'); - } - - public function test_export_pdf_endpoint() - { - $this->actingAsApiEditor(); - $book = $this->entities->book(); - - $resp = $this->get($this->baseEndpoint . "/{$book->id}/export/pdf"); - $resp->assertStatus(200); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.pdf"'); - } - - public function test_export_markdown_endpoint() - { - $this->actingAsApiEditor(); - $book = Book::visible()->has('pages')->has('chapters')->first(); - - $resp = $this->get($this->baseEndpoint . "/{$book->id}/export/markdown"); - $resp->assertStatus(200); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.md"'); - $resp->assertSee('# ' . $book->name); - $resp->assertSee('# ' . $book->pages()->first()->name); - $resp->assertSee('# ' . $book->chapters()->first()->name); - } - - public function test_cant_export_when_not_have_permission() - { - $types = ['html', 'plaintext', 'pdf', 'markdown']; - $this->actingAsApiEditor(); - $this->permissions->removeUserRolePermissions($this->users->editor(), ['content-export']); - - $book = $this->entities->book(); - foreach ($types as $type) { - $resp = $this->get($this->baseEndpoint . "/{$book->id}/export/{$type}"); - $this->assertPermissionError($resp); - } - } } diff --git a/tests/Api/ChaptersApiTest.php b/tests/Api/ChaptersApiTest.php index 9698d4dd9c2..5d7b0530891 100644 --- a/tests/Api/ChaptersApiTest.php +++ b/tests/Api/ChaptersApiTest.php @@ -269,61 +269,4 @@ public function test_delete_endpoint() $resp->assertStatus(204); $this->assertActivityExists('chapter_delete'); } - - public function test_export_html_endpoint() - { - $this->actingAsApiEditor(); - $chapter = $this->entities->chapter(); - - $resp = $this->get($this->baseEndpoint . "/{$chapter->id}/export/html"); - $resp->assertStatus(200); - $resp->assertSee($chapter->name); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.html"'); - } - - public function test_export_plain_text_endpoint() - { - $this->actingAsApiEditor(); - $chapter = $this->entities->chapter(); - - $resp = $this->get($this->baseEndpoint . "/{$chapter->id}/export/plaintext"); - $resp->assertStatus(200); - $resp->assertSee($chapter->name); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.txt"'); - } - - public function test_export_pdf_endpoint() - { - $this->actingAsApiEditor(); - $chapter = $this->entities->chapter(); - - $resp = $this->get($this->baseEndpoint . "/{$chapter->id}/export/pdf"); - $resp->assertStatus(200); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.pdf"'); - } - - public function test_export_markdown_endpoint() - { - $this->actingAsApiEditor(); - $chapter = Chapter::visible()->has('pages')->first(); - - $resp = $this->get($this->baseEndpoint . "/{$chapter->id}/export/markdown"); - $resp->assertStatus(200); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.md"'); - $resp->assertSee('# ' . $chapter->name); - $resp->assertSee('# ' . $chapter->pages()->first()->name); - } - - public function test_cant_export_when_not_have_permission() - { - $types = ['html', 'plaintext', 'pdf', 'markdown']; - $this->actingAsApiEditor(); - $this->permissions->removeUserRolePermissions($this->users->editor(), ['content-export']); - - $chapter = Chapter::visible()->has('pages')->first(); - foreach ($types as $type) { - $resp = $this->get($this->baseEndpoint . "/{$chapter->id}/export/{$type}"); - $this->assertPermissionError($resp); - } - } } diff --git a/tests/Api/ExportsApiTest.php b/tests/Api/ExportsApiTest.php new file mode 100644 index 00000000000..d427c1a4da5 --- /dev/null +++ b/tests/Api/ExportsApiTest.php @@ -0,0 +1,210 @@ +actingAsApiEditor(); + $book = $this->entities->book(); + + $resp = $this->get("/api/books/{$book->id}/export/html"); + $resp->assertStatus(200); + $resp->assertSee($book->name); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.html"'); + } + + public function test_book_plain_text_endpoint() + { + $this->actingAsApiEditor(); + $book = $this->entities->book(); + + $resp = $this->get("/api/books/{$book->id}/export/plaintext"); + $resp->assertStatus(200); + $resp->assertSee($book->name); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.txt"'); + } + + public function test_book_pdf_endpoint() + { + $this->actingAsApiEditor(); + $book = $this->entities->book(); + + $resp = $this->get("/api/books/{$book->id}/export/pdf"); + $resp->assertStatus(200); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.pdf"'); + } + + public function test_book_markdown_endpoint() + { + $this->actingAsApiEditor(); + $book = Book::visible()->has('pages')->has('chapters')->first(); + + $resp = $this->get("/api/books/{$book->id}/export/markdown"); + $resp->assertStatus(200); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.md"'); + $resp->assertSee('# ' . $book->name); + $resp->assertSee('# ' . $book->pages()->first()->name); + $resp->assertSee('# ' . $book->chapters()->first()->name); + } + + public function test_book_zip_endpoint() + { + $this->actingAsApiEditor(); + $book = Book::visible()->has('pages')->has('chapters')->first(); + + $resp = $this->get("/api/books/{$book->id}/export/zip"); + $resp->assertStatus(200); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.zip"'); + + $zip = ZipTestHelper::extractFromZipResponse($resp); + $this->assertArrayHasKey('book', $zip->data); + } + + public function test_chapter_html_endpoint() + { + $this->actingAsApiEditor(); + $chapter = $this->entities->chapter(); + + $resp = $this->get("/api/chapters/{$chapter->id}/export/html"); + $resp->assertStatus(200); + $resp->assertSee($chapter->name); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.html"'); + } + + public function test_chapter_plain_text_endpoint() + { + $this->actingAsApiEditor(); + $chapter = $this->entities->chapter(); + + $resp = $this->get("/api/chapters/{$chapter->id}/export/plaintext"); + $resp->assertStatus(200); + $resp->assertSee($chapter->name); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.txt"'); + } + + public function test_chapter_pdf_endpoint() + { + $this->actingAsApiEditor(); + $chapter = $this->entities->chapter(); + + $resp = $this->get("/api/chapters/{$chapter->id}/export/pdf"); + $resp->assertStatus(200); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.pdf"'); + } + + public function test_chapter_markdown_endpoint() + { + $this->actingAsApiEditor(); + $chapter = Chapter::visible()->has('pages')->first(); + + $resp = $this->get("/api/chapters/{$chapter->id}/export/markdown"); + $resp->assertStatus(200); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.md"'); + $resp->assertSee('# ' . $chapter->name); + $resp->assertSee('# ' . $chapter->pages()->first()->name); + } + + public function test_chapter_zip_endpoint() + { + $this->actingAsApiEditor(); + $chapter = Chapter::visible()->has('pages')->first(); + + $resp = $this->get("/api/chapters/{$chapter->id}/export/zip"); + $resp->assertStatus(200); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.zip"'); + + $zip = ZipTestHelper::extractFromZipResponse($resp); + $this->assertArrayHasKey('chapter', $zip->data); + } + + public function test_page_html_endpoint() + { + $this->actingAsApiEditor(); + $page = $this->entities->page(); + + $resp = $this->get("/api/pages/{$page->id}/export/html"); + $resp->assertStatus(200); + $resp->assertSee($page->name); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.html"'); + } + + public function test_page_plain_text_endpoint() + { + $this->actingAsApiEditor(); + $page = $this->entities->page(); + + $resp = $this->get("/api/pages/{$page->id}/export/plaintext"); + $resp->assertStatus(200); + $resp->assertSee($page->name); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.txt"'); + } + + public function test_page_pdf_endpoint() + { + $this->actingAsApiEditor(); + $page = $this->entities->page(); + + $resp = $this->get("/api/pages/{$page->id}/export/pdf"); + $resp->assertStatus(200); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.pdf"'); + } + + public function test_page_markdown_endpoint() + { + $this->actingAsApiEditor(); + $page = $this->entities->page(); + + $resp = $this->get("/api/pages/{$page->id}/export/markdown"); + $resp->assertStatus(200); + $resp->assertSee('# ' . $page->name); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.md"'); + } + + public function test_page_zip_endpoint() + { + $this->actingAsApiEditor(); + $page = $this->entities->page(); + + $resp = $this->get("/api/pages/{$page->id}/export/zip"); + $resp->assertStatus(200); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.zip"'); + + $zip = ZipTestHelper::extractFromZipResponse($resp); + $this->assertArrayHasKey('page', $zip->data); + } + + public function test_cant_export_when_not_have_permission() + { + $types = ['html', 'plaintext', 'pdf', 'markdown', 'zip']; + $this->actingAsApiEditor(); + $this->permissions->removeUserRolePermissions($this->users->editor(), ['content-export']); + + $book = $this->entities->book(); + foreach ($types as $type) { + $resp = $this->get("/api/books/{$book->id}/export/{$type}"); + $this->assertPermissionError($resp); + } + + $chapter = Chapter::visible()->has('pages')->first(); + foreach ($types as $type) { + $resp = $this->get("/api/chapters/{$chapter->id}/export/{$type}"); + $this->assertPermissionError($resp); + } + + $page = $this->entities->page(); + foreach ($types as $type) { + $resp = $this->get("/api/pages/{$page->id}/export/{$type}"); + $this->assertPermissionError($resp); + } + } +} diff --git a/tests/Api/PagesApiTest.php b/tests/Api/PagesApiTest.php index 22659d5bb72..ced8954eb11 100644 --- a/tests/Api/PagesApiTest.php +++ b/tests/Api/PagesApiTest.php @@ -308,60 +308,4 @@ public function test_delete_endpoint() $resp->assertStatus(204); $this->assertActivityExists('page_delete', $page); } - - public function test_export_html_endpoint() - { - $this->actingAsApiEditor(); - $page = $this->entities->page(); - - $resp = $this->get($this->baseEndpoint . "/{$page->id}/export/html"); - $resp->assertStatus(200); - $resp->assertSee($page->name); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.html"'); - } - - public function test_export_plain_text_endpoint() - { - $this->actingAsApiEditor(); - $page = $this->entities->page(); - - $resp = $this->get($this->baseEndpoint . "/{$page->id}/export/plaintext"); - $resp->assertStatus(200); - $resp->assertSee($page->name); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.txt"'); - } - - public function test_export_pdf_endpoint() - { - $this->actingAsApiEditor(); - $page = $this->entities->page(); - - $resp = $this->get($this->baseEndpoint . "/{$page->id}/export/pdf"); - $resp->assertStatus(200); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.pdf"'); - } - - public function test_export_markdown_endpoint() - { - $this->actingAsApiEditor(); - $page = $this->entities->page(); - - $resp = $this->get($this->baseEndpoint . "/{$page->id}/export/markdown"); - $resp->assertStatus(200); - $resp->assertSee('# ' . $page->name); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.md"'); - } - - public function test_cant_export_when_not_have_permission() - { - $types = ['html', 'plaintext', 'pdf', 'markdown']; - $this->actingAsApiEditor(); - $this->permissions->removeUserRolePermissions($this->users->editor(), ['content-export']); - - $page = $this->entities->page(); - foreach ($types as $type) { - $resp = $this->get($this->baseEndpoint . "/{$page->id}/export/{$type}"); - $this->assertPermissionError($resp); - } - } } diff --git a/tests/Exports/ZipExportTest.php b/tests/Exports/ZipExportTest.php index 1434c013f73..1310dcc2456 100644 --- a/tests/Exports/ZipExportTest.php +++ b/tests/Exports/ZipExportTest.php @@ -41,7 +41,7 @@ public function test_export_metadata() { $page = $this->entities->page(); $zipResp = $this->asEditor()->get($page->getUrl("/export/zip")); - $zip = $this->extractZipResponse($zipResp); + $zip = ZipTestHelper::extractFromZipResponse($zipResp); $this->assertEquals($page->id, $zip->data['page']['id'] ?? null); $this->assertArrayNotHasKey('book', $zip->data); @@ -83,7 +83,7 @@ public function test_page_export() { $page = $this->entities->page(); $zipResp = $this->asEditor()->get($page->getUrl("/export/zip")); - $zip = $this->extractZipResponse($zipResp); + $zip = ZipTestHelper::extractFromZipResponse($zipResp); $pageData = $zip->data['page']; $this->assertEquals([ @@ -105,7 +105,7 @@ public function test_page_export_with_markdown() $page->save(); $zipResp = $this->asEditor()->get($page->getUrl("/export/zip")); - $zip = $this->extractZipResponse($zipResp); + $zip = ZipTestHelper::extractFromZipResponse($zipResp); $pageData = $zip->data['page']; $this->assertEquals($markdown, $pageData['markdown']); @@ -121,7 +121,7 @@ public function test_page_export_with_tags() ]); $zipResp = $this->asEditor()->get($page->getUrl("/export/zip")); - $zip = $this->extractZipResponse($zipResp); + $zip = ZipTestHelper::extractFromZipResponse($zipResp); $pageData = $zip->data['page']; $this->assertEquals([ @@ -147,7 +147,7 @@ public function test_page_export_with_images() $image = Image::findOrFail($result['response']->id); $zipResp = $this->asEditor()->get($page->getUrl("/export/zip")); - $zip = $this->extractZipResponse($zipResp); + $zip = ZipTestHelper::extractFromZipResponse($zipResp); $pageData = $zip->data['page']; $this->assertCount(1, $pageData['images']); @@ -173,7 +173,7 @@ public function test_page_export_file_attachments() $attachment = $this->files->uploadAttachmentDataToPage($this, $page, 'PageAttachmentExport.txt', $contents, 'text/plain'); $zipResp = $this->get($page->getUrl("/export/zip")); - $zip = $this->extractZipResponse($zipResp); + $zip = ZipTestHelper::extractFromZipResponse($zipResp); $pageData = $zip->data['page']; $this->assertCount(1, $pageData['attachments']); @@ -203,7 +203,7 @@ public function test_page_export_link_attachments() ]); $zipResp = $this->get($page->getUrl("/export/zip")); - $zip = $this->extractZipResponse($zipResp); + $zip = ZipTestHelper::extractFromZipResponse($zipResp); $pageData = $zip->data['page']; $this->assertCount(1, $pageData['attachments']); @@ -221,7 +221,7 @@ public function test_book_export() $book->tags()->saveMany(Tag::factory()->count(2)->make()); $zipResp = $this->asEditor()->get($book->getUrl("/export/zip")); - $zip = $this->extractZipResponse($zipResp); + $zip = ZipTestHelper::extractFromZipResponse($zipResp); $this->assertArrayHasKey('book', $zip->data); $bookData = $zip->data['book']; @@ -243,7 +243,7 @@ public function test_book_export_with_cover_image() $coverImage = $book->cover()->first(); $zipResp = $this->asEditor()->get($book->getUrl("/export/zip")); - $zip = $this->extractZipResponse($zipResp); + $zip = ZipTestHelper::extractFromZipResponse($zipResp); $this->assertArrayHasKey('cover', $zip->data['book']); $coverRef = $zip->data['book']['cover']; @@ -258,7 +258,7 @@ public function test_chapter_export() $chapter->tags()->saveMany(Tag::factory()->count(2)->make()); $zipResp = $this->asEditor()->get($chapter->getUrl("/export/zip")); - $zip = $this->extractZipResponse($zipResp); + $zip = ZipTestHelper::extractFromZipResponse($zipResp); $this->assertArrayHasKey('chapter', $zip->data); $chapterData = $zip->data['chapter']; @@ -284,18 +284,18 @@ public function test_draft_pages_are_not_included() $page->save(); $zipResp = $this->actingAs($editor)->get($book->getUrl("/export/zip")); - $zip = $this->extractZipResponse($zipResp); + $zip = ZipTestHelper::extractFromZipResponse($zipResp); $this->assertCount(0, $zip->data['book']['chapters'][0]['pages'] ?? ['cat']); $zipResp = $this->actingAs($editor)->get($chapter->getUrl("/export/zip")); - $zip = $this->extractZipResponse($zipResp); + $zip = ZipTestHelper::extractFromZipResponse($zipResp); $this->assertCount(0, $zip->data['chapter']['pages'] ?? ['cat']); $page->chapter_id = 0; $page->save(); $zipResp = $this->actingAs($editor)->get($book->getUrl("/export/zip")); - $zip = $this->extractZipResponse($zipResp); + $zip = ZipTestHelper::extractFromZipResponse($zipResp); $this->assertCount(0, $zip->data['book']['pages'] ?? ['cat']); } @@ -314,7 +314,7 @@ public function test_cross_reference_links_are_converted() $page->save(); $zipResp = $this->asEditor()->get($book->getUrl("/export/zip")); - $zip = $this->extractZipResponse($zipResp); + $zip = ZipTestHelper::extractFromZipResponse($zipResp); $bookData = $zip->data['book']; $chapterData = $bookData['chapters'][0]; $pageData = $chapterData['pages'][0]; @@ -342,7 +342,7 @@ public function test_book_and_chapter_description_links_to_images_in_pages_are_c $chapter->save(); $zipResp = $this->get($book->getUrl("/export/zip")); - $zip = $this->extractZipResponse($zipResp); + $zip = ZipTestHelper::extractFromZipResponse($zipResp); $bookData = $zip->data['book']; $chapterData = $bookData['chapters'][0]; @@ -367,7 +367,7 @@ public function test_image_links_are_handled_when_using_external_storage_url() $page->save(); $zipResp = $this->get($page->getUrl("/export/zip")); - $zip = $this->extractZipResponse($zipResp); + $zip = ZipTestHelper::extractFromZipResponse($zipResp); $pageData = $zip->data['page']; $ref = '[[bsexport:image:' . $image->id . ']]'; @@ -381,7 +381,7 @@ public function test_cross_reference_links_external_to_export_are_not_converted( $page->save(); $zipResp = $this->asEditor()->get($page->getUrl("/export/zip")); - $zip = $this->extractZipResponse($zipResp); + $zip = ZipTestHelper::extractFromZipResponse($zipResp); $pageData = $zip->data['page']; $this->assertStringContainsString('href="' . $page->book->getUrl() . '"', $pageData['html']); @@ -402,7 +402,7 @@ public function test_attachments_links_are_converted() $page->save(); $zipResp = $this->asEditor()->get($page->getUrl("/export/zip")); - $zip = $this->extractZipResponse($zipResp); + $zip = ZipTestHelper::extractFromZipResponse($zipResp); $pageData = $zip->data['page']; $this->assertStringContainsString('href="[[bsexport:attachment:' . $attachment->id . ']]?open=true"', $pageData['html']); @@ -417,7 +417,7 @@ public function test_links_in_markdown_are_parsed() $page->save(); $zipResp = $this->asEditor()->get($chapter->getUrl("/export/zip")); - $zip = $this->extractZipResponse($zipResp); + $zip = ZipTestHelper::extractFromZipResponse($zipResp); $pageData = $zip->data['chapter']['pages'][0]; $this->assertStringContainsString("[Link to chapter]([[bsexport:chapter:{$chapter->id}]])", $pageData['markdown']); @@ -444,30 +444,4 @@ public function test_exports_rate_limited_higher_for_logged_in_viewers() } $this->get($page->getUrl("/export/zip"))->assertStatus(429); } - - 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/ZipTestHelper.php b/tests/Exports/ZipTestHelper.php index d830d8eb6bf..50517a87d02 100644 --- a/tests/Exports/ZipTestHelper.php +++ b/tests/Exports/ZipTestHelper.php @@ -4,6 +4,7 @@ use BookStack\Exports\Import; use Illuminate\Http\UploadedFile; +use Illuminate\Testing\TestResponse; use ZipArchive; class ZipTestHelper @@ -56,4 +57,30 @@ public static function zipUploadFromData(array $data, array $files = []): Upload return new UploadedFile($zipFile, 'upload.zip', 'application/zip', null, true); } + + public static function extractFromZipResponse(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, + ); + } } From 73025719a4bc06457de1d753aa6e6fefd0ba8777 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 18 Jul 2025 14:05:32 +0100 Subject: [PATCH 6/7] ZIP Imports: Added API test cases --- .../Controllers/ImportApiController.php | 27 ++- app/Exports/Import.php | 2 + app/Exports/ImportRepo.php | 8 +- app/Http/ApiController.php | 2 +- database/factories/Exports/ImportFactory.php | 1 + routes/api.php | 10 +- tests/Api/ImportsApiTest.php | 175 ++++++++++++++++++ 7 files changed, 214 insertions(+), 11 deletions(-) create mode 100644 tests/Api/ImportsApiTest.php diff --git a/app/Exports/Controllers/ImportApiController.php b/app/Exports/Controllers/ImportApiController.php index 13bc9d83ea5..0749ff9330a 100644 --- a/app/Exports/Controllers/ImportApiController.php +++ b/app/Exports/Controllers/ImportApiController.php @@ -26,9 +26,11 @@ public function __construct( */ public function list(): JsonResponse { - $imports = $this->imports->getVisibleImports()->all(); + $query = $this->imports->queryVisible(); - return response()->json($imports); + return $this->apiListingResponse($query, [ + 'id', 'name', 'size', 'type', 'created_by', 'created_at', 'updated_at' + ]); } /** @@ -44,7 +46,7 @@ public function upload(Request $request): JsonResponse try { $import = $this->imports->storeFromUpload($file); } catch (ZipValidationException $exception) { - $message = "ZIP upload failed with the following validation errors: \n" . implode("\n", $exception->errors); + $message = "ZIP upload failed with the following validation errors: \n" . $this->formatErrors($exception->errors); return $this->jsonError($message, 422); } @@ -53,11 +55,15 @@ public function upload(Request $request): JsonResponse /** * Read details of a pending ZIP import. + * The "details" property contains high-level metadata regarding the ZIP import content, + * and the structure of this will change depending on import "type". */ public function read(int $id): JsonResponse { $import = $this->imports->findVisible($id); + $import->setAttribute('details', $import->decodeMetadata()); + return response()->json($import); } @@ -82,7 +88,7 @@ public function run(int $id, Request $request): JsonResponse try { $entity = $this->imports->runImport($import, $parent); } catch (ZipImportException $exception) { - $message = "ZIP import failed with the following errors: \n" . implode("\n", $exception->errors); + $message = "ZIP import failed with the following errors: \n" . $this->formatErrors($exception->errors); return $this->jsonError($message); } @@ -112,4 +118,17 @@ protected function rules(): array ], ]; } + + protected function formatErrors(array $errors): string + { + $parts = []; + foreach ($errors as $key => $error) { + if (is_string($key)) { + $parts[] = "[{$key}] {$error}"; + } else { + $parts[] = $error; + } + } + return implode("\n", $parts); + } } diff --git a/app/Exports/Import.php b/app/Exports/Import.php index 9c1771c468f..ca4f529815f 100644 --- a/app/Exports/Import.php +++ b/app/Exports/Import.php @@ -28,6 +28,8 @@ class Import extends Model implements Loggable { use HasFactory; + protected $hidden = ['metadata']; + public function getSizeString(): string { $mb = round($this->size / 1000000, 2); diff --git a/app/Exports/ImportRepo.php b/app/Exports/ImportRepo.php index f72386c47bc..e030a88d261 100644 --- a/app/Exports/ImportRepo.php +++ b/app/Exports/ImportRepo.php @@ -17,6 +17,7 @@ use BookStack\Exports\ZipExports\ZipImportRunner; use BookStack\Facades\Activity; use BookStack\Uploads\FileStorage; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\DB; use Symfony\Component\HttpFoundation\File\UploadedFile; @@ -34,6 +35,11 @@ public function __construct( * @return Collection */ public function getVisibleImports(): Collection + { + return $this->queryVisible()->get(); + } + + public function queryVisible(): Builder { $query = Import::query(); @@ -41,7 +47,7 @@ public function getVisibleImports(): Collection $query->where('created_by', user()->id); } - return $query->get(); + return $query; } public function findVisible(int $id): Import diff --git a/app/Http/ApiController.php b/app/Http/ApiController.php index c0dbe2fca4a..1a92afa3380 100644 --- a/app/Http/ApiController.php +++ b/app/Http/ApiController.php @@ -8,7 +8,7 @@ abstract class ApiController extends Controller { - protected $rules = []; + protected array $rules = []; /** * Provide a paginated listing JSON response in a standard format diff --git a/database/factories/Exports/ImportFactory.php b/database/factories/Exports/ImportFactory.php index 5d0b4f89299..cdb019dd362 100644 --- a/database/factories/Exports/ImportFactory.php +++ b/database/factories/Exports/ImportFactory.php @@ -24,6 +24,7 @@ public function definition(): array 'path' => 'uploads/files/imports/' . Str::random(10) . '.zip', 'name' => $this->faker->words(3, true), 'type' => 'book', + 'size' => rand(1, 1001), 'metadata' => '{"name": "My book"}', 'created_at' => User::factory(), ]; diff --git a/routes/api.php b/routes/api.php index efb7b258c6f..98af4bb2616 100644 --- a/routes/api.php +++ b/routes/api.php @@ -88,11 +88,11 @@ Route::put('roles/{id}', [RoleApiController::class, 'update']); Route::delete('roles/{id}', [RoleApiController::class, 'delete']); -Route::get('import', [ExportControllers\ImportApiController::class, 'list']); -Route::post('import', [ExportControllers\ImportApiController::class, 'upload']); -Route::get('import/{id}', [ExportControllers\ImportApiController::class, 'read']); -Route::post('import/{id}', [ExportControllers\ImportApiController::class, 'run']); -Route::delete('import/{id}', [ExportControllers\ImportApiController::class, 'delete']); +Route::get('imports', [ExportControllers\ImportApiController::class, 'list']); +Route::post('imports', [ExportControllers\ImportApiController::class, 'upload']); +Route::get('imports/{id}', [ExportControllers\ImportApiController::class, 'read']); +Route::post('imports/{id}', [ExportControllers\ImportApiController::class, 'run']); +Route::delete('imports/{id}', [ExportControllers\ImportApiController::class, 'delete']); Route::get('recycle-bin', [EntityControllers\RecycleBinApiController::class, 'list']); Route::put('recycle-bin/{deletionId}', [EntityControllers\RecycleBinApiController::class, 'restore']); diff --git a/tests/Api/ImportsApiTest.php b/tests/Api/ImportsApiTest.php new file mode 100644 index 00000000000..523034324fc --- /dev/null +++ b/tests/Api/ImportsApiTest.php @@ -0,0 +1,175 @@ +entities->book(); + $zip = ZipTestHelper::zipUploadFromData([ + 'page' => [ + 'name' => 'My API import page', + 'tags' => [ + [ + 'name' => 'My api tag', + 'value' => 'api test value' + ] + ], + ], + ]); + + $resp = $this->actingAsApiAdmin()->call('POST', $this->baseEndpoint, [], [], ['file' => $zip]); + $resp->assertStatus(200); + + $importId = $resp->json('id'); + $import = Import::query()->findOrFail($importId); + $this->assertEquals('page', $import->type); + + $resp = $this->post($this->baseEndpoint . "/{$import->id}", [ + 'parent_type' => 'book', + 'parent_id' => $book->id, + ]); + $resp->assertJson([ + 'name' => 'My API import page', + 'book_id' => $book->id, + ]); + + $page = Page::query()->where('name', '=', 'My API import page')->first(); + $this->assertEquals('My api tag', $page->tags()->first()->name); + } + + public function test_upload_validation_error(): void + { + $zip = ZipTestHelper::zipUploadFromData([ + 'page' => [ + 'tags' => [ + [ + 'name' => 'My api tag', + 'value' => 'api test value' + ] + ], + ], + ]); + + $resp = $this->actingAsApiAdmin()->call('POST', $this->baseEndpoint, [], [], ['file' => $zip]); + $resp->assertStatus(422); + $message = $resp->json('message'); + + $this->assertStringContainsString('ZIP upload failed with the following validation errors:', $message); + $this->assertStringContainsString('[page.name] The name field is required.', $message); + } + + public function test_list(): void + { + $imports = Import::factory()->count(10)->create(); + + $resp = $this->actingAsApiAdmin()->get($this->baseEndpoint); + $resp->assertJsonCount(10, 'data'); + $resp->assertJsonPath('total', 10); + + $firstImport = $imports->first(); + $resp = $this->actingAsApiAdmin()->get($this->baseEndpoint . '?filter[id]=' . $firstImport->id); + $resp->assertJsonCount(1, 'data'); + $resp->assertJsonPath('data.0.id', $firstImport->id); + $resp->assertJsonPath('data.0.name', $firstImport->name); + $resp->assertJsonPath('data.0.size', $firstImport->size); + $resp->assertJsonPath('data.0.type', $firstImport->type); + } + + public function test_list_visibility_limited(): void + { + $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->permissions->grantUserRolePermissions($user, ['content-import']); + + $resp = $this->actingAsForApi($user)->get($this->baseEndpoint); + $resp->assertJsonCount(1, 'data'); + $resp->assertJsonPath('data.0.name', 'MySuperUserImport'); + + $this->permissions->grantUserRolePermissions($user, ['settings-manage']); + + $resp = $this->actingAsForApi($user)->get($this->baseEndpoint); + $resp->assertJsonCount(2, 'data'); + $resp->assertJsonPath('data.1.name', 'MySuperAdminImport'); + } + + public function test_read(): void + { + $zip = ZipTestHelper::zipUploadFromData([ + 'book' => [ + 'name' => 'My API import book', + 'pages' => [ + [ + 'name' => 'My import page', + 'tags' => [ + [ + 'name' => 'My api tag', + 'value' => 'api test value' + ] + ] + ] + ], + ], + ]); + + $resp = $this->actingAsApiAdmin()->call('POST', $this->baseEndpoint, [], [], ['file' => $zip]); + $resp->assertStatus(200); + + $resp = $this->get($this->baseEndpoint . "/{$resp->json('id')}"); + $resp->assertStatus(200); + + $resp->assertJsonPath('details.name', 'My API import book'); + $resp->assertJsonPath('details.pages.0.name', 'My import page'); + $resp->assertJsonPath('details.pages.0.tags.0.name', 'My api tag'); + $resp->assertJsonMissingPath('metadata'); + } + + public function test_delete(): void + { + $import = Import::factory()->create(); + + $resp = $this->actingAsApiAdmin()->delete($this->baseEndpoint . "/{$import->id}"); + $resp->assertStatus(204); + } + + public function test_content_import_permissions_needed(): void + { + $user = $this->users->viewer(); + $this->permissions->grantUserRolePermissions($user, ['access-api']); + $this->actingAsForApi($user); + $requests = [ + ['GET', $this->baseEndpoint], + ['POST', $this->baseEndpoint], + ['GET', $this->baseEndpoint . "/1"], + ['POST', $this->baseEndpoint . "/1"], + ['DELETE', $this->baseEndpoint . "/1"], + ]; + + foreach ($requests as $request) { + [$method, $endpoint] = $request; + $resp = $this->json($method, $endpoint); + $resp->assertStatus(403); + } + + $this->permissions->grantUserRolePermissions($user, ['content-import']); + + foreach ($requests as $request) { + [$method, $endpoint] = $request; + $resp = $this->call($method, $endpoint); + $this->assertNotEquals(403, $resp->status(), "A {$method} request to {$endpoint} returned 403"); + } + } +} From 32ba3a591f982ddd99aa44e4f67b1ee6e20d91ba Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 18 Jul 2025 16:19:14 +0100 Subject: [PATCH 7/7] ZIP Imports: Added API examples, finished testing Also updated some types on a couple of controllers. --- .../Controllers/ChapterApiController.php | 3 +- .../Controllers/PageApiController.php | 2 +- .../Controllers/ImportApiController.php | 28 ++++++---- .../ContentPermissionApiController.php | 2 +- app/Search/SearchApiController.php | 2 +- app/Users/Controllers/RoleApiController.php | 2 +- dev/api/requests/imports-run.json | 4 ++ dev/api/responses/imports-create.json | 10 ++++ dev/api/responses/imports-list.json | 23 +++++++++ dev/api/responses/imports-read.json | 51 +++++++++++++++++++ dev/api/responses/imports-run.json | 14 +++++ routes/api.php | 2 +- tests/Api/ImportsApiTest.php | 5 +- 13 files changed, 130 insertions(+), 18 deletions(-) create mode 100644 dev/api/requests/imports-run.json create mode 100644 dev/api/responses/imports-create.json create mode 100644 dev/api/responses/imports-list.json create mode 100644 dev/api/responses/imports-read.json create mode 100644 dev/api/responses/imports-run.json diff --git a/app/Entities/Controllers/ChapterApiController.php b/app/Entities/Controllers/ChapterApiController.php index 430654330f3..8ac0c7a60a2 100644 --- a/app/Entities/Controllers/ChapterApiController.php +++ b/app/Entities/Controllers/ChapterApiController.php @@ -9,12 +9,11 @@ use BookStack\Exceptions\PermissionsException; use BookStack\Http\ApiController; use Exception; -use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Http\Request; class ChapterApiController extends ApiController { - protected $rules = [ + protected array $rules = [ 'create' => [ 'book_id' => ['required', 'integer'], 'name' => ['required', 'string', 'max:255'], diff --git a/app/Entities/Controllers/PageApiController.php b/app/Entities/Controllers/PageApiController.php index 40598e20983..8fcba3dc6a1 100644 --- a/app/Entities/Controllers/PageApiController.php +++ b/app/Entities/Controllers/PageApiController.php @@ -12,7 +12,7 @@ class PageApiController extends ApiController { - protected $rules = [ + protected array $rules = [ 'create' => [ 'book_id' => ['required_without:chapter_id', 'integer'], 'chapter_id' => ['required_without:book_id', 'integer'], diff --git a/app/Exports/Controllers/ImportApiController.php b/app/Exports/Controllers/ImportApiController.php index 0749ff9330a..cac155c7c1b 100644 --- a/app/Exports/Controllers/ImportApiController.php +++ b/app/Exports/Controllers/ImportApiController.php @@ -23,6 +23,7 @@ public function __construct( /** * List existing ZIP imports visible to the user. + * Requires permission to import content. */ public function list(): JsonResponse { @@ -34,12 +35,18 @@ public function list(): JsonResponse } /** - * Upload, validate and store a ZIP import file. - * This does not run the import. That is performed via a separate endpoint. + * Start a new import from a ZIP file. + * This does not actually run the import since that is performed via the "run" endpoint. + * This uploads, validates and stores the ZIP file so it's ready to be imported. + * + * This "file" parameter must be a BookStack-compatible ZIP file, and this must be + * sent via a 'multipart/form-data' type request. + * + * Requires permission to import content. */ - public function upload(Request $request): JsonResponse + public function create(Request $request): JsonResponse { - $this->validate($request, $this->rules()['upload']); + $this->validate($request, $this->rules()['create']); $file = $request->file('file'); @@ -57,6 +64,7 @@ public function upload(Request $request): JsonResponse * Read details of a pending ZIP import. * The "details" property contains high-level metadata regarding the ZIP import content, * and the structure of this will change depending on import "type". + * Requires permission to import content. */ public function read(int $id): JsonResponse { @@ -69,8 +77,9 @@ public function read(int $id): JsonResponse /** * Run the import process for an uploaded ZIP import. - * The parent_id and parent_type parameters are required when the import type is 'chapter' or 'page'. - * On success, returns the imported item. + * The "parent_id" and "parent_type" parameters are required when the import type is "chapter" or "page". + * On success, this endpoint returns the imported item. + * Requires permission to import content. */ public function run(int $id, Request $request): JsonResponse { @@ -92,11 +101,12 @@ public function run(int $id, Request $request): JsonResponse return $this->jsonError($message); } - return response()->json($entity); + return response()->json($entity->withoutRelations()); } /** - * Delete a pending ZIP import. + * Delete a pending ZIP import from the system. + * Requires permission to import content. */ public function delete(int $id): Response { @@ -109,7 +119,7 @@ public function delete(int $id): Response protected function rules(): array { return [ - 'upload' => [ + 'create' => [ 'file' => ['required', ...AttachmentService::getFileValidationRules()], ], 'run' => [ diff --git a/app/Permissions/ContentPermissionApiController.php b/app/Permissions/ContentPermissionApiController.php index 23b75db359a..bddbc2c7d95 100644 --- a/app/Permissions/ContentPermissionApiController.php +++ b/app/Permissions/ContentPermissionApiController.php @@ -16,7 +16,7 @@ public function __construct( ) { } - protected $rules = [ + protected array $rules = [ 'update' => [ 'owner_id' => ['int'], diff --git a/app/Search/SearchApiController.php b/app/Search/SearchApiController.php index 79cd8cfabd0..cd4a14a3931 100644 --- a/app/Search/SearchApiController.php +++ b/app/Search/SearchApiController.php @@ -9,7 +9,7 @@ class SearchApiController extends ApiController { - protected $rules = [ + protected array $rules = [ 'all' => [ 'query' => ['required'], 'page' => ['integer', 'min:1'], diff --git a/app/Users/Controllers/RoleApiController.php b/app/Users/Controllers/RoleApiController.php index 2e96602faae..2f3638cd3e2 100644 --- a/app/Users/Controllers/RoleApiController.php +++ b/app/Users/Controllers/RoleApiController.php @@ -16,7 +16,7 @@ class RoleApiController extends ApiController 'display_name', 'description', 'mfa_enforced', 'external_auth_id', 'created_at', 'updated_at', ]; - protected $rules = [ + protected array $rules = [ 'create' => [ 'display_name' => ['required', 'string', 'min:3', 'max:180'], 'description' => ['string', 'max:180'], diff --git a/dev/api/requests/imports-run.json b/dev/api/requests/imports-run.json new file mode 100644 index 00000000000..836a66f3a94 --- /dev/null +++ b/dev/api/requests/imports-run.json @@ -0,0 +1,4 @@ +{ + "parent_type": "book", + "parent_id": 28 +} \ No newline at end of file diff --git a/dev/api/responses/imports-create.json b/dev/api/responses/imports-create.json new file mode 100644 index 00000000000..997758799a4 --- /dev/null +++ b/dev/api/responses/imports-create.json @@ -0,0 +1,10 @@ +{ + "type": "chapter", + "name": "Pension Providers", + "created_by": 1, + "size": 2757, + "path": "uploads\/files\/imports\/ghnxmS3u9QxLWu82.zip", + "updated_at": "2025-07-18T14:50:27.000000Z", + "created_at": "2025-07-18T14:50:27.000000Z", + "id": 31 +} \ No newline at end of file diff --git a/dev/api/responses/imports-list.json b/dev/api/responses/imports-list.json new file mode 100644 index 00000000000..7451e443783 --- /dev/null +++ b/dev/api/responses/imports-list.json @@ -0,0 +1,23 @@ +{ + "data": [ + { + "id": 25, + "name": "IT Department", + "size": 618462, + "type": "book", + "created_by": 1, + "created_at": "2024-12-20T18:40:38.000000Z", + "updated_at": "2024-12-20T18:40:38.000000Z" + }, + { + "id": 27, + "name": "Clients", + "size": 15364, + "type": "chapter", + "created_by": 1, + "created_at": "2025-03-20T12:41:44.000000Z", + "updated_at": "2025-03-20T12:41:44.000000Z" + } + ], + "total": 2 +} \ No newline at end of file diff --git a/dev/api/responses/imports-read.json b/dev/api/responses/imports-read.json new file mode 100644 index 00000000000..e256854d127 --- /dev/null +++ b/dev/api/responses/imports-read.json @@ -0,0 +1,51 @@ +{ + "id": 25, + "name": "IT Department", + "path": "uploads\/files\/imports\/7YOpZ6sGIEbYdRFL.zip", + "size": 618462, + "type": "book", + "created_by": 1, + "created_at": "2024-12-20T18:40:38.000000Z", + "updated_at": "2024-12-20T18:40:38.000000Z", + "details": { + "id": 4, + "name": "IT Department", + "chapters": [ + { + "id": 3, + "name": "Server Systems", + "priority": 1, + "pages": [ + { + "id": 22, + "name": "prod-aws-stonehawk", + "priority": 0, + "attachments": [], + "images": [], + "tags": [] + } + ], + "tags": [] + } + ], + "pages": [ + { + "id": 23, + "name": "Member Onboarding Guide", + "priority": 0, + "attachments": [], + "images": [], + "tags": [] + }, + { + "id": 25, + "name": "IT Holiday Party Event", + "priority": 2, + "attachments": [], + "images": [], + "tags": [] + } + ], + "tags": [] + } +} \ No newline at end of file diff --git a/dev/api/responses/imports-run.json b/dev/api/responses/imports-run.json new file mode 100644 index 00000000000..90b34d6aa09 --- /dev/null +++ b/dev/api/responses/imports-run.json @@ -0,0 +1,14 @@ +{ + "id": 1067, + "book_id": 28, + "slug": "pension-providers", + "name": "Pension Providers", + "description": "Details on the various pension providers that are available", + "priority": 7, + "created_at": "2025-07-18T14:53:35.000000Z", + "updated_at": "2025-07-18T14:53:36.000000Z", + "created_by": 1, + "updated_by": 1, + "owned_by": 1, + "default_template_id": null +} \ No newline at end of file diff --git a/routes/api.php b/routes/api.php index 98af4bb2616..99df24aed0a 100644 --- a/routes/api.php +++ b/routes/api.php @@ -89,7 +89,7 @@ Route::delete('roles/{id}', [RoleApiController::class, 'delete']); Route::get('imports', [ExportControllers\ImportApiController::class, 'list']); -Route::post('imports', [ExportControllers\ImportApiController::class, 'upload']); +Route::post('imports', [ExportControllers\ImportApiController::class, 'create']); Route::get('imports/{id}', [ExportControllers\ImportApiController::class, 'read']); Route::post('imports/{id}', [ExportControllers\ImportApiController::class, 'run']); Route::delete('imports/{id}', [ExportControllers\ImportApiController::class, 'delete']); diff --git a/tests/Api/ImportsApiTest.php b/tests/Api/ImportsApiTest.php index 523034324fc..f6df074ee38 100644 --- a/tests/Api/ImportsApiTest.php +++ b/tests/Api/ImportsApiTest.php @@ -14,7 +14,7 @@ class ImportsApiTest extends TestCase protected string $baseEndpoint = '/api/imports'; - public function test_upload_and_run(): void + public function test_create_and_run(): void { $book = $this->entities->book(); $zip = ZipTestHelper::zipUploadFromData([ @@ -44,12 +44,13 @@ public function test_upload_and_run(): void 'name' => 'My API import page', 'book_id' => $book->id, ]); + $resp->assertJsonMissingPath('book'); $page = Page::query()->where('name', '=', 'My API import page')->first(); $this->assertEquals('My api tag', $page->tags()->first()->name); } - public function test_upload_validation_error(): void + public function test_create_validation_error(): void { $zip = ZipTestHelper::zipUploadFromData([ 'page' => [