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 164946b0c..431afef14 100644 --- a/app/Exports/Controllers/BookExportApiController.php +++ b/app/Exports/Controllers/BookExportApiController.php @@ -4,6 +4,7 @@ namespace BookStack\Exports\Controllers; use BookStack\Entities\Queries\BookQueries; use BookStack\Exports\ExportFormatter; +use BookStack\Exports\ZipExports\ZipExportBuilder; use BookStack\Http\ApiController; use Throwable; @@ -63,4 +64,19 @@ class BookExportApiController extends ApiController 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 9914e2b7f..58df4c9b0 100644 --- a/app/Exports/Controllers/ChapterExportApiController.php +++ b/app/Exports/Controllers/ChapterExportApiController.php @@ -4,6 +4,7 @@ namespace BookStack\Exports\Controllers; use BookStack\Entities\Queries\ChapterQueries; use BookStack\Exports\ExportFormatter; +use BookStack\Exports\ZipExports\ZipExportBuilder; use BookStack\Http\ApiController; use Throwable; @@ -63,4 +64,13 @@ class ChapterExportApiController extends ApiController 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 c6e20b615..ef564da3e 100644 --- a/app/Exports/Controllers/PageExportApiController.php +++ b/app/Exports/Controllers/PageExportApiController.php @@ -4,6 +4,7 @@ namespace BookStack\Exports\Controllers; use BookStack\Entities\Queries\PageQueries; use BookStack\Exports\ExportFormatter; +use BookStack\Exports\ZipExports\ZipExportBuilder; use BookStack\Http\ApiController; use Throwable; @@ -63,4 +64,15 @@ class PageExportApiController extends ApiController 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 000000000..682d340b3 --- /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 710364855..bd00ea4b0 100644 --- a/routes/api.php +++ b/routes/api.php @@ -92,3 +92,9 @@ Route::get('content-permissions/{contentType}/{contentId}', [ContentPermissionAp 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 bd00ea4b0..7bc7d7d44 100644 --- a/routes/api.php +++ b/routes/api.php @@ -36,6 +36,7 @@ Route::get('books/{id}/export/html', [ExportControllers\BookExportApiController: 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/html', [ExportControllers\ChapterExportApiContr 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/html', [ExportControllers\PageExportApiController: 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 431afef14..e2d0addc3 100644 --- a/app/Exports/Controllers/BookExportApiController.php +++ b/app/Exports/Controllers/BookExportApiController.php @@ -65,18 +65,14 @@ class BookExportApiController extends ApiController 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 58df4c9b0..66e2276b5 100644 --- a/app/Exports/Controllers/ChapterExportApiController.php +++ b/app/Exports/Controllers/ChapterExportApiController.php @@ -68,9 +68,8 @@ class ChapterExportApiController extends ApiController 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 682d340b3..13bc9d83e 100644 --- a/app/Exports/Controllers/ImportApiController.php +++ b/app/Exports/Controllers/ImportApiController.php @@ -7,12 +7,13 @@ namespace BookStack\Exports\Controllers; 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 @@ class ImportApiController extends Controller } /** - * 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 ef564da3e..d6412614c 100644 --- a/app/Exports/Controllers/PageExportApiController.php +++ b/app/Exports/Controllers/PageExportApiController.php @@ -65,14 +65,11 @@ class PageExportApiController extends ApiController 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 5bdf53611..efb7b258c 100644 --- a/routes/api.php +++ b/routes/api.php @@ -88,6 +88,12 @@ Route::get('roles/{id}', [RoleApiController::class, 'read']); 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::put('content-permissions/{contentType}/{contentId}', [ContentPermissionAp 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 084cb59bd..22ccfb482 100644 --- a/tests/Api/BooksApiTest.php +++ b/tests/Api/BooksApiTest.php @@ -287,62 +287,4 @@ class BooksApiTest extends TestCase $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 9698d4dd9..5d7b05308 100644 --- a/tests/Api/ChaptersApiTest.php +++ b/tests/Api/ChaptersApiTest.php @@ -269,61 +269,4 @@ class ChaptersApiTest extends TestCase $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 000000000..d427c1a4d --- /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 22659d5bb..ced8954eb 100644 --- a/tests/Api/PagesApiTest.php +++ b/tests/Api/PagesApiTest.php @@ -308,60 +308,4 @@ class PagesApiTest extends TestCase $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 1434c013f..1310dcc24 100644 --- a/tests/Exports/ZipExportTest.php +++ b/tests/Exports/ZipExportTest.php @@ -41,7 +41,7 @@ class ZipExportTest extends TestCase { $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 @@ class ZipExportTest extends TestCase { $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 @@ class ZipExportTest extends TestCase $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 @@ class ZipExportTest extends TestCase ]); $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 @@ class ZipExportTest extends TestCase $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 @@ class ZipExportTest extends TestCase $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 @@ class ZipExportTest extends TestCase ]); $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 @@ class ZipExportTest extends TestCase $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 @@ class ZipExportTest extends TestCase $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 @@ class ZipExportTest extends TestCase $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 @@ class ZipExportTest extends TestCase $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 @@ class ZipExportTest extends TestCase $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 @@ class ZipExportTest extends TestCase $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 @@ class ZipExportTest extends TestCase $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 @@ class ZipExportTest extends TestCase $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 @@ class ZipExportTest extends TestCase $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 @@ class ZipExportTest extends TestCase $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 @@ class ZipExportTest extends TestCase } $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 d830d8eb6..50517a87d 100644 --- a/tests/Exports/ZipTestHelper.php +++ b/tests/Exports/ZipTestHelper.php @@ -4,6 +4,7 @@ namespace Tests\Exports; use BookStack\Exports\Import; use Illuminate\Http\UploadedFile; +use Illuminate\Testing\TestResponse; use ZipArchive; class ZipTestHelper @@ -56,4 +57,30 @@ class ZipTestHelper 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 13bc9d83e..0749ff933 100644 --- a/app/Exports/Controllers/ImportApiController.php +++ b/app/Exports/Controllers/ImportApiController.php @@ -26,9 +26,11 @@ class ImportApiController extends ApiController */ 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 @@ class ImportApiController extends ApiController 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 @@ class ImportApiController extends ApiController /** * 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 @@ class ImportApiController extends ApiController 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 @@ class ImportApiController extends ApiController ], ]; } + + 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 9c1771c46..ca4f52981 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 f72386c47..e030a88d2 100644 --- a/app/Exports/ImportRepo.php +++ b/app/Exports/ImportRepo.php @@ -17,6 +17,7 @@ use BookStack\Exports\ZipExports\ZipExportValidator; 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 @@ class ImportRepo * @return Collection */ public function getVisibleImports(): Collection + { + return $this->queryVisible()->get(); + } + + public function queryVisible(): Builder { $query = Import::query(); @@ -41,7 +47,7 @@ class ImportRepo $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 c0dbe2fca..1a92afa33 100644 --- a/app/Http/ApiController.php +++ b/app/Http/ApiController.php @@ -8,7 +8,7 @@ use Illuminate\Http\JsonResponse; 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 5d0b4f892..cdb019dd3 100644 --- a/database/factories/Exports/ImportFactory.php +++ b/database/factories/Exports/ImportFactory.php @@ -24,6 +24,7 @@ class ImportFactory extends Factory '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 efb7b258c..98af4bb26 100644 --- a/routes/api.php +++ b/routes/api.php @@ -88,11 +88,11 @@ Route::get('roles/{id}', [RoleApiController::class, 'read']); 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 000000000..523034324 --- /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 430654330..8ac0c7a60 100644 --- a/app/Entities/Controllers/ChapterApiController.php +++ b/app/Entities/Controllers/ChapterApiController.php @@ -9,12 +9,11 @@ use BookStack\Entities\Repos\ChapterRepo; 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 40598e209..8fcba3dc6 100644 --- a/app/Entities/Controllers/PageApiController.php +++ b/app/Entities/Controllers/PageApiController.php @@ -12,7 +12,7 @@ use Illuminate\Http\Request; 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 0749ff933..cac155c7c 100644 --- a/app/Exports/Controllers/ImportApiController.php +++ b/app/Exports/Controllers/ImportApiController.php @@ -23,6 +23,7 @@ class ImportApiController extends ApiController /** * List existing ZIP imports visible to the user. + * Requires permission to import content. */ public function list(): JsonResponse { @@ -34,12 +35,18 @@ class ImportApiController extends ApiController } /** - * 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 @@ class ImportApiController extends ApiController * 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 @@ class ImportApiController extends ApiController /** * 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 @@ class ImportApiController extends ApiController 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 @@ class ImportApiController extends ApiController 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 23b75db35..bddbc2c7d 100644 --- a/app/Permissions/ContentPermissionApiController.php +++ b/app/Permissions/ContentPermissionApiController.php @@ -16,7 +16,7 @@ class ContentPermissionApiController extends ApiController ) { } - protected $rules = [ + protected array $rules = [ 'update' => [ 'owner_id' => ['int'], diff --git a/app/Search/SearchApiController.php b/app/Search/SearchApiController.php index 79cd8cfab..cd4a14a39 100644 --- a/app/Search/SearchApiController.php +++ b/app/Search/SearchApiController.php @@ -9,7 +9,7 @@ use Illuminate\Http\Request; 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 2e96602fa..2f3638cd3 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 000000000..836a66f3a --- /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 000000000..997758799 --- /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 000000000..7451e4437 --- /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 000000000..e256854d1 --- /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 000000000..90b34d6aa --- /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 98af4bb26..99df24aed 100644 --- a/routes/api.php +++ b/routes/api.php @@ -89,7 +89,7 @@ Route::put('roles/{id}', [RoleApiController::class, 'update']); 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 523034324..f6df074ee 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 @@ class ImportsApiTest extends TestCase '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' => [