mirror of
https://github.com/BookStackApp/BookStack.git
synced 2026-02-06 00:59:39 +03:00
Compare commits
131 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d2cd20e80 | ||
|
|
b0c574356a | ||
|
|
980a684b14 | ||
|
|
d56eea9279 | ||
|
|
2be504e0d2 | ||
|
|
c84d999456 | ||
|
|
01825ddb93 | ||
|
|
1f88bc2a59 | ||
|
|
ebe2ca7faf | ||
|
|
f4005a139b | ||
|
|
fca8f928a3 | ||
|
|
ace8af077d | ||
|
|
e50cd33277 | ||
|
|
8486775edf | ||
|
|
5887322178 | ||
|
|
3f86937f74 | ||
|
|
2f119d3033 | ||
|
|
5f07f31c9f | ||
|
|
a71aa241ad | ||
|
|
97b201f61f | ||
|
|
a8ef820443 | ||
|
|
7e1a8e5ec6 | ||
|
|
19ee1c9be7 | ||
|
|
fcf0bf79a9 | ||
|
|
0ece664475 | ||
|
|
509af2463d | ||
|
|
5632fef621 | ||
|
|
8ec26e8083 | ||
|
|
617b2edea0 | ||
|
|
55d074f1a5 | ||
|
|
7e6f6af463 | ||
|
|
d00cf6e1ba | ||
|
|
9fdd100f2d | ||
|
|
57d8449660 | ||
|
|
ebd4604f21 | ||
|
|
36a4d79120 | ||
|
|
f3fa63a5ae | ||
|
|
5164375b18 | ||
|
|
fec44452cb | ||
|
|
18ab38a87b | ||
|
|
0f9957bc03 | ||
|
|
80f258c3c5 | ||
|
|
90341e0e00 | ||
|
|
3298374113 | ||
|
|
227c5e155b | ||
|
|
fdbbcf2b8a | ||
|
|
0a07b0d162 | ||
|
|
07e45a20e5 | ||
|
|
14056c69e6 | ||
|
|
fb9c840c46 | ||
|
|
94165cc18f | ||
|
|
f5ecd51461 | ||
|
|
e9f906ce56 | ||
|
|
4630f07282 | ||
|
|
978acecdcf | ||
|
|
bc1f1d92e5 | ||
|
|
415cd6a360 | ||
|
|
68ce340741 | ||
|
|
bdca9fc1ce | ||
|
|
edb684c72c | ||
|
|
17f7afe12d | ||
|
|
0a182a45ba | ||
|
|
95d62e7f57 | ||
|
|
9ecc91929a | ||
|
|
f79c6aef8d | ||
|
|
c0dff6d4a6 | ||
|
|
59cfc087e1 | ||
|
|
e2f6e50df4 | ||
|
|
c2c64e207f | ||
|
|
8645aeaa4a | ||
|
|
7681e32dca | ||
|
|
b7476a9e7f | ||
|
|
5fba4a5399 | ||
|
|
c0b377050e | ||
|
|
306b8774c2 | ||
|
|
c40ab4147e | ||
|
|
48c101aa7a | ||
|
|
378f0d595f | ||
|
|
f12946d581 | ||
|
|
d13e4d2eef | ||
|
|
f3efb6441d | ||
|
|
0cf313a21e | ||
|
|
ac27e18933 | ||
|
|
e5a6ccc4d4 | ||
|
|
e42cdbe8e0 | ||
|
|
a6ba8dd68f | ||
|
|
7017a1cae5 | ||
|
|
8120278b8c | ||
|
|
73babcbfe3 | ||
|
|
45189d9517 | ||
|
|
7b84558ca1 | ||
|
|
92cfde495e | ||
|
|
14578c2257 | ||
|
|
8f6f81948e | ||
|
|
c6109c7087 | ||
|
|
8ea3855e02 | ||
|
|
74fce9640e | ||
|
|
259aa829d4 | ||
|
|
c4ec50d437 | ||
|
|
b50b7b667d | ||
|
|
fbeb2e23d4 | ||
|
|
4b60c03caa | ||
|
|
a56a28fbb7 | ||
|
|
4051d5b803 | ||
|
|
87242ce6cb | ||
|
|
72d9ffd8b4 | ||
|
|
f606711463 | ||
|
|
d1f69feb4a | ||
|
|
e4ca3bf132 | ||
|
|
7aaf866064 | ||
|
|
484342f26a | ||
|
|
42ada66fdd | ||
|
|
f732ef05d5 | ||
|
|
4fb4fe0931 | ||
|
|
06ffd8ee72 | ||
|
|
90a8070518 | ||
|
|
3e656efb00 | ||
|
|
7c39dd5cba | ||
|
|
21ccfa97dd | ||
|
|
bf0262d7d1 | ||
|
|
42b9700673 | ||
|
|
42bd07d733 | ||
|
|
6f1c54d018 | ||
|
|
1930af91ce | ||
|
|
e088d09e47 | ||
|
|
209fa04752 | ||
|
|
f41c02cbd7 | ||
|
|
4dc75bad05 | ||
|
|
a3d0f7478f | ||
|
|
b9b5003239 | ||
|
|
2e8d6ce7d9 |
12
.github/translators.txt
vendored
12
.github/translators.txt
vendored
@@ -449,3 +449,15 @@ Avishay Rapp (AvishayRapp) :: Hebrew
|
||||
matthias4217 :: French
|
||||
Berke BOYLU2 (berkeboylu2) :: Turkish
|
||||
etwas7B :: German
|
||||
Mohammed srhiri (m.sghiri20) :: Arabic
|
||||
YongMin Kim (kym0118) :: Korean
|
||||
Rivo Zängov (Eraser) :: Estonian
|
||||
Francisco Rafael Fonseca (chicoraf) :: Portuguese, Brazilian
|
||||
ИEØ_ΙΙØZ (NEO_IIOZ) :: Chinese Traditional
|
||||
madnjpn (madnjpn.) :: Georgian
|
||||
Ásgeir Shiny Ásgeirsson (AsgeirShiny) :: Icelandic
|
||||
Mohammad Aftab Uddin (chirohorit) :: Bengali
|
||||
Yannis Karlaftis (meliseus) :: Greek
|
||||
felixxx :: German Informal
|
||||
randi (randi65535) :: Korean
|
||||
test65428 :: Greek
|
||||
|
||||
4
.github/workflows/analyse-php.yml
vendored
4
.github/workflows/analyse-php.yml
vendored
@@ -11,9 +11,9 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
|
||||
6
.github/workflows/lint-php.yml
vendored
6
.github/workflows/lint-php.yml
vendored
@@ -11,14 +11,14 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: 8.1
|
||||
php-version: 8.3
|
||||
tools: phpcs
|
||||
|
||||
- name: Run formatting check
|
||||
|
||||
6
.github/workflows/test-migrations.yml
vendored
6
.github/workflows/test-migrations.yml
vendored
@@ -13,12 +13,12 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
php: ['8.1', '8.2', '8.3']
|
||||
php: ['8.1', '8.2', '8.3', '8.4']
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
|
||||
4
.github/workflows/test-php.yml
vendored
4
.github/workflows/test-php.yml
vendored
@@ -16,9 +16,9 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
strategy:
|
||||
matrix:
|
||||
php: ['8.1', '8.2', '8.3']
|
||||
php: ['8.1', '8.2', '8.3', '8.4']
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
|
||||
@@ -71,6 +71,26 @@ class LdapService
|
||||
return $users[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the user display name from the (potentially multiple) attributes defined by the configuration.
|
||||
*/
|
||||
protected function getUserDisplayName(array $userDetails, array $displayNameAttrs, string $defaultValue): string
|
||||
{
|
||||
$displayNameParts = [];
|
||||
foreach ($displayNameAttrs as $dnAttr) {
|
||||
$dnComponent = $this->getUserResponseProperty($userDetails, $dnAttr, null);
|
||||
if ($dnComponent) {
|
||||
$displayNameParts[] = $dnComponent;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($displayNameParts)) {
|
||||
return $defaultValue;
|
||||
}
|
||||
|
||||
return implode(' ', $displayNameParts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the details of a user from LDAP using the given username.
|
||||
* User found via configurable user filter.
|
||||
@@ -81,11 +101,11 @@ class LdapService
|
||||
{
|
||||
$idAttr = $this->config['id_attribute'];
|
||||
$emailAttr = $this->config['email_attribute'];
|
||||
$displayNameAttr = $this->config['display_name_attribute'];
|
||||
$displayNameAttrs = explode('|', $this->config['display_name_attribute']);
|
||||
$thumbnailAttr = $this->config['thumbnail_attribute'];
|
||||
|
||||
$user = $this->getUserWithAttributes($userName, array_filter([
|
||||
'cn', 'dn', $idAttr, $emailAttr, $displayNameAttr, $thumbnailAttr,
|
||||
'cn', 'dn', $idAttr, $emailAttr, ...$displayNameAttrs, $thumbnailAttr,
|
||||
]));
|
||||
|
||||
if (is_null($user)) {
|
||||
@@ -95,7 +115,7 @@ class LdapService
|
||||
$userCn = $this->getUserResponseProperty($user, 'cn', null);
|
||||
$formatted = [
|
||||
'uid' => $this->getUserResponseProperty($user, $idAttr, $user['dn']),
|
||||
'name' => $this->getUserResponseProperty($user, $displayNameAttr, $userCn),
|
||||
'name' => $this->getUserDisplayName($user, $displayNameAttrs, $userCn),
|
||||
'dn' => $user['dn'],
|
||||
'email' => $this->getUserResponseProperty($user, $emailAttr, null),
|
||||
'avatar' => $thumbnailAttr ? $this->getUserResponseProperty($user, $thumbnailAttr, null) : null,
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace BookStack\Access;
|
||||
use BookStack\Access\Mfa\MfaSession;
|
||||
use BookStack\Activity\ActivityType;
|
||||
use BookStack\Exceptions\LoginAttemptException;
|
||||
use BookStack\Exceptions\LoginAttemptInvalidUserException;
|
||||
use BookStack\Exceptions\StoppedAuthenticationException;
|
||||
use BookStack\Facades\Activity;
|
||||
use BookStack\Facades\Theme;
|
||||
@@ -29,10 +30,14 @@ class LoginService
|
||||
* a reason to (MFA or Unconfirmed Email).
|
||||
* Returns a boolean to indicate the current login result.
|
||||
*
|
||||
* @throws StoppedAuthenticationException
|
||||
* @throws StoppedAuthenticationException|LoginAttemptInvalidUserException
|
||||
*/
|
||||
public function login(User $user, string $method, bool $remember = false): void
|
||||
{
|
||||
if ($user->isGuest()) {
|
||||
throw new LoginAttemptInvalidUserException('Login not allowed for guest user');
|
||||
}
|
||||
|
||||
if ($this->awaitingEmailConfirmation($user) || $this->needsMfaVerification($user)) {
|
||||
$this->setLastLoginAttemptedForUser($user, $method, $remember);
|
||||
|
||||
@@ -58,7 +63,7 @@ class LoginService
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function reattemptLoginFor(User $user)
|
||||
public function reattemptLoginFor(User $user): void
|
||||
{
|
||||
if ($user->id !== ($this->getLastLoginAttemptUser()->id ?? null)) {
|
||||
throw new Exception('Login reattempt user does align with current session state');
|
||||
@@ -152,16 +157,40 @@ class LoginService
|
||||
*/
|
||||
public function attempt(array $credentials, string $method, bool $remember = false): bool
|
||||
{
|
||||
if ($this->areCredentialsForGuest($credentials)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$result = auth()->attempt($credentials, $remember);
|
||||
if ($result) {
|
||||
$user = auth()->user();
|
||||
auth()->logout();
|
||||
$this->login($user, $method, $remember);
|
||||
try {
|
||||
$this->login($user, $method, $remember);
|
||||
} catch (LoginAttemptInvalidUserException $e) {
|
||||
// Catch and return false for non-login accounts
|
||||
// so it looks like a normal invalid login.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given credentials are likely for the system guest account.
|
||||
*/
|
||||
protected function areCredentialsForGuest(array $credentials): bool
|
||||
{
|
||||
if (isset($credentials['email'])) {
|
||||
return User::query()->where('email', '=', $credentials['email'])
|
||||
->where('system_name', '=', 'public')
|
||||
->exists();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs the current user out of the application.
|
||||
* Returns an app post-redirect path.
|
||||
|
||||
@@ -11,7 +11,9 @@ class OidcUserinfoResponse implements ProvidesClaims
|
||||
|
||||
public function __construct(ResponseInterface $response, string $issuer, array $keys)
|
||||
{
|
||||
$contentType = $response->getHeader('Content-Type')[0];
|
||||
$contentTypeHeaderValue = $response->getHeader('Content-Type')[0] ?? '';
|
||||
$contentType = strtolower(trim(explode(';', $contentTypeHeaderValue, 2)[0]));
|
||||
|
||||
if ($contentType === 'application/json') {
|
||||
$this->claims = json_decode($response->getBody()->getContents(), true);
|
||||
}
|
||||
|
||||
@@ -67,6 +67,10 @@ class ActivityType
|
||||
const WEBHOOK_UPDATE = 'webhook_update';
|
||||
const WEBHOOK_DELETE = 'webhook_delete';
|
||||
|
||||
const IMPORT_CREATE = 'import_create';
|
||||
const IMPORT_RUN = 'import_run';
|
||||
const IMPORT_DELETE = 'import_delete';
|
||||
|
||||
/**
|
||||
* Get all the possible values.
|
||||
*/
|
||||
|
||||
@@ -7,6 +7,7 @@ use BookStack\Activity\Notifications\Messages\BaseActivityNotification;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Permissions\PermissionApplicator;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
abstract class BaseNotificationHandler implements NotificationHandler
|
||||
{
|
||||
@@ -36,7 +37,11 @@ abstract class BaseNotificationHandler implements NotificationHandler
|
||||
}
|
||||
|
||||
// Send the notification
|
||||
$user->notify(new $notification($detail, $initiator));
|
||||
try {
|
||||
$user->notify(new $notification($detail, $initiator));
|
||||
} catch (\Exception $exception) {
|
||||
Log::error("Failed to send email notification to user [id:{$user->id}] with error: {$exception->getMessage()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
namespace BookStack\Api;
|
||||
|
||||
use BookStack\Entities\Models\BookChild;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
|
||||
class ApiEntityListFormatter
|
||||
{
|
||||
@@ -20,8 +22,16 @@ class ApiEntityListFormatter
|
||||
* @var array<string|int, string|callable>
|
||||
*/
|
||||
protected array $fields = [
|
||||
'id', 'name', 'slug', 'book_id', 'chapter_id', 'draft',
|
||||
'template', 'priority', 'created_at', 'updated_at',
|
||||
'id',
|
||||
'name',
|
||||
'slug',
|
||||
'book_id',
|
||||
'chapter_id',
|
||||
'draft',
|
||||
'template',
|
||||
'priority',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
];
|
||||
|
||||
public function __construct(array $list)
|
||||
@@ -62,6 +72,28 @@ class ApiEntityListFormatter
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Include parent book/chapter info in the formatted data.
|
||||
*/
|
||||
public function withParents(): self
|
||||
{
|
||||
$this->withField('book', function (Entity $entity) {
|
||||
if ($entity instanceof BookChild && $entity->book) {
|
||||
return $entity->book->only(['id', 'name', 'slug']);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
$this->withField('chapter', function (Entity $entity) {
|
||||
if ($entity instanceof Page && $entity->chapter) {
|
||||
return $entity->chapter->only(['id', 'name', 'slug']);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the data and return an array of formatted content.
|
||||
* @return array[]
|
||||
|
||||
@@ -49,6 +49,7 @@ class UpdateUrlCommand extends Command
|
||||
'chapters' => ['description_html'],
|
||||
'books' => ['description_html'],
|
||||
'bookshelves' => ['description_html'],
|
||||
'page_revisions' => ['html', 'text', 'markdown'],
|
||||
'images' => ['url'],
|
||||
'settings' => ['value'],
|
||||
'comments' => ['html', 'text'],
|
||||
@@ -77,6 +78,12 @@ class UpdateUrlCommand extends Command
|
||||
$this->info('URL update procedure complete.');
|
||||
$this->info('============================================================================');
|
||||
$this->info('Be sure to run "php artisan cache:clear" to clear any old URLs in the cache.');
|
||||
|
||||
if (!str_starts_with($newUrl, url('/'))) {
|
||||
$this->warn('You still need to update your APP_URL env value. This is currently set to:');
|
||||
$this->warn(url('/'));
|
||||
}
|
||||
|
||||
$this->info('============================================================================');
|
||||
|
||||
return 0;
|
||||
|
||||
@@ -30,6 +30,7 @@ class BookApiController extends ApiController
|
||||
{
|
||||
$books = $this->queries
|
||||
->visibleForList()
|
||||
->with(['cover:id,name,url'])
|
||||
->addSelect(['created_by', 'updated_by']);
|
||||
|
||||
return $this->apiListingResponse($books, [
|
||||
|
||||
@@ -26,6 +26,7 @@ class BookshelfApiController extends ApiController
|
||||
{
|
||||
$shelves = $this->queries
|
||||
->visibleForList()
|
||||
->with(['cover:id,name,url'])
|
||||
->addSelect(['created_by', 'updated_by']);
|
||||
|
||||
return $this->apiListingResponse($shelves, [
|
||||
|
||||
@@ -60,6 +60,7 @@ class Chapter extends BookChild
|
||||
|
||||
/**
|
||||
* Get the visible pages in this chapter.
|
||||
* @returns Collection<Page>
|
||||
*/
|
||||
public function getVisiblePages(): Collection
|
||||
{
|
||||
|
||||
@@ -87,6 +87,17 @@ class PageRepo
|
||||
return $draft;
|
||||
}
|
||||
|
||||
/**
|
||||
* Directly update the content for the given page from the provided input.
|
||||
* Used for direct content access in a way that performs required changes
|
||||
* (Search index & reference regen) without performing an official update.
|
||||
*/
|
||||
public function setContentFromInput(Page $page, array $input): void
|
||||
{
|
||||
$this->updateTemplateStatusAndContentFromInput($page, $input);
|
||||
$this->baseRepo->update($page, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a page in the system.
|
||||
*/
|
||||
@@ -121,7 +132,7 @@ class PageRepo
|
||||
return $page;
|
||||
}
|
||||
|
||||
protected function updateTemplateStatusAndContentFromInput(Page $page, array $input)
|
||||
protected function updateTemplateStatusAndContentFromInput(Page $page, array $input): void
|
||||
{
|
||||
if (isset($input['template']) && userCan('templates-manage')) {
|
||||
$page->template = ($input['template'] === 'true');
|
||||
|
||||
@@ -18,17 +18,12 @@ use Illuminate\Http\UploadedFile;
|
||||
|
||||
class Cloner
|
||||
{
|
||||
protected PageRepo $pageRepo;
|
||||
protected ChapterRepo $chapterRepo;
|
||||
protected BookRepo $bookRepo;
|
||||
protected ImageService $imageService;
|
||||
|
||||
public function __construct(PageRepo $pageRepo, ChapterRepo $chapterRepo, BookRepo $bookRepo, ImageService $imageService)
|
||||
{
|
||||
$this->pageRepo = $pageRepo;
|
||||
$this->chapterRepo = $chapterRepo;
|
||||
$this->bookRepo = $bookRepo;
|
||||
$this->imageService = $imageService;
|
||||
public function __construct(
|
||||
protected PageRepo $pageRepo,
|
||||
protected ChapterRepo $chapterRepo,
|
||||
protected BookRepo $bookRepo,
|
||||
protected ImageService $imageService,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -104,10 +104,10 @@ class PageIncludeParser
|
||||
|
||||
if ($currentOffset < $tagStartOffset) {
|
||||
$previousText = substr($text, $currentOffset, $tagStartOffset - $currentOffset);
|
||||
$textNode->parentNode->insertBefore(new DOMText($previousText), $textNode);
|
||||
$textNode->parentNode->insertBefore($this->doc->createTextNode($previousText), $textNode);
|
||||
}
|
||||
|
||||
$node = $textNode->parentNode->insertBefore(new DOMText($tagOuterContent), $textNode);
|
||||
$node = $textNode->parentNode->insertBefore($this->doc->createTextNode($tagOuterContent), $textNode);
|
||||
$includeTags[] = new PageIncludeTag($tagInnerContent, $node);
|
||||
$currentOffset = $tagStartOffset + strlen($tagOuterContent);
|
||||
}
|
||||
|
||||
7
app/Exceptions/LoginAttemptInvalidUserException.php
Normal file
7
app/Exceptions/LoginAttemptInvalidUserException.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Exceptions;
|
||||
|
||||
class LoginAttemptInvalidUserException extends LoginAttemptException
|
||||
{
|
||||
}
|
||||
7
app/Exceptions/ZipExportException.php
Normal file
7
app/Exceptions/ZipExportException.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Exceptions;
|
||||
|
||||
class ZipExportException extends \Exception
|
||||
{
|
||||
}
|
||||
13
app/Exceptions/ZipImportException.php
Normal file
13
app/Exceptions/ZipImportException.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Exceptions;
|
||||
|
||||
class ZipImportException extends \Exception
|
||||
{
|
||||
public function __construct(
|
||||
public array $errors
|
||||
) {
|
||||
$message = "Import failed with errors:" . implode("\n", $this->errors);
|
||||
parent::__construct($message);
|
||||
}
|
||||
}
|
||||
12
app/Exceptions/ZipValidationException.php
Normal file
12
app/Exceptions/ZipValidationException.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Exceptions;
|
||||
|
||||
class ZipValidationException extends \Exception
|
||||
{
|
||||
public function __construct(
|
||||
public array $errors
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Controllers;
|
||||
namespace BookStack\Exports\Controllers;
|
||||
|
||||
use BookStack\Entities\Queries\BookQueries;
|
||||
use BookStack\Entities\Tools\ExportFormatter;
|
||||
use BookStack\Exports\ExportFormatter;
|
||||
use BookStack\Http\ApiController;
|
||||
use Throwable;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Controllers;
|
||||
namespace BookStack\Exports\Controllers;
|
||||
|
||||
use BookStack\Entities\Queries\BookQueries;
|
||||
use BookStack\Entities\Tools\ExportFormatter;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use BookStack\Exports\ExportFormatter;
|
||||
use BookStack\Exports\ZipExports\ZipExportBuilder;
|
||||
use BookStack\Http\Controller;
|
||||
use Throwable;
|
||||
|
||||
@@ -63,4 +65,16 @@ class BookExportController extends Controller
|
||||
|
||||
return $this->download()->directly($textContent, $bookSlug . '.md');
|
||||
}
|
||||
|
||||
/**
|
||||
* Export a book to a contained ZIP export file.
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
public function zip(string $bookSlug, ZipExportBuilder $builder)
|
||||
{
|
||||
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
|
||||
$zip = $builder->buildForBook($book);
|
||||
|
||||
return $this->download()->streamedDirectly(fopen($zip, 'r'), $bookSlug . '.zip', filesize($zip));
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Controllers;
|
||||
namespace BookStack\Exports\Controllers;
|
||||
|
||||
use BookStack\Entities\Queries\ChapterQueries;
|
||||
use BookStack\Entities\Tools\ExportFormatter;
|
||||
use BookStack\Exports\ExportFormatter;
|
||||
use BookStack\Http\ApiController;
|
||||
use Throwable;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Controllers;
|
||||
namespace BookStack\Exports\Controllers;
|
||||
|
||||
use BookStack\Entities\Queries\ChapterQueries;
|
||||
use BookStack\Entities\Tools\ExportFormatter;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use BookStack\Exports\ExportFormatter;
|
||||
use BookStack\Exports\ZipExports\ZipExportBuilder;
|
||||
use BookStack\Http\Controller;
|
||||
use Throwable;
|
||||
|
||||
@@ -70,4 +71,16 @@ class ChapterExportController extends Controller
|
||||
|
||||
return $this->download()->directly($chapterText, $chapterSlug . '.md');
|
||||
}
|
||||
|
||||
/**
|
||||
* Export a book to a contained ZIP export file.
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
public function zip(string $bookSlug, string $chapterSlug, ZipExportBuilder $builder)
|
||||
{
|
||||
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
|
||||
$zip = $builder->buildForChapter($chapter);
|
||||
|
||||
return $this->download()->streamedDirectly(fopen($zip, 'r'), $chapterSlug . '.zip', filesize($zip));
|
||||
}
|
||||
}
|
||||
110
app/Exports/Controllers/ImportController.php
Normal file
110
app/Exports/Controllers/ImportController.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BookStack\Exports\Controllers;
|
||||
|
||||
use BookStack\Exceptions\ZipImportException;
|
||||
use BookStack\Exceptions\ZipValidationException;
|
||||
use BookStack\Exports\ImportRepo;
|
||||
use BookStack\Http\Controller;
|
||||
use BookStack\Uploads\AttachmentService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ImportController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected ImportRepo $imports,
|
||||
) {
|
||||
$this->middleware('can:content-import');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the view to start a new import, and also list out the existing
|
||||
* in progress imports that are visible to the user.
|
||||
*/
|
||||
public function start()
|
||||
{
|
||||
$imports = $this->imports->getVisibleImports();
|
||||
|
||||
$this->setPageTitle(trans('entities.import'));
|
||||
|
||||
return view('exports.import', [
|
||||
'imports' => $imports,
|
||||
'zipErrors' => session()->pull('validation_errors') ?? [],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload, validate and store an import file.
|
||||
*/
|
||||
public function upload(Request $request)
|
||||
{
|
||||
$this->validate($request, [
|
||||
'file' => ['required', ...AttachmentService::getFileValidationRules()]
|
||||
]);
|
||||
|
||||
$file = $request->file('file');
|
||||
try {
|
||||
$import = $this->imports->storeFromUpload($file);
|
||||
} catch (ZipValidationException $exception) {
|
||||
return redirect('/import')->with('validation_errors', $exception->errors);
|
||||
}
|
||||
|
||||
return redirect($import->getUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a pending import, with a form to allow progressing
|
||||
* with the import process.
|
||||
*/
|
||||
public function show(int $id)
|
||||
{
|
||||
$import = $this->imports->findVisible($id);
|
||||
|
||||
$this->setPageTitle(trans('entities.import_continue'));
|
||||
|
||||
return view('exports.import-show', [
|
||||
'import' => $import,
|
||||
'data' => $import->decodeMetadata(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the import process against an uploaded import ZIP.
|
||||
*/
|
||||
public function run(int $id, Request $request)
|
||||
{
|
||||
$import = $this->imports->findVisible($id);
|
||||
$parent = null;
|
||||
|
||||
if ($import->type === 'page' || $import->type === 'chapter') {
|
||||
session()->setPreviousUrl($import->getUrl());
|
||||
$data = $this->validate($request, [
|
||||
'parent' => ['required', 'string'],
|
||||
]);
|
||||
$parent = $data['parent'];
|
||||
}
|
||||
|
||||
try {
|
||||
$entity = $this->imports->runImport($import, $parent);
|
||||
} catch (ZipImportException $exception) {
|
||||
session()->flush();
|
||||
$this->showErrorNotification(trans('errors.import_zip_failed_notification'));
|
||||
return redirect($import->getUrl())->with('import_errors', $exception->errors);
|
||||
}
|
||||
|
||||
return redirect($entity->getUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an active pending import from the filesystem and database.
|
||||
*/
|
||||
public function delete(int $id)
|
||||
{
|
||||
$import = $this->imports->findVisible($id);
|
||||
$this->imports->deleteImport($import);
|
||||
|
||||
return redirect('/import');
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Controllers;
|
||||
namespace BookStack\Exports\Controllers;
|
||||
|
||||
use BookStack\Entities\Queries\PageQueries;
|
||||
use BookStack\Entities\Tools\ExportFormatter;
|
||||
use BookStack\Exports\ExportFormatter;
|
||||
use BookStack\Http\ApiController;
|
||||
use Throwable;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Controllers;
|
||||
namespace BookStack\Exports\Controllers;
|
||||
|
||||
use BookStack\Entities\Queries\PageQueries;
|
||||
use BookStack\Entities\Tools\ExportFormatter;
|
||||
use BookStack\Entities\Tools\PageContent;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use BookStack\Exports\ExportFormatter;
|
||||
use BookStack\Exports\ZipExports\ZipExportBuilder;
|
||||
use BookStack\Http\Controller;
|
||||
use Throwable;
|
||||
|
||||
@@ -74,4 +75,16 @@ class PageExportController extends Controller
|
||||
|
||||
return $this->download()->directly($pageText, $pageSlug . '.md');
|
||||
}
|
||||
|
||||
/**
|
||||
* Export a page to a contained ZIP export file.
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
public function zip(string $bookSlug, string $pageSlug, ZipExportBuilder $builder)
|
||||
{
|
||||
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
|
||||
$zip = $builder->buildForPage($page);
|
||||
|
||||
return $this->download()->streamedDirectly(fopen($zip, 'r'), $pageSlug . '.zip', filesize($zip));
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Tools;
|
||||
namespace BookStack\Exports;
|
||||
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Tools\BookContents;
|
||||
use BookStack\Entities\Tools\Markdown\HtmlToMarkdown;
|
||||
use BookStack\Entities\Tools\PageContent;
|
||||
use BookStack\Uploads\ImageService;
|
||||
use BookStack\Util\CspService;
|
||||
use BookStack\Util\HtmlDocument;
|
||||
@@ -315,7 +317,12 @@ class ExportFormatter
|
||||
public function chapterToMarkdown(Chapter $chapter): string
|
||||
{
|
||||
$text = '# ' . $chapter->name . "\n\n";
|
||||
$text .= $chapter->description . "\n\n";
|
||||
|
||||
$description = (new HtmlToMarkdown($chapter->descriptionHtml()))->convert();
|
||||
if ($description) {
|
||||
$text .= $description . "\n\n";
|
||||
}
|
||||
|
||||
foreach ($chapter->pages as $page) {
|
||||
$text .= $this->pageToMarkdown($page) . "\n\n";
|
||||
}
|
||||
@@ -330,6 +337,12 @@ class ExportFormatter
|
||||
{
|
||||
$bookTree = (new BookContents($book))->getTree(false, true);
|
||||
$text = '# ' . $book->name . "\n\n";
|
||||
|
||||
$description = (new HtmlToMarkdown($book->descriptionHtml()))->convert();
|
||||
if ($description) {
|
||||
$text .= $description . "\n\n";
|
||||
}
|
||||
|
||||
foreach ($bookTree as $bookChild) {
|
||||
if ($bookChild instanceof Chapter) {
|
||||
$text .= $this->chapterToMarkdown($bookChild) . "\n\n";
|
||||
66
app/Exports/Import.php
Normal file
66
app/Exports/Import.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Exports;
|
||||
|
||||
use BookStack\Activity\Models\Loggable;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportBook;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportChapter;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportPage;
|
||||
use BookStack\Users\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property string $path
|
||||
* @property string $name
|
||||
* @property int $size - ZIP size in bytes
|
||||
* @property string $type
|
||||
* @property string $metadata
|
||||
* @property int $created_by
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
* @property User $createdBy
|
||||
*/
|
||||
class Import extends Model implements Loggable
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public function getSizeString(): string
|
||||
{
|
||||
$mb = round($this->size / 1000000, 2);
|
||||
return "{$mb} MB";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the URL to view/continue this import.
|
||||
*/
|
||||
public function getUrl(string $path = ''): string
|
||||
{
|
||||
$path = ltrim($path, '/');
|
||||
return url("/import/{$this->id}" . ($path ? '/' . $path : ''));
|
||||
}
|
||||
|
||||
public function logDescriptor(): string
|
||||
{
|
||||
return "({$this->id}) {$this->name}";
|
||||
}
|
||||
|
||||
public function createdBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function decodeMetadata(): ZipExportBook|ZipExportChapter|ZipExportPage|null
|
||||
{
|
||||
$metadataArray = json_decode($this->metadata, true);
|
||||
return match ($this->type) {
|
||||
'book' => ZipExportBook::fromArray($metadataArray),
|
||||
'chapter' => ZipExportChapter::fromArray($metadataArray),
|
||||
'page' => ZipExportPage::fromArray($metadataArray),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
137
app/Exports/ImportRepo.php
Normal file
137
app/Exports/ImportRepo.php
Normal file
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Exports;
|
||||
|
||||
use BookStack\Activity\ActivityType;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Queries\EntityQueries;
|
||||
use BookStack\Exceptions\FileUploadException;
|
||||
use BookStack\Exceptions\ZipExportException;
|
||||
use BookStack\Exceptions\ZipImportException;
|
||||
use BookStack\Exceptions\ZipValidationException;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportBook;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportChapter;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportPage;
|
||||
use BookStack\Exports\ZipExports\ZipExportReader;
|
||||
use BookStack\Exports\ZipExports\ZipExportValidator;
|
||||
use BookStack\Exports\ZipExports\ZipImportRunner;
|
||||
use BookStack\Facades\Activity;
|
||||
use BookStack\Uploads\FileStorage;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
||||
class ImportRepo
|
||||
{
|
||||
public function __construct(
|
||||
protected FileStorage $storage,
|
||||
protected ZipImportRunner $importer,
|
||||
protected EntityQueries $entityQueries,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<Import>
|
||||
*/
|
||||
public function getVisibleImports(): Collection
|
||||
{
|
||||
$query = Import::query();
|
||||
|
||||
if (!userCan('settings-manage')) {
|
||||
$query->where('created_by', user()->id);
|
||||
}
|
||||
|
||||
return $query->get();
|
||||
}
|
||||
|
||||
public function findVisible(int $id): Import
|
||||
{
|
||||
$query = Import::query();
|
||||
|
||||
if (!userCan('settings-manage')) {
|
||||
$query->where('created_by', user()->id);
|
||||
}
|
||||
|
||||
return $query->findOrFail($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws FileUploadException
|
||||
* @throws ZipValidationException
|
||||
* @throws ZipExportException
|
||||
*/
|
||||
public function storeFromUpload(UploadedFile $file): Import
|
||||
{
|
||||
$zipPath = $file->getRealPath();
|
||||
$reader = new ZipExportReader($zipPath);
|
||||
|
||||
$errors = (new ZipExportValidator($reader))->validate();
|
||||
if ($errors) {
|
||||
throw new ZipValidationException($errors);
|
||||
}
|
||||
|
||||
$exportModel = $reader->decodeDataToExportModel();
|
||||
|
||||
$import = new Import();
|
||||
$import->type = match (get_class($exportModel)) {
|
||||
ZipExportPage::class => 'page',
|
||||
ZipExportChapter::class => 'chapter',
|
||||
ZipExportBook::class => 'book',
|
||||
};
|
||||
|
||||
$import->name = $exportModel->name;
|
||||
$import->created_by = user()->id;
|
||||
$import->size = filesize($zipPath);
|
||||
|
||||
$exportModel->metadataOnly();
|
||||
$import->metadata = json_encode($exportModel);
|
||||
|
||||
$path = $this->storage->uploadFile(
|
||||
$file,
|
||||
'uploads/files/imports/',
|
||||
'',
|
||||
'zip'
|
||||
);
|
||||
|
||||
$import->path = $path;
|
||||
$import->save();
|
||||
|
||||
Activity::add(ActivityType::IMPORT_CREATE, $import);
|
||||
|
||||
return $import;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ZipImportException
|
||||
*/
|
||||
public function runImport(Import $import, ?string $parent = null): Entity
|
||||
{
|
||||
$parentModel = null;
|
||||
if ($import->type === 'page' || $import->type === 'chapter') {
|
||||
$parentModel = $parent ? $this->entityQueries->findVisibleByStringIdentifier($parent) : null;
|
||||
}
|
||||
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
$model = $this->importer->run($import, $parentModel);
|
||||
} catch (ZipImportException $e) {
|
||||
DB::rollBack();
|
||||
$this->importer->revertStoredFiles();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
$this->deleteImport($import);
|
||||
Activity::add(ActivityType::IMPORT_RUN, $import);
|
||||
|
||||
return $model;
|
||||
}
|
||||
|
||||
public function deleteImport(Import $import): void
|
||||
{
|
||||
$this->storage->delete($import->path);
|
||||
$import->delete();
|
||||
|
||||
Activity::add(ActivityType::IMPORT_DELETE, $import);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Tools;
|
||||
namespace BookStack\Exports;
|
||||
|
||||
use BookStack\Exceptions\PdfExportException;
|
||||
use Knp\Snappy\Pdf as SnappyPdf;
|
||||
use Dompdf\Dompdf;
|
||||
use Knp\Snappy\Pdf as SnappyPdf;
|
||||
use Symfony\Component\Process\Exception\ProcessTimedOutException;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
66
app/Exports/ZipExports/Models/ZipExportAttachment.php
Normal file
66
app/Exports/ZipExports/Models/ZipExportAttachment.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Exports\ZipExports\Models;
|
||||
|
||||
use BookStack\Exports\ZipExports\ZipExportFiles;
|
||||
use BookStack\Exports\ZipExports\ZipValidationHelper;
|
||||
use BookStack\Uploads\Attachment;
|
||||
|
||||
class ZipExportAttachment extends ZipExportModel
|
||||
{
|
||||
public ?int $id = null;
|
||||
public string $name;
|
||||
public ?string $link = null;
|
||||
public ?string $file = null;
|
||||
|
||||
public function metadataOnly(): void
|
||||
{
|
||||
$this->link = $this->file = null;
|
||||
}
|
||||
|
||||
public static function fromModel(Attachment $model, ZipExportFiles $files): self
|
||||
{
|
||||
$instance = new self();
|
||||
$instance->id = $model->id;
|
||||
$instance->name = $model->name;
|
||||
|
||||
if ($model->external) {
|
||||
$instance->link = $model->path;
|
||||
} else {
|
||||
$instance->file = $files->referenceForAttachment($model);
|
||||
}
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
public static function fromModelArray(array $attachmentArray, ZipExportFiles $files): array
|
||||
{
|
||||
return array_values(array_map(function (Attachment $attachment) use ($files) {
|
||||
return self::fromModel($attachment, $files);
|
||||
}, $attachmentArray));
|
||||
}
|
||||
|
||||
public static function validate(ZipValidationHelper $context, array $data): array
|
||||
{
|
||||
$rules = [
|
||||
'id' => ['nullable', 'int', $context->uniqueIdRule('attachment')],
|
||||
'name' => ['required', 'string', 'min:1'],
|
||||
'link' => ['required_without:file', 'nullable', 'string'],
|
||||
'file' => ['required_without:link', 'nullable', 'string', $context->fileReferenceRule()],
|
||||
];
|
||||
|
||||
return $context->validateData($data, $rules);
|
||||
}
|
||||
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
$model = new self();
|
||||
|
||||
$model->id = $data['id'] ?? null;
|
||||
$model->name = $data['name'];
|
||||
$model->link = $data['link'] ?? null;
|
||||
$model->file = $data['file'] ?? null;
|
||||
|
||||
return $model;
|
||||
}
|
||||
}
|
||||
118
app/Exports/ZipExports/Models/ZipExportBook.php
Normal file
118
app/Exports/ZipExports/Models/ZipExportBook.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Exports\ZipExports\Models;
|
||||
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Exports\ZipExports\ZipExportFiles;
|
||||
use BookStack\Exports\ZipExports\ZipValidationHelper;
|
||||
|
||||
class ZipExportBook extends ZipExportModel
|
||||
{
|
||||
public ?int $id = null;
|
||||
public string $name;
|
||||
public ?string $description_html = null;
|
||||
public ?string $cover = null;
|
||||
/** @var ZipExportChapter[] */
|
||||
public array $chapters = [];
|
||||
/** @var ZipExportPage[] */
|
||||
public array $pages = [];
|
||||
/** @var ZipExportTag[] */
|
||||
public array $tags = [];
|
||||
|
||||
public function metadataOnly(): void
|
||||
{
|
||||
$this->description_html = $this->cover = null;
|
||||
|
||||
foreach ($this->chapters as $chapter) {
|
||||
$chapter->metadataOnly();
|
||||
}
|
||||
foreach ($this->pages as $page) {
|
||||
$page->metadataOnly();
|
||||
}
|
||||
foreach ($this->tags as $tag) {
|
||||
$tag->metadataOnly();
|
||||
}
|
||||
}
|
||||
|
||||
public function children(): array
|
||||
{
|
||||
$children = [
|
||||
...$this->pages,
|
||||
...$this->chapters,
|
||||
];
|
||||
|
||||
usort($children, function ($a, $b) {
|
||||
return ($a->priority ?? 0) - ($b->priority ?? 0);
|
||||
});
|
||||
|
||||
return $children;
|
||||
}
|
||||
|
||||
public static function fromModel(Book $model, ZipExportFiles $files): self
|
||||
{
|
||||
$instance = new self();
|
||||
$instance->id = $model->id;
|
||||
$instance->name = $model->name;
|
||||
$instance->description_html = $model->descriptionHtml();
|
||||
|
||||
if ($model->cover) {
|
||||
$instance->cover = $files->referenceForImage($model->cover);
|
||||
}
|
||||
|
||||
$instance->tags = ZipExportTag::fromModelArray($model->tags()->get()->all());
|
||||
|
||||
$chapters = [];
|
||||
$pages = [];
|
||||
|
||||
$children = $model->getDirectVisibleChildren()->all();
|
||||
foreach ($children as $child) {
|
||||
if ($child instanceof Chapter) {
|
||||
$chapters[] = $child;
|
||||
} else if ($child instanceof Page && !$child->draft) {
|
||||
$pages[] = $child;
|
||||
}
|
||||
}
|
||||
|
||||
$instance->pages = ZipExportPage::fromModelArray($pages, $files);
|
||||
$instance->chapters = ZipExportChapter::fromModelArray($chapters, $files);
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
public static function validate(ZipValidationHelper $context, array $data): array
|
||||
{
|
||||
$rules = [
|
||||
'id' => ['nullable', 'int', $context->uniqueIdRule('book')],
|
||||
'name' => ['required', 'string', 'min:1'],
|
||||
'description_html' => ['nullable', 'string'],
|
||||
'cover' => ['nullable', 'string', $context->fileReferenceRule()],
|
||||
'tags' => ['array'],
|
||||
'pages' => ['array'],
|
||||
'chapters' => ['array'],
|
||||
];
|
||||
|
||||
$errors = $context->validateData($data, $rules);
|
||||
$errors['tags'] = $context->validateRelations($data['tags'] ?? [], ZipExportTag::class);
|
||||
$errors['pages'] = $context->validateRelations($data['pages'] ?? [], ZipExportPage::class);
|
||||
$errors['chapters'] = $context->validateRelations($data['chapters'] ?? [], ZipExportChapter::class);
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
$model = new self();
|
||||
|
||||
$model->id = $data['id'] ?? null;
|
||||
$model->name = $data['name'];
|
||||
$model->description_html = $data['description_html'] ?? null;
|
||||
$model->cover = $data['cover'] ?? null;
|
||||
$model->tags = ZipExportTag::fromManyArray($data['tags'] ?? []);
|
||||
$model->pages = ZipExportPage::fromManyArray($data['pages'] ?? []);
|
||||
$model->chapters = ZipExportChapter::fromManyArray($data['chapters'] ?? []);
|
||||
|
||||
return $model;
|
||||
}
|
||||
}
|
||||
95
app/Exports/ZipExports/Models/ZipExportChapter.php
Normal file
95
app/Exports/ZipExports/Models/ZipExportChapter.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Exports\ZipExports\Models;
|
||||
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Exports\ZipExports\ZipExportFiles;
|
||||
use BookStack\Exports\ZipExports\ZipValidationHelper;
|
||||
|
||||
class ZipExportChapter extends ZipExportModel
|
||||
{
|
||||
public ?int $id = null;
|
||||
public string $name;
|
||||
public ?string $description_html = null;
|
||||
public ?int $priority = null;
|
||||
/** @var ZipExportPage[] */
|
||||
public array $pages = [];
|
||||
/** @var ZipExportTag[] */
|
||||
public array $tags = [];
|
||||
|
||||
public function metadataOnly(): void
|
||||
{
|
||||
$this->description_html = null;
|
||||
|
||||
foreach ($this->pages as $page) {
|
||||
$page->metadataOnly();
|
||||
}
|
||||
foreach ($this->tags as $tag) {
|
||||
$tag->metadataOnly();
|
||||
}
|
||||
}
|
||||
|
||||
public function children(): array
|
||||
{
|
||||
return $this->pages;
|
||||
}
|
||||
|
||||
public static function fromModel(Chapter $model, ZipExportFiles $files): self
|
||||
{
|
||||
$instance = new self();
|
||||
$instance->id = $model->id;
|
||||
$instance->name = $model->name;
|
||||
$instance->description_html = $model->descriptionHtml();
|
||||
$instance->priority = $model->priority;
|
||||
$instance->tags = ZipExportTag::fromModelArray($model->tags()->get()->all());
|
||||
|
||||
$pages = $model->getVisiblePages()->filter(fn (Page $page) => !$page->draft)->all();
|
||||
$instance->pages = ZipExportPage::fromModelArray($pages, $files);
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Chapter[] $chapterArray
|
||||
* @return self[]
|
||||
*/
|
||||
public static function fromModelArray(array $chapterArray, ZipExportFiles $files): array
|
||||
{
|
||||
return array_values(array_map(function (Chapter $chapter) use ($files) {
|
||||
return self::fromModel($chapter, $files);
|
||||
}, $chapterArray));
|
||||
}
|
||||
|
||||
public static function validate(ZipValidationHelper $context, array $data): array
|
||||
{
|
||||
$rules = [
|
||||
'id' => ['nullable', 'int', $context->uniqueIdRule('chapter')],
|
||||
'name' => ['required', 'string', 'min:1'],
|
||||
'description_html' => ['nullable', 'string'],
|
||||
'priority' => ['nullable', 'int'],
|
||||
'tags' => ['array'],
|
||||
'pages' => ['array'],
|
||||
];
|
||||
|
||||
$errors = $context->validateData($data, $rules);
|
||||
$errors['tags'] = $context->validateRelations($data['tags'] ?? [], ZipExportTag::class);
|
||||
$errors['pages'] = $context->validateRelations($data['pages'] ?? [], ZipExportPage::class);
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
$model = new self();
|
||||
|
||||
$model->id = $data['id'] ?? null;
|
||||
$model->name = $data['name'];
|
||||
$model->description_html = $data['description_html'] ?? null;
|
||||
$model->priority = isset($data['priority']) ? intval($data['priority']) : null;
|
||||
$model->tags = ZipExportTag::fromManyArray($data['tags'] ?? []);
|
||||
$model->pages = ZipExportPage::fromManyArray($data['pages'] ?? []);
|
||||
|
||||
return $model;
|
||||
}
|
||||
}
|
||||
57
app/Exports/ZipExports/Models/ZipExportImage.php
Normal file
57
app/Exports/ZipExports/Models/ZipExportImage.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Exports\ZipExports\Models;
|
||||
|
||||
use BookStack\Exports\ZipExports\ZipExportFiles;
|
||||
use BookStack\Exports\ZipExports\ZipValidationHelper;
|
||||
use BookStack\Uploads\Image;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ZipExportImage extends ZipExportModel
|
||||
{
|
||||
public ?int $id = null;
|
||||
public string $name;
|
||||
public string $file;
|
||||
public string $type;
|
||||
|
||||
public static function fromModel(Image $model, ZipExportFiles $files): self
|
||||
{
|
||||
$instance = new self();
|
||||
$instance->id = $model->id;
|
||||
$instance->name = $model->name;
|
||||
$instance->type = $model->type;
|
||||
$instance->file = $files->referenceForImage($model);
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
public function metadataOnly(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
public static function validate(ZipValidationHelper $context, array $data): array
|
||||
{
|
||||
$acceptedImageTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
|
||||
$rules = [
|
||||
'id' => ['nullable', 'int', $context->uniqueIdRule('image')],
|
||||
'name' => ['required', 'string', 'min:1'],
|
||||
'file' => ['required', 'string', $context->fileReferenceRule($acceptedImageTypes)],
|
||||
'type' => ['required', 'string', Rule::in(['gallery', 'drawio'])],
|
||||
];
|
||||
|
||||
return $context->validateData($data, $rules);
|
||||
}
|
||||
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
$model = new self();
|
||||
|
||||
$model->id = $data['id'] ?? null;
|
||||
$model->name = $data['name'];
|
||||
$model->file = $data['file'];
|
||||
$model->type = $data['type'];
|
||||
|
||||
return $model;
|
||||
}
|
||||
}
|
||||
57
app/Exports/ZipExports/Models/ZipExportModel.php
Normal file
57
app/Exports/ZipExports/Models/ZipExportModel.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Exports\ZipExports\Models;
|
||||
|
||||
use BookStack\Exports\ZipExports\ZipValidationHelper;
|
||||
use JsonSerializable;
|
||||
|
||||
abstract class ZipExportModel implements JsonSerializable
|
||||
{
|
||||
/**
|
||||
* Handle the serialization to JSON.
|
||||
* For these exports, we filter out optional (represented as nullable) fields
|
||||
* just to clean things up and prevent confusion to avoid null states in the
|
||||
* resulting export format itself.
|
||||
*/
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
$publicProps = get_object_vars(...)->__invoke($this);
|
||||
return array_filter($publicProps, fn ($value) => $value !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the given array of data intended for this model.
|
||||
* Return an array of validation errors messages.
|
||||
* Child items can be considered in the validation result by returning a keyed
|
||||
* item in the array for its own validation messages.
|
||||
*/
|
||||
abstract public static function validate(ZipValidationHelper $context, array $data): array;
|
||||
|
||||
/**
|
||||
* Decode the array of data into this export model.
|
||||
*/
|
||||
abstract public static function fromArray(array $data): self;
|
||||
|
||||
/**
|
||||
* Decode an array of array data into an array of export models.
|
||||
* @param array[] $data
|
||||
* @return self[]
|
||||
*/
|
||||
public static function fromManyArray(array $data): array
|
||||
{
|
||||
$results = [];
|
||||
foreach ($data as $item) {
|
||||
$results[] = static::fromArray($item);
|
||||
}
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove additional content in this model to reduce it down
|
||||
* to just essential id/name values for identification.
|
||||
*
|
||||
* The result of this may be something that does not pass validation, but is
|
||||
* simple for the purpose of creating a contents.
|
||||
*/
|
||||
abstract public function metadataOnly(): void;
|
||||
}
|
||||
104
app/Exports/ZipExports/Models/ZipExportPage.php
Normal file
104
app/Exports/ZipExports/Models/ZipExportPage.php
Normal file
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Exports\ZipExports\Models;
|
||||
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Tools\PageContent;
|
||||
use BookStack\Exports\ZipExports\ZipExportFiles;
|
||||
use BookStack\Exports\ZipExports\ZipValidationHelper;
|
||||
|
||||
class ZipExportPage extends ZipExportModel
|
||||
{
|
||||
public ?int $id = null;
|
||||
public string $name;
|
||||
public ?string $html = null;
|
||||
public ?string $markdown = null;
|
||||
public ?int $priority = null;
|
||||
/** @var ZipExportAttachment[] */
|
||||
public array $attachments = [];
|
||||
/** @var ZipExportImage[] */
|
||||
public array $images = [];
|
||||
/** @var ZipExportTag[] */
|
||||
public array $tags = [];
|
||||
|
||||
public function metadataOnly(): void
|
||||
{
|
||||
$this->html = $this->markdown = null;
|
||||
|
||||
foreach ($this->attachments as $attachment) {
|
||||
$attachment->metadataOnly();
|
||||
}
|
||||
foreach ($this->images as $image) {
|
||||
$image->metadataOnly();
|
||||
}
|
||||
foreach ($this->tags as $tag) {
|
||||
$tag->metadataOnly();
|
||||
}
|
||||
}
|
||||
|
||||
public static function fromModel(Page $model, ZipExportFiles $files): self
|
||||
{
|
||||
$instance = new self();
|
||||
$instance->id = $model->id;
|
||||
$instance->name = $model->name;
|
||||
$instance->html = (new PageContent($model))->render();
|
||||
$instance->priority = $model->priority;
|
||||
|
||||
if (!empty($model->markdown)) {
|
||||
$instance->markdown = $model->markdown;
|
||||
}
|
||||
|
||||
$instance->tags = ZipExportTag::fromModelArray($model->tags()->get()->all());
|
||||
$instance->attachments = ZipExportAttachment::fromModelArray($model->attachments()->get()->all(), $files);
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Page[] $pageArray
|
||||
* @return self[]
|
||||
*/
|
||||
public static function fromModelArray(array $pageArray, ZipExportFiles $files): array
|
||||
{
|
||||
return array_values(array_map(function (Page $page) use ($files) {
|
||||
return self::fromModel($page, $files);
|
||||
}, $pageArray));
|
||||
}
|
||||
|
||||
public static function validate(ZipValidationHelper $context, array $data): array
|
||||
{
|
||||
$rules = [
|
||||
'id' => ['nullable', 'int', $context->uniqueIdRule('page')],
|
||||
'name' => ['required', 'string', 'min:1'],
|
||||
'html' => ['nullable', 'string'],
|
||||
'markdown' => ['nullable', 'string'],
|
||||
'priority' => ['nullable', 'int'],
|
||||
'attachments' => ['array'],
|
||||
'images' => ['array'],
|
||||
'tags' => ['array'],
|
||||
];
|
||||
|
||||
$errors = $context->validateData($data, $rules);
|
||||
$errors['attachments'] = $context->validateRelations($data['attachments'] ?? [], ZipExportAttachment::class);
|
||||
$errors['images'] = $context->validateRelations($data['images'] ?? [], ZipExportImage::class);
|
||||
$errors['tags'] = $context->validateRelations($data['tags'] ?? [], ZipExportTag::class);
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
$model = new self();
|
||||
|
||||
$model->id = $data['id'] ?? null;
|
||||
$model->name = $data['name'];
|
||||
$model->html = $data['html'] ?? null;
|
||||
$model->markdown = $data['markdown'] ?? null;
|
||||
$model->priority = isset($data['priority']) ? intval($data['priority']) : null;
|
||||
$model->attachments = ZipExportAttachment::fromManyArray($data['attachments'] ?? []);
|
||||
$model->images = ZipExportImage::fromManyArray($data['images'] ?? []);
|
||||
$model->tags = ZipExportTag::fromManyArray($data['tags'] ?? []);
|
||||
|
||||
return $model;
|
||||
}
|
||||
}
|
||||
51
app/Exports/ZipExports/Models/ZipExportTag.php
Normal file
51
app/Exports/ZipExports/Models/ZipExportTag.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Exports\ZipExports\Models;
|
||||
|
||||
use BookStack\Activity\Models\Tag;
|
||||
use BookStack\Exports\ZipExports\ZipValidationHelper;
|
||||
|
||||
class ZipExportTag extends ZipExportModel
|
||||
{
|
||||
public string $name;
|
||||
public ?string $value = null;
|
||||
|
||||
public function metadataOnly(): void
|
||||
{
|
||||
$this->value = null;
|
||||
}
|
||||
|
||||
public static function fromModel(Tag $model): self
|
||||
{
|
||||
$instance = new self();
|
||||
$instance->name = $model->name;
|
||||
$instance->value = $model->value;
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
public static function fromModelArray(array $tagArray): array
|
||||
{
|
||||
return array_values(array_map(self::fromModel(...), $tagArray));
|
||||
}
|
||||
|
||||
public static function validate(ZipValidationHelper $context, array $data): array
|
||||
{
|
||||
$rules = [
|
||||
'name' => ['required', 'string', 'min:1'],
|
||||
'value' => ['nullable', 'string'],
|
||||
];
|
||||
|
||||
return $context->validateData($data, $rules);
|
||||
}
|
||||
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
$model = new self();
|
||||
|
||||
$model->name = $data['name'];
|
||||
$model->value = $data['value'] ?? null;
|
||||
|
||||
return $model;
|
||||
}
|
||||
}
|
||||
100
app/Exports/ZipExports/ZipExportBuilder.php
Normal file
100
app/Exports/ZipExports/ZipExportBuilder.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Exports\ZipExports;
|
||||
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Exceptions\ZipExportException;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportBook;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportChapter;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportPage;
|
||||
use ZipArchive;
|
||||
|
||||
class ZipExportBuilder
|
||||
{
|
||||
protected array $data = [];
|
||||
|
||||
public function __construct(
|
||||
protected ZipExportFiles $files,
|
||||
protected ZipExportReferences $references,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ZipExportException
|
||||
*/
|
||||
public function buildForPage(Page $page): string
|
||||
{
|
||||
$exportPage = ZipExportPage::fromModel($page, $this->files);
|
||||
$this->data['page'] = $exportPage;
|
||||
|
||||
$this->references->addPage($exportPage);
|
||||
|
||||
return $this->build();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ZipExportException
|
||||
*/
|
||||
public function buildForChapter(Chapter $chapter): string
|
||||
{
|
||||
$exportChapter = ZipExportChapter::fromModel($chapter, $this->files);
|
||||
$this->data['chapter'] = $exportChapter;
|
||||
|
||||
$this->references->addChapter($exportChapter);
|
||||
|
||||
return $this->build();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ZipExportException
|
||||
*/
|
||||
public function buildForBook(Book $book): string
|
||||
{
|
||||
$exportBook = ZipExportBook::fromModel($book, $this->files);
|
||||
$this->data['book'] = $exportBook;
|
||||
|
||||
$this->references->addBook($exportBook);
|
||||
|
||||
return $this->build();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ZipExportException
|
||||
*/
|
||||
protected function build(): string
|
||||
{
|
||||
$this->references->buildReferences($this->files);
|
||||
|
||||
$this->data['exported_at'] = date(DATE_ATOM);
|
||||
$this->data['instance'] = [
|
||||
'id' => setting('instance-id', ''),
|
||||
'version' => trim(file_get_contents(base_path('version'))),
|
||||
];
|
||||
|
||||
$zipFile = tempnam(sys_get_temp_dir(), 'bszip-');
|
||||
$zip = new ZipArchive();
|
||||
$opened = $zip->open($zipFile, ZipArchive::CREATE);
|
||||
if ($opened !== true) {
|
||||
throw new ZipExportException('Failed to create zip file for export.');
|
||||
}
|
||||
|
||||
$zip->addFromString('data.json', json_encode($this->data));
|
||||
$zip->addEmptyDir('files');
|
||||
|
||||
$toRemove = [];
|
||||
$this->files->extractEach(function ($filePath, $fileRef) use ($zip, &$toRemove) {
|
||||
$zip->addFile($filePath, "files/$fileRef");
|
||||
$toRemove[] = $filePath;
|
||||
});
|
||||
|
||||
$zip->close();
|
||||
|
||||
foreach ($toRemove as $file) {
|
||||
unlink($file);
|
||||
}
|
||||
|
||||
return $zipFile;
|
||||
}
|
||||
}
|
||||
107
app/Exports/ZipExports/ZipExportFiles.php
Normal file
107
app/Exports/ZipExports/ZipExportFiles.php
Normal file
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Exports\ZipExports;
|
||||
|
||||
use BookStack\Uploads\Attachment;
|
||||
use BookStack\Uploads\AttachmentService;
|
||||
use BookStack\Uploads\Image;
|
||||
use BookStack\Uploads\ImageService;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ZipExportFiles
|
||||
{
|
||||
/**
|
||||
* References for attachments by attachment ID.
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected array $attachmentRefsById = [];
|
||||
|
||||
/**
|
||||
* References for images by image ID.
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected array $imageRefsById = [];
|
||||
|
||||
public function __construct(
|
||||
protected AttachmentService $attachmentService,
|
||||
protected ImageService $imageService,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Gain a reference to the given attachment instance.
|
||||
* This is expected to be a file-based attachment that the user
|
||||
* has visibility of, no permission/access checks are performed here.
|
||||
*/
|
||||
public function referenceForAttachment(Attachment $attachment): string
|
||||
{
|
||||
if (isset($this->attachmentRefsById[$attachment->id])) {
|
||||
return $this->attachmentRefsById[$attachment->id];
|
||||
}
|
||||
|
||||
$existingFiles = $this->getAllFileNames();
|
||||
do {
|
||||
$fileName = Str::random(20) . '.' . $attachment->extension;
|
||||
} while (in_array($fileName, $existingFiles));
|
||||
|
||||
$this->attachmentRefsById[$attachment->id] = $fileName;
|
||||
|
||||
return $fileName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gain a reference to the given image instance.
|
||||
* This is expected to be an image that the user has visibility of,
|
||||
* no permission/access checks are performed here.
|
||||
*/
|
||||
public function referenceForImage(Image $image): string
|
||||
{
|
||||
if (isset($this->imageRefsById[$image->id])) {
|
||||
return $this->imageRefsById[$image->id];
|
||||
}
|
||||
|
||||
$existingFiles = $this->getAllFileNames();
|
||||
$extension = pathinfo($image->path, PATHINFO_EXTENSION);
|
||||
do {
|
||||
$fileName = Str::random(20) . '.' . $extension;
|
||||
} while (in_array($fileName, $existingFiles));
|
||||
|
||||
$this->imageRefsById[$image->id] = $fileName;
|
||||
|
||||
return $fileName;
|
||||
}
|
||||
|
||||
protected function getAllFileNames(): array
|
||||
{
|
||||
return array_merge(
|
||||
array_values($this->attachmentRefsById),
|
||||
array_values($this->imageRefsById),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract each of the ZIP export tracked files.
|
||||
* Calls the given callback for each tracked file, passing a temporary
|
||||
* file reference of the file contents, and the zip-local tracked reference.
|
||||
*/
|
||||
public function extractEach(callable $callback): void
|
||||
{
|
||||
foreach ($this->attachmentRefsById as $attachmentId => $ref) {
|
||||
$attachment = Attachment::query()->find($attachmentId);
|
||||
$stream = $this->attachmentService->streamAttachmentFromStorage($attachment);
|
||||
$tmpFile = tempnam(sys_get_temp_dir(), 'bszipfile-');
|
||||
$tmpFileStream = fopen($tmpFile, 'w');
|
||||
stream_copy_to_stream($stream, $tmpFileStream);
|
||||
$callback($tmpFile, $ref);
|
||||
}
|
||||
|
||||
foreach ($this->imageRefsById as $imageId => $ref) {
|
||||
$image = Image::query()->find($imageId);
|
||||
$stream = $this->imageService->getImageStream($image);
|
||||
$tmpFile = tempnam(sys_get_temp_dir(), 'bszipimage-');
|
||||
$tmpFileStream = fopen($tmpFile, 'w');
|
||||
stream_copy_to_stream($stream, $tmpFileStream);
|
||||
$callback($tmpFile, $ref);
|
||||
}
|
||||
}
|
||||
}
|
||||
111
app/Exports/ZipExports/ZipExportReader.php
Normal file
111
app/Exports/ZipExports/ZipExportReader.php
Normal file
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Exports\ZipExports;
|
||||
|
||||
use BookStack\Exceptions\ZipExportException;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportBook;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportChapter;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportPage;
|
||||
use BookStack\Util\WebSafeMimeSniffer;
|
||||
use ZipArchive;
|
||||
|
||||
class ZipExportReader
|
||||
{
|
||||
protected ZipArchive $zip;
|
||||
protected bool $open = false;
|
||||
|
||||
public function __construct(
|
||||
protected string $zipPath,
|
||||
) {
|
||||
$this->zip = new ZipArchive();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ZipExportException
|
||||
*/
|
||||
protected function open(): void
|
||||
{
|
||||
if ($this->open) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file exists
|
||||
if (!file_exists($this->zipPath) || !is_readable($this->zipPath)) {
|
||||
throw new ZipExportException(trans('errors.import_zip_cant_read'));
|
||||
}
|
||||
|
||||
// Validate file is valid zip
|
||||
$opened = $this->zip->open($this->zipPath, ZipArchive::RDONLY);
|
||||
if ($opened !== true) {
|
||||
throw new ZipExportException(trans('errors.import_zip_cant_read'));
|
||||
}
|
||||
|
||||
$this->open = true;
|
||||
}
|
||||
|
||||
public function close(): void
|
||||
{
|
||||
if ($this->open) {
|
||||
$this->zip->close();
|
||||
$this->open = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ZipExportException
|
||||
*/
|
||||
public function readData(): array
|
||||
{
|
||||
$this->open();
|
||||
|
||||
// Validate json data exists, including metadata
|
||||
$jsonData = $this->zip->getFromName('data.json') ?: '';
|
||||
$importData = json_decode($jsonData, true);
|
||||
if (!$importData) {
|
||||
throw new ZipExportException(trans('errors.import_zip_cant_decode_data'));
|
||||
}
|
||||
|
||||
return $importData;
|
||||
}
|
||||
|
||||
public function fileExists(string $fileName): bool
|
||||
{
|
||||
return $this->zip->statName("files/{$fileName}") !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return false|resource
|
||||
*/
|
||||
public function streamFile(string $fileName)
|
||||
{
|
||||
return $this->zip->getStream("files/{$fileName}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Sniff the mime type from the file of given name.
|
||||
*/
|
||||
public function sniffFileMime(string $fileName): string
|
||||
{
|
||||
$stream = $this->streamFile($fileName);
|
||||
$sniffContent = fread($stream, 2000);
|
||||
|
||||
return (new WebSafeMimeSniffer())->sniff($sniffContent);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ZipExportException
|
||||
*/
|
||||
public function decodeDataToExportModel(): ZipExportBook|ZipExportChapter|ZipExportPage
|
||||
{
|
||||
$data = $this->readData();
|
||||
if (isset($data['book'])) {
|
||||
return ZipExportBook::fromArray($data['book']);
|
||||
} else if (isset($data['chapter'])) {
|
||||
return ZipExportChapter::fromArray($data['chapter']);
|
||||
} else if (isset($data['page'])) {
|
||||
return ZipExportPage::fromArray($data['page']);
|
||||
}
|
||||
|
||||
throw new ZipExportException("Could not identify content in ZIP file data.");
|
||||
}
|
||||
}
|
||||
159
app/Exports/ZipExports/ZipExportReferences.php
Normal file
159
app/Exports/ZipExports/ZipExportReferences.php
Normal file
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Exports\ZipExports;
|
||||
|
||||
use BookStack\App\Model;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportAttachment;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportBook;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportChapter;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportImage;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportModel;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportPage;
|
||||
use BookStack\Uploads\Attachment;
|
||||
use BookStack\Uploads\Image;
|
||||
|
||||
class ZipExportReferences
|
||||
{
|
||||
/** @var ZipExportPage[] */
|
||||
protected array $pages = [];
|
||||
/** @var ZipExportChapter[] */
|
||||
protected array $chapters = [];
|
||||
/** @var ZipExportBook[] */
|
||||
protected array $books = [];
|
||||
|
||||
/** @var ZipExportAttachment[] */
|
||||
protected array $attachments = [];
|
||||
|
||||
/** @var ZipExportImage[] */
|
||||
protected array $images = [];
|
||||
|
||||
public function __construct(
|
||||
protected ZipReferenceParser $parser,
|
||||
) {
|
||||
}
|
||||
|
||||
public function addPage(ZipExportPage $page): void
|
||||
{
|
||||
if ($page->id) {
|
||||
$this->pages[$page->id] = $page;
|
||||
}
|
||||
|
||||
foreach ($page->attachments as $attachment) {
|
||||
if ($attachment->id) {
|
||||
$this->attachments[$attachment->id] = $attachment;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function addChapter(ZipExportChapter $chapter): void
|
||||
{
|
||||
if ($chapter->id) {
|
||||
$this->chapters[$chapter->id] = $chapter;
|
||||
}
|
||||
|
||||
foreach ($chapter->pages as $page) {
|
||||
$this->addPage($page);
|
||||
}
|
||||
}
|
||||
|
||||
public function addBook(ZipExportBook $book): void
|
||||
{
|
||||
if ($book->id) {
|
||||
$this->books[$book->id] = $book;
|
||||
}
|
||||
|
||||
foreach ($book->pages as $page) {
|
||||
$this->addPage($page);
|
||||
}
|
||||
|
||||
foreach ($book->chapters as $chapter) {
|
||||
$this->addChapter($chapter);
|
||||
}
|
||||
}
|
||||
|
||||
public function buildReferences(ZipExportFiles $files): void
|
||||
{
|
||||
$createHandler = function (ZipExportModel $zipModel) use ($files) {
|
||||
return function (Model $model) use ($files, $zipModel) {
|
||||
return $this->handleModelReference($model, $zipModel, $files);
|
||||
};
|
||||
};
|
||||
|
||||
// Parse page content first
|
||||
foreach ($this->pages as $page) {
|
||||
$handler = $createHandler($page);
|
||||
$page->html = $this->parser->parseLinks($page->html ?? '', $handler);
|
||||
if ($page->markdown) {
|
||||
$page->markdown = $this->parser->parseLinks($page->markdown, $handler);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse chapter description HTML
|
||||
foreach ($this->chapters as $chapter) {
|
||||
if ($chapter->description_html) {
|
||||
$handler = $createHandler($chapter);
|
||||
$chapter->description_html = $this->parser->parseLinks($chapter->description_html, $handler);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse book description HTML
|
||||
foreach ($this->books as $book) {
|
||||
if ($book->description_html) {
|
||||
$handler = $createHandler($book);
|
||||
$book->description_html = $this->parser->parseLinks($book->description_html, $handler);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected function handleModelReference(Model $model, ZipExportModel $exportModel, ZipExportFiles $files): ?string
|
||||
{
|
||||
// Handle attachment references
|
||||
// No permission check needed here since they would only already exist in this
|
||||
// reference context if already allowed via their entity access.
|
||||
if ($model instanceof Attachment) {
|
||||
if (isset($this->attachments[$model->id])) {
|
||||
return "[[bsexport:attachment:{$model->id}]]";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle image references
|
||||
if ($model instanceof Image) {
|
||||
// Only handle gallery and drawio images
|
||||
if ($model->type !== 'gallery' && $model->type !== 'drawio') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle simple links outside of page content
|
||||
if (!($exportModel instanceof ZipExportPage) && isset($this->images[$model->id])) {
|
||||
return "[[bsexport:image:{$model->id}]]";
|
||||
}
|
||||
|
||||
// Find and include images if in visibility
|
||||
$page = $model->getPage();
|
||||
if ($page && userCan('view', $page)) {
|
||||
if (!isset($this->images[$model->id])) {
|
||||
$exportImage = ZipExportImage::fromModel($model, $files);
|
||||
$this->images[$model->id] = $exportImage;
|
||||
$exportModel->images[] = $exportImage;
|
||||
}
|
||||
return "[[bsexport:image:{$model->id}]]";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle entity references
|
||||
if ($model instanceof Book && isset($this->books[$model->id])) {
|
||||
return "[[bsexport:book:{$model->id}]]";
|
||||
} else if ($model instanceof Chapter && isset($this->chapters[$model->id])) {
|
||||
return "[[bsexport:chapter:{$model->id}]]";
|
||||
} else if ($model instanceof Page && isset($this->pages[$model->id])) {
|
||||
return "[[bsexport:page:{$model->id}]]";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
57
app/Exports/ZipExports/ZipExportValidator.php
Normal file
57
app/Exports/ZipExports/ZipExportValidator.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Exports\ZipExports;
|
||||
|
||||
use BookStack\Exceptions\ZipExportException;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportBook;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportChapter;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportPage;
|
||||
|
||||
class ZipExportValidator
|
||||
{
|
||||
public function __construct(
|
||||
protected ZipExportReader $reader,
|
||||
) {
|
||||
}
|
||||
|
||||
public function validate(): array
|
||||
{
|
||||
try {
|
||||
$importData = $this->reader->readData();
|
||||
} catch (ZipExportException $exception) {
|
||||
return ['format' => $exception->getMessage()];
|
||||
}
|
||||
|
||||
$helper = new ZipValidationHelper($this->reader);
|
||||
|
||||
if (isset($importData['book'])) {
|
||||
$modelErrors = ZipExportBook::validate($helper, $importData['book']);
|
||||
$keyPrefix = 'book';
|
||||
} else if (isset($importData['chapter'])) {
|
||||
$modelErrors = ZipExportChapter::validate($helper, $importData['chapter']);
|
||||
$keyPrefix = 'chapter';
|
||||
} else if (isset($importData['page'])) {
|
||||
$modelErrors = ZipExportPage::validate($helper, $importData['page']);
|
||||
$keyPrefix = 'page';
|
||||
} else {
|
||||
return ['format' => trans('errors.import_zip_no_data')];
|
||||
}
|
||||
|
||||
return $this->flattenModelErrors($modelErrors, $keyPrefix);
|
||||
}
|
||||
|
||||
protected function flattenModelErrors(array $errors, string $keyPrefix): array
|
||||
{
|
||||
$flattened = [];
|
||||
|
||||
foreach ($errors as $key => $error) {
|
||||
if (is_array($error)) {
|
||||
$flattened = array_merge($flattened, $this->flattenModelErrors($error, $keyPrefix . '.' . $key));
|
||||
} else {
|
||||
$flattened[$keyPrefix . '.' . $key] = $error;
|
||||
}
|
||||
}
|
||||
|
||||
return $flattened;
|
||||
}
|
||||
}
|
||||
37
app/Exports/ZipExports/ZipFileReferenceRule.php
Normal file
37
app/Exports/ZipExports/ZipFileReferenceRule.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Exports\ZipExports;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
|
||||
class ZipFileReferenceRule implements ValidationRule
|
||||
{
|
||||
public function __construct(
|
||||
protected ZipValidationHelper $context,
|
||||
protected array $acceptedMimes,
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if (!$this->context->zipReader->fileExists($value)) {
|
||||
$fail('validation.zip_file')->translate();
|
||||
}
|
||||
|
||||
if (!empty($this->acceptedMimes)) {
|
||||
$fileMime = $this->context->zipReader->sniffFileMime($value);
|
||||
if (!in_array($fileMime, $this->acceptedMimes)) {
|
||||
$fail('validation.zip_file_mime')->translate([
|
||||
'attribute' => $attribute,
|
||||
'validTypes' => implode(',', $this->acceptedMimes),
|
||||
'foundType' => $fileMime
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
161
app/Exports/ZipExports/ZipImportReferences.php
Normal file
161
app/Exports/ZipExports/ZipImportReferences.php
Normal file
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Exports\ZipExports;
|
||||
|
||||
use BookStack\App\Model;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Repos\BaseRepo;
|
||||
use BookStack\Entities\Repos\PageRepo;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportBook;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportChapter;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportPage;
|
||||
use BookStack\Uploads\Attachment;
|
||||
use BookStack\Uploads\Image;
|
||||
use BookStack\Uploads\ImageResizer;
|
||||
|
||||
class ZipImportReferences
|
||||
{
|
||||
/** @var Page[] */
|
||||
protected array $pages = [];
|
||||
/** @var Chapter[] */
|
||||
protected array $chapters = [];
|
||||
/** @var Book[] */
|
||||
protected array $books = [];
|
||||
/** @var Attachment[] */
|
||||
protected array $attachments = [];
|
||||
/** @var Image[] */
|
||||
protected array $images = [];
|
||||
|
||||
/** @var array<string, Model> */
|
||||
protected array $referenceMap = [];
|
||||
|
||||
/** @var array<int, ZipExportPage> */
|
||||
protected array $zipExportPageMap = [];
|
||||
/** @var array<int, ZipExportChapter> */
|
||||
protected array $zipExportChapterMap = [];
|
||||
/** @var array<int, ZipExportBook> */
|
||||
protected array $zipExportBookMap = [];
|
||||
|
||||
public function __construct(
|
||||
protected ZipReferenceParser $parser,
|
||||
protected BaseRepo $baseRepo,
|
||||
protected PageRepo $pageRepo,
|
||||
protected ImageResizer $imageResizer,
|
||||
) {
|
||||
}
|
||||
|
||||
protected function addReference(string $type, Model $model, ?int $importId): void
|
||||
{
|
||||
if ($importId) {
|
||||
$key = $type . ':' . $importId;
|
||||
$this->referenceMap[$key] = $model;
|
||||
}
|
||||
}
|
||||
|
||||
public function addPage(Page $page, ZipExportPage $exportPage): void
|
||||
{
|
||||
$this->pages[] = $page;
|
||||
$this->zipExportPageMap[$page->id] = $exportPage;
|
||||
$this->addReference('page', $page, $exportPage->id);
|
||||
}
|
||||
|
||||
public function addChapter(Chapter $chapter, ZipExportChapter $exportChapter): void
|
||||
{
|
||||
$this->chapters[] = $chapter;
|
||||
$this->zipExportChapterMap[$chapter->id] = $exportChapter;
|
||||
$this->addReference('chapter', $chapter, $exportChapter->id);
|
||||
}
|
||||
|
||||
public function addBook(Book $book, ZipExportBook $exportBook): void
|
||||
{
|
||||
$this->books[] = $book;
|
||||
$this->zipExportBookMap[$book->id] = $exportBook;
|
||||
$this->addReference('book', $book, $exportBook->id);
|
||||
}
|
||||
|
||||
public function addAttachment(Attachment $attachment, ?int $importId): void
|
||||
{
|
||||
$this->attachments[] = $attachment;
|
||||
$this->addReference('attachment', $attachment, $importId);
|
||||
}
|
||||
|
||||
public function addImage(Image $image, ?int $importId): void
|
||||
{
|
||||
$this->images[] = $image;
|
||||
$this->addReference('image', $image, $importId);
|
||||
}
|
||||
|
||||
protected function handleReference(string $type, int $id): ?string
|
||||
{
|
||||
$key = $type . ':' . $id;
|
||||
$model = $this->referenceMap[$key] ?? null;
|
||||
if ($model instanceof Entity) {
|
||||
return $model->getUrl();
|
||||
} else if ($model instanceof Image) {
|
||||
if ($model->type === 'gallery') {
|
||||
$this->imageResizer->loadGalleryThumbnailsForImage($model, false);
|
||||
return $model->thumbs['display'] ?? $model->url;
|
||||
}
|
||||
|
||||
return $model->url;
|
||||
} else if ($model instanceof Attachment) {
|
||||
return $model->getUrl(false);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function replaceReferences(): void
|
||||
{
|
||||
foreach ($this->books as $book) {
|
||||
$exportBook = $this->zipExportBookMap[$book->id];
|
||||
$content = $exportBook->description_html ?? '';
|
||||
$parsed = $this->parser->parseReferences($content, $this->handleReference(...));
|
||||
|
||||
$this->baseRepo->update($book, [
|
||||
'description_html' => $parsed,
|
||||
]);
|
||||
}
|
||||
|
||||
foreach ($this->chapters as $chapter) {
|
||||
$exportChapter = $this->zipExportChapterMap[$chapter->id];
|
||||
$content = $exportChapter->description_html ?? '';
|
||||
$parsed = $this->parser->parseReferences($content, $this->handleReference(...));
|
||||
|
||||
$this->baseRepo->update($chapter, [
|
||||
'description_html' => $parsed,
|
||||
]);
|
||||
}
|
||||
|
||||
foreach ($this->pages as $page) {
|
||||
$exportPage = $this->zipExportPageMap[$page->id];
|
||||
$contentType = $exportPage->markdown ? 'markdown' : 'html';
|
||||
$content = $exportPage->markdown ?: ($exportPage->html ?: '');
|
||||
$parsed = $this->parser->parseReferences($content, $this->handleReference(...));
|
||||
|
||||
$this->pageRepo->setContentFromInput($page, [
|
||||
$contentType => $parsed,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @return Image[]
|
||||
*/
|
||||
public function images(): array
|
||||
{
|
||||
return $this->images;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Attachment[]
|
||||
*/
|
||||
public function attachments(): array
|
||||
{
|
||||
return $this->attachments;
|
||||
}
|
||||
}
|
||||
364
app/Exports/ZipExports/ZipImportRunner.php
Normal file
364
app/Exports/ZipExports/ZipImportRunner.php
Normal file
@@ -0,0 +1,364 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Exports\ZipExports;
|
||||
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Repos\BookRepo;
|
||||
use BookStack\Entities\Repos\ChapterRepo;
|
||||
use BookStack\Entities\Repos\PageRepo;
|
||||
use BookStack\Exceptions\ZipExportException;
|
||||
use BookStack\Exceptions\ZipImportException;
|
||||
use BookStack\Exports\Import;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportAttachment;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportBook;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportChapter;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportImage;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportPage;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportTag;
|
||||
use BookStack\Uploads\Attachment;
|
||||
use BookStack\Uploads\AttachmentService;
|
||||
use BookStack\Uploads\FileStorage;
|
||||
use BookStack\Uploads\Image;
|
||||
use BookStack\Uploads\ImageService;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
|
||||
class ZipImportRunner
|
||||
{
|
||||
protected array $tempFilesToCleanup = [];
|
||||
|
||||
public function __construct(
|
||||
protected FileStorage $storage,
|
||||
protected PageRepo $pageRepo,
|
||||
protected ChapterRepo $chapterRepo,
|
||||
protected BookRepo $bookRepo,
|
||||
protected ImageService $imageService,
|
||||
protected AttachmentService $attachmentService,
|
||||
protected ZipImportReferences $references,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the import.
|
||||
* Performs re-validation on zip, validation on parent provided, and permissions for importing
|
||||
* the planned content, before running the import process.
|
||||
* Returns the top-level entity item which was imported.
|
||||
* @throws ZipImportException
|
||||
*/
|
||||
public function run(Import $import, ?Entity $parent = null): Entity
|
||||
{
|
||||
$zipPath = $this->getZipPath($import);
|
||||
$reader = new ZipExportReader($zipPath);
|
||||
|
||||
$errors = (new ZipExportValidator($reader))->validate();
|
||||
if ($errors) {
|
||||
throw new ZipImportException([
|
||||
trans('errors.import_validation_failed'),
|
||||
...$errors,
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$exportModel = $reader->decodeDataToExportModel();
|
||||
} catch (ZipExportException $e) {
|
||||
throw new ZipImportException([$e->getMessage()]);
|
||||
}
|
||||
|
||||
// Validate parent type
|
||||
if ($exportModel instanceof ZipExportBook && ($parent !== null)) {
|
||||
throw new ZipImportException(["Must not have a parent set for a Book import."]);
|
||||
} else if ($exportModel instanceof ZipExportChapter && !($parent instanceof Book)) {
|
||||
throw new ZipImportException(["Parent book required for chapter import."]);
|
||||
} else if ($exportModel instanceof ZipExportPage && !($parent instanceof Book || $parent instanceof Chapter)) {
|
||||
throw new ZipImportException(["Parent book or chapter required for page import."]);
|
||||
}
|
||||
|
||||
$this->ensurePermissionsPermitImport($exportModel, $parent);
|
||||
|
||||
if ($exportModel instanceof ZipExportBook) {
|
||||
$entity = $this->importBook($exportModel, $reader);
|
||||
} else if ($exportModel instanceof ZipExportChapter) {
|
||||
$entity = $this->importChapter($exportModel, $parent, $reader);
|
||||
} else if ($exportModel instanceof ZipExportPage) {
|
||||
$entity = $this->importPage($exportModel, $parent, $reader);
|
||||
} else {
|
||||
throw new ZipImportException(['No importable data found in import data.']);
|
||||
}
|
||||
|
||||
$this->references->replaceReferences();
|
||||
|
||||
$reader->close();
|
||||
$this->cleanup();
|
||||
|
||||
return $entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revert any files which have been stored during this import process.
|
||||
* Considers files only, and avoids the database under the
|
||||
* assumption that the database may already have been
|
||||
* reverted as part of a transaction rollback.
|
||||
*/
|
||||
public function revertStoredFiles(): void
|
||||
{
|
||||
foreach ($this->references->images() as $image) {
|
||||
$this->imageService->destroyFileAtPath($image->type, $image->path);
|
||||
}
|
||||
|
||||
foreach ($this->references->attachments() as $attachment) {
|
||||
if (!$attachment->external) {
|
||||
$this->attachmentService->deleteFileInStorage($attachment);
|
||||
}
|
||||
}
|
||||
|
||||
$this->cleanup();
|
||||
}
|
||||
|
||||
protected function cleanup(): void
|
||||
{
|
||||
foreach ($this->tempFilesToCleanup as $file) {
|
||||
unlink($file);
|
||||
}
|
||||
|
||||
$this->tempFilesToCleanup = [];
|
||||
}
|
||||
|
||||
protected function importBook(ZipExportBook $exportBook, ZipExportReader $reader): Book
|
||||
{
|
||||
$book = $this->bookRepo->create([
|
||||
'name' => $exportBook->name,
|
||||
'description_html' => $exportBook->description_html ?? '',
|
||||
'image' => $exportBook->cover ? $this->zipFileToUploadedFile($exportBook->cover, $reader) : null,
|
||||
'tags' => $this->exportTagsToInputArray($exportBook->tags ?? []),
|
||||
]);
|
||||
|
||||
if ($book->cover) {
|
||||
$this->references->addImage($book->cover, null);
|
||||
}
|
||||
|
||||
$children = [
|
||||
...$exportBook->chapters,
|
||||
...$exportBook->pages,
|
||||
];
|
||||
|
||||
usort($children, function (ZipExportPage|ZipExportChapter $a, ZipExportPage|ZipExportChapter $b) {
|
||||
return ($a->priority ?? 0) - ($b->priority ?? 0);
|
||||
});
|
||||
|
||||
foreach ($children as $child) {
|
||||
if ($child instanceof ZipExportChapter) {
|
||||
$this->importChapter($child, $book, $reader);
|
||||
} else if ($child instanceof ZipExportPage) {
|
||||
$this->importPage($child, $book, $reader);
|
||||
}
|
||||
}
|
||||
|
||||
$this->references->addBook($book, $exportBook);
|
||||
|
||||
return $book;
|
||||
}
|
||||
|
||||
protected function importChapter(ZipExportChapter $exportChapter, Book $parent, ZipExportReader $reader): Chapter
|
||||
{
|
||||
$chapter = $this->chapterRepo->create([
|
||||
'name' => $exportChapter->name,
|
||||
'description_html' => $exportChapter->description_html ?? '',
|
||||
'tags' => $this->exportTagsToInputArray($exportChapter->tags ?? []),
|
||||
], $parent);
|
||||
|
||||
$exportPages = $exportChapter->pages;
|
||||
usort($exportPages, function (ZipExportPage $a, ZipExportPage $b) {
|
||||
return ($a->priority ?? 0) - ($b->priority ?? 0);
|
||||
});
|
||||
|
||||
foreach ($exportPages as $exportPage) {
|
||||
$this->importPage($exportPage, $chapter, $reader);
|
||||
}
|
||||
|
||||
$this->references->addChapter($chapter, $exportChapter);
|
||||
|
||||
return $chapter;
|
||||
}
|
||||
|
||||
protected function importPage(ZipExportPage $exportPage, Book|Chapter $parent, ZipExportReader $reader): Page
|
||||
{
|
||||
$page = $this->pageRepo->getNewDraftPage($parent);
|
||||
|
||||
foreach ($exportPage->attachments as $exportAttachment) {
|
||||
$this->importAttachment($exportAttachment, $page, $reader);
|
||||
}
|
||||
|
||||
foreach ($exportPage->images as $exportImage) {
|
||||
$this->importImage($exportImage, $page, $reader);
|
||||
}
|
||||
|
||||
$this->pageRepo->publishDraft($page, [
|
||||
'name' => $exportPage->name,
|
||||
'markdown' => $exportPage->markdown,
|
||||
'html' => $exportPage->html,
|
||||
'tags' => $this->exportTagsToInputArray($exportPage->tags ?? []),
|
||||
]);
|
||||
|
||||
$this->references->addPage($page, $exportPage);
|
||||
|
||||
return $page;
|
||||
}
|
||||
|
||||
protected function importAttachment(ZipExportAttachment $exportAttachment, Page $page, ZipExportReader $reader): Attachment
|
||||
{
|
||||
if ($exportAttachment->file) {
|
||||
$file = $this->zipFileToUploadedFile($exportAttachment->file, $reader);
|
||||
$attachment = $this->attachmentService->saveNewUpload($file, $page->id);
|
||||
$attachment->name = $exportAttachment->name;
|
||||
$attachment->save();
|
||||
} else {
|
||||
$attachment = $this->attachmentService->saveNewFromLink(
|
||||
$exportAttachment->name,
|
||||
$exportAttachment->link ?? '',
|
||||
$page->id,
|
||||
);
|
||||
}
|
||||
|
||||
$this->references->addAttachment($attachment, $exportAttachment->id);
|
||||
|
||||
return $attachment;
|
||||
}
|
||||
|
||||
protected function importImage(ZipExportImage $exportImage, Page $page, ZipExportReader $reader): Image
|
||||
{
|
||||
$mime = $reader->sniffFileMime($exportImage->file);
|
||||
$extension = explode('/', $mime)[1];
|
||||
|
||||
$file = $this->zipFileToUploadedFile($exportImage->file, $reader);
|
||||
$image = $this->imageService->saveNewFromUpload(
|
||||
$file,
|
||||
$exportImage->type,
|
||||
$page->id,
|
||||
null,
|
||||
null,
|
||||
true,
|
||||
$exportImage->name . '.' . $extension,
|
||||
);
|
||||
|
||||
$image->name = $exportImage->name;
|
||||
$image->save();
|
||||
|
||||
$this->references->addImage($image, $exportImage->id);
|
||||
|
||||
return $image;
|
||||
}
|
||||
|
||||
protected function exportTagsToInputArray(array $exportTags): array
|
||||
{
|
||||
$tags = [];
|
||||
|
||||
/** @var ZipExportTag $tag */
|
||||
foreach ($exportTags as $tag) {
|
||||
$tags[] = ['name' => $tag->name, 'value' => $tag->value ?? ''];
|
||||
}
|
||||
|
||||
return $tags;
|
||||
}
|
||||
|
||||
protected function zipFileToUploadedFile(string $fileName, ZipExportReader $reader): UploadedFile
|
||||
{
|
||||
$tempPath = tempnam(sys_get_temp_dir(), 'bszipextract');
|
||||
$fileStream = $reader->streamFile($fileName);
|
||||
$tempStream = fopen($tempPath, 'wb');
|
||||
stream_copy_to_stream($fileStream, $tempStream);
|
||||
fclose($tempStream);
|
||||
|
||||
$this->tempFilesToCleanup[] = $tempPath;
|
||||
|
||||
return new UploadedFile($tempPath, $fileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ZipImportException
|
||||
*/
|
||||
protected function ensurePermissionsPermitImport(ZipExportPage|ZipExportChapter|ZipExportBook $exportModel, Book|Chapter|null $parent = null): void
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
$chapters = [];
|
||||
$pages = [];
|
||||
$images = [];
|
||||
$attachments = [];
|
||||
|
||||
if ($exportModel instanceof ZipExportBook) {
|
||||
if (!userCan('book-create-all')) {
|
||||
$errors[] = trans('errors.import_perms_books');
|
||||
}
|
||||
array_push($pages, ...$exportModel->pages);
|
||||
array_push($chapters, ...$exportModel->chapters);
|
||||
} else if ($exportModel instanceof ZipExportChapter) {
|
||||
$chapters[] = $exportModel;
|
||||
} else if ($exportModel instanceof ZipExportPage) {
|
||||
$pages[] = $exportModel;
|
||||
}
|
||||
|
||||
foreach ($chapters as $chapter) {
|
||||
array_push($pages, ...$chapter->pages);
|
||||
}
|
||||
|
||||
if (count($chapters) > 0) {
|
||||
$permission = 'chapter-create' . ($parent ? '' : '-all');
|
||||
if (!userCan($permission, $parent)) {
|
||||
$errors[] = trans('errors.import_perms_chapters');
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($pages as $page) {
|
||||
array_push($attachments, ...$page->attachments);
|
||||
array_push($images, ...$page->images);
|
||||
}
|
||||
|
||||
if (count($pages) > 0) {
|
||||
if ($parent) {
|
||||
if (!userCan('page-create', $parent)) {
|
||||
$errors[] = trans('errors.import_perms_pages');
|
||||
}
|
||||
} else {
|
||||
$hasPermission = userCan('page-create-all') || userCan('page-create-own');
|
||||
if (!$hasPermission) {
|
||||
$errors[] = trans('errors.import_perms_pages');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (count($images) > 0) {
|
||||
if (!userCan('image-create-all')) {
|
||||
$errors[] = trans('errors.import_perms_images');
|
||||
}
|
||||
}
|
||||
|
||||
if (count($attachments) > 0) {
|
||||
if (!userCan('attachment-create-all')) {
|
||||
$errors[] = trans('errors.import_perms_attachments');
|
||||
}
|
||||
}
|
||||
|
||||
if (count($errors)) {
|
||||
throw new ZipImportException($errors);
|
||||
}
|
||||
}
|
||||
|
||||
protected function getZipPath(Import $import): string
|
||||
{
|
||||
if (!$this->storage->isRemote()) {
|
||||
return $this->storage->getSystemPath($import->path);
|
||||
}
|
||||
|
||||
$tempFilePath = tempnam(sys_get_temp_dir(), 'bszip-import-');
|
||||
$tempFile = fopen($tempFilePath, 'wb');
|
||||
$stream = $this->storage->getReadStream($import->path);
|
||||
stream_copy_to_stream($stream, $tempFile);
|
||||
fclose($tempFile);
|
||||
|
||||
$this->tempFilesToCleanup[] = $tempFilePath;
|
||||
|
||||
return $tempFilePath;
|
||||
}
|
||||
}
|
||||
140
app/Exports/ZipExports/ZipReferenceParser.php
Normal file
140
app/Exports/ZipExports/ZipReferenceParser.php
Normal file
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Exports\ZipExports;
|
||||
|
||||
use BookStack\App\Model;
|
||||
use BookStack\Entities\Queries\EntityQueries;
|
||||
use BookStack\References\ModelResolvers\AttachmentModelResolver;
|
||||
use BookStack\References\ModelResolvers\BookLinkModelResolver;
|
||||
use BookStack\References\ModelResolvers\ChapterLinkModelResolver;
|
||||
use BookStack\References\ModelResolvers\CrossLinkModelResolver;
|
||||
use BookStack\References\ModelResolvers\ImageModelResolver;
|
||||
use BookStack\References\ModelResolvers\PageLinkModelResolver;
|
||||
use BookStack\References\ModelResolvers\PagePermalinkModelResolver;
|
||||
use BookStack\Uploads\ImageStorage;
|
||||
|
||||
class ZipReferenceParser
|
||||
{
|
||||
/**
|
||||
* @var CrossLinkModelResolver[]|null
|
||||
*/
|
||||
protected ?array $modelResolvers = null;
|
||||
|
||||
public function __construct(
|
||||
protected EntityQueries $queries
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and replace references in the given content.
|
||||
* Calls the handler for each model link detected and replaces the link
|
||||
* with the handler return value if provided.
|
||||
* Returns the resulting content with links replaced.
|
||||
* @param callable(Model):(string|null) $handler
|
||||
*/
|
||||
public function parseLinks(string $content, callable $handler): string
|
||||
{
|
||||
$linkRegex = $this->getLinkRegex();
|
||||
$matches = [];
|
||||
preg_match_all($linkRegex, $content, $matches);
|
||||
|
||||
if (count($matches) < 2) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
foreach ($matches[1] as $link) {
|
||||
$model = $this->linkToModel($link);
|
||||
if ($model) {
|
||||
$result = $handler($model);
|
||||
if ($result !== null) {
|
||||
$content = str_replace($link, $result, $content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and replace references in the given content.
|
||||
* Calls the handler for each reference detected and replaces the link
|
||||
* with the handler return value if provided.
|
||||
* Returns the resulting content string with references replaced.
|
||||
* @param callable(string $type, int $id):(string|null) $handler
|
||||
*/
|
||||
public function parseReferences(string $content, callable $handler): string
|
||||
{
|
||||
$referenceRegex = '/\[\[bsexport:([a-z]+):(\d+)]]/';
|
||||
$matches = [];
|
||||
preg_match_all($referenceRegex, $content, $matches);
|
||||
|
||||
if (count($matches) < 3) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
for ($i = 0; $i < count($matches[0]); $i++) {
|
||||
$referenceText = $matches[0][$i];
|
||||
$type = strtolower($matches[1][$i]);
|
||||
$id = intval($matches[2][$i]);
|
||||
$result = $handler($type, $id);
|
||||
if ($result !== null) {
|
||||
$content = str_replace($referenceText, $result, $content);
|
||||
}
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Attempt to resolve the given link to a model using the instance model resolvers.
|
||||
*/
|
||||
protected function linkToModel(string $link): ?Model
|
||||
{
|
||||
foreach ($this->getModelResolvers() as $resolver) {
|
||||
$model = $resolver->resolve($link);
|
||||
if (!is_null($model)) {
|
||||
return $model;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function getModelResolvers(): array
|
||||
{
|
||||
if (isset($this->modelResolvers)) {
|
||||
return $this->modelResolvers;
|
||||
}
|
||||
|
||||
$this->modelResolvers = [
|
||||
new PagePermalinkModelResolver($this->queries->pages),
|
||||
new PageLinkModelResolver($this->queries->pages),
|
||||
new ChapterLinkModelResolver($this->queries->chapters),
|
||||
new BookLinkModelResolver($this->queries->books),
|
||||
new ImageModelResolver(),
|
||||
new AttachmentModelResolver(),
|
||||
];
|
||||
|
||||
return $this->modelResolvers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the regex to identify links we should handle in content.
|
||||
*/
|
||||
protected function getLinkRegex(): string
|
||||
{
|
||||
$urls = [rtrim(url('/'), '/')];
|
||||
$imageUrl = rtrim(ImageStorage::getPublicUrl('/'), '/');
|
||||
if ($urls[0] !== $imageUrl) {
|
||||
$urls[] = $imageUrl;
|
||||
}
|
||||
|
||||
|
||||
$urlBaseRegex = implode('|', array_map(function ($url) {
|
||||
return preg_quote($url, '/');
|
||||
}, $urls));
|
||||
|
||||
return "/(({$urlBaseRegex}).*?)[\\t\\n\\f>\"'=?#()]/";
|
||||
}
|
||||
}
|
||||
26
app/Exports/ZipExports/ZipUniqueIdRule.php
Normal file
26
app/Exports/ZipExports/ZipUniqueIdRule.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Exports\ZipExports;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
|
||||
class ZipUniqueIdRule implements ValidationRule
|
||||
{
|
||||
public function __construct(
|
||||
protected ZipValidationHelper $context,
|
||||
protected string $modelType,
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if ($this->context->hasIdBeenUsed($this->modelType, $value)) {
|
||||
$fail('validation.zip_unique')->translate(['attribute' => $attribute]);
|
||||
}
|
||||
}
|
||||
}
|
||||
77
app/Exports/ZipExports/ZipValidationHelper.php
Normal file
77
app/Exports/ZipExports/ZipValidationHelper.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Exports\ZipExports;
|
||||
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportModel;
|
||||
use Illuminate\Validation\Factory;
|
||||
|
||||
class ZipValidationHelper
|
||||
{
|
||||
protected Factory $validationFactory;
|
||||
|
||||
/**
|
||||
* Local store of validated IDs (in format "<type>:<id>". Example: "book:2")
|
||||
* which we can use to check uniqueness.
|
||||
* @var array<string, bool>
|
||||
*/
|
||||
protected array $validatedIds = [];
|
||||
|
||||
public function __construct(
|
||||
public ZipExportReader $zipReader,
|
||||
) {
|
||||
$this->validationFactory = app(Factory::class);
|
||||
}
|
||||
|
||||
public function validateData(array $data, array $rules): array
|
||||
{
|
||||
$messages = $this->validationFactory->make($data, $rules)->errors()->messages();
|
||||
|
||||
foreach ($messages as $key => $message) {
|
||||
$messages[$key] = implode("\n", $message);
|
||||
}
|
||||
|
||||
return $messages;
|
||||
}
|
||||
|
||||
public function fileReferenceRule(array $acceptedMimes = []): ZipFileReferenceRule
|
||||
{
|
||||
return new ZipFileReferenceRule($this, $acceptedMimes);
|
||||
}
|
||||
|
||||
public function uniqueIdRule(string $type): ZipUniqueIdRule
|
||||
{
|
||||
return new ZipUniqueIdRule($this, $type);
|
||||
}
|
||||
|
||||
public function hasIdBeenUsed(string $type, mixed $id): bool
|
||||
{
|
||||
$key = $type . ':' . $id;
|
||||
if (isset($this->validatedIds[$key])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->validatedIds[$key] = true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an array of relation data arrays that are expected
|
||||
* to be for the given ZipExportModel.
|
||||
* @param class-string<ZipExportModel> $model
|
||||
*/
|
||||
public function validateRelations(array $relations, string $model): array
|
||||
{
|
||||
$results = [];
|
||||
|
||||
foreach ($relations as $key => $relationData) {
|
||||
if (is_array($relationData)) {
|
||||
$results[$key] = $model::validate($this, $relationData);
|
||||
} else {
|
||||
$results[$key] = [trans('validation.zip_model_expected', ['type' => gettype($relationData)])];
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
}
|
||||
@@ -152,10 +152,8 @@ abstract class Controller extends BaseController
|
||||
|
||||
/**
|
||||
* Log an activity in the system.
|
||||
*
|
||||
* @param string|Loggable $detail
|
||||
*/
|
||||
protected function logActivity(string $type, $detail = ''): void
|
||||
protected function logActivity(string $type, string|Loggable $detail = ''): void
|
||||
{
|
||||
Activity::add($type, $detail);
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ class RangeSupportedStream
|
||||
if ($start < 0 || $start > $end) {
|
||||
$this->responseStatus = 416;
|
||||
$this->responseHeaders['Content-Range'] = sprintf('bytes */%s', $this->fileSize);
|
||||
} elseif ($end - $start < $this->fileSize - 1) {
|
||||
} else {
|
||||
$this->responseLength = $end < $this->fileSize ? $end - $start + 1 : -1;
|
||||
$this->responseOffset = $start;
|
||||
$this->responseStatus = 206;
|
||||
|
||||
22
app/References/ModelResolvers/AttachmentModelResolver.php
Normal file
22
app/References/ModelResolvers/AttachmentModelResolver.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\References\ModelResolvers;
|
||||
|
||||
use BookStack\Uploads\Attachment;
|
||||
|
||||
class AttachmentModelResolver implements CrossLinkModelResolver
|
||||
{
|
||||
public function resolve(string $link): ?Attachment
|
||||
{
|
||||
$pattern = '/^' . preg_quote(url('/attachments'), '/') . '\/(\d+)/';
|
||||
$matches = [];
|
||||
$match = preg_match($pattern, $link, $matches);
|
||||
if (!$match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$id = intval($matches[1]);
|
||||
|
||||
return Attachment::query()->find($id);
|
||||
}
|
||||
}
|
||||
58
app/References/ModelResolvers/ImageModelResolver.php
Normal file
58
app/References/ModelResolvers/ImageModelResolver.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\References\ModelResolvers;
|
||||
|
||||
use BookStack\Uploads\Image;
|
||||
use BookStack\Uploads\ImageStorage;
|
||||
|
||||
class ImageModelResolver implements CrossLinkModelResolver
|
||||
{
|
||||
protected ?string $pattern = null;
|
||||
|
||||
public function resolve(string $link): ?Image
|
||||
{
|
||||
$pattern = $this->getUrlPattern();
|
||||
$matches = [];
|
||||
$match = preg_match($pattern, $link, $matches);
|
||||
if (!$match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$path = $matches[2];
|
||||
|
||||
// Strip thumbnail element from path if existing
|
||||
$originalPathSplit = array_filter(explode('/', $path), function (string $part) {
|
||||
$resizedDir = (str_starts_with($part, 'thumbs-') || str_starts_with($part, 'scaled-'));
|
||||
$missingExtension = !str_contains($part, '.');
|
||||
|
||||
return !($resizedDir && $missingExtension);
|
||||
});
|
||||
|
||||
// Build a database-format image path and search for the image entry
|
||||
$fullPath = '/uploads/images/' . ltrim(implode('/', $originalPathSplit), '/');
|
||||
|
||||
return Image::query()->where('path', '=', $fullPath)->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the regex pattern to identify image URLs.
|
||||
* Caches the pattern since it requires looking up to settings/config.
|
||||
*/
|
||||
protected function getUrlPattern(): string
|
||||
{
|
||||
if ($this->pattern) {
|
||||
return $this->pattern;
|
||||
}
|
||||
|
||||
$urls = [url('/uploads/images')];
|
||||
$baseImageUrl = ImageStorage::getPublicUrl('/uploads/images');
|
||||
if ($baseImageUrl !== $urls[0]) {
|
||||
$urls[] = $baseImageUrl;
|
||||
}
|
||||
|
||||
$imageUrlRegex = implode('|', array_map(fn ($url) => preg_quote($url, '/'), $urls));
|
||||
$this->pattern = '/^(' . $imageUrlRegex . ')\/(.+)/';
|
||||
|
||||
return $this->pattern;
|
||||
}
|
||||
}
|
||||
@@ -9,21 +9,18 @@ use Illuminate\Http\Request;
|
||||
|
||||
class SearchApiController extends ApiController
|
||||
{
|
||||
protected SearchRunner $searchRunner;
|
||||
protected SearchResultsFormatter $resultsFormatter;
|
||||
|
||||
protected $rules = [
|
||||
'all' => [
|
||||
'query' => ['required'],
|
||||
'page' => ['integer', 'min:1'],
|
||||
'count' => ['integer', 'min:1', 'max:100'],
|
||||
'query' => ['required'],
|
||||
'page' => ['integer', 'min:1'],
|
||||
'count' => ['integer', 'min:1', 'max:100'],
|
||||
],
|
||||
];
|
||||
|
||||
public function __construct(SearchRunner $searchRunner, SearchResultsFormatter $resultsFormatter)
|
||||
{
|
||||
$this->searchRunner = $searchRunner;
|
||||
$this->resultsFormatter = $resultsFormatter;
|
||||
public function __construct(
|
||||
protected SearchRunner $searchRunner,
|
||||
protected SearchResultsFormatter $resultsFormatter
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,16 +47,16 @@ class SearchApiController extends ApiController
|
||||
$this->resultsFormatter->format($results['results']->all(), $options);
|
||||
|
||||
$data = (new ApiEntityListFormatter($results['results']->all()))
|
||||
->withType()->withTags()
|
||||
->withType()->withTags()->withParents()
|
||||
->withField('preview_html', function (Entity $entity) {
|
||||
return [
|
||||
'name' => (string) $entity->getAttribute('preview_name'),
|
||||
'name' => (string) $entity->getAttribute('preview_name'),
|
||||
'content' => (string) $entity->getAttribute('preview_content'),
|
||||
];
|
||||
})->format();
|
||||
|
||||
return response()->json([
|
||||
'data' => $data,
|
||||
'data' => $data,
|
||||
'total' => $results['total'],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ class SearchIndex
|
||||
{
|
||||
$this->deleteEntityTerms($entity);
|
||||
$terms = $this->entityToTermDataArray($entity);
|
||||
SearchTerm::query()->insert($terms);
|
||||
$this->insertTerms($terms);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -46,10 +46,7 @@ class SearchIndex
|
||||
array_push($terms, ...$entityTerms);
|
||||
}
|
||||
|
||||
$chunkedTerms = array_chunk($terms, 500);
|
||||
foreach ($chunkedTerms as $termChunk) {
|
||||
SearchTerm::query()->insert($termChunk);
|
||||
}
|
||||
$this->insertTerms($terms);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,6 +96,19 @@ class SearchIndex
|
||||
$entity->searchTerms()->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert the given terms into the database.
|
||||
* Chunks through the given terms to remain within database limits.
|
||||
* @param array[] $terms
|
||||
*/
|
||||
protected function insertTerms(array $terms): void
|
||||
{
|
||||
$chunkedTerms = array_chunk($terms, 500);
|
||||
foreach ($chunkedTerms as $termChunk) {
|
||||
SearchTerm::query()->insert($termChunk);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a scored term array from the given text, where the keys are the terms
|
||||
* and the values are their scores.
|
||||
|
||||
@@ -9,8 +9,6 @@ use Illuminate\Http\Request;
|
||||
|
||||
class SettingController extends Controller
|
||||
{
|
||||
protected array $settingCategories = ['features', 'customization', 'registration'];
|
||||
|
||||
/**
|
||||
* Handle requests to the settings index path.
|
||||
*/
|
||||
@@ -31,7 +29,7 @@ class SettingController extends Controller
|
||||
// Get application version
|
||||
$version = trim(file_get_contents(base_path('version')));
|
||||
|
||||
return view('settings.' . $category, [
|
||||
return view('settings.categories.' . $category, [
|
||||
'category' => $category,
|
||||
'version' => $version,
|
||||
'guestUser' => User::getGuest(),
|
||||
@@ -59,7 +57,7 @@ class SettingController extends Controller
|
||||
|
||||
protected function ensureCategoryExists(string $category): void
|
||||
{
|
||||
if (!in_array($category, $this->settingCategories)) {
|
||||
if (!view()->exists('settings.categories.' . $category)) {
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ class LocaleManager
|
||||
protected array $localeMap = [
|
||||
'ar' => 'ar',
|
||||
'bg' => 'bg_BG',
|
||||
'bn' => 'bn_BD',
|
||||
'bs' => 'bs_BA',
|
||||
'ca' => 'ca',
|
||||
'cs' => 'cs_CZ',
|
||||
@@ -41,6 +42,7 @@ class LocaleManager
|
||||
'hr' => 'hr_HR',
|
||||
'hu' => 'hu_HU',
|
||||
'id' => 'id_ID',
|
||||
'is' => 'is_IS',
|
||||
'it' => 'it_IT',
|
||||
'ja' => 'ja',
|
||||
'ka' => 'ka_GE',
|
||||
@@ -60,6 +62,7 @@ class LocaleManager
|
||||
'sq' => 'sq_AL',
|
||||
'sr' => 'sr_RS',
|
||||
'sv' => 'sv_SE',
|
||||
'tk' => 'tk_TM',
|
||||
'tr' => 'tr_TR',
|
||||
'uk' => 'uk_UA',
|
||||
'uz' => 'uz_UZ',
|
||||
|
||||
@@ -4,62 +4,13 @@ namespace BookStack\Uploads;
|
||||
|
||||
use BookStack\Exceptions\FileUploadException;
|
||||
use Exception;
|
||||
use Illuminate\Contracts\Filesystem\Filesystem as Storage;
|
||||
use Illuminate\Filesystem\FilesystemManager;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use League\Flysystem\WhitespacePathNormalizer;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
||||
class AttachmentService
|
||||
{
|
||||
protected FilesystemManager $fileSystem;
|
||||
|
||||
/**
|
||||
* AttachmentService constructor.
|
||||
*/
|
||||
public function __construct(FilesystemManager $fileSystem)
|
||||
{
|
||||
$this->fileSystem = $fileSystem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the storage that will be used for storing files.
|
||||
*/
|
||||
protected function getStorageDisk(): Storage
|
||||
{
|
||||
return $this->fileSystem->disk($this->getStorageDiskName());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the name of the storage disk to use.
|
||||
*/
|
||||
protected function getStorageDiskName(): string
|
||||
{
|
||||
$storageType = config('filesystems.attachments');
|
||||
|
||||
// Change to our secure-attachment disk if any of the local options
|
||||
// are used to prevent escaping that location.
|
||||
if ($storageType === 'local' || $storageType === 'local_secure' || $storageType === 'local_secure_restricted') {
|
||||
$storageType = 'local_secure_attachments';
|
||||
}
|
||||
|
||||
return $storageType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the originally provided path to fit any disk-specific requirements.
|
||||
* This also ensures the path is kept to the expected root folders.
|
||||
*/
|
||||
protected function adjustPathForStorageDisk(string $path): string
|
||||
{
|
||||
$path = (new WhitespacePathNormalizer())->normalizePath(str_replace('uploads/files/', '', $path));
|
||||
|
||||
if ($this->getStorageDiskName() === 'local_secure_attachments') {
|
||||
return $path;
|
||||
}
|
||||
|
||||
return 'uploads/files/' . $path;
|
||||
public function __construct(
|
||||
protected FileStorage $storage,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -69,7 +20,7 @@ class AttachmentService
|
||||
*/
|
||||
public function streamAttachmentFromStorage(Attachment $attachment)
|
||||
{
|
||||
return $this->getStorageDisk()->readStream($this->adjustPathForStorageDisk($attachment->path));
|
||||
return $this->storage->getReadStream($attachment->path);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,7 +28,7 @@ class AttachmentService
|
||||
*/
|
||||
public function getAttachmentFileSize(Attachment $attachment): int
|
||||
{
|
||||
return $this->getStorageDisk()->size($this->adjustPathForStorageDisk($attachment->path));
|
||||
return $this->storage->getSize($attachment->path);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -165,16 +116,18 @@ class AttachmentService
|
||||
*/
|
||||
public function updateFile(Attachment $attachment, array $requestData): Attachment
|
||||
{
|
||||
$attachment->name = $requestData['name'];
|
||||
$link = trim($requestData['link'] ?? '');
|
||||
if (isset($requestData['name'])) {
|
||||
$attachment->name = $requestData['name'];
|
||||
}
|
||||
|
||||
$link = trim($requestData['link'] ?? '');
|
||||
if (!empty($link)) {
|
||||
if (!$attachment->external) {
|
||||
$this->deleteFileInStorage($attachment);
|
||||
$attachment->external = true;
|
||||
$attachment->extension = '';
|
||||
}
|
||||
$attachment->path = $requestData['link'];
|
||||
$attachment->path = $link;
|
||||
}
|
||||
|
||||
$attachment->save();
|
||||
@@ -200,15 +153,9 @@ class AttachmentService
|
||||
* Delete a file from the filesystem it sits on.
|
||||
* Cleans any empty leftover folders.
|
||||
*/
|
||||
protected function deleteFileInStorage(Attachment $attachment)
|
||||
public function deleteFileInStorage(Attachment $attachment): void
|
||||
{
|
||||
$storage = $this->getStorageDisk();
|
||||
$dirPath = $this->adjustPathForStorageDisk(dirname($attachment->path));
|
||||
|
||||
$storage->delete($this->adjustPathForStorageDisk($attachment->path));
|
||||
if (count($storage->allFiles($dirPath)) === 0) {
|
||||
$storage->deleteDirectory($dirPath);
|
||||
}
|
||||
$this->storage->delete($attachment->path);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -218,32 +165,20 @@ class AttachmentService
|
||||
*/
|
||||
protected function putFileInStorage(UploadedFile $uploadedFile): string
|
||||
{
|
||||
$storage = $this->getStorageDisk();
|
||||
$basePath = 'uploads/files/' . date('Y-m-M') . '/';
|
||||
|
||||
$uploadFileName = Str::random(16) . '-' . $uploadedFile->getClientOriginalExtension();
|
||||
while ($storage->exists($this->adjustPathForStorageDisk($basePath . $uploadFileName))) {
|
||||
$uploadFileName = Str::random(3) . $uploadFileName;
|
||||
}
|
||||
|
||||
$attachmentStream = fopen($uploadedFile->getRealPath(), 'r');
|
||||
$attachmentPath = $basePath . $uploadFileName;
|
||||
|
||||
try {
|
||||
$storage->writeStream($this->adjustPathForStorageDisk($attachmentPath), $attachmentStream);
|
||||
} catch (Exception $e) {
|
||||
Log::error('Error when attempting file upload:' . $e->getMessage());
|
||||
|
||||
throw new FileUploadException(trans('errors.path_not_writable', ['filePath' => $attachmentPath]));
|
||||
}
|
||||
|
||||
return $attachmentPath;
|
||||
return $this->storage->uploadFile(
|
||||
$uploadedFile,
|
||||
$basePath,
|
||||
$uploadedFile->getClientOriginalExtension(),
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the file validation rules for attachments.
|
||||
*/
|
||||
public function getFileValidationRules(): array
|
||||
public static function getFileValidationRules(): array
|
||||
{
|
||||
return ['file', 'max:' . (config('app.upload_limit') * 1000)];
|
||||
}
|
||||
|
||||
132
app/Uploads/FileStorage.php
Normal file
132
app/Uploads/FileStorage.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Uploads;
|
||||
|
||||
use BookStack\Exceptions\FileUploadException;
|
||||
use Exception;
|
||||
use Illuminate\Contracts\Filesystem\Filesystem as Storage;
|
||||
use Illuminate\Filesystem\FilesystemAdapter;
|
||||
use Illuminate\Filesystem\FilesystemManager;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use League\Flysystem\WhitespacePathNormalizer;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
||||
class FileStorage
|
||||
{
|
||||
public function __construct(
|
||||
protected FilesystemManager $fileSystem,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return resource|null
|
||||
*/
|
||||
public function getReadStream(string $path)
|
||||
{
|
||||
return $this->getStorageDisk()->readStream($this->adjustPathForStorageDisk($path));
|
||||
}
|
||||
|
||||
public function getSize(string $path): int
|
||||
{
|
||||
return $this->getStorageDisk()->size($this->adjustPathForStorageDisk($path));
|
||||
}
|
||||
|
||||
public function delete(string $path, bool $removeEmptyDir = false): void
|
||||
{
|
||||
$storage = $this->getStorageDisk();
|
||||
$adjustedPath = $this->adjustPathForStorageDisk($path);
|
||||
$dir = dirname($adjustedPath);
|
||||
|
||||
$storage->delete($adjustedPath);
|
||||
if ($removeEmptyDir && count($storage->allFiles($dir)) === 0) {
|
||||
$storage->deleteDirectory($dir);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws FileUploadException
|
||||
*/
|
||||
public function uploadFile(UploadedFile $file, string $subDirectory, string $suffix, string $extension): string
|
||||
{
|
||||
$storage = $this->getStorageDisk();
|
||||
$basePath = trim($subDirectory, '/') . '/';
|
||||
|
||||
$uploadFileName = Str::random(16) . ($suffix ? "-{$suffix}" : '') . ($extension ? ".{$extension}" : '');
|
||||
while ($storage->exists($this->adjustPathForStorageDisk($basePath . $uploadFileName))) {
|
||||
$uploadFileName = Str::random(3) . $uploadFileName;
|
||||
}
|
||||
|
||||
$fileStream = fopen($file->getRealPath(), 'r');
|
||||
$filePath = $basePath . $uploadFileName;
|
||||
|
||||
try {
|
||||
$storage->writeStream($this->adjustPathForStorageDisk($filePath), $fileStream);
|
||||
} catch (Exception $e) {
|
||||
Log::error('Error when attempting file upload:' . $e->getMessage());
|
||||
|
||||
throw new FileUploadException(trans('errors.path_not_writable', ['filePath' => $filePath]));
|
||||
}
|
||||
|
||||
return $filePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the configured storage is remote from the host of this app.
|
||||
*/
|
||||
public function isRemote(): bool
|
||||
{
|
||||
return $this->getStorageDiskName() === 's3';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the actual path on system for the given relative file path.
|
||||
*/
|
||||
public function getSystemPath(string $filePath): string
|
||||
{
|
||||
if ($this->isRemote()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return storage_path('uploads/files/' . ltrim($this->adjustPathForStorageDisk($filePath), '/'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the storage that will be used for storing files.
|
||||
*/
|
||||
protected function getStorageDisk(): Storage
|
||||
{
|
||||
return $this->fileSystem->disk($this->getStorageDiskName());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the name of the storage disk to use.
|
||||
*/
|
||||
protected function getStorageDiskName(): string
|
||||
{
|
||||
$storageType = trim(strtolower(config('filesystems.attachments')));
|
||||
|
||||
// Change to our secure-attachment disk if any of the local options
|
||||
// are used to prevent escaping that location.
|
||||
if ($storageType === 'local' || $storageType === 'local_secure' || $storageType === 'local_secure_restricted') {
|
||||
$storageType = 'local_secure_attachments';
|
||||
}
|
||||
|
||||
return $storageType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the originally provided path to fit any disk-specific requirements.
|
||||
* This also ensures the path is kept to the expected root folders.
|
||||
*/
|
||||
protected function adjustPathForStorageDisk(string $path): string
|
||||
{
|
||||
$path = (new WhitespacePathNormalizer())->normalizePath(str_replace('uploads/files/', '', $path));
|
||||
|
||||
if ($this->getStorageDiskName() === 'local_secure_attachments') {
|
||||
return $path;
|
||||
}
|
||||
|
||||
return 'uploads/files/' . $path;
|
||||
}
|
||||
}
|
||||
@@ -33,9 +33,10 @@ class ImageService
|
||||
int $uploadedTo = 0,
|
||||
int $resizeWidth = null,
|
||||
int $resizeHeight = null,
|
||||
bool $keepRatio = true
|
||||
bool $keepRatio = true,
|
||||
string $imageName = '',
|
||||
): Image {
|
||||
$imageName = $uploadedFile->getClientOriginalName();
|
||||
$imageName = $imageName ?: $uploadedFile->getClientOriginalName();
|
||||
$imageData = file_get_contents($uploadedFile->getRealPath());
|
||||
|
||||
if ($resizeWidth !== null || $resizeHeight !== null) {
|
||||
@@ -133,6 +134,19 @@ class ImageService
|
||||
return $disk->get($image->path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the raw data content from an image.
|
||||
*
|
||||
* @throws Exception
|
||||
* @returns ?resource
|
||||
*/
|
||||
public function getImageStream(Image $image): mixed
|
||||
{
|
||||
$disk = $this->storage->getDisk();
|
||||
|
||||
return $disk->stream($image->path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy an image along with its revisions, thumbnails and remaining folders.
|
||||
*
|
||||
@@ -140,11 +154,19 @@ class ImageService
|
||||
*/
|
||||
public function destroy(Image $image): void
|
||||
{
|
||||
$disk = $this->storage->getDisk($image->type);
|
||||
$disk->destroyAllMatchingNameFromPath($image->path);
|
||||
$this->destroyFileAtPath($image->type, $image->path);
|
||||
$image->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the underlying image file at the given path.
|
||||
*/
|
||||
public function destroyFileAtPath(string $type, string $path): void
|
||||
{
|
||||
$disk = $this->storage->getDisk($type);
|
||||
$disk->destroyAllMatchingNameFromPath($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete gallery and drawings that are not within HTML content of pages or page revisions.
|
||||
* Checks based off of only the image name.
|
||||
|
||||
@@ -110,10 +110,20 @@ class ImageStorage
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a public facing url for an image by checking relevant environment variables.
|
||||
* Gets a public facing url for an image or location at the given path.
|
||||
*/
|
||||
public static function getPublicUrl(string $filePath): string
|
||||
{
|
||||
return static::getPublicBaseUrl() . '/' . ltrim($filePath, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the public base URL used for images.
|
||||
* Will not include any path element of the image file, just the base part
|
||||
* from where the path is then expected to start from.
|
||||
* If s3-style store is in use it will default to guessing a public bucket URL.
|
||||
*/
|
||||
public function getPublicUrl(string $filePath): string
|
||||
protected static function getPublicBaseUrl(): string
|
||||
{
|
||||
$storageUrl = config('filesystems.url');
|
||||
|
||||
@@ -131,6 +141,6 @@ class ImageStorage
|
||||
|
||||
$basePath = $storageUrl ?: url('/');
|
||||
|
||||
return rtrim($basePath, '/') . $filePath;
|
||||
return rtrim($basePath, '/');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,15 @@ class ImageStorageDisk
|
||||
return $this->filesystem->get($this->adjustPathForDisk($path));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a stream to the file at the given path.
|
||||
* @returns ?resource
|
||||
*/
|
||||
public function stream(string $path): mixed
|
||||
{
|
||||
return $this->filesystem->readStream($this->adjustPathForDisk($path));
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the given image data at the given path. Can choose to set
|
||||
* the image as public which will update its visibility after saving.
|
||||
|
||||
@@ -37,7 +37,7 @@ class UserApiController extends ApiController
|
||||
{
|
||||
return [
|
||||
'create' => [
|
||||
'name' => ['required', 'string', 'min:2', 'max:100'],
|
||||
'name' => ['required', 'string', 'min:1', 'max:100'],
|
||||
'email' => [
|
||||
'required', 'string', 'email', 'min:2', new Unique('users', 'email'),
|
||||
],
|
||||
@@ -49,7 +49,7 @@ class UserApiController extends ApiController
|
||||
'send_invite' => ['boolean'],
|
||||
],
|
||||
'update' => [
|
||||
'name' => ['string', 'min:2', 'max:100'],
|
||||
'name' => ['string', 'min:1', 'max:100'],
|
||||
'email' => [
|
||||
'string',
|
||||
'email',
|
||||
|
||||
@@ -144,7 +144,7 @@ class UserController extends Controller
|
||||
$this->checkPermission('users-manage');
|
||||
|
||||
$validated = $this->validate($request, [
|
||||
'name' => ['min:2', 'max:100'],
|
||||
'name' => ['min:1', 'max:100'],
|
||||
'email' => ['min:2', 'email', 'unique:users,email,' . $id],
|
||||
'password' => ['required_with:password_confirm', Password::default()],
|
||||
'password-confirm' => ['same:password', 'required_with:password'],
|
||||
|
||||
@@ -6,6 +6,7 @@ use DOMDocument;
|
||||
use DOMElement;
|
||||
use DOMNode;
|
||||
use DOMNodeList;
|
||||
use DOMText;
|
||||
use DOMXPath;
|
||||
|
||||
/**
|
||||
@@ -81,6 +82,14 @@ class HtmlDocument
|
||||
return $element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new text node within this document.
|
||||
*/
|
||||
public function createTextNode(string $text): DOMText
|
||||
{
|
||||
return $this->document->createTextNode($text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an element within the document of the given ID.
|
||||
*/
|
||||
|
||||
Binary file not shown.
@@ -16,13 +16,14 @@
|
||||
"ext-json": "*",
|
||||
"ext-mbstring": "*",
|
||||
"ext-xml": "*",
|
||||
"ext-zip": "*",
|
||||
"bacon/bacon-qr-code": "^3.0",
|
||||
"doctrine/dbal": "^3.5",
|
||||
"dompdf/dompdf": "^3.0",
|
||||
"guzzlehttp/guzzle": "^7.4",
|
||||
"intervention/image": "^3.5",
|
||||
"knplabs/knp-snappy": "^1.5",
|
||||
"laravel/framework": "^10.10",
|
||||
"laravel/framework": "^10.48.23",
|
||||
"laravel/socialite": "^5.10",
|
||||
"laravel/tinker": "^2.8",
|
||||
"league/commonmark": "^2.3",
|
||||
|
||||
990
composer.lock
generated
990
composer.lock
generated
File diff suppressed because it is too large
Load Diff
31
database/factories/Exports/ImportFactory.php
Normal file
31
database/factories/Exports/ImportFactory.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories\Exports;
|
||||
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ImportFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* The name of the factory's corresponding model.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $model = \BookStack\Exports\Import::class;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'path' => 'uploads/files/imports/' . Str::random(10) . '.zip',
|
||||
'name' => $this->faker->words(3, true),
|
||||
'type' => 'book',
|
||||
'metadata' => '{"name": "My book"}',
|
||||
'created_at' => User::factory(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// Create new templates-manage permission and assign to admin role
|
||||
$roles = DB::table('roles')->get('id');
|
||||
// Create new content-export permission
|
||||
$permissionId = DB::table('role_permissions')->insertGetId([
|
||||
'name' => 'content-export',
|
||||
'display_name' => 'Export Content',
|
||||
@@ -20,6 +19,7 @@ return new class extends Migration
|
||||
'updated_at' => Carbon::now()->toDateTimeString(),
|
||||
]);
|
||||
|
||||
$roles = DB::table('roles')->get('id');
|
||||
$permissionRoles = $roles->map(function ($role) use ($permissionId) {
|
||||
return [
|
||||
'role_id' => $role->id,
|
||||
@@ -27,6 +27,7 @@ return new class extends Migration
|
||||
];
|
||||
})->values()->toArray();
|
||||
|
||||
// Assign to all existing roles in the system
|
||||
DB::table('permission_role')->insert($permissionRoles);
|
||||
}
|
||||
|
||||
@@ -40,6 +41,6 @@ return new class extends Migration
|
||||
->where('name', '=', 'content-export')->first();
|
||||
|
||||
DB::table('permission_role')->where('permission_id', '=', $contentExportPermission->id)->delete();
|
||||
DB::table('role_permissions')->where('id', '=', 'content-export')->delete();
|
||||
DB::table('role_permissions')->where('id', '=', $contentExportPermission->id)->delete();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// Create new content-import permission
|
||||
$permissionId = DB::table('role_permissions')->insertGetId([
|
||||
'name' => 'content-import',
|
||||
'display_name' => 'Import Content',
|
||||
'created_at' => Carbon::now()->toDateTimeString(),
|
||||
'updated_at' => Carbon::now()->toDateTimeString(),
|
||||
]);
|
||||
|
||||
// Get existing admin-level role ids
|
||||
$settingManagePermission = DB::table('role_permissions')
|
||||
->where('name', '=', 'settings-manage')->first();
|
||||
|
||||
if (!$settingManagePermission) {
|
||||
return;
|
||||
}
|
||||
|
||||
$adminRoleIds = DB::table('permission_role')
|
||||
->where('permission_id', '=', $settingManagePermission->id)
|
||||
->pluck('role_id')->all();
|
||||
|
||||
// Assign the new permission to all existing admins
|
||||
$newPermissionRoles = array_values(array_map(function ($roleId) use ($permissionId) {
|
||||
return [
|
||||
'role_id' => $roleId,
|
||||
'permission_id' => $permissionId,
|
||||
];
|
||||
}, $adminRoleIds));
|
||||
|
||||
DB::table('permission_role')->insert($newPermissionRoles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
// Remove content-import permission
|
||||
$importPermission = DB::table('role_permissions')
|
||||
->where('name', '=', 'content-import')->first();
|
||||
|
||||
if (!$importPermission) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::table('permission_role')->where('permission_id', '=', $importPermission->id)->delete();
|
||||
DB::table('role_permissions')->where('id', '=', $importPermission->id)->delete();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('imports', function (Blueprint $table) {
|
||||
$table->increments('id');
|
||||
$table->string('name');
|
||||
$table->string('path');
|
||||
$table->integer('size');
|
||||
$table->string('type');
|
||||
$table->longText('metadata');
|
||||
$table->integer('created_by')->index();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('imports');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
DB::table('settings')->insert([
|
||||
'setting_key' => 'instance-id',
|
||||
'value' => Str::uuid(),
|
||||
'created_at' => Carbon::now(),
|
||||
'updated_at' => Carbon::now(),
|
||||
'type' => 'string',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
DB::table('settings')->where('setting_key', '=', 'instance-id')->delete();
|
||||
}
|
||||
};
|
||||
@@ -1 +1 @@
|
||||
GET /api/search?query=cats+{created_by:me}&page=1&count=2
|
||||
GET /api/search?query=cats+{created_by:me}&page=1&count=2
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"updated_at": "2019-12-11T20:57:31.000000Z",
|
||||
"created_by": 1,
|
||||
"updated_by": 1,
|
||||
"owned_by": 1
|
||||
"owned_by": 1,
|
||||
"cover": null
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
@@ -20,7 +21,12 @@
|
||||
"updated_at": "2019-12-11T20:57:23.000000Z",
|
||||
"created_by": 4,
|
||||
"updated_by": 3,
|
||||
"owned_by": 3
|
||||
"owned_by": 3,
|
||||
"cover": {
|
||||
"id": 11,
|
||||
"name": "cat_banner.jpg",
|
||||
"url": "https://example.com/uploads/images/cover_book/2021-10/cat-banner.jpg"
|
||||
}
|
||||
}
|
||||
],
|
||||
"total": 14
|
||||
|
||||
@@ -8,7 +8,12 @@
|
||||
"created_at": "2021-11-14T15:57:35.000000Z",
|
||||
"updated_at": "2021-11-14T15:57:35.000000Z",
|
||||
"type": "chapter",
|
||||
"url": "https://example.com/books/my-book/chapter/a-chapter-for-cats",
|
||||
"url": "https://example.com/books/cats/chapter/a-chapter-for-cats",
|
||||
"book": {
|
||||
"id": 1,
|
||||
"name": "Cats",
|
||||
"slug": "cats"
|
||||
},
|
||||
"preview_html": {
|
||||
"name": "A chapter for <strong>cats</strong>",
|
||||
"content": "...once a bunch of <strong>cats</strong> named tony...behaviour of <strong>cats</strong> is unsuitable"
|
||||
@@ -26,7 +31,17 @@
|
||||
"created_at": "2021-05-15T16:28:10.000000Z",
|
||||
"updated_at": "2021-11-14T15:56:49.000000Z",
|
||||
"type": "page",
|
||||
"url": "https://example.com/books/my-book/page/the-hows-and-whys-of-cats",
|
||||
"url": "https://example.com/books/cats/page/the-hows-and-whys-of-cats",
|
||||
"book": {
|
||||
"id": 1,
|
||||
"name": "Cats",
|
||||
"slug": "cats"
|
||||
},
|
||||
"chapter": {
|
||||
"id": 75,
|
||||
"name": "A chapter for cats",
|
||||
"slug": "a-chapter-for-cats"
|
||||
},
|
||||
"preview_html": {
|
||||
"name": "The hows and whys of <strong>cats</strong>",
|
||||
"content": "...people ask why <strong>cats</strong>? but there are...the reason that <strong>cats</strong> are fast are due to..."
|
||||
@@ -55,7 +70,17 @@
|
||||
"created_at": "2020-11-29T21:55:07.000000Z",
|
||||
"updated_at": "2021-11-14T16:02:39.000000Z",
|
||||
"type": "page",
|
||||
"url": "https://example.com/books/my-book/page/how-advanced-are-cats",
|
||||
"url": "https://example.com/books/big-cats/page/how-advanced-are-cats",
|
||||
"book": {
|
||||
"id": 13,
|
||||
"name": "Big Cats",
|
||||
"slug": "big-cats"
|
||||
},
|
||||
"chapter": {
|
||||
"id": 73,
|
||||
"name": "A chapter for bigger cats",
|
||||
"slug": "a-chapter-for-bigger-cats"
|
||||
},
|
||||
"preview_html": {
|
||||
"name": "How advanced are <strong>cats</strong>?",
|
||||
"content": "<strong>cats</strong> are some of the most advanced animals in the world."
|
||||
@@ -64,4 +89,4 @@
|
||||
}
|
||||
],
|
||||
"total": 3
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,12 @@
|
||||
"updated_at": "2020-04-10T13:00:45.000000Z",
|
||||
"created_by": 4,
|
||||
"updated_by": 1,
|
||||
"owned_by": 1
|
||||
"owned_by": 1,
|
||||
"cover": {
|
||||
"id": 4,
|
||||
"name": "shelf.jpg",
|
||||
"url": "https://example.com/uploads/images/cover_bookshelf/2024-12/shelf.jpg"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
@@ -20,7 +25,8 @@
|
||||
"updated_at": "2020-04-10T13:00:58.000000Z",
|
||||
"created_by": 4,
|
||||
"updated_by": 1,
|
||||
"owned_by": 1
|
||||
"owned_by": 1,
|
||||
"cover": null
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
@@ -31,7 +37,8 @@
|
||||
"updated_at": "2020-04-10T13:00:53.000000Z",
|
||||
"created_by": 4,
|
||||
"updated_by": 1,
|
||||
"owned_by": 4
|
||||
"owned_by": 4,
|
||||
"cover": null
|
||||
}
|
||||
],
|
||||
"total": 3
|
||||
|
||||
@@ -10,7 +10,7 @@ const isProd = process.argv[2] === 'production';
|
||||
|
||||
// Gather our input files
|
||||
const entryPoints = {
|
||||
app: path.join(__dirname, '../../resources/js/app.js'),
|
||||
app: path.join(__dirname, '../../resources/js/app.ts'),
|
||||
code: path.join(__dirname, '../../resources/js/code/index.mjs'),
|
||||
'legacy-modes': path.join(__dirname, '../../resources/js/code/legacy-modes.mjs'),
|
||||
markdown: path.join(__dirname, '../../resources/js/markdown/index.mjs'),
|
||||
|
||||
14
dev/build/svg-blank-transform.js
Normal file
14
dev/build/svg-blank-transform.js
Normal file
@@ -0,0 +1,14 @@
|
||||
// This is a basic transformer stub to help jest handle SVG files.
|
||||
// Essentially blanks them since we don't really need to involve them
|
||||
// in our tests (yet).
|
||||
module.exports = {
|
||||
process() {
|
||||
return {
|
||||
code: 'module.exports = \'\';',
|
||||
};
|
||||
},
|
||||
getCacheKey() {
|
||||
// The output is always the same.
|
||||
return 'svgTransform';
|
||||
},
|
||||
};
|
||||
@@ -1,34 +1,38 @@
|
||||
FROM php:8.3-apache
|
||||
|
||||
ENV APACHE_DOCUMENT_ROOT /app/public
|
||||
WORKDIR /app
|
||||
|
||||
RUN <<EOR
|
||||
# Install additional dependencies
|
||||
apt-get update
|
||||
apt-get install -y \
|
||||
git \
|
||||
zip \
|
||||
unzip \
|
||||
libpng-dev \
|
||||
libldap2-dev \
|
||||
libzip-dev \
|
||||
wait-for-it
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
RUN apt-get update && \
|
||||
apt-get install -y \
|
||||
git \
|
||||
zip \
|
||||
unzip \
|
||||
libfreetype-dev \
|
||||
libjpeg62-turbo-dev \
|
||||
libldap2-dev \
|
||||
libpng-dev \
|
||||
libzip-dev \
|
||||
wait-for-it && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Configure apache
|
||||
docker-php-ext-configure ldap --with-libdir="lib/$(gcc -dumpmachine)"
|
||||
docker-php-ext-install pdo_mysql gd ldap zip
|
||||
pecl install xdebug
|
||||
docker-php-ext-enable xdebug
|
||||
a2enmod rewrite
|
||||
sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/sites-available/*.conf
|
||||
sed -ri -e 's!/var/www/!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/apache2.conf /etc/apache2/conf-available/*.conf
|
||||
# Install PHP extensions
|
||||
RUN docker-php-ext-configure ldap --with-libdir="lib/$(gcc -dumpmachine)" && \
|
||||
docker-php-ext-configure gd --with-freetype --with-jpeg && \
|
||||
docker-php-ext-install -j$(nproc) pdo_mysql gd ldap zip && \
|
||||
pecl install xdebug && \
|
||||
docker-php-ext-enable xdebug
|
||||
|
||||
# Install composer
|
||||
curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
|
||||
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
|
||||
|
||||
# Configure apache
|
||||
RUN a2enmod rewrite && \
|
||||
sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/sites-available/*.conf && \
|
||||
sed -ri -e 's!/var/www/!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/apache2.conf /etc/apache2/conf-available/*.conf
|
||||
|
||||
# Use the default production configuration and update it as required
|
||||
mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
|
||||
sed -i 's/memory_limit = 128M/memory_limit = 512M/g' "$PHP_INI_DIR/php.ini"
|
||||
EOR
|
||||
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" && \
|
||||
sed -i 's/memory_limit = 128M/memory_limit = 512M/g' "$PHP_INI_DIR/php.ini"
|
||||
|
||||
ENV APACHE_DOCUMENT_ROOT="/app/public"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -9,7 +9,7 @@ if [[ -n "$1" ]]; then
|
||||
else
|
||||
composer install
|
||||
wait-for-it db:3306 -t 45
|
||||
php artisan migrate --database=mysql
|
||||
chown -R www-data:www-data storage
|
||||
php artisan migrate --database=mysql --force
|
||||
chown -R www-data storage public/uploads bootstrap/cache
|
||||
exec apache2-foreground
|
||||
fi
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
All development on BookStack is currently done on the `development` branch.
|
||||
When it's time for a release the `development` branch is merged into release with built & minified CSS & JS then tagged at its version. Here are the current development requirements:
|
||||
|
||||
* [Node.js](https://nodejs.org/en/) v18.0+
|
||||
* [Node.js](https://nodejs.org/en/) v20.0+
|
||||
|
||||
## Building CSS & JavaScript Assets
|
||||
|
||||
@@ -82,7 +82,7 @@ If all the conditions are met, you can proceed with the following steps:
|
||||
|
||||
1. **Copy `.env.example` to `.env`**, change `APP_KEY` to a random 32 char string and set `APP_ENV` to `local`.
|
||||
2. Make sure **port 8080 is unused** *or else* change `DEV_PORT` to a free port on your host.
|
||||
3. **Run `chgrp -R docker storage`**. The development container will chown the `storage` directory to the `www-data` user inside the container so BookStack can write to it. You need to change the group to your host's `docker` group here to not lose access to the `storage` directory.
|
||||
3. **Run `chgrp -R docker storage`**. The development container will chown the `storage`, `public/uploads` and `bootstrap/cache` directories to the `www-data` user inside the container so BookStack can write to it. You need to change the group to your host's `docker` group here to not lose access to the `storage` directory.
|
||||
4. **Run `docker-compose up`** and wait until the image is built and all database migrations have been done.
|
||||
5. You can now login with `admin@admin.com` and `password` as password on `localhost:8080` (or another port if specified).
|
||||
|
||||
|
||||
160
dev/docs/portable-zip-file-format.md
Normal file
160
dev/docs/portable-zip-file-format.md
Normal file
@@ -0,0 +1,160 @@
|
||||
# Portable ZIP File Format
|
||||
|
||||
BookStack provides exports in a "Portable ZIP" which allows the portable transfer, storage, import & export of BookStack content.
|
||||
This document details the format used, and is intended for our own internal development use in addition to detailing the format for potential external use-cases (readers, apps, import for other platforms etc...).
|
||||
|
||||
**Note:** This is not a BookStack backup format! This format misses much of the data that would be needed to re-create/restore a BookStack instance. There are existing better alternative options for this use-case.
|
||||
|
||||
## Stability
|
||||
|
||||
Following the goals & ideals of BookStack, stability is very important. We aim for this defined format to be stable and forwards compatible, to prevent breakages in use-case due to changes. Here are the general rules we follow in regard to stability & changes:
|
||||
|
||||
- New features & properties may be added with any release.
|
||||
- Where reasonably possible, we will attempt to avoid modifications/removals of existing features/properties.
|
||||
- Where potentially breaking changes do have to be made, these will be noted in BookStack release/update notes.
|
||||
|
||||
The addition of new features/properties alone are not considered as a breaking change to the format. Breaking changes are considered as such where they could impact common/expected use of the existing properties and features we document, they are not considered based upon user assumptions or any possible breakage.
|
||||
For example if your application, using the format, breaks because we added a new property while you hard-coded your application to use the third property (instead of a property name), then that's on you.
|
||||
|
||||
## Format Outline
|
||||
|
||||
The format is intended to be very simple, readable and based on open standards that could be easily read/handled in most common programming languages.
|
||||
The below outlines the structure of the format:
|
||||
|
||||
- **ZIP archive container**
|
||||
- **data.json** - Export data.
|
||||
- **files/** - Directory containing referenced files.
|
||||
- *file-a*
|
||||
- *file-b*
|
||||
- *...*
|
||||
|
||||
## References
|
||||
|
||||
Some properties in the export data JSON are indicated as `String reference`, and these are direct references to a file name within the `files/` directory of the ZIP. For example, the below book cover is directly referencing a `files/4a5m4a.jpg` within the ZIP which would be expected to exist.
|
||||
|
||||
```json
|
||||
{
|
||||
"book": {
|
||||
"cover": "4a5m4a.jpg"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Within HTML and markdown content, you may require references across to other items within the export content.
|
||||
This can be done using the following format:
|
||||
|
||||
```
|
||||
[[bsexport:<object>:<reference>]]
|
||||
```
|
||||
|
||||
References are to the `id` for data objects.
|
||||
Here's an example of each type of such reference that could be used:
|
||||
|
||||
```
|
||||
[[bsexport:image:22]]
|
||||
[[bsexport:attachment:55]]
|
||||
[[bsexport:page:40]]
|
||||
[[bsexport:chapter:2]]
|
||||
[[bsexport:book:8]]
|
||||
```
|
||||
|
||||
## HTML & Markdown Content
|
||||
|
||||
BookStack commonly stores & utilises content in the HTML format.
|
||||
Properties that expect or provided HTML will either be named `html` or contain `html` in the property name.
|
||||
While BookStack supports a range of HTML, not all HTML content will be supported by BookStack and be assured to work as desired across all BookStack features.
|
||||
The HTML supported by BookStack is not yet formally documented, but you can inspect to what the WYSIWYG editor produces as a basis.
|
||||
Generally, top-level elements should keep to common block formats (p, blockquote, h1, h2 etc...) with no nesting or custom structure apart from common inline elements.
|
||||
Some areas of BookStack where HTML is used, like book & chapter descriptions, will strictly limit/filter HTML tag & attributes to an allow-list.
|
||||
|
||||
For markdown content, in BookStack we target [the commonmark spec](https://commonmark.org/) with the addition of tables & task-lists.
|
||||
HTML within markdown is supported but not all HTML is assured to work as advised above.
|
||||
|
||||
### Content Security
|
||||
|
||||
If you're consuming HTML or markdown within an export please consider that the content is not assured to be safe, even if provided directly by a BookStack instance. It's best to treat such content as potentially unsafe.
|
||||
By default, BookStack performs some basic filtering to remove scripts among other potentially dangerous elements but this is not foolproof. BookStack itself relies on additional security mechanisms such as [CSP](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) to help prevent a range of exploits.
|
||||
|
||||
## Export Data - `data.json`
|
||||
|
||||
The `data.json` file is a JSON format file which contains all structured data for the export. The properties are as follows:
|
||||
|
||||
- `instance` - [Instance](#instance) Object, optional, details of the export source instance.
|
||||
- `exported_at` - String, optional, full ISO 8601 datetime of when the export was created.
|
||||
- `book` - [Book](#book) Object, optional, book export data.
|
||||
- `chapter` - [Chapter](#chapter) Object, optional, chapter export data.
|
||||
- `page` - [Page](#page) Object, optional, page export data.
|
||||
|
||||
Either `book`, `chapter` or `page` will exist depending on export type. You'd want to check for each to check what kind of export this is, and if it's an export you can handle. It's possible that other options are added in the future (`books` for a range of books for example) so it'd be wise to specifically check for properties that can be handled, otherwise error to indicate lack of support.
|
||||
|
||||
## Data Objects
|
||||
|
||||
The below details the objects & their properties used in Application Data.
|
||||
|
||||
#### Instance
|
||||
|
||||
These details are informational regarding the exporting BookStack instance from where an export was created from.
|
||||
|
||||
- `id` - String, required, unique identifier for the BookStack instance.
|
||||
- `version` - String, required, BookStack version of the export source instance.
|
||||
|
||||
#### Book
|
||||
|
||||
- `id` - Number, optional, original ID for the book from exported system.
|
||||
- `name` - String, required, name/title of the book.
|
||||
- `description_html` - String, optional, HTML description content.
|
||||
- `cover` - String reference, optional, reference to book cover image.
|
||||
- `chapters` - [Chapter](#chapter) array, optional, chapters within this book.
|
||||
- `pages` - [Page](#page) array, optional, direct child pages for this book.
|
||||
- `tags` - [Tag](#tag) array, optional, tags assigned to this book.
|
||||
|
||||
The `pages` are not all pages within the book, just those that are direct children (not in a chapter). To build an ordered mixed chapter/page list for the book, as what you'd see in BookStack, you'd need to combine `chapters` and `pages` together and sort by their `priority` value (low to high).
|
||||
|
||||
#### Chapter
|
||||
|
||||
- `id` - Number, optional, original ID for the chapter from exported system.
|
||||
- `name` - String, required, name/title of the chapter.
|
||||
- `description_html` - String, optional, HTML description content.
|
||||
- `priority` - Number, optional, integer order for when shown within a book (shown low to high).
|
||||
- `pages` - [Page](#page) array, optional, pages within this chapter.
|
||||
- `tags` - [Tag](#tag) array, optional, tags assigned to this chapter.
|
||||
|
||||
#### Page
|
||||
|
||||
- `id` - Number, optional, original ID for the page from exported system.
|
||||
- `name` - String, required, name/title of the page.
|
||||
- `html` - String, optional, page HTML content.
|
||||
- `markdown` - String, optional, user markdown content for this page.
|
||||
- `priority` - Number, optional, integer order for when shown within a book (shown low to high).
|
||||
- `attachments` - [Attachment](#attachment) array, optional, attachments uploaded to this page.
|
||||
- `images` - [Image](#image) array, optional, images used in this page.
|
||||
- `tags` - [Tag](#tag) array, optional, tags assigned to this page.
|
||||
|
||||
To define the page content, either `markdown` or `html` should be provided. Ideally these should be limited to the range of markdown and HTML which BookStack supports. See the ["HTML & Markdown Content"](#html--markdown-content) section.
|
||||
|
||||
The page editor type, and edit content will be determined by what content is provided. If non-empty `markdown` is provided, the page will be assumed as a markdown editor page (where permissions allow) and the HTML will be rendered from the markdown content. Otherwise, the provided `html` will be used as editor & display content.
|
||||
|
||||
#### Image
|
||||
|
||||
- `id` - Number, optional, original ID for the page from exported system.
|
||||
- `name` - String, required, name of image.
|
||||
- `file` - String reference, required, reference to image file.
|
||||
- `type` - String, required, must be 'gallery' or 'drawio'
|
||||
|
||||
File must be an image type accepted by BookStack (png, jpg, gif, webp).
|
||||
Images of type 'drawio' are expected to be png with draw.io drawing data
|
||||
embedded within it.
|
||||
|
||||
#### Attachment
|
||||
|
||||
- `id` - Number, optional, original ID for the attachment from exported system.
|
||||
- `name` - String, required, name of attachment.
|
||||
- `link` - String, semi-optional, URL of attachment.
|
||||
- `file` - String reference, semi-optional, reference to attachment file.
|
||||
|
||||
Either `link` or `file` must be present, as that will determine the type of attachment.
|
||||
|
||||
#### Tag
|
||||
|
||||
- `name` - String, required, name of the tag.
|
||||
- `value` - String, optional, value of the tag (can be empty).
|
||||
@@ -507,6 +507,12 @@ Copyright: Copyright (c) 2011 Debuggable Limited <*****@**********.***>
|
||||
Source: git://github.com/felixge/node-delayed-stream.git
|
||||
Link: https://github.com/felixge/node-delayed-stream
|
||||
-----------
|
||||
detect-libc
|
||||
License: Apache-2.0
|
||||
License File: node_modules/detect-libc/LICENSE
|
||||
Source: git://github.com/lovell/detect-libc
|
||||
Link: git://github.com/lovell/detect-libc
|
||||
-----------
|
||||
detect-newline
|
||||
License: MIT
|
||||
License File: node_modules/detect-newline/license
|
||||
@@ -1819,6 +1825,13 @@ Copyright: Copyright (c) 2018 Tobias Reich
|
||||
Source: https://github.com/electerious/nice-try.git
|
||||
Link: https://github.com/electerious/nice-try
|
||||
-----------
|
||||
node-addon-api
|
||||
License: MIT
|
||||
License File: node_modules/node-addon-api/LICENSE.md
|
||||
Copyright: Copyright (c) 2017 [Node.js API collaborators](https://github.com/nodejs/node-addon-api#collaborators)
|
||||
Source: git://github.com/nodejs/node-addon-api.git
|
||||
Link: https://github.com/nodejs/node-addon-api
|
||||
-----------
|
||||
node-int64
|
||||
License: MIT
|
||||
License File: node_modules/node-int64/LICENSE
|
||||
@@ -3525,6 +3538,11 @@ Copyright: Copyright (C) 2018 by Marijn Haverbeke <******@*********.******> and
|
||||
Source: https://github.com/lezer-parser/xml.git
|
||||
Link: https://github.com/lezer-parser/xml.git
|
||||
-----------
|
||||
@marijn/find-cluster-break
|
||||
License: MIT
|
||||
Source: git+https://github.com/marijnh/find-cluster-break.git
|
||||
Link: https://github.com/marijnh/find-cluster-break#readme
|
||||
-----------
|
||||
@nodelib/fs.scandir
|
||||
License: MIT
|
||||
License File: node_modules/@nodelib/fs.scandir/LICENSE
|
||||
@@ -3546,6 +3564,27 @@ Copyright: Copyright (c) Denis Malinochkin
|
||||
Source: https://github.com/nodelib/nodelib/tree/master/packages/fs/fs.walk
|
||||
Link: https://github.com/nodelib/nodelib/tree/master/packages/fs/fs.walk
|
||||
-----------
|
||||
@parcel/watcher-linux-x64-glibc
|
||||
License: MIT
|
||||
License File: node_modules/@parcel/watcher-linux-x64-glibc/LICENSE
|
||||
Copyright: Copyright (c) 2017-present Devon Govett
|
||||
Source: https://github.com/parcel-bundler/watcher.git
|
||||
Link: https://github.com/parcel-bundler/watcher.git
|
||||
-----------
|
||||
@parcel/watcher-linux-x64-musl
|
||||
License: MIT
|
||||
License File: node_modules/@parcel/watcher-linux-x64-musl/LICENSE
|
||||
Copyright: Copyright (c) 2017-present Devon Govett
|
||||
Source: https://github.com/parcel-bundler/watcher.git
|
||||
Link: https://github.com/parcel-bundler/watcher.git
|
||||
-----------
|
||||
@parcel/watcher
|
||||
License: MIT
|
||||
License File: node_modules/@parcel/watcher/LICENSE
|
||||
Copyright: Copyright (c) 2017-present Devon Govett
|
||||
Source: https://github.com/parcel-bundler/watcher.git
|
||||
Link: https://github.com/parcel-bundler/watcher.git
|
||||
-----------
|
||||
@rtsao/scc
|
||||
License: MIT
|
||||
License File: node_modules/@rtsao/scc/LICENSE
|
||||
|
||||
@@ -202,7 +202,7 @@ Link: https://github.com/intervention/gif
|
||||
intervention/image
|
||||
License: MIT
|
||||
License File: vendor/intervention/image/LICENSE
|
||||
Copyright: Copyright (c) 2013-2024 Oliver Vogel
|
||||
Copyright: Copyright (c) 2013-present Oliver Vogel
|
||||
Source: https://github.com/Intervention/image.git
|
||||
Link: https://image.intervention.io/
|
||||
-----------
|
||||
@@ -307,7 +307,7 @@ Link: https://github.com/thephpleague/oauth1-client.git
|
||||
league/oauth2-client
|
||||
License: MIT
|
||||
License File: vendor/league/oauth2-client/LICENSE
|
||||
Copyright: Copyright (c) 2013-2020 Alex Bilbie <*****@**********.***>
|
||||
Copyright: Copyright (c) 2013-2023 Alex Bilbie <*****@**********.***>
|
||||
Source: https://github.com/thephpleague/oauth2-client.git
|
||||
Link: https://github.com/thephpleague/oauth2-client.git
|
||||
-----------
|
||||
@@ -415,7 +415,7 @@ predis/predis
|
||||
License: MIT
|
||||
License File: vendor/predis/predis/LICENSE
|
||||
Copyright: Copyright (c) 2009-2020 Daniele Alessandri (original work)
|
||||
Copyright (c) 2021-2023 Till Krüss (modified work)
|
||||
Copyright (c) 2021-2024 Till Krüss (modified work)
|
||||
Source: https://github.com/predis/predis.git
|
||||
Link: http://github.com/predis/predis
|
||||
-----------
|
||||
@@ -514,7 +514,7 @@ Link: https://github.com/ramsey/uuid.git
|
||||
robrichards/xmlseclibs
|
||||
License: BSD-3-Clause
|
||||
License File: vendor/robrichards/xmlseclibs/LICENSE
|
||||
Copyright: Copyright (c) 2007-2019, Robert Richards <*********@*********.***>.
|
||||
Copyright: Copyright (c) 2007-2024, Robert Richards <*********@*********.***>.
|
||||
Source: https://github.com/robrichards/xmlseclibs.git
|
||||
Link: https://github.com/robrichards/xmlseclibs
|
||||
-----------
|
||||
@@ -560,9 +560,9 @@ Link: https://github.com/SocialiteProviders/Twitch.git
|
||||
ssddanbrown/htmldiff
|
||||
License: MIT
|
||||
License File: vendor/ssddanbrown/htmldiff/license.md
|
||||
Copyright: Copyright (c) 2020 Nathan Herald, Rohland de Charmoy, Dan Brown
|
||||
Source: https://github.com/ssddanbrown/HtmlDiff.git
|
||||
Link: https://github.com/ssddanbrown/htmldiff
|
||||
Copyright: Copyright (c) 2024 Nathan Herald, Rohland de Charmoy, Dan Brown
|
||||
Source: https://codeberg.org/danb/HtmlDiff
|
||||
Link: https://codeberg.org/danb/HtmlDiff
|
||||
-----------
|
||||
ssddanbrown/symfony-mailer
|
||||
License: MIT
|
||||
|
||||
@@ -185,6 +185,7 @@ const config: Config = {
|
||||
// A map from regular expressions to paths to transformers
|
||||
transform: {
|
||||
"^.+.tsx?$": ["ts-jest",{}],
|
||||
"^.+.svg$": ["<rootDir>/dev/build/svg-blank-transform.js",{}],
|
||||
},
|
||||
|
||||
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
|
||||
|
||||
@@ -84,6 +84,14 @@ return [
|
||||
'webhook_delete' => 'حذف webhook',
|
||||
'webhook_delete_notification' => 'تم حذف Webhook بنجاح',
|
||||
|
||||
// Imports
|
||||
'import_create' => 'created import',
|
||||
'import_create_notification' => 'Import successfully uploaded',
|
||||
'import_run' => 'updated import',
|
||||
'import_run_notification' => 'Content successfully imported',
|
||||
'import_delete' => 'deleted import',
|
||||
'import_delete_notification' => 'Import successfully deleted',
|
||||
|
||||
// Users
|
||||
'user_create' => 'إنشاء مستخدم',
|
||||
'user_create_notification' => 'تم انشاء الحساب',
|
||||
|
||||
@@ -89,8 +89,8 @@ return [
|
||||
'mfa_setup_action' => 'إعداد (تنصيب)',
|
||||
'mfa_backup_codes_usage_limit_warning' => 'You have less than 5 backup codes remaining, Please generate and store a new set before you run out of codes to prevent being locked out of your account.',
|
||||
'mfa_option_totp_title' => 'تطبيق الجوال',
|
||||
'mfa_option_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
|
||||
'mfa_option_backup_codes_title' => 'Backup Codes',
|
||||
'mfa_option_totp_desc' => 'لاستخدام المصادقة المتعددة العوامل، ستحتاج إلى تطبيق محمول يدعم TOTP مثل Google Authenticator أو Authy أو Microsoft Authenticer.',
|
||||
'mfa_option_backup_codes_title' => 'رموز النسخ الاحتياطي',
|
||||
'mfa_option_backup_codes_desc' => 'Generates a set of one-time-use backup codes which you\'ll enter on login to verify your identity. Make sure to store these in a safe & secure place.',
|
||||
'mfa_gen_confirm_and_enable' => 'Confirm and Enable',
|
||||
'mfa_gen_backup_codes_title' => 'Backup Codes Setup',
|
||||
|
||||
@@ -163,6 +163,8 @@ return [
|
||||
'about' => 'About the editor',
|
||||
'about_title' => 'About the WYSIWYG Editor',
|
||||
'editor_license' => 'Editor License & Copyright',
|
||||
'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.',
|
||||
'editor_lexical_license_link' => 'Full license details can be found here.',
|
||||
'editor_tiny_license' => 'This editor is built using :tinyLink which is provided under the MIT license.',
|
||||
'editor_tiny_license_link' => 'The copyright and license details of TinyMCE can be found here.',
|
||||
'save_continue' => 'Save Page & Continue',
|
||||
|
||||
@@ -39,9 +39,30 @@ return [
|
||||
'export_pdf' => 'ملف PDF',
|
||||
'export_text' => 'ملف نص عادي',
|
||||
'export_md' => 'Markdown File',
|
||||
'export_zip' => 'Portable ZIP',
|
||||
'default_template' => 'Default Page Template',
|
||||
'default_template_explain' => 'Assign a page template that will be used as the default content for all pages created within this item. Keep in mind this will only be used if the page creator has view access to the chosen template page.',
|
||||
'default_template_select' => 'Select a template page',
|
||||
'import' => 'Import',
|
||||
'import_validate' => 'Validate Import',
|
||||
'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.',
|
||||
'import_zip_select' => 'Select ZIP file to upload',
|
||||
'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:',
|
||||
'import_pending' => 'Pending Imports',
|
||||
'import_pending_none' => 'No imports have been started.',
|
||||
'import_continue' => 'Continue Import',
|
||||
'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.',
|
||||
'import_details' => 'Import Details',
|
||||
'import_run' => 'Run Import',
|
||||
'import_size' => ':size Import ZIP Size',
|
||||
'import_uploaded_at' => 'Uploaded :relativeTime',
|
||||
'import_uploaded_by' => 'Uploaded by',
|
||||
'import_location' => 'Import Location',
|
||||
'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.',
|
||||
'import_delete_confirm' => 'Are you sure you want to delete this import?',
|
||||
'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.',
|
||||
'import_errors' => 'Import Errors',
|
||||
'import_errors_desc' => 'The follow errors occurred during the import attempt:',
|
||||
|
||||
// Permissions and restrictions
|
||||
'permissions' => 'الأذونات',
|
||||
|
||||
@@ -105,6 +105,18 @@ return [
|
||||
'app_down' => ':appName لا يعمل حالياً',
|
||||
'back_soon' => 'سيعود للعمل قريباً.',
|
||||
|
||||
// Import
|
||||
'import_zip_cant_read' => 'Could not read ZIP file.',
|
||||
'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.',
|
||||
'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.',
|
||||
'import_validation_failed' => 'Import ZIP failed to validate with errors:',
|
||||
'import_zip_failed_notification' => 'Failed to import ZIP file.',
|
||||
'import_perms_books' => 'You are lacking the required permissions to create books.',
|
||||
'import_perms_chapters' => 'You are lacking the required permissions to create chapters.',
|
||||
'import_perms_pages' => 'You are lacking the required permissions to create pages.',
|
||||
'import_perms_images' => 'You are lacking the required permissions to create images.',
|
||||
'import_perms_attachments' => 'You are lacking the required permission to create attachments.',
|
||||
|
||||
// API errors
|
||||
'api_no_authorization_found' => 'لم يتم العثور على رمز ترخيص مميز في الطلب',
|
||||
'api_bad_authorization_format' => 'تم العثور على رمز ترخيص مميز في الطلب ولكن يبدو أن التنسيق غير صحيح',
|
||||
|
||||
@@ -162,6 +162,7 @@ return [
|
||||
'role_access_api' => 'الوصول إلى واجهة برمجة تطبيقات النظام API',
|
||||
'role_manage_settings' => 'إدارة إعدادات التطبيق',
|
||||
'role_export_content' => 'Export content',
|
||||
'role_import_content' => 'Import content',
|
||||
'role_editor_change' => 'Change page editor',
|
||||
'role_notifications' => 'Receive & manage notifications',
|
||||
'role_asset' => 'أذونات الأصول',
|
||||
|
||||
@@ -105,6 +105,11 @@ return [
|
||||
'url' => 'صيغة :attribute غير صالحة.',
|
||||
'uploaded' => 'تعذر تحميل الملف. قد لا يقبل الخادم ملفات بهذا الحجم.',
|
||||
|
||||
'zip_file' => 'The :attribute needs to reference a file within the ZIP.',
|
||||
'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.',
|
||||
'zip_model_expected' => 'Data object expected but ":type" found.',
|
||||
'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.',
|
||||
|
||||
// Custom validation lines
|
||||
'custom' => [
|
||||
'password-confirm' => [
|
||||
|
||||
@@ -84,6 +84,14 @@ return [
|
||||
'webhook_delete' => 'изтрита уебкука',
|
||||
'webhook_delete_notification' => 'Уебкуката е изтрита успешно',
|
||||
|
||||
// Imports
|
||||
'import_create' => 'created import',
|
||||
'import_create_notification' => 'Import successfully uploaded',
|
||||
'import_run' => 'updated import',
|
||||
'import_run_notification' => 'Content successfully imported',
|
||||
'import_delete' => 'deleted import',
|
||||
'import_delete_notification' => 'Import successfully deleted',
|
||||
|
||||
// Users
|
||||
'user_create' => 'created user',
|
||||
'user_create_notification' => 'User successfully created',
|
||||
|
||||
@@ -163,6 +163,8 @@ return [
|
||||
'about' => 'За редактора',
|
||||
'about_title' => 'Относно визуалния редактор',
|
||||
'editor_license' => 'Лиценз, авторски и сходни права на редактора',
|
||||
'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.',
|
||||
'editor_lexical_license_link' => 'Full license details can be found here.',
|
||||
'editor_tiny_license' => 'Този редактор е изграден посредством :tinyLink, което е предоставен под лиценз MIT.',
|
||||
'editor_tiny_license_link' => 'Авторското и сходните му права, както и лицензът на TinyMCE, могат да бъдат намерени тук.',
|
||||
'save_continue' => 'Запази страницата и продължи',
|
||||
|
||||
@@ -39,9 +39,30 @@ return [
|
||||
'export_pdf' => 'PDF файл',
|
||||
'export_text' => 'Обикновен текстов файл',
|
||||
'export_md' => 'Markdown файл',
|
||||
'export_zip' => 'Portable ZIP',
|
||||
'default_template' => 'Default Page Template',
|
||||
'default_template_explain' => 'Assign a page template that will be used as the default content for all pages created within this item. Keep in mind this will only be used if the page creator has view access to the chosen template page.',
|
||||
'default_template_select' => 'Select a template page',
|
||||
'import' => 'Import',
|
||||
'import_validate' => 'Validate Import',
|
||||
'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.',
|
||||
'import_zip_select' => 'Select ZIP file to upload',
|
||||
'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:',
|
||||
'import_pending' => 'Pending Imports',
|
||||
'import_pending_none' => 'No imports have been started.',
|
||||
'import_continue' => 'Continue Import',
|
||||
'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.',
|
||||
'import_details' => 'Import Details',
|
||||
'import_run' => 'Run Import',
|
||||
'import_size' => ':size Import ZIP Size',
|
||||
'import_uploaded_at' => 'Uploaded :relativeTime',
|
||||
'import_uploaded_by' => 'Uploaded by',
|
||||
'import_location' => 'Import Location',
|
||||
'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.',
|
||||
'import_delete_confirm' => 'Are you sure you want to delete this import?',
|
||||
'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.',
|
||||
'import_errors' => 'Import Errors',
|
||||
'import_errors_desc' => 'The follow errors occurred during the import attempt:',
|
||||
|
||||
// Permissions and restrictions
|
||||
'permissions' => 'Права',
|
||||
|
||||
@@ -105,6 +105,18 @@ return [
|
||||
'app_down' => ':appName не е достъпно в момента',
|
||||
'back_soon' => 'Ще се върне обратно онлайн скоро.',
|
||||
|
||||
// Import
|
||||
'import_zip_cant_read' => 'Could not read ZIP file.',
|
||||
'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.',
|
||||
'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.',
|
||||
'import_validation_failed' => 'Import ZIP failed to validate with errors:',
|
||||
'import_zip_failed_notification' => 'Failed to import ZIP file.',
|
||||
'import_perms_books' => 'You are lacking the required permissions to create books.',
|
||||
'import_perms_chapters' => 'You are lacking the required permissions to create chapters.',
|
||||
'import_perms_pages' => 'You are lacking the required permissions to create pages.',
|
||||
'import_perms_images' => 'You are lacking the required permissions to create images.',
|
||||
'import_perms_attachments' => 'You are lacking the required permission to create attachments.',
|
||||
|
||||
// API errors
|
||||
'api_no_authorization_found' => 'Но беше намерен код за достъп в заявката',
|
||||
'api_bad_authorization_format' => 'В заявката имаше код за достъп, но формата изглежда е неправилен',
|
||||
|
||||
@@ -162,6 +162,7 @@ return [
|
||||
'role_access_api' => 'Достъп до API на системата',
|
||||
'role_manage_settings' => 'Управление на настройките на приложението',
|
||||
'role_export_content' => 'Експортирай съдържанието',
|
||||
'role_import_content' => 'Import content',
|
||||
'role_editor_change' => 'Change page editor',
|
||||
'role_notifications' => 'Receive & manage notifications',
|
||||
'role_asset' => 'Настройки за достъп до активи',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user