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/BookExportApiController.php b/app/Exports/Controllers/BookExportApiController.php index 164946b0c..e2d0addc3 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,15 @@ class BookExportApiController extends ApiController return $this->download()->directly($markdown, $book->slug . '.md'); } + + /** + * Export a book to a contained ZIP export file. + */ + public function exportZip(int $id, ZipExportBuilder $builder) + { + $book = $this->queries->findVisibleByIdOrFail($id); + $zip = $builder->buildForBook($book); + + return $this->download()->streamedFileDirectly($zip, $book->slug . '.zip', true); + } } diff --git a/app/Exports/Controllers/ChapterExportApiController.php b/app/Exports/Controllers/ChapterExportApiController.php index 9914e2b7f..66e2276b5 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,12 @@ class ChapterExportApiController extends ApiController return $this->download()->directly($markdown, $chapter->slug . '.md'); } + + public function exportZip(int $id, ZipExportBuilder $builder) + { + $chapter = $this->queries->findVisibleByIdOrFail($id); + $zip = $builder->buildForChapter($chapter); + + return $this->download()->streamedFileDirectly($zip, $chapter->slug . '.zip', true); + } } diff --git a/app/Exports/Controllers/ImportApiController.php b/app/Exports/Controllers/ImportApiController.php new file mode 100644 index 000000000..cac155c7c --- /dev/null +++ b/app/Exports/Controllers/ImportApiController.php @@ -0,0 +1,144 @@ +middleware('can:content-import'); + } + + /** + * List existing ZIP imports visible to the user. + * Requires permission to import content. + */ + public function list(): JsonResponse + { + $query = $this->imports->queryVisible(); + + return $this->apiListingResponse($query, [ + 'id', 'name', 'size', 'type', 'created_by', 'created_at', 'updated_at' + ]); + } + + /** + * 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 create(Request $request): JsonResponse + { + $this->validate($request, $this->rules()['create']); + + $file = $request->file('file'); + + try { + $import = $this->imports->storeFromUpload($file); + } catch (ZipValidationException $exception) { + $message = "ZIP upload failed with the following validation errors: \n" . $this->formatErrors($exception->errors); + return $this->jsonError($message, 422); + } + + return response()->json($import); + } + + /** + * 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 + { + $import = $this->imports->findVisible($id); + + $import->setAttribute('details', $import->decodeMetadata()); + + return response()->json($import); + } + + /** + * 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, this endpoint returns the imported item. + * Requires permission to import content. + */ + 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') { + $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) { + $message = "ZIP import failed with the following errors: \n" . $this->formatErrors($exception->errors); + return $this->jsonError($message); + } + + return response()->json($entity->withoutRelations()); + } + + /** + * Delete a pending ZIP import from the system. + * Requires permission to import content. + */ + public function delete(int $id): Response + { + $import = $this->imports->findVisible($id); + $this->imports->deleteImport($import); + + return response('', 204); + } + + protected function rules(): array + { + return [ + 'create' => [ + 'file' => ['required', ...AttachmentService::getFileValidationRules()], + ], + 'run' => [ + 'parent_type' => ['string', 'in:book,chapter'], + 'parent_id' => ['int'], + ], + ]; + } + + 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/Controllers/PageExportApiController.php b/app/Exports/Controllers/PageExportApiController.php index c6e20b615..d6412614c 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,12 @@ class PageExportApiController extends ApiController return $this->download()->directly($markdown, $page->slug . '.md'); } + + public function exportZip(int $id, ZipExportBuilder $builder) + { + $page = $this->queries->findVisibleByIdOrFail($id); + $zip = $builder->buildForPage($page); + + return $this->download()->streamedFileDirectly($zip, $page->slug . '.zip', true); + } } 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/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/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/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 85e872ba4..99df24aed 100644 --- a/routes/api.php +++ b/routes/api.php @@ -37,6 +37,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']); @@ -47,6 +48,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']); @@ -58,6 +60,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']); @@ -85,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('imports', [ExportControllers\ImportApiController::class, 'list']); +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']); + 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']); 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/ImportsApiTest.php b/tests/Api/ImportsApiTest.php new file mode 100644 index 000000000..f6df074ee --- /dev/null +++ b/tests/Api/ImportsApiTest.php @@ -0,0 +1,176 @@ +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, + ]); + $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_create_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"); + } + } +} 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, + ); + } }