mirror of
https://github.com/BookStackApp/BookStack.git
synced 2026-02-07 11:19:38 +03:00
Compare commits
113 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
262f863981 | ||
|
|
a4c94390a1 | ||
|
|
7e6e1fca76 | ||
|
|
aaa2205df1 | ||
|
|
4aed3f8558 | ||
|
|
7b4086107c | ||
|
|
585bd0cc45 | ||
|
|
f18e2784be | ||
|
|
f88e6d1520 | ||
|
|
872961ef7c | ||
|
|
bbd8d63652 | ||
|
|
af39ff15ac | ||
|
|
aae3cd69d7 | ||
|
|
2d3df955ae | ||
|
|
8b5747eae2 | ||
|
|
6c699f7fab | ||
|
|
ac6eceb0e5 | ||
|
|
a2a2f3a4dd | ||
|
|
6db64763fe | ||
|
|
c9beacbfbf | ||
|
|
53f3cca85d | ||
|
|
ed08bbcecc | ||
|
|
2aace16704 | ||
|
|
ade66dcf2f | ||
|
|
d3eaaf6457 | ||
|
|
941217d9fb | ||
|
|
4239d4c54d | ||
|
|
8d91f4369b | ||
|
|
722aa04577 | ||
|
|
2d0abc4164 | ||
|
|
c3f7b39a0f | ||
|
|
de97ebf9b7 | ||
|
|
f492a660a8 | ||
|
|
ef11100863 | ||
|
|
1a26b47782 | ||
|
|
cb0d674a71 | ||
|
|
4d094331cf | ||
|
|
2312d07bb5 | ||
|
|
fbd388ba4c | ||
|
|
d3ca23b195 | ||
|
|
553954ad18 | ||
|
|
d8c45f5746 | ||
|
|
edc7c12edf | ||
|
|
a72bd75e3a | ||
|
|
31f1dca8a8 | ||
|
|
819ec55b1b | ||
|
|
dba506a20e | ||
|
|
d0de4fd8f9 | ||
|
|
00eedafbfd | ||
|
|
6e18620a0a | ||
|
|
fe54c7f27a | ||
|
|
65830b428c | ||
|
|
b438e0187c | ||
|
|
8614775c14 | ||
|
|
09436836a5 | ||
|
|
bb455d7788 | ||
|
|
b0666e5d70 | ||
|
|
fc109f7e1c | ||
|
|
21f2a7087c | ||
|
|
ff70509fca | ||
|
|
0288320700 | ||
|
|
20e093a7a1 | ||
|
|
3f9527f166 | ||
|
|
da01913616 | ||
|
|
67b6c07548 | ||
|
|
bb9cd9d610 | ||
|
|
04f37e21e2 | ||
|
|
a3ead5062a | ||
|
|
24e29c523b | ||
|
|
04d59763c3 | ||
|
|
5c04f25c86 | ||
|
|
767a82fb41 | ||
|
|
5c5a3de7cb | ||
|
|
c6e3e85e82 | ||
|
|
d0fd1b7f5c | ||
|
|
009212ab80 | ||
|
|
ba9cb591c8 | ||
|
|
632cb71af4 | ||
|
|
74ab99ec41 | ||
|
|
aa9dafec85 | ||
|
|
73a37b3cd9 | ||
|
|
e43f679e62 | ||
|
|
57fc1ba38f | ||
|
|
e765e61854 | ||
|
|
d00ac3101d | ||
|
|
f27d0d5aeb | ||
|
|
8d8b45860a | ||
|
|
3bf34b6a0d | ||
|
|
dbd4281ae8 | ||
|
|
917598f7c8 | ||
|
|
9079700170 | ||
|
|
f2cb3b94f9 | ||
|
|
6381041252 | ||
|
|
7d13666039 | ||
|
|
e6e92618b1 | ||
|
|
2342f0c1c7 | ||
|
|
ee1106630e | ||
|
|
93e80e5d4e | ||
|
|
72d19968dd | ||
|
|
2fd7b1f0d5 | ||
|
|
a93254430c | ||
|
|
e686b2cf3c | ||
|
|
4e63554cc6 | ||
|
|
882f195927 | ||
|
|
a12e346439 | ||
|
|
8dee3d3a83 | ||
|
|
0e25298db9 | ||
|
|
9cac6fad73 | ||
|
|
8716b1922b | ||
|
|
4621d8bcc5 | ||
|
|
a3a3055695 | ||
|
|
867cbe15ea | ||
|
|
b22dd3cb88 |
@@ -100,8 +100,7 @@ MEMCACHED_SERVERS=127.0.0.1:11211:100
|
||||
REDIS_SERVERS=127.0.0.1:6379:0
|
||||
|
||||
# Queue driver to use
|
||||
# Queue not really currently used but may be configurable in the future.
|
||||
# Would advise not to change this for now.
|
||||
# Can be 'sync', 'database' or 'redis'
|
||||
QUEUE_CONNECTION=sync
|
||||
|
||||
# Storage system to use
|
||||
@@ -134,7 +133,7 @@ STORAGE_S3_ENDPOINT=https://my-custom-s3-compatible.service.com:8001
|
||||
STORAGE_URL=false
|
||||
|
||||
# Authentication method to use
|
||||
# Can be 'standard', 'ldap' or 'saml2'
|
||||
# Can be 'standard', 'ldap', 'saml2' or 'oidc'
|
||||
AUTH_METHOD=standard
|
||||
|
||||
# Social authentication configuration
|
||||
@@ -242,6 +241,7 @@ SAML2_GROUP_ATTRIBUTE=group
|
||||
SAML2_REMOVE_FROM_GROUPS=false
|
||||
|
||||
# OpenID Connect authentication configuration
|
||||
# Refer to https://www.bookstackapp.com/docs/admin/oidc-auth/
|
||||
OIDC_NAME=SSO
|
||||
OIDC_DISPLAY_NAME_CLAIMS=name
|
||||
OIDC_CLIENT_ID=null
|
||||
|
||||
17
.github/translators.txt
vendored
17
.github/translators.txt
vendored
@@ -126,7 +126,7 @@ Zenahr Barzani (Zenahr) :: German; Japanese; Dutch; German Informal
|
||||
tatsuya.info :: Japanese
|
||||
fadiapp :: Arabic
|
||||
Jakub Bouček (jakubboucek) :: Czech
|
||||
Marco (cdrfun) :: German
|
||||
Marco (cdrfun) :: German; German Informal
|
||||
10935336 :: Chinese Simplified
|
||||
孟繁阳 (FanyangMeng) :: Chinese Simplified
|
||||
Andrej Močan (andrejm) :: Slovenian
|
||||
@@ -200,3 +200,18 @@ sulfo :: Danish
|
||||
Raukze :: German
|
||||
zygimantus :: Lithuanian
|
||||
marinkaberg :: Russian
|
||||
Vitaliy (gviabcua) :: Ukrainian
|
||||
mannycarreiro :: Portuguese
|
||||
Thiago Rafael Pereira de Carvalho (thiago.rafael) :: Portuguese, Brazilian
|
||||
Ken Roger Bolgnes (kenbo124) :: Norwegian Bokmal
|
||||
Nguyen Hung Phuong (hnwolf) :: Vietnamese
|
||||
Umut ERGENE (umutergene67) :: Turkish
|
||||
Tomáš Batelka (Vofy) :: Czech
|
||||
Mundo Racional (ismael.mesquita) :: Portuguese, Brazilian
|
||||
Zarik (3apuk) :: Russian
|
||||
Ali Shaatani (a.shaatani) :: Arabic
|
||||
ChacMaster :: Portuguese, Brazilian
|
||||
Saeed (saeed205) :: Persian
|
||||
Julesdevops :: French
|
||||
peter cerny (posli.to.semka) :: Slovak
|
||||
Pavel Karlin (pavelkarlin) :: Russian
|
||||
|
||||
2
.github/workflows/phpunit.yml
vendored
2
.github/workflows/phpunit.yml
vendored
@@ -36,7 +36,7 @@ jobs:
|
||||
|
||||
- name: Start Database
|
||||
run: |
|
||||
sudo /etc/init.d/mysql start
|
||||
sudo systemctl start mysql
|
||||
|
||||
- name: Setup Database
|
||||
run: |
|
||||
|
||||
2
.github/workflows/test-migrations.yml
vendored
2
.github/workflows/test-migrations.yml
vendored
@@ -36,7 +36,7 @@ jobs:
|
||||
|
||||
- name: Start MySQL
|
||||
run: |
|
||||
sudo /etc/init.d/mysql start
|
||||
sudo systemctl start mysql
|
||||
|
||||
- name: Create database & user
|
||||
run: |
|
||||
|
||||
115
app/Actions/ActivityLogger.php
Normal file
115
app/Actions/ActivityLogger.php
Normal file
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Actions;
|
||||
|
||||
use BookStack\Auth\Permissions\PermissionService;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Interfaces\Loggable;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ActivityLogger
|
||||
{
|
||||
protected $permissionService;
|
||||
|
||||
public function __construct(PermissionService $permissionService)
|
||||
{
|
||||
$this->permissionService = $permissionService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a generic activity event to the database.
|
||||
*
|
||||
* @param string|Loggable $detail
|
||||
*/
|
||||
public function add(string $type, $detail = '')
|
||||
{
|
||||
$detailToStore = ($detail instanceof Loggable) ? $detail->logDescriptor() : $detail;
|
||||
|
||||
$activity = $this->newActivityForUser($type);
|
||||
$activity->detail = $detailToStore;
|
||||
|
||||
if ($detail instanceof Entity) {
|
||||
$activity->entity_id = $detail->id;
|
||||
$activity->entity_type = $detail->getMorphClass();
|
||||
}
|
||||
|
||||
$activity->save();
|
||||
$this->setNotification($type);
|
||||
$this->dispatchWebhooks($type, $detail);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a new activity instance for the current user.
|
||||
*/
|
||||
protected function newActivityForUser(string $type): Activity
|
||||
{
|
||||
$ip = request()->ip() ?? '';
|
||||
|
||||
return (new Activity())->forceFill([
|
||||
'type' => strtolower($type),
|
||||
'user_id' => user()->id,
|
||||
'ip' => config('app.env') === 'demo' ? '127.0.0.1' : $ip,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the entity attachment from each of its activities
|
||||
* and instead uses the 'extra' field with the entities name.
|
||||
* Used when an entity is deleted.
|
||||
*/
|
||||
public function removeEntity(Entity $entity)
|
||||
{
|
||||
$entity->activity()->update([
|
||||
'detail' => $entity->name,
|
||||
'entity_id' => null,
|
||||
'entity_type' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flashes a notification message to the session if an appropriate message is available.
|
||||
*/
|
||||
protected function setNotification(string $type): void
|
||||
{
|
||||
$notificationTextKey = 'activities.' . $type . '_notification';
|
||||
if (trans()->has($notificationTextKey)) {
|
||||
$message = trans($notificationTextKey);
|
||||
session()->flash('success', $message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|Loggable $detail
|
||||
*/
|
||||
protected function dispatchWebhooks(string $type, $detail): void
|
||||
{
|
||||
$webhooks = Webhook::query()
|
||||
->whereHas('trackedEvents', function (Builder $query) use ($type) {
|
||||
$query->where('event', '=', $type)
|
||||
->orWhere('event', '=', 'all');
|
||||
})
|
||||
->where('active', '=', true)
|
||||
->get();
|
||||
|
||||
foreach ($webhooks as $webhook) {
|
||||
dispatch(new DispatchWebhookJob($webhook, $type, $detail));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log out a failed login attempt, Providing the given username
|
||||
* as part of the message if the '%u' string is used.
|
||||
*/
|
||||
public function logFailedLogin(string $username)
|
||||
{
|
||||
$message = config('logging.failed_login.message');
|
||||
if (!$message) {
|
||||
return;
|
||||
}
|
||||
|
||||
$message = str_replace('%u', $username, $message);
|
||||
$channel = config('logging.failed_login.channel');
|
||||
Log::channel($channel)->warning($message);
|
||||
}
|
||||
}
|
||||
@@ -8,84 +8,25 @@ use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Interfaces\Loggable;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ActivityService
|
||||
class ActivityQueries
|
||||
{
|
||||
protected $activity;
|
||||
protected $permissionService;
|
||||
|
||||
public function __construct(Activity $activity, PermissionService $permissionService)
|
||||
public function __construct(PermissionService $permissionService)
|
||||
{
|
||||
$this->activity = $activity;
|
||||
$this->permissionService = $permissionService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add activity data to database for an entity.
|
||||
*/
|
||||
public function addForEntity(Entity $entity, string $type)
|
||||
{
|
||||
$activity = $this->newActivityForUser($type);
|
||||
$entity->activity()->save($activity);
|
||||
$this->setNotification($type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a generic activity event to the database.
|
||||
*
|
||||
* @param string|Loggable $detail
|
||||
*/
|
||||
public function add(string $type, $detail = '')
|
||||
{
|
||||
if ($detail instanceof Loggable) {
|
||||
$detail = $detail->logDescriptor();
|
||||
}
|
||||
|
||||
$activity = $this->newActivityForUser($type);
|
||||
$activity->detail = $detail;
|
||||
$activity->save();
|
||||
$this->setNotification($type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a new activity instance for the current user.
|
||||
*/
|
||||
protected function newActivityForUser(string $type): Activity
|
||||
{
|
||||
$ip = request()->ip() ?? '';
|
||||
|
||||
return $this->activity->newInstance()->forceFill([
|
||||
'type' => strtolower($type),
|
||||
'user_id' => user()->id,
|
||||
'ip' => config('app.env') === 'demo' ? '127.0.0.1' : $ip,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the entity attachment from each of its activities
|
||||
* and instead uses the 'extra' field with the entities name.
|
||||
* Used when an entity is deleted.
|
||||
*/
|
||||
public function removeEntity(Entity $entity)
|
||||
{
|
||||
$entity->activity()->update([
|
||||
'detail' => $entity->name,
|
||||
'entity_id' => null,
|
||||
'entity_type' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the latest activity.
|
||||
*/
|
||||
public function latest(int $count = 20, int $page = 0): array
|
||||
{
|
||||
$activityList = $this->permissionService
|
||||
->filterRestrictedEntityRelations($this->activity->newQuery(), 'activities', 'entity_id', 'entity_type')
|
||||
->filterRestrictedEntityRelations(Activity::query(), 'activities', 'entity_id', 'entity_type')
|
||||
->orderBy('created_at', 'desc')
|
||||
->with(['user', 'entity'])
|
||||
->skip($count * $page)
|
||||
@@ -111,7 +52,7 @@ class ActivityService
|
||||
$queryIds[(new Page())->getMorphClass()] = $entity->pages()->scopes('visible')->pluck('id');
|
||||
}
|
||||
|
||||
$query = $this->activity->newQuery();
|
||||
$query = Activity::query();
|
||||
$query->where(function (Builder $query) use ($queryIds) {
|
||||
foreach ($queryIds as $morphClass => $idArr) {
|
||||
$query->orWhere(function (Builder $innerQuery) use ($morphClass, $idArr) {
|
||||
@@ -138,7 +79,7 @@ class ActivityService
|
||||
public function userActivity(User $user, int $count = 20, int $page = 0): array
|
||||
{
|
||||
$activityList = $this->permissionService
|
||||
->filterRestrictedEntityRelations($this->activity->newQuery(), 'activities', 'entity_id', 'entity_type')
|
||||
->filterRestrictedEntityRelations(Activity::query(), 'activities', 'entity_id', 'entity_type')
|
||||
->orderBy('created_at', 'desc')
|
||||
->where('user_id', '=', $user->id)
|
||||
->skip($count * $page)
|
||||
@@ -152,8 +93,6 @@ class ActivityService
|
||||
* Filters out similar activity.
|
||||
*
|
||||
* @param Activity[] $activities
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function filterSimilar(iterable $activities): array
|
||||
{
|
||||
@@ -170,32 +109,4 @@ class ActivityService
|
||||
|
||||
return $newActivity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flashes a notification message to the session if an appropriate message is available.
|
||||
*/
|
||||
protected function setNotification(string $type)
|
||||
{
|
||||
$notificationTextKey = 'activities.' . $type . '_notification';
|
||||
if (trans()->has($notificationTextKey)) {
|
||||
$message = trans($notificationTextKey);
|
||||
session()->flash('success', $message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log out a failed login attempt, Providing the given username
|
||||
* as part of the message if the '%u' string is used.
|
||||
*/
|
||||
public function logFailedLogin(string $username)
|
||||
{
|
||||
$message = config('logging.failed_login.message');
|
||||
if (!$message) {
|
||||
return;
|
||||
}
|
||||
|
||||
$message = str_replace('%u', $username, $message);
|
||||
$channel = config('logging.failed_login.channel');
|
||||
Log::channel($channel)->warning($message);
|
||||
}
|
||||
}
|
||||
@@ -53,4 +53,16 @@ class ActivityType
|
||||
|
||||
const MFA_SETUP_METHOD = 'mfa_setup_method';
|
||||
const MFA_REMOVE_METHOD = 'mfa_remove_method';
|
||||
|
||||
const WEBHOOK_CREATE = 'webhook_create';
|
||||
const WEBHOOK_UPDATE = 'webhook_update';
|
||||
const WEBHOOK_DELETE = 'webhook_delete';
|
||||
|
||||
/**
|
||||
* Get all the possible values.
|
||||
*/
|
||||
public static function all(): array
|
||||
{
|
||||
return (new \ReflectionClass(static::class))->getConstants();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ class CommentRepo
|
||||
$comment->parent_id = $parent_id;
|
||||
|
||||
$entity->comments()->save($comment);
|
||||
ActivityService::addForEntity($entity, ActivityType::COMMENTED_ON);
|
||||
ActivityService::add(ActivityType::COMMENTED_ON, $entity);
|
||||
|
||||
return $comment;
|
||||
}
|
||||
|
||||
132
app/Actions/DispatchWebhookJob.php
Normal file
132
app/Actions/DispatchWebhookJob.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Actions;
|
||||
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Facades\Theme;
|
||||
use BookStack\Interfaces\Loggable;
|
||||
use BookStack\Model;
|
||||
use BookStack\Theming\ThemeEvents;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class DispatchWebhookJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
/**
|
||||
* @var Webhook
|
||||
*/
|
||||
protected $webhook;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $event;
|
||||
|
||||
/**
|
||||
* @var string|Loggable
|
||||
*/
|
||||
protected $detail;
|
||||
|
||||
/**
|
||||
* @var User
|
||||
*/
|
||||
protected $initiator;
|
||||
|
||||
/**
|
||||
* @var int
|
||||
*/
|
||||
protected $initiatedTime;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(Webhook $webhook, string $event, $detail)
|
||||
{
|
||||
$this->webhook = $webhook;
|
||||
$this->event = $event;
|
||||
$this->detail = $detail;
|
||||
$this->initiator = user();
|
||||
$this->initiatedTime = time();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$themeResponse = Theme::dispatch(ThemeEvents::WEBHOOK_CALL_BEFORE, $this->event, $this->webhook, $this->detail);
|
||||
$webhookData = $themeResponse ?? $this->buildWebhookData();
|
||||
$lastError = null;
|
||||
|
||||
try {
|
||||
$response = Http::asJson()
|
||||
->withOptions(['allow_redirects' => ['strict' => true]])
|
||||
->timeout($this->webhook->timeout)
|
||||
->post($this->webhook->endpoint, $webhookData);
|
||||
} catch (\Exception $exception) {
|
||||
$lastError = $exception->getMessage();
|
||||
Log::error("Webhook call to endpoint {$this->webhook->endpoint} failed with error \"{$lastError}\"");
|
||||
}
|
||||
|
||||
if (isset($response) && $response->failed()) {
|
||||
$lastError = "Response status from endpoint was {$response->status()}";
|
||||
Log::error("Webhook call to endpoint {$this->webhook->endpoint} failed with status {$response->status()}");
|
||||
}
|
||||
|
||||
$this->webhook->last_called_at = now();
|
||||
if ($lastError) {
|
||||
$this->webhook->last_errored_at = now();
|
||||
$this->webhook->last_error = $lastError;
|
||||
}
|
||||
|
||||
$this->webhook->save();
|
||||
}
|
||||
|
||||
protected function buildWebhookData(): array
|
||||
{
|
||||
$textParts = [
|
||||
$this->initiator->name,
|
||||
trans('activities.' . $this->event),
|
||||
];
|
||||
|
||||
if ($this->detail instanceof Entity) {
|
||||
$textParts[] = '"' . $this->detail->name . '"';
|
||||
}
|
||||
|
||||
$data = [
|
||||
'event' => $this->event,
|
||||
'text' => implode(' ', $textParts),
|
||||
'triggered_at' => Carbon::createFromTimestampUTC($this->initiatedTime)->toISOString(),
|
||||
'triggered_by' => $this->initiator->attributesToArray(),
|
||||
'triggered_by_profile_url' => $this->initiator->getProfileUrl(),
|
||||
'webhook_id' => $this->webhook->id,
|
||||
'webhook_name' => $this->webhook->name,
|
||||
];
|
||||
|
||||
if (method_exists($this->detail, 'getUrl')) {
|
||||
$data['url'] = $this->detail->getUrl();
|
||||
}
|
||||
|
||||
if ($this->detail instanceof Model) {
|
||||
$data['related_item'] = $this->detail->attributesToArray();
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
85
app/Actions/Webhook.php
Normal file
85
app/Actions/Webhook.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Actions;
|
||||
|
||||
use BookStack\Interfaces\Loggable;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property string $name
|
||||
* @property string $endpoint
|
||||
* @property Collection $trackedEvents
|
||||
* @property bool $active
|
||||
* @property int $timeout
|
||||
* @property string $last_error
|
||||
* @property Carbon $last_called_at
|
||||
* @property Carbon $last_errored_at
|
||||
*/
|
||||
class Webhook extends Model implements Loggable
|
||||
{
|
||||
protected $fillable = ['name', 'endpoint', 'timeout'];
|
||||
|
||||
use HasFactory;
|
||||
|
||||
protected $casts = [
|
||||
'last_called_at' => 'datetime',
|
||||
'last_errored_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* Define the tracked event relation a webhook.
|
||||
*/
|
||||
public function trackedEvents(): HasMany
|
||||
{
|
||||
return $this->hasMany(WebhookTrackedEvent::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the tracked events for a webhook from the given list of event types.
|
||||
*/
|
||||
public function updateTrackedEvents(array $events): void
|
||||
{
|
||||
$this->trackedEvents()->delete();
|
||||
|
||||
$eventsToStore = array_intersect($events, array_values(ActivityType::all()));
|
||||
if (in_array('all', $events)) {
|
||||
$eventsToStore = ['all'];
|
||||
}
|
||||
|
||||
$trackedEvents = [];
|
||||
foreach ($eventsToStore as $event) {
|
||||
$trackedEvents[] = new WebhookTrackedEvent(['event' => $event]);
|
||||
}
|
||||
|
||||
$this->trackedEvents()->saveMany($trackedEvents);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this webhook tracks the given event.
|
||||
*/
|
||||
public function tracksEvent(string $event): bool
|
||||
{
|
||||
return $this->trackedEvents->pluck('event')->contains($event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a URL for this webhook within the settings interface.
|
||||
*/
|
||||
public function getUrl(string $path = ''): string
|
||||
{
|
||||
return url('/settings/webhooks/' . $this->id . '/' . ltrim($path, '/'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the string descriptor for this item.
|
||||
*/
|
||||
public function logDescriptor(): string
|
||||
{
|
||||
return "({$this->id}) {$this->name}";
|
||||
}
|
||||
}
|
||||
18
app/Actions/WebhookTrackedEvent.php
Normal file
18
app/Actions/WebhookTrackedEvent.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Actions;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $webhook_id
|
||||
* @property string $event
|
||||
*/
|
||||
class WebhookTrackedEvent extends Model
|
||||
{
|
||||
protected $fillable = ['event'];
|
||||
|
||||
use HasFactory;
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace BookStack\Auth;
|
||||
|
||||
use Activity;
|
||||
use BookStack\Entities\EntityProvider;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
@@ -63,13 +62,16 @@ class UserRepo
|
||||
|
||||
/**
|
||||
* Get all the users with their permissions in a paginated format.
|
||||
* Note: Due to the use of email search this should only be used when
|
||||
* user is assumed to be trusted. (Admin users).
|
||||
* Email search can be abused to extract email addresses.
|
||||
*/
|
||||
public function getAllUsersPaginatedAndSorted(int $count, array $sortData): LengthAwarePaginator
|
||||
{
|
||||
$sort = $sortData['sort'];
|
||||
|
||||
$query = User::query()->select(['*'])
|
||||
->withLastActivityAt()
|
||||
->scopes(['withLastActivityAt'])
|
||||
->with(['roles', 'avatar'])
|
||||
->withCount('mfaValues')
|
||||
->orderBy($sort, $sortData['order']);
|
||||
@@ -215,14 +217,6 @@ class UserRepo
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest activity for a user.
|
||||
*/
|
||||
public function getActivity(User $user, int $count = 20, int $page = 0): array
|
||||
{
|
||||
return Activity::userActivity($user, $count, $page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the recently created content for this given user.
|
||||
*/
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
return [
|
||||
|
||||
// Default driver to use for the queue
|
||||
// Options: null, sync, redis
|
||||
// Options: sync, database, redis
|
||||
'default' => env('QUEUE_CONNECTION', 'sync'),
|
||||
|
||||
// Queue connection configuration
|
||||
|
||||
@@ -4,6 +4,9 @@ namespace BookStack\Console\Commands;
|
||||
|
||||
use BookStack\Auth\UserRepo;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Illuminate\Validation\Rules\Unique;
|
||||
use Symfony\Component\Console\Command\Command as SymfonyCommand;
|
||||
|
||||
class CreateAdmin extends Command
|
||||
@@ -45,43 +48,33 @@ class CreateAdmin extends Command
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$email = trim($this->option('email'));
|
||||
if (empty($email)) {
|
||||
$email = $this->ask('Please specify an email address for the new admin user');
|
||||
$details = $this->options();
|
||||
|
||||
if (empty($details['email'])) {
|
||||
$details['email'] = $this->ask('Please specify an email address for the new admin user');
|
||||
}
|
||||
if (mb_strlen($email) < 5 || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$this->error('Invalid email address provided');
|
||||
if (empty($details['name'])) {
|
||||
$details['name'] = $this->ask('Please specify a name for the new admin user');
|
||||
}
|
||||
if (empty($details['password'])) {
|
||||
$details['password'] = $this->ask('Please specify a password for the new admin user (8 characters min)');
|
||||
}
|
||||
|
||||
$validator = Validator::make($details, [
|
||||
'email' => ['required', 'email', 'min:5', new Unique('users', 'email')],
|
||||
'name' => ['required', 'min:2'],
|
||||
'password' => ['required', Password::default()],
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
foreach ($validator->errors()->all() as $error) {
|
||||
$this->error($error);
|
||||
}
|
||||
|
||||
return SymfonyCommand::FAILURE;
|
||||
}
|
||||
|
||||
if ($this->userRepo->getByEmail($email) !== null) {
|
||||
$this->error('A user with the provided email already exists!');
|
||||
|
||||
return SymfonyCommand::FAILURE;
|
||||
}
|
||||
|
||||
$name = trim($this->option('name'));
|
||||
if (empty($name)) {
|
||||
$name = $this->ask('Please specify an name for the new admin user');
|
||||
}
|
||||
if (mb_strlen($name) < 2) {
|
||||
$this->error('Invalid name provided');
|
||||
|
||||
return SymfonyCommand::FAILURE;
|
||||
}
|
||||
|
||||
$password = trim($this->option('password'));
|
||||
if (empty($password)) {
|
||||
$password = $this->secret('Please specify a password for the new admin user');
|
||||
}
|
||||
if (mb_strlen($password) < 5) {
|
||||
$this->error('Invalid password provided, Must be at least 5 characters');
|
||||
|
||||
return SymfonyCommand::FAILURE;
|
||||
}
|
||||
|
||||
$user = $this->userRepo->create(['email' => $email, 'name' => $name, 'password' => $password]);
|
||||
$user = $this->userRepo->create($validator->validated());
|
||||
$this->userRepo->attachSystemRole($user, 'admin');
|
||||
$this->userRepo->downloadAndAssignUserAvatar($user);
|
||||
$user->email_confirmed = true;
|
||||
|
||||
@@ -18,7 +18,7 @@ class Chapter extends BookChild
|
||||
|
||||
public $searchFactor = 1.2;
|
||||
|
||||
protected $fillable = ['name', 'description', 'priority', 'book_id'];
|
||||
protected $fillable = ['name', 'description', 'priority'];
|
||||
protected $hidden = ['restricted', 'pivot', 'deleted_at'];
|
||||
|
||||
/**
|
||||
|
||||
@@ -59,7 +59,7 @@ class Deletion extends Model implements Loggable
|
||||
/**
|
||||
* Get a URL for this specific deletion.
|
||||
*/
|
||||
public function getUrl($path): string
|
||||
public function getUrl(string $path = 'restore'): string
|
||||
{
|
||||
return url("/settings/recycle-bin/{$this->id}/" . ltrim($path, '/'));
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ use BookStack\Entities\Tools\SlugGenerator;
|
||||
use BookStack\Facades\Permissions;
|
||||
use BookStack\Interfaces\Deletable;
|
||||
use BookStack\Interfaces\Favouritable;
|
||||
use BookStack\Interfaces\Loggable;
|
||||
use BookStack\Interfaces\Sluggable;
|
||||
use BookStack\Interfaces\Viewable;
|
||||
use BookStack\Model;
|
||||
@@ -35,6 +36,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
* @property string $slug
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
* @property Carbon $deleted_at
|
||||
* @property int $created_by
|
||||
* @property int $updated_by
|
||||
* @property bool $restricted
|
||||
@@ -45,7 +47,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
* @method static Builder withLastView()
|
||||
* @method static Builder withViewCount()
|
||||
*/
|
||||
abstract class Entity extends Model implements Sluggable, Favouritable, Viewable, Deletable
|
||||
abstract class Entity extends Model implements Sluggable, Favouritable, Viewable, Deletable, Loggable
|
||||
{
|
||||
use SoftDeletes;
|
||||
use HasCreatorAndUpdater;
|
||||
@@ -321,4 +323,12 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
|
||||
->where('user_id', '=', user()->id)
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function logDescriptor(): string
|
||||
{
|
||||
return "({$this->id}) {$this->name}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,19 +46,10 @@ class PageRevision extends Model
|
||||
|
||||
/**
|
||||
* Get the url for this revision.
|
||||
*
|
||||
* @param null|string $path
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getUrl($path = null)
|
||||
public function getUrl(string $path = ''): string
|
||||
{
|
||||
$url = $this->page->getUrl() . '/revisions/' . $this->id;
|
||||
if ($path) {
|
||||
return $url . '/' . trim($path, '/');
|
||||
}
|
||||
|
||||
return $url;
|
||||
return $this->page->getUrl('/revisions/' . $this->id . '/' . ltrim($path, '/'));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -91,7 +91,7 @@ class BookRepo
|
||||
{
|
||||
$book = new Book();
|
||||
$this->baseRepo->create($book, $input);
|
||||
Activity::addForEntity($book, ActivityType::BOOK_CREATE);
|
||||
Activity::add(ActivityType::BOOK_CREATE, $book);
|
||||
|
||||
return $book;
|
||||
}
|
||||
@@ -102,7 +102,7 @@ class BookRepo
|
||||
public function update(Book $book, array $input): Book
|
||||
{
|
||||
$this->baseRepo->update($book, $input);
|
||||
Activity::addForEntity($book, ActivityType::BOOK_UPDATE);
|
||||
Activity::add(ActivityType::BOOK_UPDATE, $book);
|
||||
|
||||
return $book;
|
||||
}
|
||||
@@ -127,7 +127,7 @@ class BookRepo
|
||||
{
|
||||
$trashCan = new TrashCan();
|
||||
$trashCan->softDestroyBook($book);
|
||||
Activity::addForEntity($book, ActivityType::BOOK_DELETE);
|
||||
Activity::add(ActivityType::BOOK_DELETE, $book);
|
||||
|
||||
$trashCan->autoClearOld();
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ class BookshelfRepo
|
||||
$shelf = new Bookshelf();
|
||||
$this->baseRepo->create($shelf, $input);
|
||||
$this->updateBooks($shelf, $bookIds);
|
||||
Activity::addForEntity($shelf, ActivityType::BOOKSHELF_CREATE);
|
||||
Activity::add(ActivityType::BOOKSHELF_CREATE, $shelf);
|
||||
|
||||
return $shelf;
|
||||
}
|
||||
@@ -106,7 +106,7 @@ class BookshelfRepo
|
||||
$this->updateBooks($shelf, $bookIds);
|
||||
}
|
||||
|
||||
Activity::addForEntity($shelf, ActivityType::BOOKSHELF_UPDATE);
|
||||
Activity::add(ActivityType::BOOKSHELF_UPDATE, $shelf);
|
||||
|
||||
return $shelf;
|
||||
}
|
||||
@@ -177,7 +177,7 @@ class BookshelfRepo
|
||||
{
|
||||
$trashCan = new TrashCan();
|
||||
$trashCan->softDestroyShelf($shelf);
|
||||
Activity::addForEntity($shelf, ActivityType::BOOKSHELF_DELETE);
|
||||
Activity::add(ActivityType::BOOKSHELF_DELETE, $shelf);
|
||||
$trashCan->autoClearOld();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,12 @@ namespace BookStack\Entities\Repos;
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Tools\BookContents;
|
||||
use BookStack\Entities\Tools\TrashCan;
|
||||
use BookStack\Exceptions\MoveOperationException;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use BookStack\Exceptions\PermissionsException;
|
||||
use BookStack\Facades\Activity;
|
||||
use Exception;
|
||||
|
||||
@@ -49,7 +51,7 @@ class ChapterRepo
|
||||
$chapter->book_id = $parentBook->id;
|
||||
$chapter->priority = (new BookContents($parentBook))->getLastPriority() + 1;
|
||||
$this->baseRepo->create($chapter, $input);
|
||||
Activity::addForEntity($chapter, ActivityType::CHAPTER_CREATE);
|
||||
Activity::add(ActivityType::CHAPTER_CREATE, $chapter);
|
||||
|
||||
return $chapter;
|
||||
}
|
||||
@@ -60,7 +62,7 @@ class ChapterRepo
|
||||
public function update(Chapter $chapter, array $input): Chapter
|
||||
{
|
||||
$this->baseRepo->update($chapter, $input);
|
||||
Activity::addForEntity($chapter, ActivityType::CHAPTER_UPDATE);
|
||||
Activity::add(ActivityType::CHAPTER_UPDATE, $chapter);
|
||||
|
||||
return $chapter;
|
||||
}
|
||||
@@ -74,7 +76,7 @@ class ChapterRepo
|
||||
{
|
||||
$trashCan = new TrashCan();
|
||||
$trashCan->softDestroyChapter($chapter);
|
||||
Activity::addForEntity($chapter, ActivityType::CHAPTER_DELETE);
|
||||
Activity::add(ActivityType::CHAPTER_DELETE, $chapter);
|
||||
$trashCan->autoClearOld();
|
||||
}
|
||||
|
||||
@@ -84,27 +86,43 @@ class ChapterRepo
|
||||
* 'book:<id>' (book:5).
|
||||
*
|
||||
* @throws MoveOperationException
|
||||
* @throws PermissionsException
|
||||
*/
|
||||
public function move(Chapter $chapter, string $parentIdentifier): Book
|
||||
{
|
||||
$stringExploded = explode(':', $parentIdentifier);
|
||||
$entityType = $stringExploded[0];
|
||||
$entityId = intval($stringExploded[1]);
|
||||
|
||||
if ($entityType !== 'book') {
|
||||
throw new MoveOperationException('Chapters can only be moved into books');
|
||||
$parent = $this->findParentByIdentifier($parentIdentifier);
|
||||
if (is_null($parent)) {
|
||||
throw new MoveOperationException('Book to move chapter into not found');
|
||||
}
|
||||
|
||||
/** @var Book $parent */
|
||||
$parent = Book::visible()->where('id', '=', $entityId)->first();
|
||||
if ($parent === null) {
|
||||
throw new MoveOperationException('Book to move chapter into not found');
|
||||
if (!userCan('chapter-create', $parent)) {
|
||||
throw new PermissionsException('User does not have permission to create a chapter within the chosen book');
|
||||
}
|
||||
|
||||
$chapter->changeBook($parent->id);
|
||||
$chapter->rebuildPermissions();
|
||||
Activity::addForEntity($chapter, ActivityType::CHAPTER_MOVE);
|
||||
Activity::add(ActivityType::CHAPTER_MOVE, $chapter);
|
||||
|
||||
return $parent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a page parent entity via an identifier string in the format:
|
||||
* {type}:{id}
|
||||
* Example: (book:5).
|
||||
*
|
||||
* @throws MoveOperationException
|
||||
*/
|
||||
public function findParentByIdentifier(string $identifier): ?Book
|
||||
{
|
||||
$stringExploded = explode(':', $identifier);
|
||||
$entityType = $stringExploded[0];
|
||||
$entityId = intval($stringExploded[1]);
|
||||
|
||||
if ($entityType !== 'book') {
|
||||
throw new MoveOperationException('Chapters can only be in books');
|
||||
}
|
||||
|
||||
return Book::visible()->where('id', '=', $entityId)->first();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@ class PageRepo
|
||||
$draft->indexForSearch();
|
||||
$draft->refresh();
|
||||
|
||||
Activity::addForEntity($draft, ActivityType::PAGE_CREATE);
|
||||
Activity::add(ActivityType::PAGE_CREATE, $draft);
|
||||
|
||||
return $draft;
|
||||
}
|
||||
@@ -205,7 +205,7 @@ class PageRepo
|
||||
$this->savePageRevision($page, $summary);
|
||||
}
|
||||
|
||||
Activity::addForEntity($page, ActivityType::PAGE_UPDATE);
|
||||
Activity::add(ActivityType::PAGE_UPDATE, $page);
|
||||
|
||||
return $page;
|
||||
}
|
||||
@@ -281,7 +281,7 @@ class PageRepo
|
||||
{
|
||||
$trashCan = new TrashCan();
|
||||
$trashCan->softDestroyPage($page);
|
||||
Activity::addForEntity($page, ActivityType::PAGE_DELETE);
|
||||
Activity::add(ActivityType::PAGE_DELETE, $page);
|
||||
$trashCan->autoClearOld();
|
||||
}
|
||||
|
||||
@@ -312,7 +312,7 @@ class PageRepo
|
||||
$summary = trans('entities.pages_revision_restored_from', ['id' => strval($revisionId), 'summary' => $revision->summary]);
|
||||
$this->savePageRevision($page, $summary);
|
||||
|
||||
Activity::addForEntity($page, ActivityType::PAGE_RESTORE);
|
||||
Activity::add(ActivityType::PAGE_RESTORE, $page);
|
||||
|
||||
return $page;
|
||||
}
|
||||
@@ -328,7 +328,7 @@ class PageRepo
|
||||
public function move(Page $page, string $parentIdentifier): Entity
|
||||
{
|
||||
$parent = $this->findParentByIdentifier($parentIdentifier);
|
||||
if ($parent === null) {
|
||||
if (is_null($parent)) {
|
||||
throw new MoveOperationException('Book or chapter to move page into not found');
|
||||
}
|
||||
|
||||
@@ -341,56 +341,19 @@ class PageRepo
|
||||
$page->changeBook($newBookId);
|
||||
$page->rebuildPermissions();
|
||||
|
||||
Activity::addForEntity($page, ActivityType::PAGE_MOVE);
|
||||
Activity::add(ActivityType::PAGE_MOVE, $page);
|
||||
|
||||
return $parent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy an existing page in the system.
|
||||
* Optionally providing a new parent via string identifier and a new name.
|
||||
*
|
||||
* @throws MoveOperationException
|
||||
* @throws PermissionsException
|
||||
*/
|
||||
public function copy(Page $page, string $parentIdentifier = null, string $newName = null): Page
|
||||
{
|
||||
$parent = $parentIdentifier ? $this->findParentByIdentifier($parentIdentifier) : $page->getParent();
|
||||
if ($parent === null) {
|
||||
throw new MoveOperationException('Book or chapter to move page into not found');
|
||||
}
|
||||
|
||||
if (!userCan('page-create', $parent)) {
|
||||
throw new PermissionsException('User does not have permission to create a page within the new parent');
|
||||
}
|
||||
|
||||
$copyPage = $this->getNewDraftPage($parent);
|
||||
$pageData = $page->getAttributes();
|
||||
|
||||
// Update name
|
||||
if (!empty($newName)) {
|
||||
$pageData['name'] = $newName;
|
||||
}
|
||||
|
||||
// Copy tags from previous page if set
|
||||
if ($page->tags) {
|
||||
$pageData['tags'] = [];
|
||||
foreach ($page->tags as $tag) {
|
||||
$pageData['tags'][] = ['name' => $tag->name, 'value' => $tag->value];
|
||||
}
|
||||
}
|
||||
|
||||
return $this->publishDraft($copyPage, $pageData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a page parent entity via a identifier string in the format:
|
||||
* Find a page parent entity via an identifier string in the format:
|
||||
* {type}:{id}
|
||||
* Example: (book:5).
|
||||
*
|
||||
* @throws MoveOperationException
|
||||
*/
|
||||
protected function findParentByIdentifier(string $identifier): ?Entity
|
||||
public function findParentByIdentifier(string $identifier): ?Entity
|
||||
{
|
||||
$stringExploded = explode(':', $identifier);
|
||||
$entityType = $stringExploded[0];
|
||||
|
||||
@@ -7,7 +7,6 @@ use BookStack\Entities\Models\BookChild;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Exceptions\SortOperationException;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class BookContents
|
||||
@@ -107,111 +106,209 @@ class BookContents
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort the books content using the given map.
|
||||
* The map is a single-dimension collection of objects in the following format:
|
||||
* {
|
||||
* +"id": "294" (ID of item)
|
||||
* +"sort": 1 (Sort order index)
|
||||
* +"parentChapter": false (ID of parent chapter, as string, or false)
|
||||
* +"type": "page" (Entity type of item)
|
||||
* +"book": "1" (Id of book to place item in)
|
||||
* }.
|
||||
*
|
||||
* Sort the books content using the given sort map.
|
||||
* Returns a list of books that were involved in the operation.
|
||||
*
|
||||
* @throws SortOperationException
|
||||
* @returns Book[]
|
||||
*/
|
||||
public function sortUsingMap(Collection $sortMap): Collection
|
||||
public function sortUsingMap(BookSortMap $sortMap): array
|
||||
{
|
||||
// Load models into map
|
||||
$this->loadModelsIntoSortMap($sortMap);
|
||||
$booksInvolved = $this->getBooksInvolvedInSort($sortMap);
|
||||
$modelMap = $this->loadModelsFromSortMap($sortMap);
|
||||
|
||||
// Sort our changes from our map to be chapters first
|
||||
// Since they need to be process to ensure book alignment for child page changes.
|
||||
$sortMapItems = $sortMap->all();
|
||||
usort($sortMapItems, function (BookSortMapItem $itemA, BookSortMapItem $itemB) {
|
||||
$aScore = $itemA->type === 'page' ? 2 : 1;
|
||||
$bScore = $itemB->type === 'page' ? 2 : 1;
|
||||
|
||||
return $aScore - $bScore;
|
||||
});
|
||||
|
||||
// Perform the sort
|
||||
$sortMap->each(function ($mapItem) {
|
||||
$this->applySortUpdates($mapItem);
|
||||
});
|
||||
foreach ($sortMapItems as $item) {
|
||||
$this->applySortUpdates($item, $modelMap);
|
||||
}
|
||||
|
||||
// Update permissions and activity.
|
||||
$booksInvolved->each(function (Book $book) {
|
||||
/** @var Book[] $booksInvolved */
|
||||
$booksInvolved = array_values(array_filter($modelMap, function (string $key) {
|
||||
return strpos($key, 'book:') === 0;
|
||||
}, ARRAY_FILTER_USE_KEY));
|
||||
|
||||
// Update permissions of books involved
|
||||
foreach ($booksInvolved as $book) {
|
||||
$book->rebuildPermissions();
|
||||
});
|
||||
}
|
||||
|
||||
return $booksInvolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Using the given sort map item, detect changes for the related model
|
||||
* and update it if required.
|
||||
* and update it if required. Changes where permissions are lacking will
|
||||
* be skipped and not throw an error.
|
||||
*
|
||||
* @param array<string, Entity> $modelMap
|
||||
*/
|
||||
protected function applySortUpdates(\stdClass $sortMapItem)
|
||||
protected function applySortUpdates(BookSortMapItem $sortMapItem, array $modelMap): void
|
||||
{
|
||||
/** @var BookChild $model */
|
||||
$model = $sortMapItem->model;
|
||||
$model = $modelMap[$sortMapItem->type . ':' . $sortMapItem->id] ?? null;
|
||||
if (!$model) {
|
||||
return;
|
||||
}
|
||||
|
||||
$priorityChanged = intval($model->priority) !== intval($sortMapItem->sort);
|
||||
$bookChanged = intval($model->book_id) !== intval($sortMapItem->book);
|
||||
$chapterChanged = ($model instanceof Page) && intval($model->chapter_id) !== $sortMapItem->parentChapter;
|
||||
$priorityChanged = $model->priority !== $sortMapItem->sort;
|
||||
$bookChanged = $model->book_id !== $sortMapItem->parentBookId;
|
||||
$chapterChanged = ($model instanceof Page) && $model->chapter_id !== $sortMapItem->parentChapterId;
|
||||
|
||||
// Stop if there's no change
|
||||
if (!$priorityChanged && !$bookChanged && !$chapterChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
$currentParentKey = 'book:' . $model->book_id;
|
||||
if ($model instanceof Page && $model->chapter_id) {
|
||||
$currentParentKey = 'chapter:' . $model->chapter_id;
|
||||
}
|
||||
|
||||
$currentParent = $modelMap[$currentParentKey] ?? null;
|
||||
/** @var Book $newBook */
|
||||
$newBook = $modelMap['book:' . $sortMapItem->parentBookId] ?? null;
|
||||
/** @var ?Chapter $newChapter */
|
||||
$newChapter = $sortMapItem->parentChapterId ? ($modelMap['chapter:' . $sortMapItem->parentChapterId] ?? null) : null;
|
||||
|
||||
if (!$this->isSortChangePermissible($sortMapItem, $model, $currentParent, $newBook, $newChapter)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Action the required changes
|
||||
if ($bookChanged) {
|
||||
$model->changeBook($sortMapItem->book);
|
||||
$model->changeBook($newBook->id);
|
||||
}
|
||||
|
||||
if ($chapterChanged) {
|
||||
$model->chapter_id = intval($sortMapItem->parentChapter);
|
||||
$model->save();
|
||||
$model->chapter_id = $newChapter->id ?? 0;
|
||||
}
|
||||
|
||||
if ($priorityChanged) {
|
||||
$model->priority = intval($sortMapItem->sort);
|
||||
$model->priority = $sortMapItem->sort;
|
||||
}
|
||||
|
||||
if ($chapterChanged || $priorityChanged) {
|
||||
$model->save();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current user has permissions to apply the given sorting change.
|
||||
* Is quite complex since items can gain a different parent change. Acts as a:
|
||||
* - Update of old parent element (Change of content/order).
|
||||
* - Update of sorted/moved element.
|
||||
* - Deletion of element (Relative to parent upon move).
|
||||
* - Creation of element within parent (Upon move to new parent).
|
||||
*/
|
||||
protected function isSortChangePermissible(BookSortMapItem $sortMapItem, BookChild $model, ?Entity $currentParent, ?Entity $newBook, ?Entity $newChapter): bool
|
||||
{
|
||||
// Stop if we can't see the current parent or new book.
|
||||
if (!$currentParent || !$newBook) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$hasNewParent = $newBook->id !== $model->book_id || ($model instanceof Page && $model->chapter_id !== ($sortMapItem->parentChapterId ?? 0));
|
||||
if ($model instanceof Chapter) {
|
||||
$hasPermission = userCan('book-update', $currentParent)
|
||||
&& userCan('book-update', $newBook)
|
||||
&& userCan('chapter-update', $model)
|
||||
&& (!$hasNewParent || userCan('chapter-create', $newBook))
|
||||
&& (!$hasNewParent || userCan('chapter-delete', $model));
|
||||
|
||||
if (!$hasPermission) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if ($model instanceof Page) {
|
||||
$parentPermission = ($currentParent instanceof Chapter) ? 'chapter-update' : 'book-update';
|
||||
$hasCurrentParentPermission = userCan($parentPermission, $currentParent);
|
||||
|
||||
// This needs to check if there was an intended chapter location in the original sort map
|
||||
// rather than inferring from the $newChapter since that variable may be null
|
||||
// due to other reasons (Visibility).
|
||||
$newParent = $sortMapItem->parentChapterId ? $newChapter : $newBook;
|
||||
if (!$newParent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$hasPageEditPermission = userCan('page-update', $model);
|
||||
$newParentInRightLocation = ($newParent instanceof Book || $newParent->book_id === $newBook->id);
|
||||
$newParentPermission = ($newParent instanceof Chapter) ? 'chapter-update' : 'book-update';
|
||||
$hasNewParentPermission = userCan($newParentPermission, $newParent);
|
||||
|
||||
$hasDeletePermissionIfMoving = (!$hasNewParent || userCan('page-delete', $model));
|
||||
$hasCreatePermissionIfMoving = (!$hasNewParent || userCan('page-create', $newParent));
|
||||
|
||||
$hasPermission = $hasCurrentParentPermission
|
||||
&& $newParentInRightLocation
|
||||
&& $hasNewParentPermission
|
||||
&& $hasPageEditPermission
|
||||
&& $hasDeletePermissionIfMoving
|
||||
&& $hasCreatePermissionIfMoving;
|
||||
|
||||
if (!$hasPermission) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load models from the database into the given sort map.
|
||||
*/
|
||||
protected function loadModelsIntoSortMap(Collection $sortMap): void
|
||||
{
|
||||
$keyMap = $sortMap->keyBy(function (\stdClass $sortMapItem) {
|
||||
return $sortMapItem->type . ':' . $sortMapItem->id;
|
||||
});
|
||||
$pageIds = $sortMap->where('type', '=', 'page')->pluck('id');
|
||||
$chapterIds = $sortMap->where('type', '=', 'chapter')->pluck('id');
|
||||
|
||||
$pages = Page::visible()->whereIn('id', $pageIds)->get();
|
||||
$chapters = Chapter::visible()->whereIn('id', $chapterIds)->get();
|
||||
|
||||
foreach ($pages as $page) {
|
||||
$sortItem = $keyMap->get('page:' . $page->id);
|
||||
$sortItem->model = $page;
|
||||
}
|
||||
|
||||
foreach ($chapters as $chapter) {
|
||||
$sortItem = $keyMap->get('chapter:' . $chapter->id);
|
||||
$sortItem->model = $chapter;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the books involved in a sort.
|
||||
* The given sort map should have its models loaded first.
|
||||
*
|
||||
* @throws SortOperationException
|
||||
* @return array<string, Entity>
|
||||
*/
|
||||
protected function getBooksInvolvedInSort(Collection $sortMap): Collection
|
||||
protected function loadModelsFromSortMap(BookSortMap $sortMap): array
|
||||
{
|
||||
$bookIdsInvolved = collect([$this->book->id]);
|
||||
$bookIdsInvolved = $bookIdsInvolved->concat($sortMap->pluck('book'));
|
||||
$bookIdsInvolved = $bookIdsInvolved->concat($sortMap->pluck('model.book_id'));
|
||||
$bookIdsInvolved = $bookIdsInvolved->unique()->toArray();
|
||||
$modelMap = [];
|
||||
$ids = [
|
||||
'chapter' => [],
|
||||
'page' => [],
|
||||
'book' => [],
|
||||
];
|
||||
|
||||
$books = Book::hasPermission('update')->whereIn('id', $bookIdsInvolved)->get();
|
||||
|
||||
if (count($books) !== count($bookIdsInvolved)) {
|
||||
throw new SortOperationException('Could not find all books requested in sort operation');
|
||||
foreach ($sortMap->all() as $sortMapItem) {
|
||||
$ids[$sortMapItem->type][] = $sortMapItem->id;
|
||||
$ids['book'][] = $sortMapItem->parentBookId;
|
||||
if ($sortMapItem->parentChapterId) {
|
||||
$ids['chapter'][] = $sortMapItem->parentChapterId;
|
||||
}
|
||||
}
|
||||
|
||||
return $books;
|
||||
$pages = Page::visible()->whereIn('id', array_unique($ids['page']))->get(Page::$listAttributes);
|
||||
/** @var Page $page */
|
||||
foreach ($pages as $page) {
|
||||
$modelMap['page:' . $page->id] = $page;
|
||||
$ids['book'][] = $page->book_id;
|
||||
if ($page->chapter_id) {
|
||||
$ids['chapter'][] = $page->chapter_id;
|
||||
}
|
||||
}
|
||||
|
||||
$chapters = Chapter::visible()->whereIn('id', array_unique($ids['chapter']))->get();
|
||||
/** @var Chapter $chapter */
|
||||
foreach ($chapters as $chapter) {
|
||||
$modelMap['chapter:' . $chapter->id] = $chapter;
|
||||
$ids['book'][] = $chapter->book_id;
|
||||
}
|
||||
|
||||
$books = Book::visible()->whereIn('id', array_unique($ids['book']))->get();
|
||||
/** @var Book $book */
|
||||
foreach ($books as $book) {
|
||||
$modelMap['book:' . $book->id] = $book;
|
||||
}
|
||||
|
||||
return $modelMap;
|
||||
}
|
||||
}
|
||||
|
||||
44
app/Entities/Tools/BookSortMap.php
Normal file
44
app/Entities/Tools/BookSortMap.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Tools;
|
||||
|
||||
class BookSortMap
|
||||
{
|
||||
/**
|
||||
* @var BookSortMapItem[]
|
||||
*/
|
||||
protected $mapData = [];
|
||||
|
||||
public function addItem(BookSortMapItem $mapItem): void
|
||||
{
|
||||
$this->mapData[] = $mapItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BookSortMapItem[]
|
||||
*/
|
||||
public function all(): array
|
||||
{
|
||||
return $this->mapData;
|
||||
}
|
||||
|
||||
public static function fromJson(string $json): self
|
||||
{
|
||||
$map = new BookSortMap();
|
||||
$mapData = json_decode($json);
|
||||
|
||||
foreach ($mapData as $mapDataItem) {
|
||||
$item = new BookSortMapItem(
|
||||
intval($mapDataItem->id),
|
||||
intval($mapDataItem->sort),
|
||||
$mapDataItem->parentChapter ? intval($mapDataItem->parentChapter) : null,
|
||||
$mapDataItem->type,
|
||||
intval($mapDataItem->book)
|
||||
);
|
||||
|
||||
$map->addItem($item);
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
}
|
||||
40
app/Entities/Tools/BookSortMapItem.php
Normal file
40
app/Entities/Tools/BookSortMapItem.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Tools;
|
||||
|
||||
class BookSortMapItem
|
||||
{
|
||||
/**
|
||||
* @var int
|
||||
*/
|
||||
public $id;
|
||||
|
||||
/**
|
||||
* @var int
|
||||
*/
|
||||
public $sort;
|
||||
|
||||
/**
|
||||
* @var ?int
|
||||
*/
|
||||
public $parentChapterId;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
public $type;
|
||||
|
||||
/**
|
||||
* @var int
|
||||
*/
|
||||
public $parentBookId;
|
||||
|
||||
public function __construct(int $id, int $sort, ?int $parentChapterId, string $type, int $parentBookId)
|
||||
{
|
||||
$this->id = $id;
|
||||
$this->sort = $sort;
|
||||
$this->parentChapterId = $parentChapterId;
|
||||
$this->type = $type;
|
||||
$this->parentBookId = $parentBookId;
|
||||
}
|
||||
}
|
||||
147
app/Entities/Tools/Cloner.php
Normal file
147
app/Entities/Tools/Cloner.php
Normal file
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Tools;
|
||||
|
||||
use BookStack\Actions\Tag;
|
||||
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\Uploads\Image;
|
||||
use BookStack\Uploads\ImageService;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
|
||||
class Cloner
|
||||
{
|
||||
/**
|
||||
* @var PageRepo
|
||||
*/
|
||||
protected $pageRepo;
|
||||
|
||||
/**
|
||||
* @var ChapterRepo
|
||||
*/
|
||||
protected $chapterRepo;
|
||||
|
||||
/**
|
||||
* @var BookRepo
|
||||
*/
|
||||
protected $bookRepo;
|
||||
|
||||
/**
|
||||
* @var ImageService
|
||||
*/
|
||||
protected $imageService;
|
||||
|
||||
public function __construct(PageRepo $pageRepo, ChapterRepo $chapterRepo, BookRepo $bookRepo, ImageService $imageService)
|
||||
{
|
||||
$this->pageRepo = $pageRepo;
|
||||
$this->chapterRepo = $chapterRepo;
|
||||
$this->bookRepo = $bookRepo;
|
||||
$this->imageService = $imageService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone the given page into the given parent using the provided name.
|
||||
*/
|
||||
public function clonePage(Page $original, Entity $parent, string $newName): Page
|
||||
{
|
||||
$copyPage = $this->pageRepo->getNewDraftPage($parent);
|
||||
$pageData = $original->getAttributes();
|
||||
|
||||
// Update name & tags
|
||||
$pageData['name'] = $newName;
|
||||
$pageData['tags'] = $this->entityTagsToInputArray($original);
|
||||
|
||||
return $this->pageRepo->publishDraft($copyPage, $pageData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone the given page into the given parent using the provided name.
|
||||
* Clones all child pages.
|
||||
*/
|
||||
public function cloneChapter(Chapter $original, Book $parent, string $newName): Chapter
|
||||
{
|
||||
$chapterDetails = $original->getAttributes();
|
||||
$chapterDetails['name'] = $newName;
|
||||
$chapterDetails['tags'] = $this->entityTagsToInputArray($original);
|
||||
|
||||
$copyChapter = $this->chapterRepo->create($chapterDetails, $parent);
|
||||
|
||||
if (userCan('page-create', $copyChapter)) {
|
||||
/** @var Page $page */
|
||||
foreach ($original->getVisiblePages() as $page) {
|
||||
$this->clonePage($page, $copyChapter, $page->name);
|
||||
}
|
||||
}
|
||||
|
||||
return $copyChapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone the given book.
|
||||
* Clones all child chapters & pages.
|
||||
*/
|
||||
public function cloneBook(Book $original, string $newName): Book
|
||||
{
|
||||
$bookDetails = $original->getAttributes();
|
||||
$bookDetails['name'] = $newName;
|
||||
$bookDetails['tags'] = $this->entityTagsToInputArray($original);
|
||||
|
||||
$copyBook = $this->bookRepo->create($bookDetails);
|
||||
|
||||
$directChildren = $original->getDirectChildren();
|
||||
foreach ($directChildren as $child) {
|
||||
if ($child instanceof Chapter && userCan('chapter-create', $copyBook)) {
|
||||
$this->cloneChapter($child, $copyBook, $child->name);
|
||||
}
|
||||
|
||||
if ($child instanceof Page && !$child->draft && userCan('page-create', $copyBook)) {
|
||||
$this->clonePage($child, $copyBook, $child->name);
|
||||
}
|
||||
}
|
||||
|
||||
if ($original->cover) {
|
||||
try {
|
||||
$tmpImgFile = tmpfile();
|
||||
$uploadedFile = $this->imageToUploadedFile($original->cover, $tmpImgFile);
|
||||
$this->bookRepo->updateCoverImage($copyBook, $uploadedFile, false);
|
||||
} catch (\Exception $exception) {
|
||||
}
|
||||
}
|
||||
|
||||
return $copyBook;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an image instance to an UploadedFile instance to mimic
|
||||
* a file being uploaded.
|
||||
*/
|
||||
protected function imageToUploadedFile(Image $image, &$tmpFile): ?UploadedFile
|
||||
{
|
||||
$imgData = $this->imageService->getImageData($image);
|
||||
$tmpImgFilePath = stream_get_meta_data($tmpFile)['uri'];
|
||||
file_put_contents($tmpImgFilePath, $imgData);
|
||||
|
||||
return new UploadedFile($tmpImgFilePath, basename($image->path));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the tags on the given entity to the raw format
|
||||
* that's used for incoming request data.
|
||||
*/
|
||||
protected function entityTagsToInputArray(Entity $entity): array
|
||||
{
|
||||
$tags = [];
|
||||
|
||||
/** @var Tag $tag */
|
||||
foreach ($entity->tags as $tag) {
|
||||
$tags[] = ['name' => $tag->name, 'value' => $tag->value];
|
||||
}
|
||||
|
||||
return $tags;
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,7 @@ class ExportFormatter
|
||||
$html = view('pages.export', [
|
||||
'page' => $page,
|
||||
'format' => 'pdf',
|
||||
'engine' => $this->pdfGenerator->getActiveEngine(),
|
||||
])->render();
|
||||
|
||||
return $this->htmlToPdf($html);
|
||||
@@ -113,6 +114,7 @@ class ExportFormatter
|
||||
'chapter' => $chapter,
|
||||
'pages' => $pages,
|
||||
'format' => 'pdf',
|
||||
'engine' => $this->pdfGenerator->getActiveEngine(),
|
||||
])->render();
|
||||
|
||||
return $this->htmlToPdf($html);
|
||||
@@ -130,6 +132,7 @@ class ExportFormatter
|
||||
'book' => $book,
|
||||
'bookChildren' => $bookTree,
|
||||
'format' => 'pdf',
|
||||
'engine' => $this->pdfGenerator->getActiveEngine(),
|
||||
])->render();
|
||||
|
||||
return $this->htmlToPdf($html);
|
||||
|
||||
@@ -7,14 +7,15 @@ use Barryvdh\Snappy\Facades\SnappyPdf;
|
||||
|
||||
class PdfGenerator
|
||||
{
|
||||
const ENGINE_DOMPDF = 'dompdf';
|
||||
const ENGINE_WKHTML = 'wkhtml';
|
||||
|
||||
/**
|
||||
* Generate PDF content from the given HTML content.
|
||||
*/
|
||||
public function fromHtml(string $html): string
|
||||
{
|
||||
$useWKHTML = config('snappy.pdf.binary') !== false && config('app.allow_untrusted_server_fetching') === true;
|
||||
|
||||
if ($useWKHTML) {
|
||||
if ($this->getActiveEngine() === self::ENGINE_WKHTML) {
|
||||
$pdf = SnappyPDF::loadHTML($html);
|
||||
$pdf->setOption('print-media-type', true);
|
||||
} else {
|
||||
@@ -23,4 +24,15 @@ class PdfGenerator
|
||||
|
||||
return $pdf->output();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the currently active PDF engine.
|
||||
* Returns the value of an `ENGINE_` const on this class.
|
||||
*/
|
||||
public function getActiveEngine(): string
|
||||
{
|
||||
$useWKHTML = config('snappy.pdf.binary') !== false && config('app.allow_untrusted_server_fetching') === true;
|
||||
|
||||
return $useWKHTML ? self::ENGINE_WKHTML : self::ENGINE_DOMPDF;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ class PermissionsUpdater
|
||||
$entity->save();
|
||||
$entity->rebuildPermissions();
|
||||
|
||||
Activity::addForEntity($entity, ActivityType::PERMISSIONS_UPDATE);
|
||||
Activity::add(ActivityType::PERMISSIONS_UPDATE, $entity);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -57,17 +57,17 @@ class SearchResultsFormatter
|
||||
protected function highlightTagsContainingTerms(array $tags, array $terms): void
|
||||
{
|
||||
foreach ($tags as $tag) {
|
||||
$tagName = strtolower($tag->name);
|
||||
$tagValue = strtolower($tag->value);
|
||||
$tagName = mb_strtolower($tag->name);
|
||||
$tagValue = mb_strtolower($tag->value);
|
||||
|
||||
foreach ($terms as $term) {
|
||||
$termLower = strtolower($term);
|
||||
$termLower = mb_strtolower($term);
|
||||
|
||||
if (strpos($tagName, $termLower) !== false) {
|
||||
if (mb_strpos($tagName, $termLower) !== false) {
|
||||
$tag->setAttribute('highlight_name', true);
|
||||
}
|
||||
|
||||
if (strpos($tagValue, $termLower) !== false) {
|
||||
if (mb_strpos($tagValue, $termLower) !== false) {
|
||||
$tag->setAttribute('highlight_value', true);
|
||||
}
|
||||
}
|
||||
@@ -84,17 +84,17 @@ class SearchResultsFormatter
|
||||
protected function getMatchPositions(string $text, array $terms): array
|
||||
{
|
||||
$matchRefs = [];
|
||||
$text = strtolower($text);
|
||||
$text = mb_strtolower($text);
|
||||
|
||||
foreach ($terms as $term) {
|
||||
$offset = 0;
|
||||
$term = strtolower($term);
|
||||
$pos = strpos($text, $term, $offset);
|
||||
$term = mb_strtolower($term);
|
||||
$pos = mb_strpos($text, $term, $offset);
|
||||
while ($pos !== false) {
|
||||
$end = $pos + strlen($term);
|
||||
$end = $pos + mb_strlen($term);
|
||||
$matchRefs[$pos] = $end;
|
||||
$offset = $end;
|
||||
$pos = strpos($text, $term, $offset);
|
||||
$pos = mb_strpos($text, $term, $offset);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ class SearchResultsFormatter
|
||||
*/
|
||||
protected function formatTextUsingMatchPositions(array $matchPositions, string $originalText, int $targetLength): string
|
||||
{
|
||||
$maxEnd = strlen($originalText);
|
||||
$maxEnd = mb_strlen($originalText);
|
||||
$fetchAll = ($targetLength === 0);
|
||||
$contextLength = ($fetchAll ? 0 : 32);
|
||||
|
||||
@@ -165,7 +165,7 @@ class SearchResultsFormatter
|
||||
$contextStart = $start;
|
||||
// Trims off '$startDiff' number of characters to bring it back to the start
|
||||
// if this current match zone.
|
||||
$content = substr($content, 0, strlen($content) + $startDiff);
|
||||
$content = mb_substr($content, 0, mb_strlen($content) + $startDiff);
|
||||
$contentTextLength += $startDiff;
|
||||
}
|
||||
|
||||
@@ -176,16 +176,16 @@ class SearchResultsFormatter
|
||||
} elseif ($fetchAll) {
|
||||
// Or fill in gap since the previous match
|
||||
$fillLength = $contextStart - $lastEnd;
|
||||
$content .= e(substr($originalText, $lastEnd, $fillLength));
|
||||
$content .= e(mb_substr($originalText, $lastEnd, $fillLength));
|
||||
$contentTextLength += $fillLength;
|
||||
}
|
||||
|
||||
// Add our content including the bolded matching text
|
||||
$content .= e(substr($originalText, $contextStart, $start - $contextStart));
|
||||
$content .= e(mb_substr($originalText, $contextStart, $start - $contextStart));
|
||||
$contentTextLength += $start - $contextStart;
|
||||
$content .= '<strong>' . e(substr($originalText, $start, $end - $start)) . '</strong>';
|
||||
$content .= '<strong>' . e(mb_substr($originalText, $start, $end - $start)) . '</strong>';
|
||||
$contentTextLength += $end - $start;
|
||||
$content .= e(substr($originalText, $end, $contextEnd - $end));
|
||||
$content .= e(mb_substr($originalText, $end, $contextEnd - $end));
|
||||
$contentTextLength += $contextEnd - $end;
|
||||
|
||||
// Update our last end position
|
||||
@@ -204,7 +204,7 @@ class SearchResultsFormatter
|
||||
|
||||
// Just copy out the content if we haven't moved along anywhere.
|
||||
if ($lastEnd === 0) {
|
||||
$content = e(substr($originalText, 0, $targetLength));
|
||||
$content = e(mb_substr($originalText, 0, $targetLength));
|
||||
$contentTextLength = $targetLength;
|
||||
$lastEnd = $targetLength;
|
||||
}
|
||||
@@ -213,7 +213,7 @@ class SearchResultsFormatter
|
||||
$remainder = $targetLength - $contentTextLength;
|
||||
if ($remainder > 10) {
|
||||
$padEndLength = min($maxEnd - $lastEnd, $remainder);
|
||||
$content .= e(substr($originalText, $lastEnd, $padEndLength));
|
||||
$content .= e(mb_substr($originalText, $lastEnd, $padEndLength));
|
||||
$lastEnd += $padEndLength;
|
||||
$contentTextLength += $padEndLength;
|
||||
}
|
||||
@@ -223,7 +223,7 @@ class SearchResultsFormatter
|
||||
$firstStart = $firstStart ?: 0;
|
||||
if (!$fetchAll && $remainder > 10 && $firstStart !== 0) {
|
||||
$padStart = max(0, $firstStart - $remainder);
|
||||
$content = ($padStart === 0 ? '' : '...') . e(substr($originalText, $padStart, $firstStart - $padStart)) . substr($content, 4);
|
||||
$content = ($padStart === 0 ? '' : '...') . e(mb_substr($originalText, $padStart, $firstStart - $padStart)) . mb_substr($content, 4);
|
||||
}
|
||||
|
||||
// Add ellipsis if we're not at the end
|
||||
|
||||
@@ -22,9 +22,12 @@ class TrashCan
|
||||
{
|
||||
/**
|
||||
* Send a shelf to the recycle bin.
|
||||
*
|
||||
* @throws NotifyException
|
||||
*/
|
||||
public function softDestroyShelf(Bookshelf $shelf)
|
||||
{
|
||||
$this->ensureDeletable($shelf);
|
||||
Deletion::createForEntity($shelf);
|
||||
$shelf->delete();
|
||||
}
|
||||
@@ -36,6 +39,7 @@ class TrashCan
|
||||
*/
|
||||
public function softDestroyBook(Book $book)
|
||||
{
|
||||
$this->ensureDeletable($book);
|
||||
Deletion::createForEntity($book);
|
||||
|
||||
foreach ($book->pages as $page) {
|
||||
@@ -57,6 +61,7 @@ class TrashCan
|
||||
public function softDestroyChapter(Chapter $chapter, bool $recordDelete = true)
|
||||
{
|
||||
if ($recordDelete) {
|
||||
$this->ensureDeletable($chapter);
|
||||
Deletion::createForEntity($chapter);
|
||||
}
|
||||
|
||||
@@ -77,19 +82,47 @@ class TrashCan
|
||||
public function softDestroyPage(Page $page, bool $recordDelete = true)
|
||||
{
|
||||
if ($recordDelete) {
|
||||
$this->ensureDeletable($page);
|
||||
Deletion::createForEntity($page);
|
||||
}
|
||||
|
||||
// Check if set as custom homepage & remove setting if not used or throw error if active
|
||||
$customHome = setting('app-homepage', '0:');
|
||||
if (intval($page->id) === intval(explode(':', $customHome)[0])) {
|
||||
if (setting('app-homepage-type') === 'page') {
|
||||
throw new NotifyException(trans('errors.page_custom_home_deletion'), $page->getUrl());
|
||||
$page->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the given entity is deletable.
|
||||
* Is not for permissions, but logical conditions within the application.
|
||||
* Will throw if not deletable.
|
||||
*
|
||||
* @throws NotifyException
|
||||
*/
|
||||
protected function ensureDeletable(Entity $entity): void
|
||||
{
|
||||
$customHomeId = intval(explode(':', setting('app-homepage', '0:'))[0]);
|
||||
$customHomeActive = setting('app-homepage-type') === 'page';
|
||||
$removeCustomHome = false;
|
||||
|
||||
// Check custom homepage usage for pages
|
||||
if ($entity instanceof Page && $entity->id === $customHomeId) {
|
||||
if ($customHomeActive) {
|
||||
throw new NotifyException(trans('errors.page_custom_home_deletion'), $entity->getUrl());
|
||||
}
|
||||
setting()->remove('app-homepage');
|
||||
$removeCustomHome = true;
|
||||
}
|
||||
|
||||
$page->delete();
|
||||
// Check custom homepage usage within chapters or books
|
||||
if ($entity instanceof Chapter || $entity instanceof Book) {
|
||||
if ($entity->pages()->where('id', '=', $customHomeId)->exists()) {
|
||||
if ($customHomeActive) {
|
||||
throw new NotifyException(trans('errors.page_custom_home_deletion'), $entity->getUrl());
|
||||
}
|
||||
$removeCustomHome = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($removeCustomHome) {
|
||||
setting()->remove('app-homepage');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
class SortOperationException extends Exception
|
||||
{
|
||||
}
|
||||
@@ -4,6 +4,9 @@ namespace BookStack\Facades;
|
||||
|
||||
use Illuminate\Support\Facades\Facade;
|
||||
|
||||
/**
|
||||
* @see \BookStack\Actions\ActivityLogger
|
||||
*/
|
||||
class Activity extends Facade
|
||||
{
|
||||
/**
|
||||
|
||||
@@ -4,12 +4,14 @@ namespace BookStack\Http\Controllers\Api;
|
||||
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Tools\SearchOptions;
|
||||
use BookStack\Entities\Tools\SearchResultsFormatter;
|
||||
use BookStack\Entities\Tools\SearchRunner;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SearchApiController extends ApiController
|
||||
{
|
||||
protected $searchRunner;
|
||||
protected $resultsFormatter;
|
||||
|
||||
protected $rules = [
|
||||
'all' => [
|
||||
@@ -19,9 +21,10 @@ class SearchApiController extends ApiController
|
||||
],
|
||||
];
|
||||
|
||||
public function __construct(SearchRunner $searchRunner)
|
||||
public function __construct(SearchRunner $searchRunner, SearchResultsFormatter $resultsFormatter)
|
||||
{
|
||||
$this->searchRunner = $searchRunner;
|
||||
$this->resultsFormatter = $resultsFormatter;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,6 +48,7 @@ class SearchApiController extends ApiController
|
||||
$count = min(intval($request->get('count', '0')) ?: 20, 100);
|
||||
|
||||
$results = $this->searchRunner->searchEntities($options, 'all', $page, $count);
|
||||
$this->resultsFormatter->format($results['results']->all(), $options);
|
||||
|
||||
/** @var Entity $result */
|
||||
foreach ($results['results'] as $result) {
|
||||
@@ -52,9 +56,14 @@ class SearchApiController extends ApiController
|
||||
'id', 'name', 'slug', 'book_id',
|
||||
'chapter_id', 'draft', 'template',
|
||||
'created_at', 'updated_at',
|
||||
'tags', 'type',
|
||||
'tags', 'type', 'preview_html', 'url',
|
||||
]);
|
||||
$result->setAttribute('type', $result->getType());
|
||||
$result->setAttribute('url', $result->getUrl());
|
||||
$result->setAttribute('preview_html', [
|
||||
'name' => (string) $result->getAttribute('preview_name'),
|
||||
'content' => (string) $result->getAttribute('preview_content'),
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
|
||||
@@ -20,6 +20,7 @@ class AuditLogController extends Controller
|
||||
'date_from' => $request->get('date_from', ''),
|
||||
'date_to' => $request->get('date_to', ''),
|
||||
'user' => $request->get('user', ''),
|
||||
'ip' => $request->get('ip', ''),
|
||||
];
|
||||
|
||||
$query = Activity::query()
|
||||
@@ -44,6 +45,9 @@ class AuditLogController extends Controller
|
||||
if ($listDetails['date_to']) {
|
||||
$query->where('created_at', '<=', $listDetails['date_to']);
|
||||
}
|
||||
if ($listDetails['ip']) {
|
||||
$query->where('ip', 'like', $listDetails['ip'] . '%');
|
||||
}
|
||||
|
||||
$activities = $query->paginate(100);
|
||||
$activities->appends($listDetails);
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
namespace BookStack\Http\Controllers\Auth;
|
||||
|
||||
use Activity;
|
||||
use BookStack\Auth\Access\LoginService;
|
||||
use BookStack\Auth\Access\SocialAuthService;
|
||||
use BookStack\Exceptions\LoginAttemptEmailNeededException;
|
||||
use BookStack\Exceptions\LoginAttemptException;
|
||||
use BookStack\Facades\Activity;
|
||||
use BookStack\Http\Controllers\Controller;
|
||||
use Illuminate\Foundation\Auth\AuthenticatesUsers;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
@@ -29,6 +29,8 @@ class MfaBackupCodesController extends Controller
|
||||
|
||||
$downloadUrl = 'data:application/octet-stream;base64,' . base64_encode(implode("\n\n", $codes));
|
||||
|
||||
$this->setPageTitle(trans('auth.mfa_gen_backup_codes_title'));
|
||||
|
||||
return view('mfa.backup-codes-generate', [
|
||||
'codes' => $codes,
|
||||
'downloadUrl' => $downloadUrl,
|
||||
|
||||
@@ -21,6 +21,8 @@ class MfaController extends Controller
|
||||
->get(['id', 'method'])
|
||||
->groupBy('method');
|
||||
|
||||
$this->setPageTitle(trans('auth.mfa_setup'));
|
||||
|
||||
return view('mfa.setup', [
|
||||
'userMethods' => $userMethods,
|
||||
]);
|
||||
|
||||
@@ -34,6 +34,8 @@ class MfaTotpController extends Controller
|
||||
$qrCodeUrl = $totp->generateUrl($totpSecret, $this->currentOrLastAttemptedUser());
|
||||
$svg = $totp->generateQrCodeSvg($qrCodeUrl);
|
||||
|
||||
$this->setPageTitle(trans('auth.mfa_gen_totp_title'));
|
||||
|
||||
return view('mfa.totp-generate', [
|
||||
'url' => $qrCodeUrl,
|
||||
'svg' => $svg,
|
||||
|
||||
@@ -13,6 +13,7 @@ use Illuminate\Foundation\Auth\RegistersUsers;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class RegisterController extends Controller
|
||||
{
|
||||
@@ -70,7 +71,7 @@ class RegisterController extends Controller
|
||||
return Validator::make($data, [
|
||||
'name' => ['required', 'min:2', 'max:255'],
|
||||
'email' => ['required', 'email', 'max:255', 'unique:users'],
|
||||
'password' => ['required', 'min:8'],
|
||||
'password' => ['required', Password::default()],
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ use Exception;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Redirector;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class UserInviteController extends Controller
|
||||
{
|
||||
@@ -55,7 +56,7 @@ class UserInviteController extends Controller
|
||||
public function setPassword(Request $request, string $token)
|
||||
{
|
||||
$this->validate($request, [
|
||||
'password' => ['required', 'min:8'],
|
||||
'password' => ['required', Password::default()],
|
||||
]);
|
||||
|
||||
try {
|
||||
|
||||
@@ -2,15 +2,18 @@
|
||||
|
||||
namespace BookStack\Http\Controllers;
|
||||
|
||||
use Activity;
|
||||
use BookStack\Actions\ActivityQueries;
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Actions\View;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Repos\BookRepo;
|
||||
use BookStack\Entities\Tools\BookContents;
|
||||
use BookStack\Entities\Tools\Cloner;
|
||||
use BookStack\Entities\Tools\PermissionsUpdater;
|
||||
use BookStack\Entities\Tools\ShelfContext;
|
||||
use BookStack\Exceptions\ImageUploadException;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use BookStack\Facades\Activity;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Throwable;
|
||||
@@ -101,7 +104,7 @@ class BookController extends Controller
|
||||
|
||||
if ($bookshelf) {
|
||||
$bookshelf->appendBook($book);
|
||||
Activity::addForEntity($bookshelf, ActivityType::BOOKSHELF_UPDATE);
|
||||
Activity::add(ActivityType::BOOKSHELF_UPDATE, $bookshelf);
|
||||
}
|
||||
|
||||
return redirect($book->getUrl());
|
||||
@@ -110,7 +113,7 @@ class BookController extends Controller
|
||||
/**
|
||||
* Display the specified book.
|
||||
*/
|
||||
public function show(Request $request, string $slug)
|
||||
public function show(Request $request, ActivityQueries $activities, string $slug)
|
||||
{
|
||||
$book = $this->bookRepo->getBySlug($slug);
|
||||
$bookChildren = (new BookContents($book))->getTree(true);
|
||||
@@ -128,7 +131,7 @@ class BookController extends Controller
|
||||
'current' => $book,
|
||||
'bookChildren' => $bookChildren,
|
||||
'bookParentShelves' => $bookParentShelves,
|
||||
'activity' => Activity::entityActivity($book, 20, 1),
|
||||
'activity' => $activities->entityActivity($book, 20, 1),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -224,4 +227,39 @@ class BookController extends Controller
|
||||
|
||||
return redirect($book->getUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the view to copy a book.
|
||||
*
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
public function showCopy(string $bookSlug)
|
||||
{
|
||||
$book = $this->bookRepo->getBySlug($bookSlug);
|
||||
$this->checkOwnablePermission('book-view', $book);
|
||||
|
||||
session()->flashInput(['name' => $book->name]);
|
||||
|
||||
return view('books.copy', [
|
||||
'book' => $book,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a copy of a book within the requested target destination.
|
||||
*
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
public function copy(Request $request, Cloner $cloner, string $bookSlug)
|
||||
{
|
||||
$book = $this->bookRepo->getBySlug($bookSlug);
|
||||
$this->checkOwnablePermission('book-view', $book);
|
||||
$this->checkPermission('book-create-all');
|
||||
|
||||
$newName = $request->get('name') ?: $book->name;
|
||||
$bookCopy = $cloner->cloneBook($book, $newName);
|
||||
$this->showSuccessNotification(trans('entities.books_copy_success'));
|
||||
|
||||
return redirect($bookCopy->getUrl());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
namespace BookStack\Http\Controllers;
|
||||
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Repos\BookRepo;
|
||||
use BookStack\Entities\Tools\BookContents;
|
||||
use BookStack\Exceptions\SortOperationException;
|
||||
use BookStack\Entities\Tools\BookSortMap;
|
||||
use BookStack\Facades\Activity;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
@@ -59,20 +58,14 @@ class BookSortController extends Controller
|
||||
return redirect($book->getUrl());
|
||||
}
|
||||
|
||||
$sortMap = collect(json_decode($request->get('sort-tree')));
|
||||
$sortMap = BookSortMap::fromJson($request->get('sort-tree'));
|
||||
$bookContents = new BookContents($book);
|
||||
$booksInvolved = collect();
|
||||
|
||||
try {
|
||||
$booksInvolved = $bookContents->sortUsingMap($sortMap);
|
||||
} catch (SortOperationException $exception) {
|
||||
$this->showPermissionError();
|
||||
}
|
||||
$booksInvolved = $bookContents->sortUsingMap($sortMap);
|
||||
|
||||
// Rebuild permissions and add activity for involved books.
|
||||
$booksInvolved->each(function (Book $book) {
|
||||
Activity::addForEntity($book, ActivityType::BOOK_SORT);
|
||||
});
|
||||
foreach ($booksInvolved as $bookInvolved) {
|
||||
Activity::add(ActivityType::BOOK_SORT, $bookInvolved);
|
||||
}
|
||||
|
||||
return redirect($book->getUrl());
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace BookStack\Http\Controllers;
|
||||
|
||||
use Activity;
|
||||
use BookStack\Actions\ActivityQueries;
|
||||
use BookStack\Actions\View;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Repos\BookshelfRepo;
|
||||
@@ -101,7 +101,7 @@ class BookshelfController extends Controller
|
||||
*
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
public function show(string $slug)
|
||||
public function show(ActivityQueries $activities, string $slug)
|
||||
{
|
||||
$shelf = $this->bookshelfRepo->getBySlug($slug);
|
||||
$this->checkOwnablePermission('book-view', $shelf);
|
||||
@@ -124,7 +124,7 @@ class BookshelfController extends Controller
|
||||
'shelf' => $shelf,
|
||||
'sortedVisibleShelfBooks' => $sortedVisibleShelfBooks,
|
||||
'view' => $view,
|
||||
'activity' => Activity::entityActivity($shelf, 20, 1),
|
||||
'activity' => $activities->entityActivity($shelf, 20, 1),
|
||||
'order' => $order,
|
||||
'sort' => $sort,
|
||||
]);
|
||||
|
||||
@@ -6,10 +6,12 @@ use BookStack\Actions\View;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Repos\ChapterRepo;
|
||||
use BookStack\Entities\Tools\BookContents;
|
||||
use BookStack\Entities\Tools\Cloner;
|
||||
use BookStack\Entities\Tools\NextPreviousContentLocator;
|
||||
use BookStack\Entities\Tools\PermissionsUpdater;
|
||||
use BookStack\Exceptions\MoveOperationException;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use BookStack\Exceptions\PermissionsException;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Throwable;
|
||||
@@ -179,6 +181,8 @@ class ChapterController extends Controller
|
||||
|
||||
try {
|
||||
$newBook = $this->chapterRepo->move($chapter, $entitySelection);
|
||||
} catch (PermissionsException $exception) {
|
||||
$this->showPermissionError();
|
||||
} catch (MoveOperationException $exception) {
|
||||
$this->showErrorNotification(trans('errors.selected_book_not_found'));
|
||||
|
||||
@@ -190,6 +194,53 @@ class ChapterController extends Controller
|
||||
return redirect($chapter->getUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the view to copy a chapter.
|
||||
*
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
public function showCopy(string $bookSlug, string $chapterSlug)
|
||||
{
|
||||
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
|
||||
$this->checkOwnablePermission('chapter-view', $chapter);
|
||||
|
||||
session()->flashInput(['name' => $chapter->name]);
|
||||
|
||||
return view('chapters.copy', [
|
||||
'book' => $chapter->book,
|
||||
'chapter' => $chapter,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a copy of a chapter within the requested target destination.
|
||||
*
|
||||
* @throws NotFoundException
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function copy(Request $request, Cloner $cloner, string $bookSlug, string $chapterSlug)
|
||||
{
|
||||
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
|
||||
$this->checkOwnablePermission('chapter-view', $chapter);
|
||||
|
||||
$entitySelection = $request->get('entity_selection') ?: null;
|
||||
$newParentBook = $entitySelection ? $this->chapterRepo->findParentByIdentifier($entitySelection) : $chapter->getParent();
|
||||
|
||||
if (is_null($newParentBook)) {
|
||||
$this->showErrorNotification(trans('errors.selected_book_not_found'));
|
||||
|
||||
return redirect()->back();
|
||||
}
|
||||
|
||||
$this->checkOwnablePermission('chapter-create', $newParentBook);
|
||||
|
||||
$newName = $request->get('name') ?: $chapter->name;
|
||||
$chapterCopy = $cloner->cloneChapter($chapter, $newParentBook, $newName);
|
||||
$this->showSuccessNotification(trans('entities.chapters_copy_success'));
|
||||
|
||||
return redirect($chapterCopy->getUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the Restrictions view.
|
||||
*
|
||||
|
||||
@@ -48,6 +48,8 @@ abstract class Controller extends BaseController
|
||||
/**
|
||||
* On a permission error redirect to home and display.
|
||||
* the error as a notification.
|
||||
*
|
||||
* @return never
|
||||
*/
|
||||
protected function showPermissionError()
|
||||
{
|
||||
|
||||
@@ -21,6 +21,8 @@ class FavouriteController extends Controller
|
||||
|
||||
$hasMoreLink = ($favourites->count() > $viewCount) ? url('/favourites?page=' . ($page + 1)) : null;
|
||||
|
||||
$this->setPageTitle(trans('entities.my_favourites'));
|
||||
|
||||
return view('common.detailed-listing-with-more', [
|
||||
'title' => trans('entities.my_favourites'),
|
||||
'entities' => $favourites->slice(0, $viewCount),
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace BookStack\Http\Controllers;
|
||||
|
||||
use Activity;
|
||||
use BookStack\Actions\ActivityQueries;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Queries\RecentlyViewed;
|
||||
@@ -16,9 +16,9 @@ class HomeController extends Controller
|
||||
/**
|
||||
* Display the homepage.
|
||||
*/
|
||||
public function index()
|
||||
public function index(ActivityQueries $activities)
|
||||
{
|
||||
$activity = Activity::latest(10);
|
||||
$activity = $activities->latest(10);
|
||||
$draftPages = [];
|
||||
|
||||
if ($this->isSignedIn()) {
|
||||
|
||||
@@ -67,7 +67,7 @@ class MaintenanceController extends Controller
|
||||
$this->logActivity(ActivityType::MAINTENANCE_ACTION_RUN, 'send-test-email');
|
||||
|
||||
try {
|
||||
user()->notify(new TestEmail());
|
||||
user()->notifyNow(new TestEmail());
|
||||
$this->showSuccessNotification(trans('settings.maint_send_test_email_success', ['address' => user()->email]));
|
||||
} catch (\Exception $exception) {
|
||||
$errorMessage = trans('errors.maintenance_test_email_failure') . "\n" . $exception->getMessage();
|
||||
|
||||
@@ -6,6 +6,7 @@ use BookStack\Actions\View;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Repos\PageRepo;
|
||||
use BookStack\Entities\Tools\BookContents;
|
||||
use BookStack\Entities\Tools\Cloner;
|
||||
use BookStack\Entities\Tools\NextPreviousContentLocator;
|
||||
use BookStack\Entities\Tools\PageContent;
|
||||
use BookStack\Entities\Tools\PageEditActivity;
|
||||
@@ -13,6 +14,7 @@ use BookStack\Entities\Tools\PermissionsUpdater;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use BookStack\Exceptions\PermissionsException;
|
||||
use Exception;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Throwable;
|
||||
@@ -363,13 +365,22 @@ class PageController extends Controller
|
||||
*/
|
||||
public function showRecentlyUpdated()
|
||||
{
|
||||
$pages = Page::visible()->orderBy('updated_at', 'desc')
|
||||
$visibleBelongsScope = function (BelongsTo $query) {
|
||||
$query->scopes('visible');
|
||||
};
|
||||
|
||||
$pages = Page::visible()->with(['updatedBy', 'book' => $visibleBelongsScope, 'chapter' => $visibleBelongsScope])
|
||||
->orderBy('updated_at', 'desc')
|
||||
->paginate(20)
|
||||
->setPath(url('/pages/recently-updated'));
|
||||
|
||||
$this->setPageTitle(trans('entities.recently_updated_pages'));
|
||||
|
||||
return view('common.detailed-listing-paginated', [
|
||||
'title' => trans('entities.recently_updated_pages'),
|
||||
'entities' => $pages,
|
||||
'title' => trans('entities.recently_updated_pages'),
|
||||
'entities' => $pages,
|
||||
'showUpdatedBy' => true,
|
||||
'showPath' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -409,11 +420,9 @@ class PageController extends Controller
|
||||
|
||||
try {
|
||||
$parent = $this->pageRepo->move($page, $entitySelection);
|
||||
} catch (PermissionsException $exception) {
|
||||
$this->showPermissionError();
|
||||
} catch (Exception $exception) {
|
||||
if ($exception instanceof PermissionsException) {
|
||||
$this->showPermissionError();
|
||||
}
|
||||
|
||||
$this->showErrorNotification(trans('errors.selected_book_chapter_not_found'));
|
||||
|
||||
return redirect()->back();
|
||||
@@ -447,26 +456,24 @@ class PageController extends Controller
|
||||
* @throws NotFoundException
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function copy(Request $request, string $bookSlug, string $pageSlug)
|
||||
public function copy(Request $request, Cloner $cloner, string $bookSlug, string $pageSlug)
|
||||
{
|
||||
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
|
||||
$this->checkOwnablePermission('page-view', $page);
|
||||
|
||||
$entitySelection = $request->get('entity_selection', null) ?? null;
|
||||
$newName = $request->get('name', null);
|
||||
|
||||
try {
|
||||
$pageCopy = $this->pageRepo->copy($page, $entitySelection, $newName);
|
||||
} catch (Exception $exception) {
|
||||
if ($exception instanceof PermissionsException) {
|
||||
$this->showPermissionError();
|
||||
}
|
||||
$entitySelection = $request->get('entity_selection') ?: null;
|
||||
$newParent = $entitySelection ? $this->pageRepo->findParentByIdentifier($entitySelection) : $page->getParent();
|
||||
|
||||
if (is_null($newParent)) {
|
||||
$this->showErrorNotification(trans('errors.selected_book_chapter_not_found'));
|
||||
|
||||
return redirect()->back();
|
||||
}
|
||||
|
||||
$this->checkOwnablePermission('page-create', $newParent);
|
||||
|
||||
$newName = $request->get('name') ?: $page->name;
|
||||
$pageCopy = $cloner->clonePage($page, $newParent, $newName);
|
||||
$this->showSuccessNotification(trans('entities.pages_copy_success'));
|
||||
|
||||
return redirect($pageCopy->getUrl());
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace BookStack\Http\Controllers;
|
||||
|
||||
use BookStack\Auth\Permissions\PermissionsRepo;
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Exceptions\PermissionsException;
|
||||
use Exception;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -23,22 +24,36 @@ class RoleController extends Controller
|
||||
/**
|
||||
* Show a listing of the roles in the system.
|
||||
*/
|
||||
public function list()
|
||||
public function index()
|
||||
{
|
||||
$this->checkPermission('user-roles-manage');
|
||||
$roles = $this->permissionsRepo->getAllRoles();
|
||||
|
||||
$this->setPageTitle(trans('settings.roles'));
|
||||
|
||||
return view('settings.roles.index', ['roles' => $roles]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form to create a new role.
|
||||
*/
|
||||
public function create()
|
||||
public function create(Request $request)
|
||||
{
|
||||
$this->checkPermission('user-roles-manage');
|
||||
|
||||
return view('settings.roles.create');
|
||||
/** @var ?Role $role */
|
||||
$role = null;
|
||||
if ($request->has('copy_from')) {
|
||||
$role = Role::query()->find($request->get('copy_from'));
|
||||
}
|
||||
|
||||
if ($role) {
|
||||
$role->display_name .= ' (' . trans('common.copy') . ')';
|
||||
}
|
||||
|
||||
$this->setPageTitle(trans('settings.role_create'));
|
||||
|
||||
return view('settings.roles.create', ['role' => $role]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,7 +64,7 @@ class RoleController extends Controller
|
||||
$this->checkPermission('user-roles-manage');
|
||||
$this->validate($request, [
|
||||
'display_name' => ['required', 'min:3', 'max:180'],
|
||||
'description' => 'max:180',
|
||||
'description' => ['max:180'],
|
||||
]);
|
||||
|
||||
$this->permissionsRepo->saveNewRole($request->all());
|
||||
@@ -71,6 +86,8 @@ class RoleController extends Controller
|
||||
throw new PermissionsException(trans('errors.role_cannot_be_edited'));
|
||||
}
|
||||
|
||||
$this->setPageTitle(trans('settings.role_edit'));
|
||||
|
||||
return view('settings.roles.edit', ['role' => $role]);
|
||||
}
|
||||
|
||||
@@ -84,7 +101,7 @@ class RoleController extends Controller
|
||||
$this->checkPermission('user-roles-manage');
|
||||
$this->validate($request, [
|
||||
'display_name' => ['required', 'min:3', 'max:180'],
|
||||
'description' => 'max:180',
|
||||
'description' => ['max:180'],
|
||||
]);
|
||||
|
||||
$this->permissionsRepo->updateRole($id, $request->all());
|
||||
@@ -105,6 +122,8 @@ class RoleController extends Controller
|
||||
$blankRole = $role->newInstance(['display_name' => trans('settings.role_delete_no_migration')]);
|
||||
$roles->prepend($blankRole);
|
||||
|
||||
$this->setPageTitle(trans('settings.role_delete'));
|
||||
|
||||
return view('settings.roles.delete', ['role' => $role, 'roles' => $roles]);
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@ class TagController extends Controller
|
||||
'name' => $nameFilter,
|
||||
]));
|
||||
|
||||
$this->setPageTitle(trans('entities.tags'));
|
||||
|
||||
return view('tags.index', [
|
||||
'tags' => $tags,
|
||||
'search' => $search,
|
||||
|
||||
@@ -12,7 +12,9 @@ use BookStack\Exceptions\UserUpdateException;
|
||||
use BookStack\Uploads\ImageRepo;
|
||||
use Exception;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class UserController extends Controller
|
||||
@@ -82,7 +84,7 @@ class UserController extends Controller
|
||||
$sendInvite = ($request->get('send_invite', 'false') === 'true');
|
||||
|
||||
if ($authMethod === 'standard' && !$sendInvite) {
|
||||
$validationRules['password'] = ['required', 'min:6'];
|
||||
$validationRules['password'] = ['required', Password::default()];
|
||||
$validationRules['password-confirm'] = ['required', 'same:password'];
|
||||
} elseif ($authMethod === 'ldap' || $authMethod === 'saml2' || $authMethod === 'openid') {
|
||||
$validationRules['external_auth_id'] = ['required'];
|
||||
@@ -98,20 +100,23 @@ class UserController extends Controller
|
||||
}
|
||||
|
||||
$user->refreshSlug();
|
||||
$user->save();
|
||||
|
||||
if ($sendInvite) {
|
||||
$this->inviteService->sendInvitation($user);
|
||||
}
|
||||
DB::transaction(function () use ($user, $sendInvite, $request) {
|
||||
$user->save();
|
||||
|
||||
if ($request->filled('roles')) {
|
||||
$roles = $request->get('roles');
|
||||
$this->userRepo->setUserRoles($user, $roles);
|
||||
}
|
||||
if ($sendInvite) {
|
||||
$this->inviteService->sendInvitation($user);
|
||||
}
|
||||
|
||||
$this->userRepo->downloadAndAssignUserAvatar($user);
|
||||
if ($request->filled('roles')) {
|
||||
$roles = $request->get('roles');
|
||||
$this->userRepo->setUserRoles($user, $roles);
|
||||
}
|
||||
|
||||
$this->logActivity(ActivityType::USER_CREATE, $user);
|
||||
$this->userRepo->downloadAndAssignUserAvatar($user);
|
||||
|
||||
$this->logActivity(ActivityType::USER_CREATE, $user);
|
||||
});
|
||||
|
||||
return redirect('/settings/users');
|
||||
}
|
||||
@@ -155,11 +160,11 @@ class UserController extends Controller
|
||||
$this->checkPermissionOrCurrentUser('users-manage', $id);
|
||||
|
||||
$this->validate($request, [
|
||||
'name' => 'min:2',
|
||||
'name' => ['min:2'],
|
||||
'email' => ['min:2', 'email', 'unique:users,email,' . $id],
|
||||
'password' => ['min:6', 'required_with:password_confirm'],
|
||||
'password' => ['required_with:password_confirm', Password::default()],
|
||||
'password-confirm' => ['same:password', 'required_with:password'],
|
||||
'setting' => 'array',
|
||||
'setting' => ['array'],
|
||||
'profile_image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
]);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace BookStack\Http\Controllers;
|
||||
|
||||
use BookStack\Actions\ActivityQueries;
|
||||
use BookStack\Auth\UserRepo;
|
||||
|
||||
class UserProfileController extends Controller
|
||||
@@ -9,14 +10,16 @@ class UserProfileController extends Controller
|
||||
/**
|
||||
* Show the user profile page.
|
||||
*/
|
||||
public function show(UserRepo $repo, string $slug)
|
||||
public function show(UserRepo $repo, ActivityQueries $activities, string $slug)
|
||||
{
|
||||
$user = $repo->getBySlug($slug);
|
||||
|
||||
$userActivity = $repo->getActivity($user);
|
||||
$userActivity = $activities->userActivity($user);
|
||||
$recentlyCreated = $repo->getRecentlyCreated($user, 5);
|
||||
$assetCounts = $repo->getAssetCounts($user);
|
||||
|
||||
$this->setPageTitle($user->name);
|
||||
|
||||
return view('users.profile', [
|
||||
'user' => $user,
|
||||
'activity' => $userActivity,
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace BookStack\Http\Controllers;
|
||||
|
||||
use BookStack\Auth\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class UserSearchController extends Controller
|
||||
@@ -14,19 +13,27 @@ class UserSearchController extends Controller
|
||||
*/
|
||||
public function forSelect(Request $request)
|
||||
{
|
||||
$hasPermission = signedInUser() && (
|
||||
userCan('users-manage')
|
||||
|| userCan('restrictions-manage-own')
|
||||
|| userCan('restrictions-manage-all')
|
||||
);
|
||||
|
||||
if (!$hasPermission) {
|
||||
$this->showPermissionError();
|
||||
}
|
||||
|
||||
$search = $request->get('search', '');
|
||||
$query = User::query()->orderBy('name', 'desc')
|
||||
$query = User::query()
|
||||
->orderBy('name', 'asc')
|
||||
->take(20);
|
||||
|
||||
if (!empty($search)) {
|
||||
$query->where(function (Builder $query) use ($search) {
|
||||
$query->where('email', 'like', '%' . $search . '%')
|
||||
->orWhere('name', 'like', '%' . $search . '%');
|
||||
});
|
||||
$query->where('name', 'like', '%' . $search . '%');
|
||||
}
|
||||
|
||||
$users = $query->get();
|
||||
|
||||
return view('form.user-select-list', compact('users'));
|
||||
return view('form.user-select-list', [
|
||||
'users' => $query->get(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
134
app/Http/Controllers/WebhookController.php
Normal file
134
app/Http/Controllers/WebhookController.php
Normal file
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Http\Controllers;
|
||||
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Actions\Webhook;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class WebhookController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware([
|
||||
'can:settings-manage',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show all webhooks configured in the system.
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$webhooks = Webhook::query()
|
||||
->orderBy('name', 'desc')
|
||||
->with('trackedEvents')
|
||||
->get();
|
||||
|
||||
$this->setPageTitle(trans('settings.webhooks'));
|
||||
|
||||
return view('settings.webhooks.index', ['webhooks' => $webhooks]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the view for creating a new webhook in the system.
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
$this->setPageTitle(trans('settings.webhooks_create'));
|
||||
|
||||
return view('settings.webhooks.create');
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new webhook in the system.
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $this->validate($request, [
|
||||
'name' => ['required', 'max:150'],
|
||||
'endpoint' => ['required', 'url', 'max:500'],
|
||||
'events' => ['required', 'array'],
|
||||
'active' => ['required'],
|
||||
'timeout' => ['required', 'integer', 'min:1', 'max:600'],
|
||||
]);
|
||||
|
||||
$webhook = new Webhook($validated);
|
||||
$webhook->active = $validated['active'] === 'true';
|
||||
$webhook->save();
|
||||
$webhook->updateTrackedEvents(array_values($validated['events']));
|
||||
|
||||
$this->logActivity(ActivityType::WEBHOOK_CREATE, $webhook);
|
||||
|
||||
return redirect('/settings/webhooks');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the view to edit an existing webhook.
|
||||
*/
|
||||
public function edit(string $id)
|
||||
{
|
||||
/** @var Webhook $webhook */
|
||||
$webhook = Webhook::query()
|
||||
->with('trackedEvents')
|
||||
->findOrFail($id);
|
||||
|
||||
$this->setPageTitle(trans('settings.webhooks_edit'));
|
||||
|
||||
return view('settings.webhooks.edit', ['webhook' => $webhook]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing webhook with the provided request data.
|
||||
*/
|
||||
public function update(Request $request, string $id)
|
||||
{
|
||||
$validated = $this->validate($request, [
|
||||
'name' => ['required', 'max:150'],
|
||||
'endpoint' => ['required', 'url', 'max:500'],
|
||||
'events' => ['required', 'array'],
|
||||
'active' => ['required'],
|
||||
'timeout' => ['required', 'integer', 'min:1', 'max:600'],
|
||||
]);
|
||||
|
||||
/** @var Webhook $webhook */
|
||||
$webhook = Webhook::query()->findOrFail($id);
|
||||
|
||||
$webhook->active = $validated['active'] === 'true';
|
||||
$webhook->fill($validated)->save();
|
||||
$webhook->updateTrackedEvents($validated['events']);
|
||||
|
||||
$this->logActivity(ActivityType::WEBHOOK_UPDATE, $webhook);
|
||||
|
||||
return redirect('/settings/webhooks');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the view to delete a webhook.
|
||||
*/
|
||||
public function delete(string $id)
|
||||
{
|
||||
/** @var Webhook $webhook */
|
||||
$webhook = Webhook::query()->findOrFail($id);
|
||||
|
||||
$this->setPageTitle(trans('settings.webhooks_delete'));
|
||||
|
||||
return view('settings.webhooks.delete', ['webhook' => $webhook]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy a webhook from the system.
|
||||
*/
|
||||
public function destroy(string $id)
|
||||
{
|
||||
/** @var Webhook $webhook */
|
||||
$webhook = Webhook::query()->findOrFail($id);
|
||||
|
||||
$webhook->trackedEvents()->delete();
|
||||
$webhook->delete();
|
||||
|
||||
$this->logActivity(ActivityType::WEBHOOK_DELETE, $webhook);
|
||||
|
||||
return redirect('/settings/webhooks');
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use BookStack\Auth\Access\LoginService;
|
||||
use BookStack\Auth\Access\RegistrationService;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class AuthServiceProvider extends ServiceProvider
|
||||
{
|
||||
@@ -21,6 +22,12 @@ class AuthServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function boot()
|
||||
{
|
||||
// Password Configuration
|
||||
Password::defaults(function () {
|
||||
return Password::min(8);
|
||||
});
|
||||
|
||||
// Custom guards
|
||||
Auth::extend('api-token', function ($app, $name, array $config) {
|
||||
return new ApiTokenGuard($app['request'], $app->make(LoginService::class));
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace BookStack\Providers;
|
||||
|
||||
use BookStack\Actions\ActivityService;
|
||||
use BookStack\Actions\ActivityLogger;
|
||||
use BookStack\Auth\Permissions\PermissionService;
|
||||
use BookStack\Theming\ThemeService;
|
||||
use BookStack\Uploads\ImageService;
|
||||
@@ -28,7 +28,7 @@ class CustomFacadeProvider extends ServiceProvider
|
||||
public function register()
|
||||
{
|
||||
$this->app->singleton('activity', function () {
|
||||
return $this->app->make(ActivityService::class);
|
||||
return $this->app->make(ActivityLogger::class);
|
||||
});
|
||||
|
||||
$this->app->singleton('images', function () {
|
||||
|
||||
@@ -79,4 +79,20 @@ class ThemeEvents
|
||||
* @returns \League\CommonMark\ConfigurableEnvironmentInterface|null
|
||||
*/
|
||||
const COMMONMARK_ENVIRONMENT_CONFIGURE = 'commonmark_environment_configure';
|
||||
|
||||
/**
|
||||
* Webhook call before event.
|
||||
* Runs before a webhook endpoint is called. Allows for customization
|
||||
* of the data format & content within the webhook POST request.
|
||||
* Provides the original event name as a string (see \BookStack\Actions\ActivityType)
|
||||
* along with the webhook instance along with the event detail which may be a
|
||||
* "Loggable" model type or a string.
|
||||
* If the listener returns a non-null value, that will be used as the POST data instead
|
||||
* of the system default.
|
||||
*
|
||||
* @param string $event
|
||||
* @param \BookStack\Actions\Webhook $webhook
|
||||
* @param string|\BookStack\Interfaces\Loggable $detail
|
||||
*/
|
||||
const WEBHOOK_CALL_BEFORE = 'webhook_call_before';
|
||||
}
|
||||
|
||||
@@ -228,6 +228,21 @@ class ImageService
|
||||
return strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'gif';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given image and image data is apng.
|
||||
*/
|
||||
protected function isApngData(Image $image, string &$imageData): bool
|
||||
{
|
||||
$isPng = strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'png';
|
||||
if (!$isPng) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$initialHeader = substr($imageData, 0, strpos($imageData, 'IDAT'));
|
||||
|
||||
return strpos($initialHeader, 'acTL') !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the thumbnail for an image.
|
||||
* If $keepRatio is true only the width will be used.
|
||||
@@ -238,6 +253,7 @@ class ImageService
|
||||
*/
|
||||
public function getThumbnail(Image $image, ?int $width, ?int $height, bool $keepRatio = false): string
|
||||
{
|
||||
// Do not resize GIF images where we're not cropping
|
||||
if ($keepRatio && $this->isGif($image)) {
|
||||
return $this->getPublicUrl($image->path);
|
||||
}
|
||||
@@ -246,19 +262,35 @@ class ImageService
|
||||
$imagePath = $image->path;
|
||||
$thumbFilePath = dirname($imagePath) . $thumbDirName . basename($imagePath);
|
||||
|
||||
if ($this->cache->has('images-' . $image->id . '-' . $thumbFilePath) && $this->cache->get('images-' . $thumbFilePath)) {
|
||||
return $this->getPublicUrl($thumbFilePath);
|
||||
$thumbCacheKey = 'images::' . $image->id . '::' . $thumbFilePath;
|
||||
|
||||
// Return path if in cache
|
||||
$cachedThumbPath = $this->cache->get($thumbCacheKey);
|
||||
if ($cachedThumbPath) {
|
||||
return $this->getPublicUrl($cachedThumbPath);
|
||||
}
|
||||
|
||||
// If thumbnail has already been generated, serve that and cache path
|
||||
$storage = $this->getStorageDisk($image->type);
|
||||
if ($storage->exists($this->adjustPathForStorageDisk($thumbFilePath, $image->type))) {
|
||||
$this->cache->put($thumbCacheKey, $thumbFilePath, 60 * 60 * 72);
|
||||
|
||||
return $this->getPublicUrl($thumbFilePath);
|
||||
}
|
||||
|
||||
$thumbData = $this->resizeImage($storage->get($this->adjustPathForStorageDisk($imagePath, $image->type)), $width, $height, $keepRatio);
|
||||
$imageData = $storage->get($this->adjustPathForStorageDisk($imagePath, $image->type));
|
||||
|
||||
// Do not resize apng images where we're not cropping
|
||||
if ($keepRatio && $this->isApngData($image, $imageData)) {
|
||||
$this->cache->put($thumbCacheKey, $image->path, 60 * 60 * 72);
|
||||
|
||||
return $this->getPublicUrl($image->path);
|
||||
}
|
||||
|
||||
// If not in cache and thumbnail does not exist, generate thumb and cache path
|
||||
$thumbData = $this->resizeImage($imageData, $width, $height, $keepRatio);
|
||||
$this->saveImageDataInPublicSpace($storage, $this->adjustPathForStorageDisk($thumbFilePath, $image->type), $thumbData);
|
||||
$this->cache->put('images-' . $image->id . '-' . $thumbFilePath, $thumbFilePath, 60 * 60 * 72);
|
||||
$this->cache->put($thumbCacheKey, $thumbFilePath, 60 * 60 * 72);
|
||||
|
||||
return $this->getPublicUrl($thumbFilePath);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ class WebSafeMimeSniffer
|
||||
'application/json',
|
||||
'application/octet-stream',
|
||||
'application/pdf',
|
||||
'image/apng',
|
||||
'image/bmp',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"socialiteproviders/okta": "^4.1",
|
||||
"socialiteproviders/slack": "^4.1",
|
||||
"socialiteproviders/twitch": "^5.3",
|
||||
"ssddanbrown/htmldiff": "^1.0.1"
|
||||
"ssddanbrown/htmldiff": "^1.0.2"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.16",
|
||||
|
||||
1608
composer.lock
generated
1608
composer.lock
generated
File diff suppressed because it is too large
Load Diff
26
database/factories/Actions/WebhookFactory.php
Normal file
26
database/factories/Actions/WebhookFactory.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories\Actions;
|
||||
|
||||
use BookStack\Actions\Webhook;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
class WebhookFactory extends Factory
|
||||
{
|
||||
protected $model = Webhook::class;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function definition()
|
||||
{
|
||||
return [
|
||||
'name' => 'My webhook for ' . $this->faker->country(),
|
||||
'endpoint' => $this->faker->url,
|
||||
'active' => true,
|
||||
'timeout' => 3,
|
||||
];
|
||||
}
|
||||
}
|
||||
23
database/factories/Actions/WebhookTrackedEventFactory.php
Normal file
23
database/factories/Actions/WebhookTrackedEventFactory.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories\Actions;
|
||||
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Actions\Webhook;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
class WebhookTrackedEventFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function definition()
|
||||
{
|
||||
return [
|
||||
'webhook_id' => Webhook::factory(),
|
||||
'event' => ActivityType::all()[array_rand(ActivityType::all())],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace Database\Factories\Auth;
|
||||
|
||||
use BookStack\Auth\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
@@ -12,7 +13,7 @@ class UserFactory extends Factory
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $model = \BookStack\Auth\User::class;
|
||||
protected $model = User::class;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
@@ -26,7 +27,7 @@ class UserFactory extends Factory
|
||||
return [
|
||||
'name' => $name,
|
||||
'email' => $this->faker->email,
|
||||
'slug' => \Illuminate\Support\Str::slug($name . '-' . \Illuminate\Support\Str::random(5)),
|
||||
'slug' => Str::slug($name . '-' . Str::random(5)),
|
||||
'password' => Str::random(10),
|
||||
'remember_token' => Str::random(10),
|
||||
'email_confirmed' => 1,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class AddIndexForUserIp extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::table('activities', function (Blueprint $table) {
|
||||
$table->index('ip', 'activities_ip_index');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::table('activities', function (Blueprint $table) {
|
||||
$table->dropIndex('activities_ip_index');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class CreateWebhooksTable extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::create('webhooks', function (Blueprint $table) {
|
||||
$table->increments('id');
|
||||
$table->string('name', 150);
|
||||
$table->boolean('active');
|
||||
$table->string('endpoint', 500);
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('name');
|
||||
$table->index('active');
|
||||
});
|
||||
|
||||
Schema::create('webhook_tracked_events', function (Blueprint $table) {
|
||||
$table->increments('id');
|
||||
$table->integer('webhook_id');
|
||||
$table->string('event', 50);
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('event');
|
||||
$table->index('webhook_id');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::dropIfExists('webhooks');
|
||||
Schema::dropIfExists('webhook_tracked_events');
|
||||
}
|
||||
}
|
||||
36
database/migrations/2021_12_13_152024_create_jobs_table.php
Normal file
36
database/migrations/2021_12_13_152024_create_jobs_table.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class CreateJobsTable extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::create('jobs', function (Blueprint $table) {
|
||||
$table->bigIncrements('id');
|
||||
$table->string('queue')->index();
|
||||
$table->longText('payload');
|
||||
$table->unsignedTinyInteger('attempts');
|
||||
$table->unsignedInteger('reserved_at')->nullable();
|
||||
$table->unsignedInteger('available_at');
|
||||
$table->unsignedInteger('created_at');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::dropIfExists('jobs');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class CreateFailedJobsTable extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::create('failed_jobs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('uuid')->unique();
|
||||
$table->text('connection');
|
||||
$table->text('queue');
|
||||
$table->longText('payload');
|
||||
$table->longText('exception');
|
||||
$table->timestamp('failed_at')->useCurrent();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::dropIfExists('failed_jobs');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class AddWebhooksTimeoutErrorColumns extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::table('webhooks', function (Blueprint $table) {
|
||||
$table->unsignedInteger('timeout')->default(3);
|
||||
$table->text('last_error')->default('');
|
||||
$table->timestamp('last_called_at')->nullable();
|
||||
$table->timestamp('last_errored_at')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::table('webhooks', function (Blueprint $table) {
|
||||
$table->dropColumn('timeout');
|
||||
$table->dropColumn('last_error');
|
||||
$table->dropColumn('last_called_at');
|
||||
$table->dropColumn('last_errored_at');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,11 @@
|
||||
"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",
|
||||
"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"
|
||||
},
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
@@ -21,6 +26,11 @@
|
||||
"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",
|
||||
"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..."
|
||||
},
|
||||
"tags": [
|
||||
{
|
||||
"name": "Animal",
|
||||
@@ -45,6 +55,11 @@
|
||||
"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",
|
||||
"preview_html": {
|
||||
"name": "How advanced are <strong>cats</strong>?",
|
||||
"content": "<strong>cats</strong> are some of the most advanced animals in the world."
|
||||
},
|
||||
"tags": []
|
||||
}
|
||||
],
|
||||
|
||||
@@ -8,6 +8,8 @@ RUN apt-get update -y \
|
||||
&& apt-get install -y git zip unzip libpng-dev libldap2-dev libzip-dev wait-for-it \
|
||||
&& docker-php-ext-configure ldap --with-libdir=lib/x86_64-linux-gnu \
|
||||
&& 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
|
||||
|
||||
7
dev/docker/php/conf.d/xdebug.ini
Normal file
7
dev/docker/php/conf.d/xdebug.ini
Normal file
@@ -0,0 +1,7 @@
|
||||
zend_extension=xdebug
|
||||
|
||||
[xdebug]
|
||||
xdebug.mode=debug
|
||||
xdebug.client_host=host.docker.internal
|
||||
xdebug.start_with_request=yes
|
||||
xdebug.client_port=9090
|
||||
@@ -38,7 +38,10 @@ services:
|
||||
- ${DEV_PORT:-8080}:80
|
||||
volumes:
|
||||
- ./:/app
|
||||
- ./dev/docker/php/conf.d/xdebug.ini:/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
|
||||
entrypoint: /app/dev/docker/entrypoint.app.sh
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
node:
|
||||
image: node:alpine
|
||||
working_dir: /app
|
||||
|
||||
385
package-lock.json
generated
385
package-lock.json
generated
@@ -6,19 +6,19 @@
|
||||
"": {
|
||||
"dependencies": {
|
||||
"clipboard": "^2.0.8",
|
||||
"codemirror": "^5.63.3",
|
||||
"codemirror": "^5.65.1",
|
||||
"dropzone": "^5.9.3",
|
||||
"markdown-it": "^12.2.0",
|
||||
"markdown-it": "^12.3.2",
|
||||
"markdown-it-task-lists": "^2.1.1",
|
||||
"sortablejs": "^1.14.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"chokidar-cli": "^3.0",
|
||||
"esbuild": "0.13.12",
|
||||
"esbuild": "0.14.13",
|
||||
"livereload": "^0.9.3",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"punycode": "^2.1.1",
|
||||
"sass": "^1.43.4"
|
||||
"sass": "^1.49.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
@@ -194,9 +194,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/codemirror": {
|
||||
"version": "5.63.3",
|
||||
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.63.3.tgz",
|
||||
"integrity": "sha512-1C+LELr+5grgJYqwZKqxrcbPsHFHapVaVAloBsFBASbpLnQqLw1U8yXJ3gT5D+rhxIiSpo+kTqN+hQ+9ialIXw=="
|
||||
"version": "5.65.1",
|
||||
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.65.1.tgz",
|
||||
"integrity": "sha512-s6aac+DD+4O2u1aBmdxhB7yz2XU7tG3snOyQ05Kxifahz7hoxnfxIRHxiCSEv3TUC38dIVH8G+lZH9UWSfGQxA=="
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "1.9.3",
|
||||
@@ -341,38 +341,39 @@
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.13.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.13.12.tgz",
|
||||
"integrity": "sha512-vTKKUt+yoz61U/BbrnmlG9XIjwpdIxmHB8DlPR0AAW6OdS+nBQBci6LUHU2q9WbBobMEIQxxDpKbkmOGYvxsow==",
|
||||
"version": "0.14.13",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.14.13.tgz",
|
||||
"integrity": "sha512-FIxvAdj3i2oHA6ex+E67bG7zlSTO+slt8kU2ogHDgGtrQLy2HNChv3PYjiFTYkt8hZbEAniZCXVeHn+FrHt7dA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"esbuild-android-arm64": "0.13.12",
|
||||
"esbuild-darwin-64": "0.13.12",
|
||||
"esbuild-darwin-arm64": "0.13.12",
|
||||
"esbuild-freebsd-64": "0.13.12",
|
||||
"esbuild-freebsd-arm64": "0.13.12",
|
||||
"esbuild-linux-32": "0.13.12",
|
||||
"esbuild-linux-64": "0.13.12",
|
||||
"esbuild-linux-arm": "0.13.12",
|
||||
"esbuild-linux-arm64": "0.13.12",
|
||||
"esbuild-linux-mips64le": "0.13.12",
|
||||
"esbuild-linux-ppc64le": "0.13.12",
|
||||
"esbuild-netbsd-64": "0.13.12",
|
||||
"esbuild-openbsd-64": "0.13.12",
|
||||
"esbuild-sunos-64": "0.13.12",
|
||||
"esbuild-windows-32": "0.13.12",
|
||||
"esbuild-windows-64": "0.13.12",
|
||||
"esbuild-windows-arm64": "0.13.12"
|
||||
"esbuild-android-arm64": "0.14.13",
|
||||
"esbuild-darwin-64": "0.14.13",
|
||||
"esbuild-darwin-arm64": "0.14.13",
|
||||
"esbuild-freebsd-64": "0.14.13",
|
||||
"esbuild-freebsd-arm64": "0.14.13",
|
||||
"esbuild-linux-32": "0.14.13",
|
||||
"esbuild-linux-64": "0.14.13",
|
||||
"esbuild-linux-arm": "0.14.13",
|
||||
"esbuild-linux-arm64": "0.14.13",
|
||||
"esbuild-linux-mips64le": "0.14.13",
|
||||
"esbuild-linux-ppc64le": "0.14.13",
|
||||
"esbuild-linux-s390x": "0.14.13",
|
||||
"esbuild-netbsd-64": "0.14.13",
|
||||
"esbuild-openbsd-64": "0.14.13",
|
||||
"esbuild-sunos-64": "0.14.13",
|
||||
"esbuild-windows-32": "0.14.13",
|
||||
"esbuild-windows-64": "0.14.13",
|
||||
"esbuild-windows-arm64": "0.14.13"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild-android-arm64": {
|
||||
"version": "0.13.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-android-arm64/-/esbuild-android-arm64-0.13.12.tgz",
|
||||
"integrity": "sha512-TSVZVrb4EIXz6KaYjXfTzPyyRpXV5zgYIADXtQsIenjZ78myvDGaPi11o4ZSaHIwFHsuwkB6ne5SZRBwAQ7maw==",
|
||||
"version": "0.14.13",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-android-arm64/-/esbuild-android-arm64-0.14.13.tgz",
|
||||
"integrity": "sha512-rhtwl+KJ3BzzXkK09N3/YbEF1i5WhriysJEStoeWNBzchx9hlmzyWmDGQQhu56HF78ua3JrVPyLOsdLGvtMvxQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -383,9 +384,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/esbuild-darwin-64": {
|
||||
"version": "0.13.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.13.12.tgz",
|
||||
"integrity": "sha512-c51C+N+UHySoV2lgfWSwwmlnLnL0JWj/LzuZt9Ltk9ub1s2Y8cr6SQV5W3mqVH1egUceew6KZ8GyI4nwu+fhsw==",
|
||||
"version": "0.14.13",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.14.13.tgz",
|
||||
"integrity": "sha512-Fl47xIt5RMu50WIgMU93kwmUUJb+BPuL8R895n/aBNQqavS+KUMpLPoqKGABBV4myfx/fnAD/97X8Gt1C1YW6w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -396,9 +397,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/esbuild-darwin-arm64": {
|
||||
"version": "0.13.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.13.12.tgz",
|
||||
"integrity": "sha512-JvAMtshP45Hd8A8wOzjkY1xAnTKTYuP/QUaKp5eUQGX+76GIie3fCdUUr2ZEKdvpSImNqxiZSIMziEiGB5oUmQ==",
|
||||
"version": "0.14.13",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.13.tgz",
|
||||
"integrity": "sha512-UttqKRFXsWvuivcyAbFmo54vdkC9Me1ZYQNuoz/uBYDbkb2MgqKYG2+xoVKPBhLvhT0CKM5QGKD81flMH5BE6A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -409,9 +410,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/esbuild-freebsd-64": {
|
||||
"version": "0.13.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-freebsd-64/-/esbuild-freebsd-64-0.13.12.tgz",
|
||||
"integrity": "sha512-r6On/Skv9f0ZjTu6PW5o7pdXr8aOgtFOEURJZYf1XAJs0IQ+gW+o1DzXjVkIoT+n1cm3N/t1KRJfX71MPg/ZUA==",
|
||||
"version": "0.14.13",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.13.tgz",
|
||||
"integrity": "sha512-dlIhPFSp29Yq2TPh7Cm3/4M0uKjlfvOylHVNCRvRNiOvDbBol6/NZ3kLisczms+Yra0rxVapBPN1oMbSMuts9g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -422,9 +423,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/esbuild-freebsd-arm64": {
|
||||
"version": "0.13.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.13.12.tgz",
|
||||
"integrity": "sha512-F6LmI2Q1gii073kmBE3NOTt/6zLL5zvZsxNLF8PMAwdHc+iBhD1vzfI8uQZMJA1IgXa3ocr3L3DJH9fLGXy6Yw==",
|
||||
"version": "0.14.13",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.13.tgz",
|
||||
"integrity": "sha512-bNOHLu7Oq6RwaAMnwPbJ40DVGPl9GlAOnfH/dFZ792f8hFEbopkbtVzo1SU1jjfY3TGLWOgqHNWxPxx1N7Au+g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -435,9 +436,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/esbuild-linux-32": {
|
||||
"version": "0.13.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-linux-32/-/esbuild-linux-32-0.13.12.tgz",
|
||||
"integrity": "sha512-U1UZwG3UIwF7/V4tCVAo/nkBV9ag5KJiJTt+gaCmLVWH3bPLX7y+fNlhIWZy8raTMnXhMKfaTvWZ9TtmXzvkuQ==",
|
||||
"version": "0.14.13",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-linux-32/-/esbuild-linux-32-0.14.13.tgz",
|
||||
"integrity": "sha512-WzXyBx6zx16adGi7wPBvH2lRCBzYMcqnBRrJ8ciLIqYyruGvprZocX1nFWfiexjLcFxIElWnMNPX6LG7ULqyXA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -448,9 +449,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/esbuild-linux-64": {
|
||||
"version": "0.13.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.13.12.tgz",
|
||||
"integrity": "sha512-YpXSwtu2NxN3N4ifJxEdsgd6Q5d8LYqskrAwjmoCT6yQnEHJSF5uWcxv783HWN7lnGpJi9KUtDvYsnMdyGw71Q==",
|
||||
"version": "0.14.13",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.14.13.tgz",
|
||||
"integrity": "sha512-P6OFAfcoUvE7g9h/0UKm3qagvTovwqpCF1wbFLWe/BcCY8BS1bR/+SxUjCeKX2BcpIsg4/43ezHDE/ntg/iOpw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -461,9 +462,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/esbuild-linux-arm": {
|
||||
"version": "0.13.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.13.12.tgz",
|
||||
"integrity": "sha512-SyiT/JKxU6J+DY2qUiSLZJqCAftIt3uoGejZ0HDnUM2MGJqEGSGh7p1ecVL2gna3PxS4P+j6WAehCwgkBPXNIw==",
|
||||
"version": "0.14.13",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.14.13.tgz",
|
||||
"integrity": "sha512-4jmm0UySCg3Wi6FEBS7jpiPb1IyckI5um5kzYRwulHxPzkiokd6cgpcsTakR4/Y84UEicS8LnFAghHhXHZhbFg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -474,9 +475,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/esbuild-linux-arm64": {
|
||||
"version": "0.13.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-linux-arm64/-/esbuild-linux-arm64-0.13.12.tgz",
|
||||
"integrity": "sha512-sgDNb8kb3BVodtAlcFGgwk+43KFCYjnFOaOfJibXnnIojNWuJHpL6aQJ4mumzNWw8Rt1xEtDQyuGK9f+Y24jGA==",
|
||||
"version": "0.14.13",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.13.tgz",
|
||||
"integrity": "sha512-k/uIvmkm4mc7vyMvJVwILgGxi2F+FuvLdmESIIWoHrnxEfEekC5AWpI/R6GQ2OMfp8snebSQLs8KL05QPnt1zA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -487,9 +488,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/esbuild-linux-mips64le": {
|
||||
"version": "0.13.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.13.12.tgz",
|
||||
"integrity": "sha512-qQJHlZBG+QwVIA8AbTEtbvF084QgDi4DaUsUnA+EolY1bxrG+UyOuGflM2ZritGhfS/k7THFjJbjH2wIeoKA2g==",
|
||||
"version": "0.14.13",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.13.tgz",
|
||||
"integrity": "sha512-vwYtgjQ1TRlUGL88km9wH9TjXsdZyZ/Xht1ASptg5XGRlqGquVjLGH11PfLLunoMdkQ0YTXR68b4l5gRfjVbyg==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
@@ -500,9 +501,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/esbuild-linux-ppc64le": {
|
||||
"version": "0.13.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.13.12.tgz",
|
||||
"integrity": "sha512-2dSnm1ldL7Lppwlo04CGQUpwNn5hGqXI38OzaoPOkRsBRWFBozyGxTFSee/zHFS+Pdh3b28JJbRK3owrrRgWNw==",
|
||||
"version": "0.14.13",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.13.tgz",
|
||||
"integrity": "sha512-0KqDSIkZaYugtcdpFCd3eQ38Fg6TzhxmOpkhDIKNTwD/W2RoXeiS+Z4y5yQ3oysb/ySDOxWkwNqTdXS4sz2LdQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -512,10 +513,23 @@
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/esbuild-linux-s390x": {
|
||||
"version": "0.14.13",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-linux-s390x/-/esbuild-linux-s390x-0.14.13.tgz",
|
||||
"integrity": "sha512-bG20i7d0CN97fwPN9LaLe64E2IrI0fPZWEcoiff9hzzsvo/fQCx0YjMbPC2T3gqQ48QZRltdU9hQilTjHk3geQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/esbuild-netbsd-64": {
|
||||
"version": "0.13.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-netbsd-64/-/esbuild-netbsd-64-0.13.12.tgz",
|
||||
"integrity": "sha512-D4raxr02dcRiQNbxOLzpqBzcJNFAdsDNxjUbKkDMZBkL54Z0vZh4LRndycdZAMcIdizC/l/Yp/ZsBdAFxc5nbA==",
|
||||
"version": "0.14.13",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.13.tgz",
|
||||
"integrity": "sha512-jz96PQb0ltqyqLggPpcRbWxzLvWHvrZBHZQyjcOzKRDqg1fR/R1y10b1Cuv84xoIbdAf+ceNUJkMN21FfR9G2g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -526,9 +540,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/esbuild-openbsd-64": {
|
||||
"version": "0.13.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-openbsd-64/-/esbuild-openbsd-64-0.13.12.tgz",
|
||||
"integrity": "sha512-KuLCmYMb2kh05QuPJ+va60bKIH5wHL8ypDkmpy47lzwmdxNsuySeCMHuTv5o2Af1RUn5KLO5ZxaZeq4GEY7DaQ==",
|
||||
"version": "0.14.13",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.13.tgz",
|
||||
"integrity": "sha512-bp6zSo3kDCXKPM5MmVUg6DEpt+yXDx37iDGzNTn3Kf9xh6d0cdITxUC4Bx6S3Di79GVYubWs+wNjSRVFIJpryw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -539,9 +553,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/esbuild-sunos-64": {
|
||||
"version": "0.13.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.13.12.tgz",
|
||||
"integrity": "sha512-jBsF+e0woK3miKI8ufGWKG3o3rY9DpHvCVRn5eburMIIE+2c+y3IZ1srsthKyKI6kkXLvV4Cf/E7w56kLipMXw==",
|
||||
"version": "0.14.13",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.14.13.tgz",
|
||||
"integrity": "sha512-08Fne1T9QHYxUnu55sV9V4i/yECADOaI1zMGET2YUa8SRkib10i80hc89U7U/G02DxpN/KUJMWEGq2wKTn0QFQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -552,9 +566,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/esbuild-windows-32": {
|
||||
"version": "0.13.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-windows-32/-/esbuild-windows-32-0.13.12.tgz",
|
||||
"integrity": "sha512-L9m4lLFQrFeR7F+eLZXG82SbXZfUhyfu6CexZEil6vm+lc7GDCE0Q8DiNutkpzjv1+RAbIGVva9muItQ7HVTkQ==",
|
||||
"version": "0.14.13",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-windows-32/-/esbuild-windows-32-0.14.13.tgz",
|
||||
"integrity": "sha512-MW3BMIi9+fzTyDdljH0ftfT/qlD3t+aVzle1O+zZ2MgHRMQD20JwWgyqoJXhe6uDVyunrAUbcjH3qTIEZN3isg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -565,9 +579,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/esbuild-windows-64": {
|
||||
"version": "0.13.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.13.12.tgz",
|
||||
"integrity": "sha512-k4tX4uJlSbSkfs78W5d9+I9gpd+7N95W7H2bgOMFPsYREVJs31+Q2gLLHlsnlY95zBoPQMIzHooUIsixQIBjaQ==",
|
||||
"version": "0.14.13",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.14.13.tgz",
|
||||
"integrity": "sha512-d7+0N+EOgBKdi/nMxlQ8QA5xHBlpcLtSrYnHsA+Xp4yZk28dYfRw1+embsHf5uN5/1iPvrJwPrcpgDH1xyy4JA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -578,9 +592,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/esbuild-windows-arm64": {
|
||||
"version": "0.13.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-windows-arm64/-/esbuild-windows-arm64-0.13.12.tgz",
|
||||
"integrity": "sha512-2tTv/BpYRIvuwHpp2M960nG7uvL+d78LFW/ikPItO+2GfK51CswIKSetSpDii+cjz8e9iSPgs+BU4o8nWICBwQ==",
|
||||
"version": "0.14.13",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.13.tgz",
|
||||
"integrity": "sha512-oX5hmgXk9yNKbb5AxThzRQm/E9kiHyDll7JJeyeT1fuGENTifv33f0INCpjBQ+Ty5ChKc84++ZQTEBwLCA12Kw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -771,6 +785,12 @@
|
||||
"integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/immutable": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.0.0.tgz",
|
||||
"integrity": "sha512-zIE9hX70qew5qTUjSS7wi1iwj/l7+m54KWU247nhM3v806UdGj1yDndXj+IOYxxtW9zyLI+xqFNZjTuDaLUqFw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/internal-slot": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz",
|
||||
@@ -1088,9 +1108,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/markdown-it": {
|
||||
"version": "12.2.0",
|
||||
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-12.2.0.tgz",
|
||||
"integrity": "sha512-Wjws+uCrVQRqOoJvze4HCqkKl1AsSh95iFAeQDwnyfxM09divCBSXlDR1uTvyUP3Grzpn4Ru8GeCxYPM8vkCQg==",
|
||||
"version": "12.3.2",
|
||||
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-12.3.2.tgz",
|
||||
"integrity": "sha512-TchMembfxfNVpHkbtriWltGWc+m3xszaRD0CZup7GFFhzIgQqxIfn3eGj1yZpfuflzPvfkt611B2Q/Bsk1YnGg==",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1",
|
||||
"entities": "~2.1.0",
|
||||
@@ -1409,12 +1429,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/sass": {
|
||||
"version": "1.43.4",
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.43.4.tgz",
|
||||
"integrity": "sha512-/ptG7KE9lxpGSYiXn7Ar+lKOv37xfWsZRtFYal2QHNigyVQDx685VFT/h7ejVr+R8w7H4tmUgtulsKl5YpveOg==",
|
||||
"version": "1.49.0",
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.49.0.tgz",
|
||||
"integrity": "sha512-TVwVdNDj6p6b4QymJtNtRS2YtLJ/CqZriGg0eIAbAKMlN8Xy6kbv33FsEZSF7FufFFM705SQviHjjThfaQ4VNw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"chokidar": ">=3.0.0 <4.0.0"
|
||||
"chokidar": ">=3.0.0 <4.0.0",
|
||||
"immutable": "^4.0.0",
|
||||
"source-map-js": ">=0.6.2 <2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"sass": "sass.js"
|
||||
@@ -1489,6 +1511,15 @@
|
||||
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.14.0.tgz",
|
||||
"integrity": "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w=="
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
|
||||
"integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/spdx-correct": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.1.tgz",
|
||||
@@ -1901,9 +1932,9 @@
|
||||
}
|
||||
},
|
||||
"codemirror": {
|
||||
"version": "5.63.3",
|
||||
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.63.3.tgz",
|
||||
"integrity": "sha512-1C+LELr+5grgJYqwZKqxrcbPsHFHapVaVAloBsFBASbpLnQqLw1U8yXJ3gT5D+rhxIiSpo+kTqN+hQ+9ialIXw=="
|
||||
"version": "5.65.1",
|
||||
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.65.1.tgz",
|
||||
"integrity": "sha512-s6aac+DD+4O2u1aBmdxhB7yz2XU7tG3snOyQ05Kxifahz7hoxnfxIRHxiCSEv3TUC38dIVH8G+lZH9UWSfGQxA=="
|
||||
},
|
||||
"color-convert": {
|
||||
"version": "1.9.3",
|
||||
@@ -2024,146 +2055,154 @@
|
||||
}
|
||||
},
|
||||
"esbuild": {
|
||||
"version": "0.13.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.13.12.tgz",
|
||||
"integrity": "sha512-vTKKUt+yoz61U/BbrnmlG9XIjwpdIxmHB8DlPR0AAW6OdS+nBQBci6LUHU2q9WbBobMEIQxxDpKbkmOGYvxsow==",
|
||||
"version": "0.14.13",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.14.13.tgz",
|
||||
"integrity": "sha512-FIxvAdj3i2oHA6ex+E67bG7zlSTO+slt8kU2ogHDgGtrQLy2HNChv3PYjiFTYkt8hZbEAniZCXVeHn+FrHt7dA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"esbuild-android-arm64": "0.13.12",
|
||||
"esbuild-darwin-64": "0.13.12",
|
||||
"esbuild-darwin-arm64": "0.13.12",
|
||||
"esbuild-freebsd-64": "0.13.12",
|
||||
"esbuild-freebsd-arm64": "0.13.12",
|
||||
"esbuild-linux-32": "0.13.12",
|
||||
"esbuild-linux-64": "0.13.12",
|
||||
"esbuild-linux-arm": "0.13.12",
|
||||
"esbuild-linux-arm64": "0.13.12",
|
||||
"esbuild-linux-mips64le": "0.13.12",
|
||||
"esbuild-linux-ppc64le": "0.13.12",
|
||||
"esbuild-netbsd-64": "0.13.12",
|
||||
"esbuild-openbsd-64": "0.13.12",
|
||||
"esbuild-sunos-64": "0.13.12",
|
||||
"esbuild-windows-32": "0.13.12",
|
||||
"esbuild-windows-64": "0.13.12",
|
||||
"esbuild-windows-arm64": "0.13.12"
|
||||
"esbuild-android-arm64": "0.14.13",
|
||||
"esbuild-darwin-64": "0.14.13",
|
||||
"esbuild-darwin-arm64": "0.14.13",
|
||||
"esbuild-freebsd-64": "0.14.13",
|
||||
"esbuild-freebsd-arm64": "0.14.13",
|
||||
"esbuild-linux-32": "0.14.13",
|
||||
"esbuild-linux-64": "0.14.13",
|
||||
"esbuild-linux-arm": "0.14.13",
|
||||
"esbuild-linux-arm64": "0.14.13",
|
||||
"esbuild-linux-mips64le": "0.14.13",
|
||||
"esbuild-linux-ppc64le": "0.14.13",
|
||||
"esbuild-linux-s390x": "0.14.13",
|
||||
"esbuild-netbsd-64": "0.14.13",
|
||||
"esbuild-openbsd-64": "0.14.13",
|
||||
"esbuild-sunos-64": "0.14.13",
|
||||
"esbuild-windows-32": "0.14.13",
|
||||
"esbuild-windows-64": "0.14.13",
|
||||
"esbuild-windows-arm64": "0.14.13"
|
||||
}
|
||||
},
|
||||
"esbuild-android-arm64": {
|
||||
"version": "0.13.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-android-arm64/-/esbuild-android-arm64-0.13.12.tgz",
|
||||
"integrity": "sha512-TSVZVrb4EIXz6KaYjXfTzPyyRpXV5zgYIADXtQsIenjZ78myvDGaPi11o4ZSaHIwFHsuwkB6ne5SZRBwAQ7maw==",
|
||||
"version": "0.14.13",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-android-arm64/-/esbuild-android-arm64-0.14.13.tgz",
|
||||
"integrity": "sha512-rhtwl+KJ3BzzXkK09N3/YbEF1i5WhriysJEStoeWNBzchx9hlmzyWmDGQQhu56HF78ua3JrVPyLOsdLGvtMvxQ==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"esbuild-darwin-64": {
|
||||
"version": "0.13.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.13.12.tgz",
|
||||
"integrity": "sha512-c51C+N+UHySoV2lgfWSwwmlnLnL0JWj/LzuZt9Ltk9ub1s2Y8cr6SQV5W3mqVH1egUceew6KZ8GyI4nwu+fhsw==",
|
||||
"version": "0.14.13",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.14.13.tgz",
|
||||
"integrity": "sha512-Fl47xIt5RMu50WIgMU93kwmUUJb+BPuL8R895n/aBNQqavS+KUMpLPoqKGABBV4myfx/fnAD/97X8Gt1C1YW6w==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"esbuild-darwin-arm64": {
|
||||
"version": "0.13.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.13.12.tgz",
|
||||
"integrity": "sha512-JvAMtshP45Hd8A8wOzjkY1xAnTKTYuP/QUaKp5eUQGX+76GIie3fCdUUr2ZEKdvpSImNqxiZSIMziEiGB5oUmQ==",
|
||||
"version": "0.14.13",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.13.tgz",
|
||||
"integrity": "sha512-UttqKRFXsWvuivcyAbFmo54vdkC9Me1ZYQNuoz/uBYDbkb2MgqKYG2+xoVKPBhLvhT0CKM5QGKD81flMH5BE6A==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"esbuild-freebsd-64": {
|
||||
"version": "0.13.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-freebsd-64/-/esbuild-freebsd-64-0.13.12.tgz",
|
||||
"integrity": "sha512-r6On/Skv9f0ZjTu6PW5o7pdXr8aOgtFOEURJZYf1XAJs0IQ+gW+o1DzXjVkIoT+n1cm3N/t1KRJfX71MPg/ZUA==",
|
||||
"version": "0.14.13",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.13.tgz",
|
||||
"integrity": "sha512-dlIhPFSp29Yq2TPh7Cm3/4M0uKjlfvOylHVNCRvRNiOvDbBol6/NZ3kLisczms+Yra0rxVapBPN1oMbSMuts9g==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"esbuild-freebsd-arm64": {
|
||||
"version": "0.13.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.13.12.tgz",
|
||||
"integrity": "sha512-F6LmI2Q1gii073kmBE3NOTt/6zLL5zvZsxNLF8PMAwdHc+iBhD1vzfI8uQZMJA1IgXa3ocr3L3DJH9fLGXy6Yw==",
|
||||
"version": "0.14.13",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.13.tgz",
|
||||
"integrity": "sha512-bNOHLu7Oq6RwaAMnwPbJ40DVGPl9GlAOnfH/dFZ792f8hFEbopkbtVzo1SU1jjfY3TGLWOgqHNWxPxx1N7Au+g==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"esbuild-linux-32": {
|
||||
"version": "0.13.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-linux-32/-/esbuild-linux-32-0.13.12.tgz",
|
||||
"integrity": "sha512-U1UZwG3UIwF7/V4tCVAo/nkBV9ag5KJiJTt+gaCmLVWH3bPLX7y+fNlhIWZy8raTMnXhMKfaTvWZ9TtmXzvkuQ==",
|
||||
"version": "0.14.13",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-linux-32/-/esbuild-linux-32-0.14.13.tgz",
|
||||
"integrity": "sha512-WzXyBx6zx16adGi7wPBvH2lRCBzYMcqnBRrJ8ciLIqYyruGvprZocX1nFWfiexjLcFxIElWnMNPX6LG7ULqyXA==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"esbuild-linux-64": {
|
||||
"version": "0.13.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.13.12.tgz",
|
||||
"integrity": "sha512-YpXSwtu2NxN3N4ifJxEdsgd6Q5d8LYqskrAwjmoCT6yQnEHJSF5uWcxv783HWN7lnGpJi9KUtDvYsnMdyGw71Q==",
|
||||
"version": "0.14.13",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.14.13.tgz",
|
||||
"integrity": "sha512-P6OFAfcoUvE7g9h/0UKm3qagvTovwqpCF1wbFLWe/BcCY8BS1bR/+SxUjCeKX2BcpIsg4/43ezHDE/ntg/iOpw==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"esbuild-linux-arm": {
|
||||
"version": "0.13.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.13.12.tgz",
|
||||
"integrity": "sha512-SyiT/JKxU6J+DY2qUiSLZJqCAftIt3uoGejZ0HDnUM2MGJqEGSGh7p1ecVL2gna3PxS4P+j6WAehCwgkBPXNIw==",
|
||||
"version": "0.14.13",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.14.13.tgz",
|
||||
"integrity": "sha512-4jmm0UySCg3Wi6FEBS7jpiPb1IyckI5um5kzYRwulHxPzkiokd6cgpcsTakR4/Y84UEicS8LnFAghHhXHZhbFg==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"esbuild-linux-arm64": {
|
||||
"version": "0.13.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-linux-arm64/-/esbuild-linux-arm64-0.13.12.tgz",
|
||||
"integrity": "sha512-sgDNb8kb3BVodtAlcFGgwk+43KFCYjnFOaOfJibXnnIojNWuJHpL6aQJ4mumzNWw8Rt1xEtDQyuGK9f+Y24jGA==",
|
||||
"version": "0.14.13",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.13.tgz",
|
||||
"integrity": "sha512-k/uIvmkm4mc7vyMvJVwILgGxi2F+FuvLdmESIIWoHrnxEfEekC5AWpI/R6GQ2OMfp8snebSQLs8KL05QPnt1zA==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"esbuild-linux-mips64le": {
|
||||
"version": "0.13.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.13.12.tgz",
|
||||
"integrity": "sha512-qQJHlZBG+QwVIA8AbTEtbvF084QgDi4DaUsUnA+EolY1bxrG+UyOuGflM2ZritGhfS/k7THFjJbjH2wIeoKA2g==",
|
||||
"version": "0.14.13",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.13.tgz",
|
||||
"integrity": "sha512-vwYtgjQ1TRlUGL88km9wH9TjXsdZyZ/Xht1ASptg5XGRlqGquVjLGH11PfLLunoMdkQ0YTXR68b4l5gRfjVbyg==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"esbuild-linux-ppc64le": {
|
||||
"version": "0.13.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.13.12.tgz",
|
||||
"integrity": "sha512-2dSnm1ldL7Lppwlo04CGQUpwNn5hGqXI38OzaoPOkRsBRWFBozyGxTFSee/zHFS+Pdh3b28JJbRK3owrrRgWNw==",
|
||||
"version": "0.14.13",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.13.tgz",
|
||||
"integrity": "sha512-0KqDSIkZaYugtcdpFCd3eQ38Fg6TzhxmOpkhDIKNTwD/W2RoXeiS+Z4y5yQ3oysb/ySDOxWkwNqTdXS4sz2LdQ==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"esbuild-linux-s390x": {
|
||||
"version": "0.14.13",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-linux-s390x/-/esbuild-linux-s390x-0.14.13.tgz",
|
||||
"integrity": "sha512-bG20i7d0CN97fwPN9LaLe64E2IrI0fPZWEcoiff9hzzsvo/fQCx0YjMbPC2T3gqQ48QZRltdU9hQilTjHk3geQ==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"esbuild-netbsd-64": {
|
||||
"version": "0.13.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-netbsd-64/-/esbuild-netbsd-64-0.13.12.tgz",
|
||||
"integrity": "sha512-D4raxr02dcRiQNbxOLzpqBzcJNFAdsDNxjUbKkDMZBkL54Z0vZh4LRndycdZAMcIdizC/l/Yp/ZsBdAFxc5nbA==",
|
||||
"version": "0.14.13",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.13.tgz",
|
||||
"integrity": "sha512-jz96PQb0ltqyqLggPpcRbWxzLvWHvrZBHZQyjcOzKRDqg1fR/R1y10b1Cuv84xoIbdAf+ceNUJkMN21FfR9G2g==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"esbuild-openbsd-64": {
|
||||
"version": "0.13.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-openbsd-64/-/esbuild-openbsd-64-0.13.12.tgz",
|
||||
"integrity": "sha512-KuLCmYMb2kh05QuPJ+va60bKIH5wHL8ypDkmpy47lzwmdxNsuySeCMHuTv5o2Af1RUn5KLO5ZxaZeq4GEY7DaQ==",
|
||||
"version": "0.14.13",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.13.tgz",
|
||||
"integrity": "sha512-bp6zSo3kDCXKPM5MmVUg6DEpt+yXDx37iDGzNTn3Kf9xh6d0cdITxUC4Bx6S3Di79GVYubWs+wNjSRVFIJpryw==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"esbuild-sunos-64": {
|
||||
"version": "0.13.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.13.12.tgz",
|
||||
"integrity": "sha512-jBsF+e0woK3miKI8ufGWKG3o3rY9DpHvCVRn5eburMIIE+2c+y3IZ1srsthKyKI6kkXLvV4Cf/E7w56kLipMXw==",
|
||||
"version": "0.14.13",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.14.13.tgz",
|
||||
"integrity": "sha512-08Fne1T9QHYxUnu55sV9V4i/yECADOaI1zMGET2YUa8SRkib10i80hc89U7U/G02DxpN/KUJMWEGq2wKTn0QFQ==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"esbuild-windows-32": {
|
||||
"version": "0.13.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-windows-32/-/esbuild-windows-32-0.13.12.tgz",
|
||||
"integrity": "sha512-L9m4lLFQrFeR7F+eLZXG82SbXZfUhyfu6CexZEil6vm+lc7GDCE0Q8DiNutkpzjv1+RAbIGVva9muItQ7HVTkQ==",
|
||||
"version": "0.14.13",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-windows-32/-/esbuild-windows-32-0.14.13.tgz",
|
||||
"integrity": "sha512-MW3BMIi9+fzTyDdljH0ftfT/qlD3t+aVzle1O+zZ2MgHRMQD20JwWgyqoJXhe6uDVyunrAUbcjH3qTIEZN3isg==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"esbuild-windows-64": {
|
||||
"version": "0.13.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.13.12.tgz",
|
||||
"integrity": "sha512-k4tX4uJlSbSkfs78W5d9+I9gpd+7N95W7H2bgOMFPsYREVJs31+Q2gLLHlsnlY95zBoPQMIzHooUIsixQIBjaQ==",
|
||||
"version": "0.14.13",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.14.13.tgz",
|
||||
"integrity": "sha512-d7+0N+EOgBKdi/nMxlQ8QA5xHBlpcLtSrYnHsA+Xp4yZk28dYfRw1+embsHf5uN5/1iPvrJwPrcpgDH1xyy4JA==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"esbuild-windows-arm64": {
|
||||
"version": "0.13.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-windows-arm64/-/esbuild-windows-arm64-0.13.12.tgz",
|
||||
"integrity": "sha512-2tTv/BpYRIvuwHpp2M960nG7uvL+d78LFW/ikPItO+2GfK51CswIKSetSpDii+cjz8e9iSPgs+BU4o8nWICBwQ==",
|
||||
"version": "0.14.13",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.13.tgz",
|
||||
"integrity": "sha512-oX5hmgXk9yNKbb5AxThzRQm/E9kiHyDll7JJeyeT1fuGENTifv33f0INCpjBQ+Ty5ChKc84++ZQTEBwLCA12Kw==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
@@ -2296,6 +2335,12 @@
|
||||
"integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==",
|
||||
"dev": true
|
||||
},
|
||||
"immutable": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.0.0.tgz",
|
||||
"integrity": "sha512-zIE9hX70qew5qTUjSS7wi1iwj/l7+m54KWU247nhM3v806UdGj1yDndXj+IOYxxtW9zyLI+xqFNZjTuDaLUqFw==",
|
||||
"dev": true
|
||||
},
|
||||
"internal-slot": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz",
|
||||
@@ -2523,9 +2568,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"markdown-it": {
|
||||
"version": "12.2.0",
|
||||
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-12.2.0.tgz",
|
||||
"integrity": "sha512-Wjws+uCrVQRqOoJvze4HCqkKl1AsSh95iFAeQDwnyfxM09divCBSXlDR1uTvyUP3Grzpn4Ru8GeCxYPM8vkCQg==",
|
||||
"version": "12.3.2",
|
||||
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-12.3.2.tgz",
|
||||
"integrity": "sha512-TchMembfxfNVpHkbtriWltGWc+m3xszaRD0CZup7GFFhzIgQqxIfn3eGj1yZpfuflzPvfkt611B2Q/Bsk1YnGg==",
|
||||
"requires": {
|
||||
"argparse": "^2.0.1",
|
||||
"entities": "~2.1.0",
|
||||
@@ -2758,12 +2803,14 @@
|
||||
}
|
||||
},
|
||||
"sass": {
|
||||
"version": "1.43.4",
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.43.4.tgz",
|
||||
"integrity": "sha512-/ptG7KE9lxpGSYiXn7Ar+lKOv37xfWsZRtFYal2QHNigyVQDx685VFT/h7ejVr+R8w7H4tmUgtulsKl5YpveOg==",
|
||||
"version": "1.49.0",
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.49.0.tgz",
|
||||
"integrity": "sha512-TVwVdNDj6p6b4QymJtNtRS2YtLJ/CqZriGg0eIAbAKMlN8Xy6kbv33FsEZSF7FufFFM705SQviHjjThfaQ4VNw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"chokidar": ">=3.0.0 <4.0.0"
|
||||
"chokidar": ">=3.0.0 <4.0.0",
|
||||
"immutable": "^4.0.0",
|
||||
"source-map-js": ">=0.6.2 <2.0.0"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
@@ -2820,6 +2867,12 @@
|
||||
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.14.0.tgz",
|
||||
"integrity": "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w=="
|
||||
},
|
||||
"source-map-js": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
|
||||
"integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==",
|
||||
"dev": true
|
||||
},
|
||||
"spdx-correct": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.1.tgz",
|
||||
|
||||
@@ -16,17 +16,17 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"chokidar-cli": "^3.0",
|
||||
"esbuild": "0.13.12",
|
||||
"esbuild": "0.14.13",
|
||||
"livereload": "^0.9.3",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"punycode": "^2.1.1",
|
||||
"sass": "^1.43.4"
|
||||
"sass": "^1.49.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"clipboard": "^2.0.8",
|
||||
"codemirror": "^5.63.3",
|
||||
"codemirror": "^5.65.1",
|
||||
"dropzone": "^5.9.3",
|
||||
"markdown-it": "^12.2.0",
|
||||
"markdown-it": "^12.3.2",
|
||||
"markdown-it-task-lists": "^2.1.1",
|
||||
"sortablejs": "^1.14.0"
|
||||
}
|
||||
|
||||
98
public/dist/app.js
vendored
98
public/dist/app.js
vendored
File diff suppressed because one or more lines are too long
BIN
public/loading_error.png
Normal file
BIN
public/loading_error.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
13
readme.md
13
readme.md
@@ -34,13 +34,17 @@ Big thanks to these companies for supporting the project.
|
||||
Note: Listed services are not tested, vetted nor supported by the official BookStack project in any manner.
|
||||
[View all sponsors](https://github.com/sponsors/ssddanbrown).
|
||||
|
||||
#### Bronze Sponsors
|
||||
#### Silver Sponsor
|
||||
|
||||
<table><tbody><tr>
|
||||
<td><a href="https://www.diagrams.net/" target="_blank">
|
||||
<img width="280" src="https://media.githubusercontent.com/media/BookStackApp/website/main/static/images/sponsors/diagramsnet.png" alt="Diagrams.net">
|
||||
<img width="420" src="https://media.githubusercontent.com/media/BookStackApp/website/main/static/images/sponsors/diagramsnet.png" alt="Diagrams.net">
|
||||
</a></td>
|
||||
</tr></tbody></table>
|
||||
|
||||
#### Bronze Sponsor
|
||||
|
||||
<table><tbody><tr>
|
||||
<td><a href="https://www.stellarhosted.com/bookstack/" target="_blank">
|
||||
<img width="280" src="https://media.githubusercontent.com/media/BookStackApp/website/main/static/images/sponsors/stellarhosted.png" alt="Stellar Hosted">
|
||||
</a></td>
|
||||
@@ -152,6 +156,11 @@ Once the database has been migrated & seeded, you can run the tests like so:
|
||||
docker-compose run app php vendor/bin/phpunit
|
||||
```
|
||||
|
||||
#### Debugging
|
||||
|
||||
The docker-compose setup ships with Xdebug, which you can listen to on port 9090.
|
||||
NB : For some editors like Visual Studio Code, you might need to map your workspace folder to the /app folder within the docker container for this to work.
|
||||
|
||||
## 🌎 Translations
|
||||
|
||||
Translations for text within BookStack is managed through the [BookStack project on Crowdin](https://crowdin.com/project/bookstack). Some strings have colon-prefixed variables in such as `:userName`. Leave these values as they are as they will be replaced at run-time. Crowdin is the preferred way to provide translations, otherwise the raw translations files can be found within the `resources/lang` path.
|
||||
|
||||
1
resources/icons/webhooks.svg
Normal file
1
resources/icons/webhooks.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M10,15l5.88,0c0.27-0.31,0.67-0.5,1.12-0.5c0.83,0,1.5,0.67,1.5,1.5c0,0.83-0.67,1.5-1.5,1.5c-0.44,0-0.84-0.19-1.12-0.5 l-3.98,0c-0.46,2.28-2.48,4-4.9,4c-2.76,0-5-2.24-5-5c0-2.42,1.72-4.44,4-4.9l0,2.07C4.84,13.58,4,14.7,4,16c0,1.65,1.35,3,3,3 s3-1.35,3-3V15z M12.5,4c1.65,0,3,1.35,3,3h2c0-2.76-2.24-5-5-5l0,0c-2.76,0-5,2.24-5,5c0,1.43,0.6,2.71,1.55,3.62l-2.35,3.9 C6.02,14.66,5.5,15.27,5.5,16c0,0.83,0.67,1.5,1.5,1.5s1.5-0.67,1.5-1.5c0-0.16-0.02-0.31-0.07-0.45l3.38-5.63 C10.49,9.61,9.5,8.42,9.5,7C9.5,5.35,10.85,4,12.5,4z M17,13c-0.64,0-1.23,0.2-1.72,0.54l-3.05-5.07C11.53,8.35,11,7.74,11,7 c0-0.83,0.67-1.5,1.5-1.5S14,6.17,14,7c0,0.15-0.02,0.29-0.06,0.43l2.19,3.65C16.41,11.03,16.7,11,17,11l0,0c2.76,0,5,2.24,5,5 c0,2.76-2.24,5-5,5c-1.85,0-3.47-1.01-4.33-2.5l2.67,0C15.82,18.82,16.39,19,17,19c1.65,0,3-1.35,3-3S18.65,13,17,13z"/></svg>
|
||||
|
After Width: | Height: | Size: 903 B |
@@ -41,7 +41,9 @@ class EditorToolbox {
|
||||
if (cName === tabName) this.contentElements[i].style.display = 'block';
|
||||
}
|
||||
|
||||
if (openToolbox) this.elem.classList.add('open');
|
||||
if (openToolbox && !this.elem.classList.contains('open')) {
|
||||
this.toggle();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ class EntitySelectorPopup {
|
||||
setup() {
|
||||
this.elem = this.$el;
|
||||
this.selectButton = this.$refs.select;
|
||||
this.searchInput = this.$refs.searchInput;
|
||||
|
||||
window.EntitySelectorPopup = this;
|
||||
|
||||
this.callback = null;
|
||||
@@ -20,6 +22,7 @@ class EntitySelectorPopup {
|
||||
show(callback) {
|
||||
this.callback = callback;
|
||||
this.elem.components.popup.show();
|
||||
this.searchInput.focus();
|
||||
}
|
||||
|
||||
hide() {
|
||||
|
||||
@@ -74,6 +74,10 @@ class ImageManager {
|
||||
|
||||
this.listContainer.addEventListener('event-emit-select-image', this.onImageSelectEvent.bind(this));
|
||||
|
||||
this.listContainer.addEventListener('error', event => {
|
||||
event.target.src = baseUrl('loading_error.png');
|
||||
}, true);
|
||||
|
||||
onSelect(this.selectButton, () => {
|
||||
if (this.callback) {
|
||||
this.callback(this.lastSelected);
|
||||
@@ -118,6 +122,9 @@ class ImageManager {
|
||||
};
|
||||
|
||||
const {data: html} = await window.$http.get(`images/${this.type}`, params);
|
||||
if (params.page === 1) {
|
||||
this.listContainer.innerHTML = '';
|
||||
}
|
||||
this.addReturnedHtmlElementsToList(html);
|
||||
removeLoading(this.listContainer);
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ import templateManager from "./template-manager.js"
|
||||
import toggleSwitch from "./toggle-switch.js"
|
||||
import triLayout from "./tri-layout.js"
|
||||
import userSelect from "./user-select.js"
|
||||
import webhookEvents from "./webhook-events";
|
||||
import wysiwygEditor from "./wysiwyg-editor.js"
|
||||
|
||||
const componentMapping = {
|
||||
@@ -105,6 +106,7 @@ const componentMapping = {
|
||||
"toggle-switch": toggleSwitch,
|
||||
"tri-layout": triLayout,
|
||||
"user-select": userSelect,
|
||||
"webhook-events": webhookEvents,
|
||||
"wysiwyg-editor": wysiwygEditor,
|
||||
};
|
||||
|
||||
|
||||
@@ -112,6 +112,11 @@ class MarkdownEditor {
|
||||
if (scrollText) {
|
||||
this.scrollToText(scrollText);
|
||||
}
|
||||
|
||||
// Refresh CodeMirror on container resize
|
||||
const resizeDebounced = debounce(() => code.updateLayout(this.cm), 100, false);
|
||||
const observer = new ResizeObserver(resizeDebounced);
|
||||
observer.observe(this.elem);
|
||||
}
|
||||
|
||||
// Update the input content and render the display.
|
||||
@@ -395,8 +400,9 @@ class MarkdownEditor {
|
||||
actionInsertImage() {
|
||||
const cursorPos = this.cm.getCursor('from');
|
||||
window.ImageManager.show(image => {
|
||||
const imageUrl = image.thumbs.display || image.url;
|
||||
let selectedText = this.cm.getSelection();
|
||||
let newText = "[](" + image.url + ")";
|
||||
let newText = "[](" + image.url + ")";
|
||||
this.cm.focus();
|
||||
this.cm.replaceSelection(newText);
|
||||
this.cm.setCursor(cursorPos.line, cursorPos.ch + newText.length);
|
||||
|
||||
32
resources/js/components/webhook-events.js
Normal file
32
resources/js/components/webhook-events.js
Normal file
@@ -0,0 +1,32 @@
|
||||
|
||||
/**
|
||||
* Webhook Events
|
||||
* Manages dynamic selection control in the webhook form interface.
|
||||
* @extends {Component}
|
||||
*/
|
||||
class WebhookEvents {
|
||||
|
||||
setup() {
|
||||
this.checkboxes = this.$el.querySelectorAll('input[type="checkbox"]');
|
||||
this.allCheckbox = this.$el.querySelector('input[type="checkbox"][value="all"]');
|
||||
|
||||
this.$el.addEventListener('change', event => {
|
||||
if (event.target.checked && event.target === this.allCheckbox) {
|
||||
this.deselectIndividualEvents();
|
||||
} else if (event.target.checked) {
|
||||
this.allCheckbox.checked = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
deselectIndividualEvents() {
|
||||
for (const checkbox of this.checkboxes) {
|
||||
if (checkbox !== this.allCheckbox) {
|
||||
checkbox.checked = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default WebhookEvents;
|
||||
@@ -563,8 +563,9 @@ class WysiwygEditor {
|
||||
}
|
||||
|
||||
// Replace the actively selected content with the linked image
|
||||
const imageUrl = image.thumbs.display || image.url;
|
||||
let html = `<a href="${image.url}" target="_blank">`;
|
||||
html += `<img src="${image.thumbs.display}" alt="${image.name}">`;
|
||||
html += `<img src="${imageUrl}" alt="${image.name}">`;
|
||||
html += '</a>';
|
||||
win.tinyMCE.activeEditor.execCommand('mceInsertContent', false, html);
|
||||
}, 'gallery');
|
||||
@@ -723,8 +724,9 @@ class WysiwygEditor {
|
||||
tooltip: 'Insert an image',
|
||||
onclick: function () {
|
||||
window.ImageManager.show(function (image) {
|
||||
const imageUrl = image.thumbs.display || image.url;
|
||||
let html = `<a href="${image.url}" target="_blank">`;
|
||||
html += `<img src="${image.thumbs.display}" alt="${image.name}">`;
|
||||
html += `<img src="${imageUrl}" alt="${image.name}">`;
|
||||
html += '</a>';
|
||||
editor.execCommand('mceInsertContent', false, html);
|
||||
}, 'gallery');
|
||||
|
||||
@@ -211,9 +211,9 @@ function wysiwygView(elem) {
|
||||
const doc = elem.ownerDocument;
|
||||
const codeElem = elem.querySelector('code');
|
||||
|
||||
let lang = (elem.className || '').replace('language-', '');
|
||||
if (lang === '' && codeElem) {
|
||||
lang = (codeElem.className || '').replace('language-', '')
|
||||
let lang = getLanguageFromCssClasses(elem.className || '');
|
||||
if (!lang && codeElem) {
|
||||
lang = getLanguageFromCssClasses(codeElem.className || '');
|
||||
}
|
||||
|
||||
elem.innerHTML = elem.innerHTML.replace(/<br\s*[\/]?>/gi ,'\n');
|
||||
@@ -228,7 +228,7 @@ function wysiwygView(elem) {
|
||||
elem.parentNode.replaceChild(newWrap, elem);
|
||||
|
||||
newWrap.appendChild(newTextArea);
|
||||
newWrap.contentEditable = false;
|
||||
newWrap.contentEditable = 'false';
|
||||
newTextArea.textContent = content;
|
||||
|
||||
let cm = CodeMirror(function(elt) {
|
||||
@@ -245,6 +245,16 @@ function wysiwygView(elem) {
|
||||
return {wrap: newWrap, editor: cm};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the code language from the given css classes.
|
||||
* @param {String} classes
|
||||
* @return {String}
|
||||
*/
|
||||
function getLanguageFromCssClasses(classes) {
|
||||
const langClasses = classes.split(' ').filter(cssClass => cssClass.startsWith('language-'));
|
||||
return (langClasses[0] || '').replace('language-', '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a CodeMirror instance to show in the WYSIWYG pop-up editor
|
||||
* @param {HTMLElement} elem
|
||||
|
||||
@@ -7,41 +7,41 @@ return [
|
||||
|
||||
// Pages
|
||||
'page_create' => 'تم إنشاء صفحة',
|
||||
'page_create_notification' => 'تم إنشاء الصفحة بنجاح',
|
||||
'page_create_notification' => 'Page successfully created',
|
||||
'page_update' => 'تم تحديث الصفحة',
|
||||
'page_update_notification' => 'تم تحديث الصفحة بنجاح',
|
||||
'page_update_notification' => 'Page successfully updated',
|
||||
'page_delete' => 'تم حذف الصفحة',
|
||||
'page_delete_notification' => 'تم حذف الصفحة بنجاح',
|
||||
'page_delete_notification' => 'Page successfully deleted',
|
||||
'page_restore' => 'تمت استعادة الصفحة',
|
||||
'page_restore_notification' => 'تمت استعادة الصفحة بنجاح',
|
||||
'page_restore_notification' => 'Page successfully restored',
|
||||
'page_move' => 'تم نقل الصفحة',
|
||||
|
||||
// Chapters
|
||||
'chapter_create' => 'تم إنشاء فصل',
|
||||
'chapter_create_notification' => 'تم إنشاء فصل بنجاح',
|
||||
'chapter_create_notification' => 'Chapter successfully created',
|
||||
'chapter_update' => 'تم تحديث الفصل',
|
||||
'chapter_update_notification' => 'تم تحديث الفصل بنجاح',
|
||||
'chapter_update_notification' => 'Chapter successfully updated',
|
||||
'chapter_delete' => 'تم حذف الفصل',
|
||||
'chapter_delete_notification' => 'تم حذف الفصل بنجاح',
|
||||
'chapter_delete_notification' => 'Chapter successfully deleted',
|
||||
'chapter_move' => 'تم نقل الفصل',
|
||||
|
||||
// Books
|
||||
'book_create' => 'تم إنشاء كتاب',
|
||||
'book_create_notification' => 'تم إنشاء كتاب بنجاح',
|
||||
'book_create_notification' => 'Book successfully created',
|
||||
'book_update' => 'تم تحديث الكتاب',
|
||||
'book_update_notification' => 'تم تحديث الكتاب بنجاح',
|
||||
'book_update_notification' => 'Book successfully updated',
|
||||
'book_delete' => 'تم حذف الكتاب',
|
||||
'book_delete_notification' => 'تم حذف الكتاب بنجاح',
|
||||
'book_delete_notification' => 'Book successfully deleted',
|
||||
'book_sort' => 'تم سرد الكتاب',
|
||||
'book_sort_notification' => 'أُعِيدَ سرد الكتاب بنجاح',
|
||||
'book_sort_notification' => 'Book successfully re-sorted',
|
||||
|
||||
// Bookshelves
|
||||
'bookshelf_create' => 'تم إنشاء رف الكتب',
|
||||
'bookshelf_create_notification' => 'تم إنشاء الرف بنجاح',
|
||||
'bookshelf_create' => 'created bookshelf',
|
||||
'bookshelf_create_notification' => 'Bookshelf successfully created',
|
||||
'bookshelf_update' => 'تم تحديث الرف',
|
||||
'bookshelf_update_notification' => 'تم تحديث الرف بنجاح',
|
||||
'bookshelf_update_notification' => 'Bookshelf successfully updated',
|
||||
'bookshelf_delete' => 'تم تحديث الرف',
|
||||
'bookshelf_delete_notification' => 'تم حذف الرف بنجاح',
|
||||
'bookshelf_delete_notification' => 'Bookshelf successfully deleted',
|
||||
|
||||
// Favourites
|
||||
'favourite_add_notification' => '":name" has been added to your favourites',
|
||||
@@ -51,6 +51,14 @@ return [
|
||||
'mfa_setup_method_notification' => 'Multi-factor method successfully configured',
|
||||
'mfa_remove_method_notification' => 'Multi-factor method successfully removed',
|
||||
|
||||
// Webhooks
|
||||
'webhook_create' => 'created webhook',
|
||||
'webhook_create_notification' => 'Webhook successfully created',
|
||||
'webhook_update' => 'updated webhook',
|
||||
'webhook_update_notification' => 'Webhook successfully updated',
|
||||
'webhook_delete' => 'deleted webhook',
|
||||
'webhook_delete_notification' => 'Webhook successfully deleted',
|
||||
|
||||
// Other
|
||||
'commented_on' => 'تم التعليق',
|
||||
'permissions_update' => 'تحديث الأذونات',
|
||||
|
||||
@@ -21,7 +21,7 @@ return [
|
||||
'email' => 'البريد الإلكتروني',
|
||||
'password' => 'كلمة المرور',
|
||||
'password_confirm' => 'تأكيد كلمة المرور',
|
||||
'password_hint' => 'يجب أن تكون أكثر من 7 حروف',
|
||||
'password_hint' => 'Must be at least 8 characters',
|
||||
'forgot_password' => 'نسيت كلمة المرور؟',
|
||||
'remember_me' => 'تذكرني',
|
||||
'ldap_email_hint' => 'الرجاء إدخال عنوان بريد إلكتروني لاستخدامه مع الحساب.',
|
||||
@@ -79,7 +79,7 @@ return [
|
||||
'mfa_setup_configured' => 'Already configured',
|
||||
'mfa_setup_reconfigure' => 'Reconfigure',
|
||||
'mfa_setup_remove_confirmation' => 'Are you sure you want to remove this multi-factor authentication method?',
|
||||
'mfa_setup_action' => 'Setup',
|
||||
'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' => 'Mobile App',
|
||||
'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.',
|
||||
|
||||
@@ -71,6 +71,10 @@ return [
|
||||
'list_view' => 'عرض منسدل',
|
||||
'default' => 'افتراضي',
|
||||
'breadcrumb' => 'شريط التنقل',
|
||||
'status' => 'Status',
|
||||
'status_active' => 'Active',
|
||||
'status_inactive' => 'Inactive',
|
||||
'never' => 'Never',
|
||||
|
||||
// Header
|
||||
'header_menu_expand' => 'عرض القائمة',
|
||||
|
||||
@@ -143,6 +143,8 @@ return [
|
||||
'books_sort_chapters_last' => 'الفصول الأخيرة',
|
||||
'books_sort_show_other' => 'عرض كتب أخرى',
|
||||
'books_sort_save' => 'حفظ الترتيب الجديد',
|
||||
'books_copy' => 'Copy Book',
|
||||
'books_copy_success' => 'Book successfully copied',
|
||||
|
||||
// Chapters
|
||||
'chapter' => 'فصل',
|
||||
@@ -161,6 +163,8 @@ return [
|
||||
'chapters_move' => 'نقل الفصل',
|
||||
'chapters_move_named' => 'نقل فصل :chapterName',
|
||||
'chapter_move_success' => 'تم نقل الفصل إلى :bookName',
|
||||
'chapters_copy' => 'Copy Chapter',
|
||||
'chapters_copy_success' => 'Chapter successfully copied',
|
||||
'chapters_permissions' => 'أذونات الفصل',
|
||||
'chapters_empty' => 'لا توجد أي صفحات في هذا الفصل حالياً',
|
||||
'chapters_permissions_active' => 'أذونات الفصل مفعلة',
|
||||
@@ -332,4 +336,12 @@ return [
|
||||
'revision_restore_confirm' => 'هل أنت متأكد من أنك تريد استعادة هذه المراجعة؟ سيتم استبدال محتوى الصفحة الحالية.',
|
||||
'revision_delete_success' => 'تم حذف المراجعة',
|
||||
'revision_cannot_delete_latest' => 'لايمكن حذف آخر مراجعة.',
|
||||
|
||||
// Copy view
|
||||
'copy_consider' => 'Please consider the below when copying content.',
|
||||
'copy_consider_permissions' => 'Custom permission settings will not be copied.',
|
||||
'copy_consider_owner' => 'You will become the owner of all copied content.',
|
||||
'copy_consider_images' => 'Page image files will not be duplicated & the original images will retain their relation to the page they were originally uploaded to.',
|
||||
'copy_consider_attachments' => 'Page attachments will not be copied.',
|
||||
'copy_consider_access' => 'A change of location, owner or permissions may result in this content being accessible to those previously without access.',
|
||||
];
|
||||
|
||||
@@ -174,7 +174,7 @@ return [
|
||||
'users_role' => 'أدوار المستخدمين',
|
||||
'users_role_desc' => 'حدد الأدوار التي سيتم تعيين هذا المستخدم لها. إذا تم تعيين مستخدم لأدوار متعددة ، فسيتم تكديس الأذونات من هذه الأدوار وسيتلقى كل قدرات الأدوار المعينة.',
|
||||
'users_password' => 'كلمة مرور المستخدم',
|
||||
'users_password_desc' => 'قم بتعيين كلمة مرور مستخدمة لتسجيل الدخول إلى التطبيق. يجب ألا يقل طول هذه الكلمة عن 6 أحرف.',
|
||||
'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 8 characters long.',
|
||||
'users_send_invite_text' => 'يمكنك اختيار إرسال دعوة بالبريد الإلكتروني إلى هذا المستخدم مما يسمح له بتعيين كلمة المرور الخاصة به أو يمكنك تعيين كلمة المرور الخاصة به بنفسك.',
|
||||
'users_send_invite_option' => 'أرسل بريدًا إلكترونيًا لدعوة المستخدم',
|
||||
'users_external_auth_id' => 'ربط الحساب بمواقع التواصل',
|
||||
@@ -233,6 +233,34 @@ return [
|
||||
'user_api_token_delete_confirm' => 'هل أنت متأكد من أنك تريد حذف رمز API؟',
|
||||
'user_api_token_delete_success' => 'تم حذف رمز الـ API بنجاح',
|
||||
|
||||
// Webhooks
|
||||
'webhooks' => 'Webhooks',
|
||||
'webhooks_create' => 'Create New Webhook',
|
||||
'webhooks_none_created' => 'No webhooks have yet been created.',
|
||||
'webhooks_edit' => 'Edit Webhook',
|
||||
'webhooks_save' => 'Save Webhook',
|
||||
'webhooks_details' => 'Webhook Details',
|
||||
'webhooks_details_desc' => 'Provide a user friendly name and a POST endpoint as a location for the webhook data to be sent to.',
|
||||
'webhooks_events' => 'Webhook Events',
|
||||
'webhooks_events_desc' => 'Select all the events that should trigger this webhook to be called.',
|
||||
'webhooks_events_warning' => 'Keep in mind that these events will be triggered for all selected events, even if custom permissions are applied. Ensure that use of this webhook won\'t expose confidential content.',
|
||||
'webhooks_events_all' => 'All system events',
|
||||
'webhooks_name' => 'Webhook Name',
|
||||
'webhooks_timeout' => 'Webhook Request Timeout (Seconds)',
|
||||
'webhooks_endpoint' => 'Webhook Endpoint',
|
||||
'webhooks_active' => 'Webhook Active',
|
||||
'webhook_events_table_header' => 'Events',
|
||||
'webhooks_delete' => 'Delete Webhook',
|
||||
'webhooks_delete_warning' => 'This will fully delete this webhook, with the name \':webhookName\', from the system.',
|
||||
'webhooks_delete_confirm' => 'Are you sure you want to delete this webhook?',
|
||||
'webhooks_format_example' => 'Webhook Format Example',
|
||||
'webhooks_format_example_desc' => 'Webhook data is sent as a POST request to the configured endpoint as JSON following the format below. The "related_item" and "url" properties are optional and will depend on the type of event triggered.',
|
||||
'webhooks_status' => 'Webhook Status',
|
||||
'webhooks_last_called' => 'Last Called:',
|
||||
'webhooks_last_errored' => 'Last Errored:',
|
||||
'webhooks_last_error_message' => 'Last Error Message:',
|
||||
|
||||
|
||||
//! If editing translations files directly please ignore this in all
|
||||
//! languages apart from en. Content will be auto-copied from en.
|
||||
//!////////////////////////////////
|
||||
|
||||
@@ -7,41 +7,41 @@ return [
|
||||
|
||||
// Pages
|
||||
'page_create' => 'създадена страница',
|
||||
'page_create_notification' => 'Страницата беше успешно създадена',
|
||||
'page_create_notification' => 'Page successfully created',
|
||||
'page_update' => 'обновена страница',
|
||||
'page_update_notification' => 'Страницата успешно обновена',
|
||||
'page_update_notification' => 'Page successfully updated',
|
||||
'page_delete' => 'изтрита страница',
|
||||
'page_delete_notification' => 'Страницата беше успешно изтрита',
|
||||
'page_delete_notification' => 'Page successfully deleted',
|
||||
'page_restore' => 'възстановена страница',
|
||||
'page_restore_notification' => 'Страницата беше успешно възстановена',
|
||||
'page_restore_notification' => 'Page successfully restored',
|
||||
'page_move' => 'преместена страница',
|
||||
|
||||
// Chapters
|
||||
'chapter_create' => 'създадена страница',
|
||||
'chapter_create_notification' => 'Главата беше успешно създадена',
|
||||
'chapter_create_notification' => 'Chapter successfully created',
|
||||
'chapter_update' => 'обновена глава',
|
||||
'chapter_update_notification' => 'Главата беше успешно обновена',
|
||||
'chapter_update_notification' => 'Chapter successfully updated',
|
||||
'chapter_delete' => 'изтрита глава',
|
||||
'chapter_delete_notification' => 'Главата беше успешно изтрита',
|
||||
'chapter_delete_notification' => 'Chapter successfully deleted',
|
||||
'chapter_move' => 'преместена глава',
|
||||
|
||||
// Books
|
||||
'book_create' => 'създадена книга',
|
||||
'book_create_notification' => 'Книгата беше успешно създадена',
|
||||
'book_create_notification' => 'Book successfully created',
|
||||
'book_update' => 'обновена книга',
|
||||
'book_update_notification' => 'Книгата беше успешно обновена',
|
||||
'book_update_notification' => 'Book successfully updated',
|
||||
'book_delete' => 'изтрита книга',
|
||||
'book_delete_notification' => 'Книгата беше успешно изтрита',
|
||||
'book_delete_notification' => 'Book successfully deleted',
|
||||
'book_sort' => 'сортирана книга',
|
||||
'book_sort_notification' => 'Книгата беше успешно преподредена',
|
||||
'book_sort_notification' => 'Book successfully re-sorted',
|
||||
|
||||
// Bookshelves
|
||||
'bookshelf_create' => 'създаден рафт',
|
||||
'bookshelf_create_notification' => 'Рафтът беше успешно създаден',
|
||||
'bookshelf_create' => 'created bookshelf',
|
||||
'bookshelf_create_notification' => 'Bookshelf successfully created',
|
||||
'bookshelf_update' => 'обновен рафт',
|
||||
'bookshelf_update_notification' => 'Рафтът беше успешно обновен',
|
||||
'bookshelf_update_notification' => 'Bookshelf successfully updated',
|
||||
'bookshelf_delete' => 'изтрит рафт',
|
||||
'bookshelf_delete_notification' => 'Рафтът беше успешно изтрит',
|
||||
'bookshelf_delete_notification' => 'Bookshelf successfully deleted',
|
||||
|
||||
// Favourites
|
||||
'favourite_add_notification' => '":name" has been added to your favourites',
|
||||
@@ -51,6 +51,14 @@ return [
|
||||
'mfa_setup_method_notification' => 'Multi-factor method successfully configured',
|
||||
'mfa_remove_method_notification' => 'Multi-factor method successfully removed',
|
||||
|
||||
// Webhooks
|
||||
'webhook_create' => 'created webhook',
|
||||
'webhook_create_notification' => 'Webhook successfully created',
|
||||
'webhook_update' => 'updated webhook',
|
||||
'webhook_update_notification' => 'Webhook successfully updated',
|
||||
'webhook_delete' => 'deleted webhook',
|
||||
'webhook_delete_notification' => 'Webhook successfully deleted',
|
||||
|
||||
// Other
|
||||
'commented_on' => 'коментирано на',
|
||||
'permissions_update' => 'updated permissions',
|
||||
|
||||
@@ -21,7 +21,7 @@ return [
|
||||
'email' => 'Имейл',
|
||||
'password' => 'Парола',
|
||||
'password_confirm' => 'Потвърди паролата',
|
||||
'password_hint' => 'Трябва да бъде поне 7 символа',
|
||||
'password_hint' => 'Must be at least 8 characters',
|
||||
'forgot_password' => 'Забравена парола?',
|
||||
'remember_me' => 'Запомни ме',
|
||||
'ldap_email_hint' => 'Моля въведете емейл, който да използвате за дадения акаунт.',
|
||||
|
||||
@@ -71,6 +71,10 @@ return [
|
||||
'list_view' => 'Изглед списък',
|
||||
'default' => 'Основен',
|
||||
'breadcrumb' => 'Трасиране',
|
||||
'status' => 'Status',
|
||||
'status_active' => 'Active',
|
||||
'status_inactive' => 'Inactive',
|
||||
'never' => 'Never',
|
||||
|
||||
// Header
|
||||
'header_menu_expand' => 'Expand Header Menu',
|
||||
|
||||
@@ -143,6 +143,8 @@ return [
|
||||
'books_sort_chapters_last' => 'Последна глава',
|
||||
'books_sort_show_other' => 'Покажи други книги',
|
||||
'books_sort_save' => 'Запази новата подредба',
|
||||
'books_copy' => 'Copy Book',
|
||||
'books_copy_success' => 'Book successfully copied',
|
||||
|
||||
// Chapters
|
||||
'chapter' => 'Глава',
|
||||
@@ -161,6 +163,8 @@ return [
|
||||
'chapters_move' => 'Премести глава',
|
||||
'chapters_move_named' => 'Премести глава :chapterName',
|
||||
'chapter_move_success' => 'Главата беше преместена в :bookName',
|
||||
'chapters_copy' => 'Copy Chapter',
|
||||
'chapters_copy_success' => 'Chapter successfully copied',
|
||||
'chapters_permissions' => 'Настойки за достъп на главата',
|
||||
'chapters_empty' => 'Няма създадени страници в тази глава.',
|
||||
'chapters_permissions_active' => 'Настройките за достъп до глава са активни',
|
||||
@@ -332,4 +336,12 @@ return [
|
||||
'revision_restore_confirm' => 'Сигурни ли сте, че искате да изтриете тази версия? Настоящата страница ще бъде заместена.',
|
||||
'revision_delete_success' => 'Версията беше изтрита',
|
||||
'revision_cannot_delete_latest' => 'Не може да изтриете последната версия.',
|
||||
|
||||
// Copy view
|
||||
'copy_consider' => 'Please consider the below when copying content.',
|
||||
'copy_consider_permissions' => 'Custom permission settings will not be copied.',
|
||||
'copy_consider_owner' => 'You will become the owner of all copied content.',
|
||||
'copy_consider_images' => 'Page image files will not be duplicated & the original images will retain their relation to the page they were originally uploaded to.',
|
||||
'copy_consider_attachments' => 'Page attachments will not be copied.',
|
||||
'copy_consider_access' => 'A change of location, owner or permissions may result in this content being accessible to those previously without access.',
|
||||
];
|
||||
|
||||
@@ -174,7 +174,7 @@ return [
|
||||
'users_role' => 'User Roles',
|
||||
'users_role_desc' => 'Select which roles this user will be assigned to. If a user is assigned to multiple roles the permissions from those roles will stack and they will receive all abilities of the assigned roles.',
|
||||
'users_password' => 'User Password',
|
||||
'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 6 characters long.',
|
||||
'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 8 characters long.',
|
||||
'users_send_invite_text' => 'You can choose to send this user an invitation email which allows them to set their own password otherwise you can set their password yourself.',
|
||||
'users_send_invite_option' => 'Send user invite email',
|
||||
'users_external_auth_id' => 'External Authentication ID',
|
||||
@@ -233,6 +233,34 @@ return [
|
||||
'user_api_token_delete_confirm' => 'Are you sure you want to delete this API token?',
|
||||
'user_api_token_delete_success' => 'API token successfully deleted',
|
||||
|
||||
// Webhooks
|
||||
'webhooks' => 'Webhooks',
|
||||
'webhooks_create' => 'Create New Webhook',
|
||||
'webhooks_none_created' => 'No webhooks have yet been created.',
|
||||
'webhooks_edit' => 'Edit Webhook',
|
||||
'webhooks_save' => 'Save Webhook',
|
||||
'webhooks_details' => 'Webhook Details',
|
||||
'webhooks_details_desc' => 'Provide a user friendly name and a POST endpoint as a location for the webhook data to be sent to.',
|
||||
'webhooks_events' => 'Webhook Events',
|
||||
'webhooks_events_desc' => 'Select all the events that should trigger this webhook to be called.',
|
||||
'webhooks_events_warning' => 'Keep in mind that these events will be triggered for all selected events, even if custom permissions are applied. Ensure that use of this webhook won\'t expose confidential content.',
|
||||
'webhooks_events_all' => 'All system events',
|
||||
'webhooks_name' => 'Webhook Name',
|
||||
'webhooks_timeout' => 'Webhook Request Timeout (Seconds)',
|
||||
'webhooks_endpoint' => 'Webhook Endpoint',
|
||||
'webhooks_active' => 'Webhook Active',
|
||||
'webhook_events_table_header' => 'Events',
|
||||
'webhooks_delete' => 'Delete Webhook',
|
||||
'webhooks_delete_warning' => 'This will fully delete this webhook, with the name \':webhookName\', from the system.',
|
||||
'webhooks_delete_confirm' => 'Are you sure you want to delete this webhook?',
|
||||
'webhooks_format_example' => 'Webhook Format Example',
|
||||
'webhooks_format_example_desc' => 'Webhook data is sent as a POST request to the configured endpoint as JSON following the format below. The "related_item" and "url" properties are optional and will depend on the type of event triggered.',
|
||||
'webhooks_status' => 'Webhook Status',
|
||||
'webhooks_last_called' => 'Last Called:',
|
||||
'webhooks_last_errored' => 'Last Errored:',
|
||||
'webhooks_last_error_message' => 'Last Error Message:',
|
||||
|
||||
|
||||
//! If editing translations files directly please ignore this in all
|
||||
//! languages apart from en. Content will be auto-copied from en.
|
||||
//!////////////////////////////////
|
||||
|
||||
@@ -7,41 +7,41 @@ return [
|
||||
|
||||
// Pages
|
||||
'page_create' => 'je kreirao/la stranicu',
|
||||
'page_create_notification' => 'Stranica Uspješno Kreirana',
|
||||
'page_create_notification' => 'Page successfully created',
|
||||
'page_update' => 'je ažurirao/la stranicu',
|
||||
'page_update_notification' => 'Stranica Uspješno Ažurirana',
|
||||
'page_update_notification' => 'Page successfully updated',
|
||||
'page_delete' => 'je izbrisao/la stranicu',
|
||||
'page_delete_notification' => 'Stranica Uspješno Izbrisana',
|
||||
'page_delete_notification' => 'Page successfully deleted',
|
||||
'page_restore' => 'je vratio/la stranicu',
|
||||
'page_restore_notification' => 'Stranica Uspješno Vraćena',
|
||||
'page_restore_notification' => 'Page successfully restored',
|
||||
'page_move' => 'je premjestio/la stranicu',
|
||||
|
||||
// Chapters
|
||||
'chapter_create' => 'je kreirao/la poglavlje',
|
||||
'chapter_create_notification' => 'Poglavlje Uspješno Kreirano',
|
||||
'chapter_create_notification' => 'Chapter successfully created',
|
||||
'chapter_update' => 'je ažurirao/la poglavlje',
|
||||
'chapter_update_notification' => 'Poglavlje Uspješno Ažurirano',
|
||||
'chapter_update_notification' => 'Chapter successfully updated',
|
||||
'chapter_delete' => 'je izbrisao/la poglavlje',
|
||||
'chapter_delete_notification' => 'Poglavlje Uspješno Izbrisano',
|
||||
'chapter_delete_notification' => 'Chapter successfully deleted',
|
||||
'chapter_move' => 'je premjestio/la poglavlje',
|
||||
|
||||
// Books
|
||||
'book_create' => 'je kreirao/la knjigu',
|
||||
'book_create_notification' => 'Knjiga Uspješno Kreirana',
|
||||
'book_create_notification' => 'Book successfully created',
|
||||
'book_update' => 'je ažurirao/la knjigu',
|
||||
'book_update_notification' => 'Knjiga Uspješno Ažurirana',
|
||||
'book_update_notification' => 'Book successfully updated',
|
||||
'book_delete' => 'je izbrisao/la knjigu',
|
||||
'book_delete_notification' => 'Knjiga Uspješno Izbrisana',
|
||||
'book_delete_notification' => 'Book successfully deleted',
|
||||
'book_sort' => 'je sortirao/la knjigu',
|
||||
'book_sort_notification' => 'Knjiga Uspješno Ponovno Sortirana',
|
||||
'book_sort_notification' => 'Book successfully re-sorted',
|
||||
|
||||
// Bookshelves
|
||||
'bookshelf_create' => 'je kreirao/la Policu za knjige',
|
||||
'bookshelf_create_notification' => 'Polica za knjige Uspješno Kreirana',
|
||||
'bookshelf_create' => 'created bookshelf',
|
||||
'bookshelf_create_notification' => 'Bookshelf successfully created',
|
||||
'bookshelf_update' => 'je ažurirao/la policu za knjige',
|
||||
'bookshelf_update_notification' => 'Polica za knjige Uspješno Ažurirana',
|
||||
'bookshelf_update_notification' => 'Bookshelf successfully updated',
|
||||
'bookshelf_delete' => 'je izbrisao/la policu za knjige',
|
||||
'bookshelf_delete_notification' => 'Polica za knjige Uspješno Izbrisana',
|
||||
'bookshelf_delete_notification' => 'Bookshelf successfully deleted',
|
||||
|
||||
// Favourites
|
||||
'favourite_add_notification' => '":name" je dodan u tvoje favorite',
|
||||
@@ -51,6 +51,14 @@ return [
|
||||
'mfa_setup_method_notification' => 'Multi-factor method successfully configured',
|
||||
'mfa_remove_method_notification' => 'Multi-factor method successfully removed',
|
||||
|
||||
// Webhooks
|
||||
'webhook_create' => 'created webhook',
|
||||
'webhook_create_notification' => 'Webhook successfully created',
|
||||
'webhook_update' => 'updated webhook',
|
||||
'webhook_update_notification' => 'Webhook successfully updated',
|
||||
'webhook_delete' => 'deleted webhook',
|
||||
'webhook_delete_notification' => 'Webhook successfully deleted',
|
||||
|
||||
// Other
|
||||
'commented_on' => 'je komentarisao/la na',
|
||||
'permissions_update' => 'je ažurirao/la dozvole',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user