mirror of
https://github.com/BookStackApp/BookStack.git
synced 2026-02-05 08:39:55 +03:00
Compare commits
110 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||
|
||||
13
.github/translators.txt
vendored
13
.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,14 @@ 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
|
||||
|
||||
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
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
@@ -44,7 +46,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 +122,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 +212,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 +322,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}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,10 +63,8 @@ class PageRevision extends Model
|
||||
|
||||
/**
|
||||
* 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 +82,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 static();
|
||||
$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,6 +15,7 @@ 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
|
||||
@@ -141,11 +142,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 +182,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 +235,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 +344,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.
|
||||
*
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
```
|
||||
```
|
||||
|
||||
66
public/dist/app.js
vendored
66
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
@@ -34,13 +34,17 @@ Big thanks to these companies for supporting the project.
|
||||
Note: Listed services are not tested, vetted nor supported by the official BookStack project in any manner.
|
||||
[View all sponsors](https://github.com/sponsors/ssddanbrown).
|
||||
|
||||
#### Bronze Sponsors
|
||||
#### Silver Sponsor
|
||||
|
||||
<table><tbody><tr>
|
||||
<td><a href="https://www.diagrams.net/" target="_blank">
|
||||
<img width="280" src="https://media.githubusercontent.com/media/BookStackApp/website/main/static/images/sponsors/diagramsnet.png" alt="Diagrams.net">
|
||||
<img width="420" src="https://media.githubusercontent.com/media/BookStackApp/website/main/static/images/sponsors/diagramsnet.png" alt="Diagrams.net">
|
||||
</a></td>
|
||||
</tr></tbody></table>
|
||||
|
||||
#### Bronze Sponsor
|
||||
|
||||
<table><tbody><tr>
|
||||
<td><a href="https://www.stellarhosted.com/bookstack/" target="_blank">
|
||||
<img width="280" src="https://media.githubusercontent.com/media/BookStackApp/website/main/static/images/sponsors/stellarhosted.png" alt="Stellar Hosted">
|
||||
</a></td>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user