mirror of
https://github.com/BookStackApp/BookStack.git
synced 2026-02-05 16:49:47 +03:00
Compare commits
121 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
d00ac2f34e | ||
|
|
bd4dc6d463 | ||
|
|
e6c8ecba9c | ||
|
|
9490457d04 | ||
|
|
3e97fdf827 | ||
|
|
3b3eb0f44f | ||
|
|
b4fa82e329 | ||
|
|
42703dd859 | ||
|
|
2c21850da7 | ||
|
|
709533c1fb | ||
|
|
d91180a909 | ||
|
|
bc2913a5cb | ||
|
|
cd7788f2e9 | ||
|
|
f63d7f60aa | ||
|
|
197caddf96 | ||
|
|
096ed722dd | ||
|
|
024924eef3 | ||
|
|
1bf59f434b | ||
|
|
c6e196989e | ||
|
|
cb30c258df | ||
|
|
cdaad2f40e | ||
|
|
4ddbc9556b | ||
|
|
9a5adc026a | ||
|
|
37db51a627 | ||
|
|
f8c16494fd | ||
|
|
0d740ca681 | ||
|
|
876bc10d4d | ||
|
|
754403a29e |
@@ -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
|
||||
|
||||
14
.github/translators.txt
vendored
14
.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
|
||||
@@ -199,3 +199,15 @@ M Nafis Al Mukhdi (mnafisalmukhdi1) :: Indonesian
|
||||
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
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
112
app/Actions/ActivityQueries.php
Normal file
112
app/Actions/ActivityQueries.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Actions;
|
||||
|
||||
use BookStack\Auth\Permissions\PermissionService;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
|
||||
class ActivityQueries
|
||||
{
|
||||
protected $permissionService;
|
||||
|
||||
public function __construct(PermissionService $permissionService)
|
||||
{
|
||||
$this->permissionService = $permissionService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the latest activity.
|
||||
*/
|
||||
public function latest(int $count = 20, int $page = 0): array
|
||||
{
|
||||
$activityList = $this->permissionService
|
||||
->filterRestrictedEntityRelations(Activity::query(), 'activities', 'entity_id', 'entity_type')
|
||||
->orderBy('created_at', 'desc')
|
||||
->with(['user', 'entity'])
|
||||
->skip($count * $page)
|
||||
->take($count)
|
||||
->get();
|
||||
|
||||
return $this->filterSimilar($activityList);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the latest activity for an entity, Filtering out similar
|
||||
* items to prevent a message activity list.
|
||||
*/
|
||||
public function entityActivity(Entity $entity, int $count = 20, int $page = 1): array
|
||||
{
|
||||
/** @var array<string, int[]> $queryIds */
|
||||
$queryIds = [$entity->getMorphClass() => [$entity->id]];
|
||||
|
||||
if ($entity instanceof Book) {
|
||||
$queryIds[(new Chapter())->getMorphClass()] = $entity->chapters()->scopes('visible')->pluck('id');
|
||||
}
|
||||
if ($entity instanceof Book || $entity instanceof Chapter) {
|
||||
$queryIds[(new Page())->getMorphClass()] = $entity->pages()->scopes('visible')->pluck('id');
|
||||
}
|
||||
|
||||
$query = Activity::query();
|
||||
$query->where(function (Builder $query) use ($queryIds) {
|
||||
foreach ($queryIds as $morphClass => $idArr) {
|
||||
$query->orWhere(function (Builder $innerQuery) use ($morphClass, $idArr) {
|
||||
$innerQuery->where('entity_type', '=', $morphClass)
|
||||
->whereIn('entity_id', $idArr);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$activity = $query->orderBy('created_at', 'desc')
|
||||
->with(['entity' => function (Relation $query) {
|
||||
$query->withTrashed();
|
||||
}, 'user.avatar'])
|
||||
->skip($count * ($page - 1))
|
||||
->take($count)
|
||||
->get();
|
||||
|
||||
return $this->filterSimilar($activity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest activity for a user, Filtering out similar items.
|
||||
*/
|
||||
public function userActivity(User $user, int $count = 20, int $page = 0): array
|
||||
{
|
||||
$activityList = $this->permissionService
|
||||
->filterRestrictedEntityRelations(Activity::query(), 'activities', 'entity_id', 'entity_type')
|
||||
->orderBy('created_at', 'desc')
|
||||
->where('user_id', '=', $user->id)
|
||||
->skip($count * $page)
|
||||
->take($count)
|
||||
->get();
|
||||
|
||||
return $this->filterSimilar($activityList);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters out similar activity.
|
||||
*
|
||||
* @param Activity[] $activities
|
||||
*/
|
||||
protected function filterSimilar(iterable $activities): array
|
||||
{
|
||||
$newActivity = [];
|
||||
$previousItem = null;
|
||||
|
||||
foreach ($activities as $activityItem) {
|
||||
if (!$previousItem || !$activityItem->isSimilarTo($previousItem)) {
|
||||
$newActivity[] = $activityItem;
|
||||
}
|
||||
|
||||
$previousItem = $activityItem;
|
||||
}
|
||||
|
||||
return $newActivity;
|
||||
}
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Actions;
|
||||
|
||||
use BookStack\Auth\Permissions\PermissionService;
|
||||
use BookStack\Auth\User;
|
||||
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
|
||||
{
|
||||
protected $activity;
|
||||
protected $permissionService;
|
||||
|
||||
public function __construct(Activity $activity, 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')
|
||||
->orderBy('created_at', 'desc')
|
||||
->with(['user', 'entity'])
|
||||
->skip($count * $page)
|
||||
->take($count)
|
||||
->get();
|
||||
|
||||
return $this->filterSimilar($activityList);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the latest activity for an entity, Filtering out similar
|
||||
* items to prevent a message activity list.
|
||||
*/
|
||||
public function entityActivity(Entity $entity, int $count = 20, int $page = 1): array
|
||||
{
|
||||
/** @var [string => int[]] $queryIds */
|
||||
$queryIds = [$entity->getMorphClass() => [$entity->id]];
|
||||
|
||||
if ($entity->isA('book')) {
|
||||
$queryIds[(new Chapter())->getMorphClass()] = $entity->chapters()->visible()->pluck('id');
|
||||
}
|
||||
if ($entity->isA('book') || $entity->isA('chapter')) {
|
||||
$queryIds[(new Page())->getMorphClass()] = $entity->pages()->visible()->pluck('id');
|
||||
}
|
||||
|
||||
$query = $this->activity->newQuery();
|
||||
$query->where(function (Builder $query) use ($queryIds) {
|
||||
foreach ($queryIds as $morphClass => $idArr) {
|
||||
$query->orWhere(function (Builder $innerQuery) use ($morphClass, $idArr) {
|
||||
$innerQuery->where('entity_type', '=', $morphClass)
|
||||
->whereIn('entity_id', $idArr);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$activity = $query->orderBy('created_at', 'desc')
|
||||
->with(['entity' => function (Relation $query) {
|
||||
$query->withTrashed();
|
||||
}, 'user.avatar'])
|
||||
->skip($count * ($page - 1))
|
||||
->take($count)
|
||||
->get();
|
||||
|
||||
return $this->filterSimilar($activity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get latest activity for a user, Filtering out similar items.
|
||||
*/
|
||||
public function userActivity(User $user, int $count = 20, int $page = 0): array
|
||||
{
|
||||
$activityList = $this->permissionService
|
||||
->filterRestrictedEntityRelations($this->activity->newQuery(), 'activities', 'entity_id', 'entity_type')
|
||||
->orderBy('created_at', 'desc')
|
||||
->where('user_id', '=', $user->id)
|
||||
->skip($count * $page)
|
||||
->take($count)
|
||||
->get();
|
||||
|
||||
return $this->filterSimilar($activityList);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters out similar activity.
|
||||
*
|
||||
* @param Activity[] $activities
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function filterSimilar(iterable $activities): array
|
||||
{
|
||||
$newActivity = [];
|
||||
$previousItem = null;
|
||||
|
||||
foreach ($activities as $activityItem) {
|
||||
if (!$previousItem || !$activityItem->isSimilarTo($previousItem)) {
|
||||
$newActivity[] = $activityItem;
|
||||
}
|
||||
|
||||
$previousItem = $activityItem;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace BookStack\Auth\Access;
|
||||
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
use Illuminate\Contracts\Auth\UserProvider;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ExternalBaseUserProvider implements UserProvider
|
||||
{
|
||||
@@ -16,8 +17,6 @@ class ExternalBaseUserProvider implements UserProvider
|
||||
|
||||
/**
|
||||
* LdapUserProvider constructor.
|
||||
*
|
||||
* @param $model
|
||||
*/
|
||||
public function __construct(string $model)
|
||||
{
|
||||
@@ -27,7 +26,7 @@ class ExternalBaseUserProvider implements UserProvider
|
||||
/**
|
||||
* Create a new instance of the model.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Model
|
||||
* @return Model
|
||||
*/
|
||||
public function createModel()
|
||||
{
|
||||
@@ -41,7 +40,7 @@ class ExternalBaseUserProvider implements UserProvider
|
||||
*
|
||||
* @param mixed $identifier
|
||||
*
|
||||
* @return \Illuminate\Contracts\Auth\Authenticatable|null
|
||||
* @return Authenticatable|null
|
||||
*/
|
||||
public function retrieveById($identifier)
|
||||
{
|
||||
@@ -54,7 +53,7 @@ class ExternalBaseUserProvider implements UserProvider
|
||||
* @param mixed $identifier
|
||||
* @param string $token
|
||||
*
|
||||
* @return \Illuminate\Contracts\Auth\Authenticatable|null
|
||||
* @return Authenticatable|null
|
||||
*/
|
||||
public function retrieveByToken($identifier, $token)
|
||||
{
|
||||
@@ -64,8 +63,8 @@ class ExternalBaseUserProvider implements UserProvider
|
||||
/**
|
||||
* Update the "remember me" token for the given user in storage.
|
||||
*
|
||||
* @param \Illuminate\Contracts\Auth\Authenticatable $user
|
||||
* @param string $token
|
||||
* @param Authenticatable $user
|
||||
* @param string $token
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
@@ -79,7 +78,7 @@ class ExternalBaseUserProvider implements UserProvider
|
||||
*
|
||||
* @param array $credentials
|
||||
*
|
||||
* @return \Illuminate\Contracts\Auth\Authenticatable|null
|
||||
* @return Authenticatable|null
|
||||
*/
|
||||
public function retrieveByCredentials(array $credentials)
|
||||
{
|
||||
@@ -94,8 +93,8 @@ class ExternalBaseUserProvider implements UserProvider
|
||||
/**
|
||||
* Validate a user against the given credentials.
|
||||
*
|
||||
* @param \Illuminate\Contracts\Auth\Authenticatable $user
|
||||
* @param array $credentials
|
||||
* @param Authenticatable $user
|
||||
* @param array $credentials
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
|
||||
@@ -165,7 +165,7 @@ class LdapService
|
||||
* Bind the system user to the LDAP connection using the given credentials
|
||||
* otherwise anonymous access is attempted.
|
||||
*
|
||||
* @param $connection
|
||||
* @param resource $connection
|
||||
*
|
||||
* @throws LdapException
|
||||
*/
|
||||
|
||||
@@ -41,16 +41,18 @@ class OidcJwtSigningKey
|
||||
protected function loadFromPath(string $path)
|
||||
{
|
||||
try {
|
||||
$this->key = PublicKeyLoader::load(
|
||||
$key = PublicKeyLoader::load(
|
||||
file_get_contents($path)
|
||||
)->withPadding(RSA::SIGNATURE_PKCS1);
|
||||
);
|
||||
} catch (\Exception $exception) {
|
||||
throw new OidcInvalidKeyException("Failed to load key from file path with error: {$exception->getMessage()}");
|
||||
}
|
||||
|
||||
if (!($this->key instanceof RSA)) {
|
||||
if (!$key instanceof RSA) {
|
||||
throw new OidcInvalidKeyException('Key loaded from file path is not an RSA key as expected');
|
||||
}
|
||||
|
||||
$this->key = $key->withPadding(RSA::SIGNATURE_PKCS1);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,14 +83,19 @@ class OidcJwtSigningKey
|
||||
$n = strtr($jwk['n'] ?? '', '-_', '+/');
|
||||
|
||||
try {
|
||||
/** @var RSA $key */
|
||||
$this->key = PublicKeyLoader::load([
|
||||
$key = PublicKeyLoader::load([
|
||||
'e' => new BigInteger(base64_decode($jwk['e']), 256),
|
||||
'n' => new BigInteger(base64_decode($n), 256),
|
||||
])->withPadding(RSA::SIGNATURE_PKCS1);
|
||||
]);
|
||||
} catch (\Exception $exception) {
|
||||
throw new OidcInvalidKeyException("Failed to load key from JWK parameters with error: {$exception->getMessage()}");
|
||||
}
|
||||
|
||||
if (!$key instanceof RSA) {
|
||||
throw new OidcInvalidKeyException('Key loaded from file path is not an RSA key as expected');
|
||||
}
|
||||
|
||||
$this->key = $key->withPadding(RSA::SIGNATURE_PKCS1);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,6 +12,7 @@ use Illuminate\Support\Str;
|
||||
use Laravel\Socialite\Contracts\Factory as Socialite;
|
||||
use Laravel\Socialite\Contracts\Provider;
|
||||
use Laravel\Socialite\Contracts\User as SocialUser;
|
||||
use Laravel\Socialite\Two\GoogleProvider;
|
||||
use SocialiteProviders\Manager\SocialiteWasCalled;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
|
||||
@@ -278,7 +279,7 @@ class SocialAuthService
|
||||
{
|
||||
$driver = $this->socialite->driver($driverName);
|
||||
|
||||
if ($driverName === 'google' && config('services.google.select_account')) {
|
||||
if ($driver instanceof GoogleProvider && config('services.google.select_account')) {
|
||||
$driver->with(['prompt' => 'select_account']);
|
||||
}
|
||||
|
||||
|
||||
@@ -602,25 +602,35 @@ class PermissionService
|
||||
|
||||
/**
|
||||
* Filter items that have entities set as a polymorphic relation.
|
||||
* For simplicity, this will not return results attached to draft pages.
|
||||
* Draft pages should never really have related items though.
|
||||
*
|
||||
* @param Builder|QueryBuilder $query
|
||||
*/
|
||||
public function filterRestrictedEntityRelations($query, string $tableName, string $entityIdColumn, string $entityTypeColumn, string $action = 'view')
|
||||
{
|
||||
$tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn, 'entityTypeColumn' => $entityTypeColumn];
|
||||
$pageMorphClass = (new Page())->getMorphClass();
|
||||
|
||||
$q = $query->where(function ($query) use ($tableDetails, $action) {
|
||||
$query->whereExists(function ($permissionQuery) use (&$tableDetails, $action) {
|
||||
/** @var Builder $permissionQuery */
|
||||
$permissionQuery->select(['role_id'])->from('joint_permissions')
|
||||
->whereColumn('joint_permissions.entity_id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
|
||||
->whereColumn('joint_permissions.entity_type', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'])
|
||||
->where('action', '=', $action)
|
||||
->whereIn('role_id', $this->getCurrentUserRoles())
|
||||
->where(function (QueryBuilder $query) {
|
||||
$this->addJointHasPermissionCheck($query, $this->currentUser()->id);
|
||||
});
|
||||
});
|
||||
$q = $query->whereExists(function ($permissionQuery) use (&$tableDetails, $action) {
|
||||
/** @var Builder $permissionQuery */
|
||||
$permissionQuery->select(['role_id'])->from('joint_permissions')
|
||||
->whereColumn('joint_permissions.entity_id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
|
||||
->whereColumn('joint_permissions.entity_type', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'])
|
||||
->where('joint_permissions.action', '=', $action)
|
||||
->whereIn('joint_permissions.role_id', $this->getCurrentUserRoles())
|
||||
->where(function (QueryBuilder $query) {
|
||||
$this->addJointHasPermissionCheck($query, $this->currentUser()->id);
|
||||
});
|
||||
})->where(function ($query) use ($tableDetails, $pageMorphClass) {
|
||||
/** @var Builder $query */
|
||||
$query->where($tableDetails['entityTypeColumn'], '!=', $pageMorphClass)
|
||||
->orWhereExists(function (QueryBuilder $query) use ($tableDetails, $pageMorphClass) {
|
||||
$query->select('id')->from('pages')
|
||||
->whereColumn('pages.id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
|
||||
->where($tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'], '=', $pageMorphClass)
|
||||
->where('pages.draft', '=', false);
|
||||
});
|
||||
});
|
||||
|
||||
$this->clean();
|
||||
@@ -634,25 +644,39 @@ class PermissionService
|
||||
*/
|
||||
public function filterRelatedEntity(string $entityClass, Builder $query, string $tableName, string $entityIdColumn): Builder
|
||||
{
|
||||
$tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn];
|
||||
$morphClass = app($entityClass)->getMorphClass();
|
||||
$fullEntityIdColumn = $tableName . '.' . $entityIdColumn;
|
||||
$instance = new $entityClass();
|
||||
$morphClass = $instance->getMorphClass();
|
||||
|
||||
$q = $query->where(function ($query) use ($tableDetails, $morphClass) {
|
||||
$query->where(function ($query) use (&$tableDetails, $morphClass) {
|
||||
$query->whereExists(function ($permissionQuery) use (&$tableDetails, $morphClass) {
|
||||
/** @var Builder $permissionQuery */
|
||||
$permissionQuery->select('id')->from('joint_permissions')
|
||||
->whereColumn('joint_permissions.entity_id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
|
||||
->where('entity_type', '=', $morphClass)
|
||||
->where('action', '=', 'view')
|
||||
->whereIn('role_id', $this->getCurrentUserRoles())
|
||||
->where(function (QueryBuilder $query) {
|
||||
$this->addJointHasPermissionCheck($query, $this->currentUser()->id);
|
||||
});
|
||||
$existsQuery = function ($permissionQuery) use ($fullEntityIdColumn, $morphClass) {
|
||||
/** @var Builder $permissionQuery */
|
||||
$permissionQuery->select('joint_permissions.role_id')->from('joint_permissions')
|
||||
->whereColumn('joint_permissions.entity_id', '=', $fullEntityIdColumn)
|
||||
->where('joint_permissions.entity_type', '=', $morphClass)
|
||||
->where('joint_permissions.action', '=', 'view')
|
||||
->whereIn('joint_permissions.role_id', $this->getCurrentUserRoles())
|
||||
->where(function (QueryBuilder $query) {
|
||||
$this->addJointHasPermissionCheck($query, $this->currentUser()->id);
|
||||
});
|
||||
})->orWhere($tableDetails['entityIdColumn'], '=', 0);
|
||||
};
|
||||
|
||||
$q = $query->where(function ($query) use ($existsQuery, $fullEntityIdColumn) {
|
||||
$query->whereExists($existsQuery)
|
||||
->orWhere($fullEntityIdColumn, '=', 0);
|
||||
});
|
||||
|
||||
if ($instance instanceof Page) {
|
||||
// Prevent visibility of non-owned draft pages
|
||||
$q->whereExists(function (QueryBuilder $query) use ($fullEntityIdColumn) {
|
||||
$query->select('id')->from('pages')
|
||||
->whereColumn('pages.id', '=', $fullEntityIdColumn)
|
||||
->where(function (QueryBuilder $query) {
|
||||
$query->where('pages.draft', '=', false)
|
||||
->orWhere('pages.owned_by', '=', $this->currentUser()->id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
$this->clean();
|
||||
|
||||
return $q;
|
||||
@@ -666,9 +690,9 @@ class PermissionService
|
||||
*/
|
||||
protected function addJointHasPermissionCheck($query, int $userIdToCheck)
|
||||
{
|
||||
$query->where('has_permission', '=', true)->orWhere(function ($query) use ($userIdToCheck) {
|
||||
$query->where('has_permission_own', '=', true)
|
||||
->where('owned_by', '=', $userIdToCheck);
|
||||
$query->where('joint_permissions.has_permission', '=', true)->orWhere(function ($query) use ($userIdToCheck) {
|
||||
$query->where('joint_permissions.has_permission_own', '=', true)
|
||||
->where('joint_permissions.owned_by', '=', $userIdToCheck);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace BookStack\Auth\Permissions;
|
||||
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
@@ -13,19 +14,15 @@ class RolePermission extends Model
|
||||
/**
|
||||
* The roles that belong to the permission.
|
||||
*/
|
||||
public function roles()
|
||||
public function roles(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Role::class, 'permission_role', 'permission_id', 'role_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the permission object by name.
|
||||
*
|
||||
* @param $name
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public static function getByName($name)
|
||||
public static function getByName(string $name): ?RolePermission
|
||||
{
|
||||
return static::where('name', '=', $name)->first();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -49,7 +49,7 @@ class RegenerateSearch extends Command
|
||||
DB::setDefaultConnection($this->option('database'));
|
||||
}
|
||||
|
||||
$this->searchIndex->indexAllEntities(function (Entity $model, int $processed, int $total) {
|
||||
$this->searchIndex->indexAllEntities(function (Entity $model, int $processed, int $total): void {
|
||||
$this->info('Indexed ' . class_basename($model) . ' entries (' . $processed . '/' . $total . ')');
|
||||
});
|
||||
|
||||
|
||||
@@ -79,53 +79,43 @@ class Book extends Entity implements HasCoverImage
|
||||
|
||||
/**
|
||||
* Get all pages within this book.
|
||||
*
|
||||
* @return HasMany
|
||||
*/
|
||||
public function pages()
|
||||
public function pages(): HasMany
|
||||
{
|
||||
return $this->hasMany(Page::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the direct child pages of this book.
|
||||
*
|
||||
* @return HasMany
|
||||
*/
|
||||
public function directPages()
|
||||
public function directPages(): HasMany
|
||||
{
|
||||
return $this->pages()->where('chapter_id', '=', '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all chapters within this book.
|
||||
*
|
||||
* @return HasMany
|
||||
*/
|
||||
public function chapters()
|
||||
public function chapters(): HasMany
|
||||
{
|
||||
return $this->hasMany(Chapter::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the shelves this book is contained within.
|
||||
*
|
||||
* @return BelongsToMany
|
||||
*/
|
||||
public function shelves()
|
||||
public function shelves(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Bookshelf::class, 'bookshelves_books', 'book_id', 'bookshelf_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the direct child items within this book.
|
||||
*
|
||||
* @return Collection
|
||||
*/
|
||||
public function getDirectChildren(): Collection
|
||||
{
|
||||
$pages = $this->directPages()->visible()->get();
|
||||
$chapters = $this->chapters()->visible()->get();
|
||||
$pages = $this->directPages()->scopes('visible')->get();
|
||||
$chapters = $this->chapters()->scopes('visible')->get();
|
||||
|
||||
return $pages->concat($chapters)->sortBy('priority')->sortByDesc('draft');
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ class Bookshelf extends Entity implements HasCoverImage
|
||||
*/
|
||||
public function visibleBooks(): BelongsToMany
|
||||
{
|
||||
return $this->books()->visible();
|
||||
return $this->books()->scopes('visible');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,11 +18,13 @@ 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'];
|
||||
|
||||
/**
|
||||
* Get the pages that this chapter contains.
|
||||
*
|
||||
* @return HasMany<Page>
|
||||
*/
|
||||
public function pages(string $dir = 'ASC'): HasMany
|
||||
{
|
||||
@@ -50,7 +52,8 @@ class Chapter extends BookChild
|
||||
*/
|
||||
public function getVisiblePages(): Collection
|
||||
{
|
||||
return $this->pages()->visible()
|
||||
return $this->pages()
|
||||
->scopes('visible')
|
||||
->orderBy('draft', 'desc')
|
||||
->orderBy('priority', 'asc')
|
||||
->get();
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Interfaces\Deletable;
|
||||
use BookStack\Interfaces\Loggable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
/**
|
||||
* @property Model $deletable
|
||||
* @property Deletable $deletable
|
||||
*/
|
||||
class Deletion extends Model implements Loggable
|
||||
{
|
||||
@@ -58,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, '/'));
|
||||
}
|
||||
|
||||
@@ -12,7 +12,9 @@ use BookStack\Auth\Permissions\JointPermission;
|
||||
use BookStack\Entities\Tools\SearchIndex;
|
||||
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;
|
||||
@@ -34,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
|
||||
@@ -44,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
|
||||
abstract class Entity extends Model implements Sluggable, Favouritable, Viewable, Deletable, Loggable
|
||||
{
|
||||
use SoftDeletes;
|
||||
use HasCreatorAndUpdater;
|
||||
@@ -120,11 +123,11 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
|
||||
return true;
|
||||
}
|
||||
|
||||
if (($entity->isA('chapter') || $entity->isA('page')) && $this->isA('book')) {
|
||||
if (($entity instanceof BookChild) && $this instanceof Book) {
|
||||
return $entity->book_id === $this->id;
|
||||
}
|
||||
|
||||
if ($entity->isA('page') && $this->isA('chapter')) {
|
||||
if ($entity instanceof Page && $this instanceof Chapter) {
|
||||
return $entity->chapter_id === $this->id;
|
||||
}
|
||||
|
||||
@@ -210,6 +213,8 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
|
||||
/**
|
||||
* Check if this instance or class is a certain type of entity.
|
||||
* Examples of $type are 'page', 'book', 'chapter'.
|
||||
*
|
||||
* @deprecated Use instanceof instead.
|
||||
*/
|
||||
public static function isA(string $type): bool
|
||||
{
|
||||
@@ -318,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,27 +46,16 @@ 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, '/'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the previous revision for the same page if existing.
|
||||
*
|
||||
* @return \BookStack\Entities\PageRevision|null
|
||||
*/
|
||||
public function getPrevious()
|
||||
public function getPrevious(): ?PageRevision
|
||||
{
|
||||
$id = static::newQuery()->where('page_id', '=', $this->page_id)
|
||||
->where('id', '<', $this->id)
|
||||
@@ -84,11 +73,9 @@ class PageRevision extends Model
|
||||
* Included here to align with entities in similar use cases.
|
||||
* (Yup, Bit of an awkward hack).
|
||||
*
|
||||
* @param $type
|
||||
*
|
||||
* @return bool
|
||||
* @deprecated Use instanceof instead.
|
||||
*/
|
||||
public static function isA($type)
|
||||
public static function isA(string $type): bool
|
||||
{
|
||||
return $type === 'revision';
|
||||
}
|
||||
|
||||
@@ -67,10 +67,12 @@ class BaseRepo
|
||||
/**
|
||||
* Update the given items' cover image, or clear it.
|
||||
*
|
||||
* @param Entity&HasCoverImage $entity
|
||||
*
|
||||
* @throws ImageUploadException
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function updateCoverImage(HasCoverImage $entity, ?UploadedFile $coverImage, bool $removeImage = false)
|
||||
public function updateCoverImage($entity, ?UploadedFile $coverImage, bool $removeImage = false)
|
||||
{
|
||||
if ($coverImage) {
|
||||
$this->imageRepo->destroyImage($entity->cover);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,9 +69,10 @@ class PageRepo
|
||||
*/
|
||||
public function getByOldSlug(string $bookSlug, string $pageSlug): ?Page
|
||||
{
|
||||
/** @var ?PageRevision $revision */
|
||||
$revision = PageRevision::query()
|
||||
->whereHas('page', function (Builder $query) {
|
||||
$query->visible();
|
||||
$query->scopes('visible');
|
||||
})
|
||||
->where('slug', '=', $pageSlug)
|
||||
->where('type', '=', 'version')
|
||||
@@ -80,7 +81,7 @@ class PageRepo
|
||||
->with('page')
|
||||
->first();
|
||||
|
||||
return $revision ? $revision->page : null;
|
||||
return $revision->page ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -170,7 +171,7 @@ class PageRepo
|
||||
$draft->indexForSearch();
|
||||
$draft->refresh();
|
||||
|
||||
Activity::addForEntity($draft, ActivityType::PAGE_CREATE);
|
||||
Activity::add(ActivityType::PAGE_CREATE, $draft);
|
||||
|
||||
return $draft;
|
||||
}
|
||||
@@ -204,7 +205,7 @@ class PageRepo
|
||||
$this->savePageRevision($page, $summary);
|
||||
}
|
||||
|
||||
Activity::addForEntity($page, ActivityType::PAGE_UPDATE);
|
||||
Activity::add(ActivityType::PAGE_UPDATE, $page);
|
||||
|
||||
return $page;
|
||||
}
|
||||
@@ -280,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();
|
||||
}
|
||||
|
||||
@@ -290,6 +291,8 @@ class PageRepo
|
||||
public function restoreRevision(Page $page, int $revisionId): Page
|
||||
{
|
||||
$page->revision_count++;
|
||||
|
||||
/** @var PageRevision $revision */
|
||||
$revision = $page->revisions()->where('id', '=', $revisionId)->first();
|
||||
|
||||
$page->fill($revision->toArray());
|
||||
@@ -309,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;
|
||||
}
|
||||
@@ -325,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');
|
||||
}
|
||||
|
||||
@@ -334,59 +337,23 @@ class PageRepo
|
||||
}
|
||||
|
||||
$page->chapter_id = ($parent instanceof Chapter) ? $parent->id : null;
|
||||
$page->changeBook($parent instanceof Book ? $parent->id : $parent->book->id);
|
||||
$newBookId = ($parent instanceof Chapter) ? $parent->book->id : $parent->id;
|
||||
$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];
|
||||
@@ -406,7 +373,7 @@ class PageRepo
|
||||
*/
|
||||
protected function changeParent(Page $page, Entity $parent)
|
||||
{
|
||||
$book = ($parent instanceof Book) ? $parent : $parent->book;
|
||||
$book = ($parent instanceof Chapter) ? $parent->book : $parent;
|
||||
$page->chapter_id = ($parent instanceof Chapter) ? $parent->id : 0;
|
||||
$page->save();
|
||||
|
||||
@@ -467,6 +434,7 @@ class PageRepo
|
||||
{
|
||||
$parent = $page->getParent();
|
||||
if ($parent instanceof Chapter) {
|
||||
/** @var ?Page $lastPage */
|
||||
$lastPage = $parent->pages('desc')->first();
|
||||
|
||||
return $lastPage ? $lastPage->priority + 1 : 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
|
||||
@@ -67,7 +66,7 @@ class BookContents
|
||||
$all->each(function (Entity $entity) use ($renderPages) {
|
||||
$entity->setRelation('book', $this->book);
|
||||
|
||||
if ($renderPages && $entity->isA('page')) {
|
||||
if ($renderPages && $entity instanceof Page) {
|
||||
$entity->html = (new PageContent($entity))->render();
|
||||
}
|
||||
});
|
||||
@@ -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 = ($sortMapItem->type === '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;
|
||||
}
|
||||
}
|
||||
@@ -7,21 +7,24 @@ use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Tools\Markdown\HtmlToMarkdown;
|
||||
use BookStack\Uploads\ImageService;
|
||||
use DomPDF;
|
||||
use DOMDocument;
|
||||
use DOMElement;
|
||||
use DOMXPath;
|
||||
use Exception;
|
||||
use SnappyPDF;
|
||||
use Throwable;
|
||||
|
||||
class ExportFormatter
|
||||
{
|
||||
protected $imageService;
|
||||
protected $pdfGenerator;
|
||||
|
||||
/**
|
||||
* ExportService constructor.
|
||||
*/
|
||||
public function __construct(ImageService $imageService)
|
||||
public function __construct(ImageService $imageService, PdfGenerator $pdfGenerator)
|
||||
{
|
||||
$this->imageService = $imageService;
|
||||
$this->pdfGenerator = $pdfGenerator;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -139,16 +142,40 @@ class ExportFormatter
|
||||
*/
|
||||
protected function htmlToPdf(string $html): string
|
||||
{
|
||||
$containedHtml = $this->containHtml($html);
|
||||
$useWKHTML = config('snappy.pdf.binary') !== false && config('app.allow_untrusted_server_fetching') === true;
|
||||
if ($useWKHTML) {
|
||||
$pdf = SnappyPDF::loadHTML($containedHtml);
|
||||
$pdf->setOption('print-media-type', true);
|
||||
} else {
|
||||
$pdf = DomPDF::loadHTML($containedHtml);
|
||||
$html = $this->containHtml($html);
|
||||
$html = $this->replaceIframesWithLinks($html);
|
||||
|
||||
return $this->pdfGenerator->fromHtml($html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Within the given HTML content, replace any iframe elements
|
||||
* with anchor links within paragraph blocks.
|
||||
*/
|
||||
protected function replaceIframesWithLinks(string $html): string
|
||||
{
|
||||
libxml_use_internal_errors(true);
|
||||
|
||||
$doc = new DOMDocument();
|
||||
$doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
|
||||
$xPath = new DOMXPath($doc);
|
||||
|
||||
$iframes = $xPath->query('//iframe');
|
||||
/** @var DOMElement $iframe */
|
||||
foreach ($iframes as $iframe) {
|
||||
$link = $iframe->getAttribute('src');
|
||||
if (strpos($link, '//') === 0) {
|
||||
$link = 'https:' . $link;
|
||||
}
|
||||
|
||||
$anchor = $doc->createElement('a', $link);
|
||||
$anchor->setAttribute('href', $link);
|
||||
$paragraph = $doc->createElement('p');
|
||||
$paragraph->appendChild($anchor);
|
||||
$iframe->parentNode->replaceChild($paragraph, $iframe);
|
||||
}
|
||||
|
||||
return $pdf->output();
|
||||
return $doc->saveHTML();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -64,7 +64,7 @@ class NextPreviousContentLocator
|
||||
/** @var Entity $item */
|
||||
foreach ($bookTree->all() as $item) {
|
||||
$flatOrdered->push($item);
|
||||
$childPages = $item->visible_pages ?? [];
|
||||
$childPages = $item->getAttribute('visible_pages') ?? [];
|
||||
$flatOrdered = $flatOrdered->concat($childPages);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ use BookStack\Uploads\ImageRepo;
|
||||
use BookStack\Uploads\ImageService;
|
||||
use BookStack\Util\HtmlContentFilter;
|
||||
use DOMDocument;
|
||||
use DOMElement;
|
||||
use DOMNode;
|
||||
use DOMNodeList;
|
||||
use DOMXPath;
|
||||
use Illuminate\Support\Str;
|
||||
@@ -156,7 +158,7 @@ class PageContent
|
||||
/**
|
||||
* Parse a base64 image URI into the data and extension.
|
||||
*
|
||||
* @return array{extension: array, data: string}
|
||||
* @return array{extension: string, data: string}
|
||||
*/
|
||||
protected function parseBase64ImageUri(string $uri): array
|
||||
{
|
||||
@@ -193,6 +195,15 @@ class PageContent
|
||||
}
|
||||
}
|
||||
|
||||
// Set ids on nested header nodes
|
||||
$nestedHeaders = $xPath->query('//body//*//h1|//body//*//h2|//body//*//h3|//body//*//h4|//body//*//h5|//body//*//h6');
|
||||
foreach ($nestedHeaders as $nestedHeader) {
|
||||
[$oldId, $newId] = $this->setUniqueId($nestedHeader, $idMap);
|
||||
if ($newId && $newId !== $oldId) {
|
||||
$this->updateLinks($xPath, '#' . $oldId, '#' . $newId);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure no duplicate ids within child items
|
||||
$idElems = $xPath->query('//body//*//*[@id]');
|
||||
foreach ($idElems as $domElem) {
|
||||
@@ -228,9 +239,9 @@ class PageContent
|
||||
* A map for existing ID's should be passed in to check for current existence.
|
||||
* Returns a pair of strings in the format [old_id, new_id].
|
||||
*/
|
||||
protected function setUniqueId(\DOMNode $element, array &$idMap): array
|
||||
protected function setUniqueId(DOMNode $element, array &$idMap): array
|
||||
{
|
||||
if (get_class($element) !== 'DOMElement') {
|
||||
if (!$element instanceof DOMElement) {
|
||||
return ['', ''];
|
||||
}
|
||||
|
||||
@@ -242,7 +253,7 @@ class PageContent
|
||||
return [$existingId, $existingId];
|
||||
}
|
||||
|
||||
// Create an unique id for the element
|
||||
// Create a unique id for the element
|
||||
// Uses the content as a basis to ensure output is the same every time
|
||||
// the same content is passed through.
|
||||
$contentId = 'bkmrk-' . mb_substr(strtolower(preg_replace('/\s+/', '-', trim($element->nodeValue))), 0, 20);
|
||||
@@ -312,7 +323,7 @@ class PageContent
|
||||
*/
|
||||
protected function headerNodesToLevelList(DOMNodeList $nodeList): array
|
||||
{
|
||||
$tree = collect($nodeList)->map(function ($header) {
|
||||
$tree = collect($nodeList)->map(function (DOMElement $header) {
|
||||
$text = trim(str_replace("\xc2\xa0", '', $header->nodeValue));
|
||||
$text = mb_substr($text, 0, 100);
|
||||
|
||||
|
||||
26
app/Entities/Tools/PdfGenerator.php
Normal file
26
app/Entities/Tools/PdfGenerator.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Tools;
|
||||
|
||||
use Barryvdh\DomPDF\Facade as DomPDF;
|
||||
use Barryvdh\Snappy\Facades\SnappyPdf;
|
||||
|
||||
class PdfGenerator
|
||||
{
|
||||
/**
|
||||
* 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) {
|
||||
$pdf = SnappyPDF::loadHTML($html);
|
||||
$pdf->setOption('print-media-type', true);
|
||||
} else {
|
||||
$pdf = DomPDF::loadHTML($html);
|
||||
}
|
||||
|
||||
return $pdf->output();
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@ class PermissionsUpdater
|
||||
$entity->save();
|
||||
$entity->rebuildPermissions();
|
||||
|
||||
Activity::addForEntity($entity, ActivityType::PERMISSIONS_UPDATE);
|
||||
Activity::add(ActivityType::PERMISSIONS_UPDATE, $entity);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,7 @@ use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Models\SearchTerm;
|
||||
use DOMDocument;
|
||||
use DOMNode;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class SearchIndex
|
||||
@@ -67,7 +68,7 @@ class SearchIndex
|
||||
* - The number that have been processed so far.
|
||||
* - The total number of that model to be processed.
|
||||
*
|
||||
* @param callable(Entity, int, int)|null $progressCallback
|
||||
* @param callable(Entity, int, int):void|null $progressCallback
|
||||
*/
|
||||
public function indexAllEntities(?callable $progressCallback = null)
|
||||
{
|
||||
@@ -76,7 +77,9 @@ class SearchIndex
|
||||
foreach ($this->entityProvider->all() as $entityModel) {
|
||||
$indexContentField = $entityModel instanceof Page ? 'html' : 'description';
|
||||
$selectFields = ['id', 'name', $indexContentField];
|
||||
$total = $entityModel->newQuery()->withTrashed()->count();
|
||||
/** @var Builder<Entity> $query */
|
||||
$query = $entityModel->newQuery();
|
||||
$total = $query->withTrashed()->count();
|
||||
$chunkSize = 250;
|
||||
$processed = 0;
|
||||
|
||||
@@ -223,7 +226,7 @@ class SearchIndex
|
||||
if ($entity instanceof Page) {
|
||||
$bodyTermsMap = $this->generateTermScoreMapFromHtml($entity->html);
|
||||
} else {
|
||||
$bodyTermsMap = $this->generateTermScoreMapFromText($entity->description ?? '', $entity->searchFactor);
|
||||
$bodyTermsMap = $this->generateTermScoreMapFromText($entity->getAttribute('description') ?? '', $entity->searchFactor);
|
||||
}
|
||||
|
||||
$mergedScoreMap = $this->mergeTermScoreMaps($nameTermsMap, $bodyTermsMap, $tagTermsMap);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,6 +9,7 @@ use BookStack\Entities\Models\BookChild;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Models\SearchTerm;
|
||||
use Illuminate\Database\Connection;
|
||||
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@@ -144,13 +145,13 @@ class SearchRunner
|
||||
|
||||
if ($entityModelInstance instanceof BookChild) {
|
||||
$relations['book'] = function (BelongsTo $query) {
|
||||
$query->visible();
|
||||
$query->scopes('visible');
|
||||
};
|
||||
}
|
||||
|
||||
if ($entityModelInstance instanceof Page) {
|
||||
$relations['chapter'] = function (BelongsTo $query) {
|
||||
$query->visible();
|
||||
$query->scopes('visible');
|
||||
};
|
||||
}
|
||||
|
||||
@@ -356,7 +357,9 @@ class SearchRunner
|
||||
// We have to do a raw sql query for this since otherwise PDO will quote the value and MySQL will
|
||||
// search the value as a string which prevents being able to do number-based operations
|
||||
// on the tag values. We ensure it has a numeric value and then cast it just to be sure.
|
||||
$tagValue = (float) trim($query->getConnection()->getPdo()->quote($tagValue), "'");
|
||||
/** @var Connection $connection */
|
||||
$connection = $query->getConnection();
|
||||
$tagValue = (float) trim($connection->getPdo()->quote($tagValue), "'");
|
||||
$query->whereRaw("value ${tagOperator} ${tagValue}");
|
||||
} else {
|
||||
$query->where('value', $tagOperator, $tagValue);
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace BookStack\Entities\Tools;
|
||||
use BookStack\Entities\EntityProvider;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
@@ -24,7 +25,7 @@ class SiblingFetcher
|
||||
}
|
||||
|
||||
// Page in book or chapter
|
||||
if (($entity instanceof Page && !$entity->chapter) || $entity->isA('chapter')) {
|
||||
if (($entity instanceof Page && !$entity->chapter) || $entity instanceof Chapter) {
|
||||
$entities = $entity->book->getDirectChildren();
|
||||
}
|
||||
|
||||
|
||||
@@ -15,15 +15,19 @@ use BookStack\Facades\Activity;
|
||||
use BookStack\Uploads\AttachmentService;
|
||||
use BookStack\Uploads\ImageService;
|
||||
use Exception;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class TrashCan
|
||||
{
|
||||
/**
|
||||
* Send a shelf to the recycle bin.
|
||||
*
|
||||
* @throws NotifyException
|
||||
*/
|
||||
public function softDestroyShelf(Bookshelf $shelf)
|
||||
{
|
||||
$this->ensureDeletable($shelf);
|
||||
Deletion::createForEntity($shelf);
|
||||
$shelf->delete();
|
||||
}
|
||||
@@ -35,6 +39,7 @@ class TrashCan
|
||||
*/
|
||||
public function softDestroyBook(Book $book)
|
||||
{
|
||||
$this->ensureDeletable($book);
|
||||
Deletion::createForEntity($book);
|
||||
|
||||
foreach ($book->pages as $page) {
|
||||
@@ -56,6 +61,7 @@ class TrashCan
|
||||
public function softDestroyChapter(Chapter $chapter, bool $recordDelete = true)
|
||||
{
|
||||
if ($recordDelete) {
|
||||
$this->ensureDeletable($chapter);
|
||||
Deletion::createForEntity($chapter);
|
||||
}
|
||||
|
||||
@@ -76,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');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -141,11 +175,9 @@ class TrashCan
|
||||
{
|
||||
$count = 0;
|
||||
$pages = $chapter->pages()->withTrashed()->get();
|
||||
if (count($pages)) {
|
||||
foreach ($pages as $page) {
|
||||
$this->destroyPage($page);
|
||||
$count++;
|
||||
}
|
||||
foreach ($pages as $page) {
|
||||
$this->destroyPage($page);
|
||||
$count++;
|
||||
}
|
||||
|
||||
$this->destroyCommonRelations($chapter);
|
||||
@@ -183,9 +215,10 @@ class TrashCan
|
||||
{
|
||||
$counts = [];
|
||||
|
||||
/** @var Entity $instance */
|
||||
foreach ((new EntityProvider())->all() as $key => $instance) {
|
||||
$counts[$key] = $instance->newQuery()->onlyTrashed()->count();
|
||||
/** @var Builder<Entity> $query */
|
||||
$query = $instance->newQuery();
|
||||
$counts[$key] = $query->onlyTrashed()->count();
|
||||
}
|
||||
|
||||
return $counts;
|
||||
@@ -235,13 +268,15 @@ class TrashCan
|
||||
{
|
||||
$shouldRestore = true;
|
||||
$restoreCount = 0;
|
||||
$parent = $deletion->deletable->getParent();
|
||||
|
||||
if ($parent && $parent->trashed()) {
|
||||
$shouldRestore = false;
|
||||
if ($deletion->deletable instanceof Entity) {
|
||||
$parent = $deletion->deletable->getParent();
|
||||
if ($parent && $parent->trashed()) {
|
||||
$shouldRestore = false;
|
||||
}
|
||||
}
|
||||
|
||||
if ($shouldRestore) {
|
||||
if ($deletion->deletable instanceof Entity && $shouldRestore) {
|
||||
$restoreCount = $this->restoreEntity($deletion->deletable);
|
||||
}
|
||||
|
||||
@@ -342,9 +377,9 @@ class TrashCan
|
||||
$entity->deletions()->delete();
|
||||
$entity->favourites()->delete();
|
||||
|
||||
if ($entity instanceof HasCoverImage && $entity->cover) {
|
||||
if ($entity instanceof HasCoverImage && $entity->cover()->exists()) {
|
||||
$imageService = app()->make(ImageService::class);
|
||||
$imageService->destroy($entity->cover);
|
||||
$imageService->destroy($entity->cover()->first());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace BookStack\Exceptions;
|
||||
|
||||
use Exception;
|
||||
use Illuminate\Auth\AuthenticationException;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -75,15 +76,20 @@ class Handler extends ExceptionHandler
|
||||
/**
|
||||
* Render an exception when the API is in use.
|
||||
*/
|
||||
protected function renderApiException(Exception $e): JsonResponse
|
||||
protected function renderApiException(Throwable $e): JsonResponse
|
||||
{
|
||||
$code = $e->getCode() === 0 ? 500 : $e->getCode();
|
||||
$code = 500;
|
||||
$headers = [];
|
||||
|
||||
if ($e instanceof HttpException) {
|
||||
$code = $e->getStatusCode();
|
||||
$headers = $e->getHeaders();
|
||||
}
|
||||
|
||||
if ($e instanceof ModelNotFoundException) {
|
||||
$code = 404;
|
||||
}
|
||||
|
||||
$responseData = [
|
||||
'error' => [
|
||||
'message' => $e->getMessage(),
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/**
|
||||
|
||||
@@ -75,7 +75,7 @@ class BookshelfApiController extends ApiController
|
||||
$shelf = Bookshelf::visible()->with([
|
||||
'tags', 'cover', 'createdBy', 'updatedBy', 'ownedBy',
|
||||
'books' => function (BelongsToMany $query) {
|
||||
$query->visible()->get(['id', 'name', 'slug']);
|
||||
$query->scopes('visible')->get(['id', 'name', 'slug']);
|
||||
},
|
||||
])->findOrFail($id);
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ class ChapterApiController extends ApiController
|
||||
public function read(string $id)
|
||||
{
|
||||
$chapter = Chapter::visible()->with(['tags', 'createdBy', 'updatedBy', 'ownedBy', 'pages' => function (HasMany $query) {
|
||||
$query->visible()->get(['id', 'name', 'slug']);
|
||||
$query->scopes('visible')->get(['id', 'name', 'slug']);
|
||||
}])->findOrFail($id);
|
||||
|
||||
return response()->json($chapter);
|
||||
|
||||
@@ -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,11 +113,11 @@ 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);
|
||||
$bookParentShelves = $book->shelves()->visible()->get();
|
||||
$bookParentShelves = $book->shelves()->scopes('visible')->get();
|
||||
|
||||
View::incrementFor($book);
|
||||
if ($request->has('shelf')) {
|
||||
@@ -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()) {
|
||||
@@ -39,7 +39,7 @@ class HomeController extends Controller
|
||||
$recentlyUpdatedPages = Page::visible()->with('book')
|
||||
->where('draft', false)
|
||||
->orderBy('updated_at', 'desc')
|
||||
->take($favourites->count() > 0 ? 6 : 12)
|
||||
->take($favourites->count() > 0 ? 5 : 10)
|
||||
->select(Page::$listAttributes)
|
||||
->get();
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -367,6 +368,8 @@ class PageController extends Controller
|
||||
->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,
|
||||
@@ -409,11 +412,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 +448,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());
|
||||
|
||||
@@ -58,6 +58,7 @@ class RecycleBinController extends Controller
|
||||
$searching = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** @var ?Deletion $parentDeletion */
|
||||
$parentDeletion = ($currentDeletable === $deletion->deletable) ? null : $currentDeletable->deletions()->first();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -13,6 +13,7 @@ use BookStack\Uploads\ImageRepo;
|
||||
use Exception;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class UserController extends Controller
|
||||
@@ -82,7 +83,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'];
|
||||
@@ -155,11 +156,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');
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@ class ApiAuthenticate
|
||||
// Return if the user is already found to be signed in via session-based auth.
|
||||
// This is to make it easy to browser the API via browser after just logging into the system.
|
||||
if (signedInUser() || session()->isStarted()) {
|
||||
if (!user()->can('access-api')) {
|
||||
if (!$this->sessionUserHasApiAccess()) {
|
||||
throw new ApiAuthException(trans('errors.api_user_no_api_permission'), 403);
|
||||
}
|
||||
|
||||
@@ -49,6 +49,16 @@ class ApiAuthenticate
|
||||
auth()->authenticate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the active session user has API access.
|
||||
*/
|
||||
protected function sessionUserHasApiAccess(): bool
|
||||
{
|
||||
$hasApiPermission = user()->can('access-api');
|
||||
|
||||
return $hasApiPermission && hasAppAccess();
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide a standard API unauthorised response.
|
||||
*/
|
||||
|
||||
14
app/Interfaces/Deletable.php
Normal file
14
app/Interfaces/Deletable.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Interfaces;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
|
||||
/**
|
||||
* A model that can be deleted in a manner that deletions
|
||||
* are tracked to be part of the recycle bin system.
|
||||
*/
|
||||
interface Deletable
|
||||
{
|
||||
public function deletions(): MorphMany;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
namespace BookStack\Theming;
|
||||
|
||||
use BookStack\Auth\Access\SocialAuthService;
|
||||
use Illuminate\Console\Application;
|
||||
use Illuminate\Console\Application as Artisan;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
|
||||
class ThemeService
|
||||
{
|
||||
@@ -43,6 +46,16 @@ class ThemeService
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new custom artisan command to be available.
|
||||
*/
|
||||
public function registerCommand(Command $command)
|
||||
{
|
||||
Artisan::starting(function (Application $application) use ($command) {
|
||||
$application->addCommands([$command]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read any actions from the set theme path if the 'functions.php' file exists.
|
||||
*/
|
||||
|
||||
@@ -103,7 +103,10 @@ class ImageRepo
|
||||
if ($filterType === 'page') {
|
||||
$query->where('uploaded_to', '=', $contextPage->id);
|
||||
} elseif ($filterType === 'book') {
|
||||
$validPageIds = $contextPage->book->pages()->visible()->pluck('id')->toArray();
|
||||
$validPageIds = $contextPage->book->pages()
|
||||
->scopes('visible')
|
||||
->pluck('id')
|
||||
->toArray();
|
||||
$query->whereIn('uploaded_to', $validPageIds);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
1083
composer.lock
generated
1083
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": []
|
||||
}
|
||||
],
|
||||
|
||||
@@ -6,6 +6,8 @@ WARNING: This system is currently in alpha so may incur changes. Once we've gath
|
||||
|
||||
## Getting Started
|
||||
|
||||
*[Video Guide](https://www.youtube.com/watch?v=YVbpm_35crQ)*
|
||||
|
||||
This makes use of the theme system. Create a folder for your theme within your BookStack `themes` directory. As an example we'll use `my_theme`, so we'd create a `themes/my_theme` folder.
|
||||
You'll need to tell BookStack to use your theme via the `APP_THEME` option in your `.env` file. For example: `APP_THEME=my_theme`.
|
||||
|
||||
@@ -50,6 +52,23 @@ This method allows you to register a custom social authentication driver within
|
||||
|
||||
*See "Custom Socialite Service Example" below.*
|
||||
|
||||
### `Theme::registerCommand`
|
||||
|
||||
This method allows you to register a custom command which can then be used via the artisan console.
|
||||
|
||||
**Arguments**
|
||||
- string $driverName
|
||||
- array $config
|
||||
- string $socialiteHandler
|
||||
|
||||
**Example**
|
||||
|
||||
*See "Custom Command Registration Example" below for a more detailed example.*
|
||||
|
||||
```php
|
||||
Theme::registerCommand(new SayHelloCommand());
|
||||
```
|
||||
|
||||
## Available Events
|
||||
|
||||
All available events dispatched by BookStack are exposed as static properties on the `\BookStack\Theming\ThemeEvents` class, which can be found within the file `app/Theming/ThemeEvents.php` relative to your root BookStack folder. Alternatively, the events for the latest release can be [seen on GitHub here](https://github.com/BookStackApp/BookStack/blob/release/app/Theming/ThemeEvents.php).
|
||||
@@ -77,6 +96,33 @@ Theme::listen(ThemeEvents::APP_BOOT, function($app) {
|
||||
});
|
||||
```
|
||||
|
||||
## Custom Command Registration Example
|
||||
|
||||
The logical theme system supports adding custom [artisan commands](https://laravel.com/docs/8.x/artisan) to BookStack.
|
||||
These can be registered in your `functions.php` file by calling `Theme::registerCommand($command)`, where `$command` is an instance of `\Symfony\Component\Console\Command\Command`.
|
||||
|
||||
Below is an example of registering a command that could then be ran using `php artisan bookstack:meow` on the command line.
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use BookStack\Facades\Theme;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class MeowCommand extends Command
|
||||
{
|
||||
protected $signature = 'bookstack:meow';
|
||||
protected $description = 'Say meow on the command line';
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$this->line('Meow there!');
|
||||
}
|
||||
}
|
||||
|
||||
Theme::registerCommand(new MeowCommand);
|
||||
```
|
||||
|
||||
## Custom Socialite Service Example
|
||||
|
||||
The below shows an example of adding a custom reddit socialite service to BookStack.
|
||||
|
||||
@@ -6,6 +6,8 @@ This theme system itself is maintained and supported but usages of this system,
|
||||
|
||||
## Getting Started
|
||||
|
||||
*[Video Guide](https://www.youtube.com/watch?v=gLy_2GBse48)*
|
||||
|
||||
This makes use of the theme system. Create a folder for your theme within your BookStack `themes` directory. As an example we'll use `my_theme`, so we'd create a `themes/my_theme` folder.
|
||||
You'll need to tell BookStack to use your theme via the `APP_THEME` option in your `.env` file. For example: `APP_THEME=my_theme`.
|
||||
|
||||
@@ -28,4 +30,4 @@ As an example, Say I wanted to change 'Search' to 'Find'; Within a `themes/<them
|
||||
return [
|
||||
'search' => 'find',
|
||||
];
|
||||
```
|
||||
```
|
||||
|
||||
48
public/dist/app.js
vendored
48
public/dist/app.js
vendored
File diff suppressed because one or more lines are too long
2
public/dist/export-styles.css
vendored
2
public/dist/export-styles.css
vendored
File diff suppressed because one or more lines are too long
2
public/dist/styles.css
vendored
2
public/dist/styles.css
vendored
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user