Skip to content

API: ZIP Import/Export #5721

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Jul 18, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions app/Entities/Controllers/ChapterApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,11 @@
use BookStack\Exceptions\PermissionsException;
use BookStack\Http\ApiController;
use Exception;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Http\Request;

class ChapterApiController extends ApiController
{
protected $rules = [
protected array $rules = [
'create' => [
'book_id' => ['required', 'integer'],
'name' => ['required', 'string', 'max:255'],
Expand Down
2 changes: 1 addition & 1 deletion app/Entities/Controllers/PageApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

class PageApiController extends ApiController
{
protected $rules = [
protected array $rules = [
'create' => [
'book_id' => ['required_without:chapter_id', 'integer'],
'chapter_id' => ['required_without:book_id', 'integer'],
Expand Down
12 changes: 12 additions & 0 deletions app/Exports/Controllers/BookExportApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use BookStack\Entities\Queries\BookQueries;
use BookStack\Exports\ExportFormatter;
use BookStack\Exports\ZipExports\ZipExportBuilder;
use BookStack\Http\ApiController;
use Throwable;

Expand Down Expand Up @@ -63,4 +64,15 @@ public function exportMarkdown(int $id)

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);
}
}
9 changes: 9 additions & 0 deletions app/Exports/Controllers/ChapterExportApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use BookStack\Entities\Queries\ChapterQueries;
use BookStack\Exports\ExportFormatter;
use BookStack\Exports\ZipExports\ZipExportBuilder;
use BookStack\Http\ApiController;
use Throwable;

Expand Down Expand Up @@ -63,4 +64,12 @@ public function exportMarkdown(int $id)

return $this->download()->directly($markdown, $chapter->slug . '.md');
}

public function exportZip(int $id, ZipExportBuilder $builder)
{
$chapter = $this->queries->findVisibleByIdOrFail($id);
$zip = $builder->buildForChapter($chapter);

return $this->download()->streamedFileDirectly($zip, $chapter->slug . '.zip', true);
}
}
144 changes: 144 additions & 0 deletions app/Exports/Controllers/ImportApiController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
<?php

declare(strict_types=1);

namespace BookStack\Exports\Controllers;

use BookStack\Exceptions\ZipImportException;
use BookStack\Exceptions\ZipValidationException;
use BookStack\Exports\ImportRepo;
use BookStack\Http\ApiController;
use BookStack\Uploads\AttachmentService;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;

class ImportApiController extends ApiController
{
public function __construct(
protected ImportRepo $imports,
) {
$this->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);
}
}
9 changes: 9 additions & 0 deletions app/Exports/Controllers/PageExportApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use BookStack\Entities\Queries\PageQueries;
use BookStack\Exports\ExportFormatter;
use BookStack\Exports\ZipExports\ZipExportBuilder;
use BookStack\Http\ApiController;
use Throwable;

Expand Down Expand Up @@ -63,4 +64,12 @@ public function exportMarkdown(int $id)

return $this->download()->directly($markdown, $page->slug . '.md');
}

public function exportZip(int $id, ZipExportBuilder $builder)
{
$page = $this->queries->findVisibleByIdOrFail($id);
$zip = $builder->buildForPage($page);

return $this->download()->streamedFileDirectly($zip, $page->slug . '.zip', true);
}
}
2 changes: 2 additions & 0 deletions app/Exports/Import.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
8 changes: 7 additions & 1 deletion app/Exports/ImportRepo.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use BookStack\Exports\ZipExports\ZipImportRunner;
use BookStack\Facades\Activity;
use BookStack\Uploads\FileStorage;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\File\UploadedFile;
Expand All @@ -34,14 +35,19 @@ public function __construct(
* @return Collection<Import>
*/
public function getVisibleImports(): Collection
{
return $this->queryVisible()->get();
}

public function queryVisible(): Builder
{
$query = Import::query();

if (!userCan('settings-manage')) {
$query->where('created_by', user()->id);
}

return $query->get();
return $query;
}

public function findVisible(int $id): Import
Expand Down
2 changes: 1 addition & 1 deletion app/Http/ApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

abstract class ApiController extends Controller
{
protected $rules = [];
protected array $rules = [];

/**
* Provide a paginated listing JSON response in a standard format
Expand Down
2 changes: 1 addition & 1 deletion app/Permissions/ContentPermissionApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public function __construct(
) {
}

protected $rules = [
protected array $rules = [
'update' => [
'owner_id' => ['int'],

Expand Down
2 changes: 1 addition & 1 deletion app/Search/SearchApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

class SearchApiController extends ApiController
{
protected $rules = [
protected array $rules = [
'all' => [
'query' => ['required'],
'page' => ['integer', 'min:1'],
Expand Down
2 changes: 1 addition & 1 deletion app/Users/Controllers/RoleApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
1 change: 1 addition & 0 deletions database/factories/Exports/ImportFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public function definition(): array
'path' => 'uploads/files/imports/' . Str::random(10) . '.zip',
'name' => $this->faker->words(3, true),
'type' => 'book',
'size' => rand(1, 1001),
'metadata' => '{"name": "My book"}',
'created_at' => User::factory(),
];
Expand Down
4 changes: 4 additions & 0 deletions dev/api/requests/imports-run.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"parent_type": "book",
"parent_id": 28
}
10 changes: 10 additions & 0 deletions dev/api/responses/imports-create.json
Original file line number Diff line number Diff line change
@@ -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
}
23 changes: 23 additions & 0 deletions dev/api/responses/imports-list.json
Original file line number Diff line number Diff line change
@@ -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
}
Loading