mirror of
https://github.com/BookStackApp/BookStack.git
synced 2026-02-05 08:39:55 +03:00
Compare commits
171 Commits
v24.12
...
drawio_ren
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9f5e98ba9 | ||
|
|
c4839c783a | ||
|
|
a5751a584c | ||
|
|
f518a3be37 | ||
|
|
0208f066c5 | ||
|
|
2d0461b63a | ||
|
|
b913ae703d | ||
|
|
1611b0399f | ||
|
|
8d4b8ff4f3 | ||
|
|
77a88618c2 | ||
|
|
8b062d4795 | ||
|
|
717b516341 | ||
|
|
fda242d3da | ||
|
|
aac547934c | ||
|
|
5c9b90ea0d | ||
|
|
074f193e2f | ||
|
|
7f2604c8e8 | ||
|
|
b71b2a4376 | ||
|
|
68df43e5a8 | ||
|
|
c5ca865723 | ||
|
|
b862f12a50 | ||
|
|
b0f8b11054 | ||
|
|
7650ebf2f9 | ||
|
|
d9ea52522e | ||
|
|
2e718c12e1 | ||
|
|
a43a1832f5 | ||
|
|
c4f7368c1c | ||
|
|
2a32475541 | ||
|
|
1243108e0f | ||
|
|
3280919370 | ||
|
|
d149b809b1 | ||
|
|
eb47e11916 | ||
|
|
9d6bc1ad4d | ||
|
|
30bf0ce632 | ||
|
|
b64c9b31d5 | ||
|
|
f9dbbe5d70 | ||
|
|
05f7f4cb17 | ||
|
|
454b152b95 | ||
|
|
b29fe5c46d | ||
|
|
131ac29df4 | ||
|
|
3a9d18a6cd | ||
|
|
59e2c5e52a | ||
|
|
d29b14ebfd | ||
|
|
cdd446ac73 | ||
|
|
1dd1024eba | ||
|
|
752cfe2f67 | ||
|
|
25baaa8189 | ||
|
|
d2d0331782 | ||
|
|
8121418e18 | ||
|
|
5ab31a8191 | ||
|
|
0e69ab1938 | ||
|
|
058007109e | ||
|
|
32b29fcdfc | ||
|
|
8f92b6f21b | ||
|
|
62f78f1c6d | ||
|
|
f8c0aaff03 | ||
|
|
a27df485bb | ||
|
|
3e99ce4098 | ||
|
|
ce1e20501c | ||
|
|
295532fa7a | ||
|
|
642ba668b1 | ||
|
|
4f36cdd757 | ||
|
|
8821844c4a | ||
|
|
1262083fcf | ||
|
|
c82fa33210 | ||
|
|
15c79c38db | ||
|
|
e7dcc2dcdf | ||
|
|
099f6104d0 | ||
|
|
8bdf948743 | ||
|
|
e8f44186a8 | ||
|
|
ecda4e1d6f | ||
|
|
f656a82fe7 | ||
|
|
5bfba281fc | ||
|
|
18ede9bbd3 | ||
|
|
2e7544a865 | ||
|
|
5e3c3ad634 | ||
|
|
add238fe9f | ||
|
|
8d159f77e4 | ||
|
|
fa566f156a | ||
|
|
78a0a2f519 | ||
|
|
42cbd6adef | ||
|
|
6117349893 | ||
|
|
1256320c72 | ||
|
|
1ba0d26fdd | ||
|
|
802f69cf35 | ||
|
|
bb44334224 | ||
|
|
9bfcadd95f | ||
|
|
62c8eb3357 | ||
|
|
c03e44124a | ||
|
|
5c6671b3bf | ||
|
|
abe7467ae5 | ||
|
|
0ec0913846 | ||
|
|
e980564fd6 | ||
|
|
8a9215ecad | ||
|
|
304a1d8f91 | ||
|
|
dfbc78947f | ||
|
|
4f5ad171ac | ||
|
|
94b1cffa2d | ||
|
|
13dae24cbe | ||
|
|
6211d6bcfc | ||
|
|
a384599cfa | ||
|
|
dca14feaaa | ||
|
|
d7ccb3ce6a | ||
|
|
6548ea4a12 | ||
|
|
c3a1fabbf0 | ||
|
|
d2542d6265 | ||
|
|
0e343c408f | ||
|
|
5c78f8352e | ||
|
|
35b45a2b8d | ||
|
|
5050719ea3 | ||
|
|
5508c171db | ||
|
|
3b4d3430a5 | ||
|
|
213a86e3c0 | ||
|
|
2b746425c9 | ||
|
|
5c15f4add2 | ||
|
|
92ad81429f | ||
|
|
f1b8e857bf | ||
|
|
c291d27c19 | ||
|
|
f4449928f8 | ||
|
|
45a15b4792 | ||
|
|
2291d78382 | ||
|
|
7901ca9e6b | ||
|
|
a7de251876 | ||
|
|
7bd89316bc | ||
|
|
b9306a9029 | ||
|
|
a208c46b62 | ||
|
|
a65701294e | ||
|
|
69683d50ec | ||
|
|
37d020c083 | ||
|
|
ec79517493 | ||
|
|
d938565839 | ||
|
|
ccd94684eb | ||
|
|
103a8a8e8e | ||
|
|
c13ce18837 | ||
|
|
7093daa49d | ||
|
|
b897af2ed0 | ||
|
|
d28278bba6 | ||
|
|
12cc2f0689 | ||
|
|
bf8a84a8b1 | ||
|
|
4f5f7c10b1 | ||
|
|
a34023f715 | ||
|
|
b2ac3e0834 | ||
|
|
5b0cb3dd50 | ||
|
|
ac0cd9995d | ||
|
|
7e03a973d8 | ||
|
|
d89a2fdb15 | ||
|
|
958b537a49 | ||
|
|
8a66365d48 | ||
|
|
da82e70ca3 | ||
|
|
04cca77ae6 | ||
|
|
c091f67db3 | ||
|
|
7f5fd16dc6 | ||
|
|
0d1a237f81 | ||
|
|
786a434c03 | ||
|
|
25c4f4b02b | ||
|
|
481580be17 | ||
|
|
593645acfe | ||
|
|
b9751807e7 | ||
|
|
ee88832f1a | ||
|
|
dbda82ef92 | ||
|
|
ad8bc5fe21 | ||
|
|
5bf75786c6 | ||
|
|
cf9ccfcd5b | ||
|
|
5116d83d38 | ||
|
|
33b46882f3 | ||
|
|
9a5c287470 | ||
|
|
6effc6d262 | ||
|
|
ff6c5aaecb | ||
|
|
1ff2826678 | ||
|
|
7e31725d48 | ||
|
|
6d7ff59a89 |
@@ -56,6 +56,7 @@ APP_PROXIES=null
|
||||
|
||||
# Database details
|
||||
# Host can contain a port (localhost:3306) or a separate DB_PORT option can be used.
|
||||
# An ipv6 address can be used via the square bracket format ([::1]).
|
||||
DB_HOST=localhost
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=database_database
|
||||
|
||||
1
.github/ISSUE_TEMPLATE/support_request.yml
vendored
1
.github/ISSUE_TEMPLATE/support_request.yml
vendored
@@ -42,6 +42,7 @@ body:
|
||||
label: Log Content
|
||||
description: If the issue has produced an error, provide any [BookStack or server log](https://www.bookstackapp.com/docs/admin/debugging/) content below.
|
||||
placeholder: Be sure to remove any confidential details in your logs
|
||||
render: text
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
|
||||
9
.github/ISSUE_TEMPLATE/z_blank_request.yml
vendored
Normal file
9
.github/ISSUE_TEMPLATE/z_blank_request.yml
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
name: Blank Request (Maintainers Only)
|
||||
description: For maintainers only - Start a blank request
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: "**This blank request option is only for existing official maintainers of the project!** Please instead use a different request option. If you use this your issue will be closed off."
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Description
|
||||
28
.github/translators.txt
vendored
28
.github/translators.txt
vendored
@@ -461,3 +461,31 @@ Yannis Karlaftis (meliseus) :: Greek
|
||||
felixxx :: German Informal
|
||||
randi (randi65535) :: Korean
|
||||
test65428 :: Greek
|
||||
zeronell :: Chinese Simplified
|
||||
julien Vinber (julienVinber) :: French
|
||||
Hyunwoo Park (oksure) :: Korean
|
||||
aram.rafeq.7 (aramrafeq2) :: Kurdish
|
||||
Raphael Moreno (RaphaelMoreno) :: Portuguese, Brazilian
|
||||
yn (user99) :: Arabic
|
||||
Pavel Zlatarov (pzlatarov) :: Bulgarian
|
||||
ingelres :: French
|
||||
mabdullah :: Arabic
|
||||
Skrabák Csaba (kekcsi) :: Hungarian
|
||||
Evert Meulie (Evert) :: Norwegian Bokmal
|
||||
Jasper Backer (jasperb) :: Dutch
|
||||
Alexandar Cavdarovski (ace.200112) :: Swedish
|
||||
구닥다리TV (yjj8353) :: Korean
|
||||
Onur Oskay (o.oskay) :: Turkish
|
||||
Sébastien Merveille (SebastienMerv) :: French
|
||||
Maxim Kouznetsov (masya.work) :: Hebrew
|
||||
neodvisnost :: Slovenian
|
||||
Soubi Agatsuma (bisouya) :: Hebrew
|
||||
Ilya Shaulov (ishaulov) :: Russian
|
||||
Konstantin Bobkov (b.konstantv) :: Russian
|
||||
Ruben Sutter (rubensutter) :: German
|
||||
jellium :: French
|
||||
Qxlkdr :: Swedish
|
||||
Hari (muhhari) :: Indonesian
|
||||
仙君御 (xjy) :: Chinese Simplified
|
||||
TapioM :: Finnish
|
||||
lingb58 :: Chinese Traditional
|
||||
|
||||
2
.github/workflows/test-migrations.yml
vendored
2
.github/workflows/test-migrations.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
php: ['8.1', '8.2', '8.3', '8.4']
|
||||
php: ['8.2', '8.3', '8.4']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
||||
4
.github/workflows/test-php.yml
vendored
4
.github/workflows/test-php.yml
vendored
@@ -13,10 +13,10 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
php: ['8.1', '8.2', '8.3', '8.4']
|
||||
php: ['8.2', '8.3', '8.4']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -32,3 +32,4 @@ webpack-stats.json
|
||||
phpstan.neon
|
||||
esbuild-meta.json
|
||||
.phpactor.json
|
||||
/*.zip
|
||||
|
||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2015-2024, Dan Brown and the BookStack Project contributors.
|
||||
Copyright (c) 2015-2025, Dan Brown and the BookStack project contributors.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -8,27 +8,15 @@ use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ExternalBaseUserProvider implements UserProvider
|
||||
{
|
||||
/**
|
||||
* The user model.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $model;
|
||||
|
||||
/**
|
||||
* LdapUserProvider constructor.
|
||||
*/
|
||||
public function __construct(string $model)
|
||||
{
|
||||
$this->model = $model;
|
||||
public function __construct(
|
||||
protected string $model
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new instance of the model.
|
||||
*
|
||||
* @return Model
|
||||
*/
|
||||
public function createModel()
|
||||
public function createModel(): Model
|
||||
{
|
||||
$class = '\\' . ltrim($this->model, '\\');
|
||||
|
||||
@@ -37,12 +25,8 @@ class ExternalBaseUserProvider implements UserProvider
|
||||
|
||||
/**
|
||||
* Retrieve a user by their unique identifier.
|
||||
*
|
||||
* @param mixed $identifier
|
||||
*
|
||||
* @return Authenticatable|null
|
||||
*/
|
||||
public function retrieveById($identifier)
|
||||
public function retrieveById(mixed $identifier): ?Authenticatable
|
||||
{
|
||||
return $this->createModel()->newQuery()->find($identifier);
|
||||
}
|
||||
@@ -50,12 +34,9 @@ class ExternalBaseUserProvider implements UserProvider
|
||||
/**
|
||||
* Retrieve a user by their unique identifier and "remember me" token.
|
||||
*
|
||||
* @param mixed $identifier
|
||||
* @param string $token
|
||||
*
|
||||
* @return Authenticatable|null
|
||||
*/
|
||||
public function retrieveByToken($identifier, $token)
|
||||
public function retrieveByToken(mixed $identifier, $token): null
|
||||
{
|
||||
return null;
|
||||
}
|
||||
@@ -75,12 +56,8 @@ class ExternalBaseUserProvider implements UserProvider
|
||||
|
||||
/**
|
||||
* Retrieve a user by the given credentials.
|
||||
*
|
||||
* @param array $credentials
|
||||
*
|
||||
* @return Authenticatable|null
|
||||
*/
|
||||
public function retrieveByCredentials(array $credentials)
|
||||
public function retrieveByCredentials(array $credentials): ?Authenticatable
|
||||
{
|
||||
// Search current user base by looking up a uid
|
||||
$model = $this->createModel();
|
||||
@@ -92,15 +69,15 @@ class ExternalBaseUserProvider implements UserProvider
|
||||
|
||||
/**
|
||||
* Validate a user against the given credentials.
|
||||
*
|
||||
* @param Authenticatable $user
|
||||
* @param array $credentials
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function validateCredentials(Authenticatable $user, array $credentials)
|
||||
public function validateCredentials(Authenticatable $user, array $credentials): bool
|
||||
{
|
||||
// Should be done in the guard.
|
||||
return false;
|
||||
}
|
||||
|
||||
public function rehashPasswordIfRequired(Authenticatable $user, #[\SensitiveParameter] array $credentials, bool $force = false)
|
||||
{
|
||||
// No action to perform, any passwords are external in the auth system
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ class Ldap
|
||||
*
|
||||
* @return \LDAP\Result|array|false
|
||||
*/
|
||||
public function search($ldapConnection, string $baseDn, string $filter, array $attributes = null)
|
||||
public function search($ldapConnection, string $baseDn, string $filter, array $attributes = [])
|
||||
{
|
||||
return ldap_search($ldapConnection, $baseDn, $filter, $attributes);
|
||||
}
|
||||
@@ -66,7 +66,7 @@ class Ldap
|
||||
*
|
||||
* @return \LDAP\Result|array|false
|
||||
*/
|
||||
public function read($ldapConnection, string $baseDn, string $filter, array $attributes = null)
|
||||
public function read($ldapConnection, string $baseDn, string $filter, array $attributes = [])
|
||||
{
|
||||
return ldap_read($ldapConnection, $baseDn, $filter, $attributes);
|
||||
}
|
||||
@@ -87,7 +87,7 @@ class Ldap
|
||||
*
|
||||
* @param resource|\LDAP\Connection $ldapConnection
|
||||
*/
|
||||
public function searchAndGetEntries($ldapConnection, string $baseDn, string $filter, array $attributes = null): array|false
|
||||
public function searchAndGetEntries($ldapConnection, string $baseDn, string $filter, array $attributes = []): array|false
|
||||
{
|
||||
$search = $this->search($ldapConnection, $baseDn, $filter, $attributes);
|
||||
|
||||
@@ -99,7 +99,7 @@ class Ldap
|
||||
*
|
||||
* @param resource|\LDAP\Connection $ldapConnection
|
||||
*/
|
||||
public function bind($ldapConnection, string $bindRdn = null, string $bindPassword = null): bool
|
||||
public function bind($ldapConnection, ?string $bindRdn = null, ?string $bindPassword = null): bool
|
||||
{
|
||||
return ldap_bind($ldapConnection, $bindRdn, $bindPassword);
|
||||
}
|
||||
|
||||
@@ -112,10 +112,14 @@ class LdapService
|
||||
return null;
|
||||
}
|
||||
|
||||
$userCn = $this->getUserResponseProperty($user, 'cn', null);
|
||||
$nameDefault = $this->getUserResponseProperty($user, 'cn', null);
|
||||
if (is_null($nameDefault)) {
|
||||
$nameDefault = ldap_explode_dn($user['dn'], 1)[0] ?? $user['dn'];
|
||||
}
|
||||
|
||||
$formatted = [
|
||||
'uid' => $this->getUserResponseProperty($user, $idAttr, $user['dn']),
|
||||
'name' => $this->getUserDisplayName($user, $displayNameAttrs, $userCn),
|
||||
'name' => $this->getUserDisplayName($user, $displayNameAttrs, $nameDefault),
|
||||
'dn' => $user['dn'],
|
||||
'email' => $this->getUserResponseProperty($user, $emailAttr, null),
|
||||
'avatar' => $thumbnailAttr ? $this->getUserResponseProperty($user, $thumbnailAttr, null) : null,
|
||||
|
||||
@@ -11,6 +11,7 @@ use BookStack\Exceptions\UserRegistrationException;
|
||||
use BookStack\Facades\Theme;
|
||||
use BookStack\Http\HttpRequestService;
|
||||
use BookStack\Theming\ThemeEvents;
|
||||
use BookStack\Uploads\UserAvatars;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use League\OAuth2\Client\OptionProvider\HttpBasicAuthOptionProvider;
|
||||
@@ -26,7 +27,8 @@ class OidcService
|
||||
protected RegistrationService $registrationService,
|
||||
protected LoginService $loginService,
|
||||
protected HttpRequestService $http,
|
||||
protected GroupSyncService $groupService
|
||||
protected GroupSyncService $groupService,
|
||||
protected UserAvatars $userAvatars
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -220,6 +222,10 @@ class OidcService
|
||||
throw new OidcException($exception->getMessage());
|
||||
}
|
||||
|
||||
if ($this->config()['fetch_avatar'] && !$user->avatar()->exists() && $userDetails->picture) {
|
||||
$this->userAvatars->assignToUserFromUrl($user, $userDetails->picture);
|
||||
}
|
||||
|
||||
if ($this->shouldSyncGroups()) {
|
||||
$detachExisting = $this->config()['remove_from_groups'];
|
||||
$this->groupService->syncUserWithFoundGroups($user, $userDetails->groups ?? [], $detachExisting);
|
||||
|
||||
@@ -11,6 +11,7 @@ class OidcUserDetails
|
||||
public ?string $email = null,
|
||||
public ?string $name = null,
|
||||
public ?array $groups = null,
|
||||
public ?string $picture = null,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -40,15 +41,16 @@ class OidcUserDetails
|
||||
$this->email = $claims->getClaim('email') ?? $this->email;
|
||||
$this->name = static::getUserDisplayName($displayNameClaims, $claims) ?? $this->name;
|
||||
$this->groups = static::getUserGroups($groupsClaim, $claims) ?? $this->groups;
|
||||
$this->picture = static::getPicture($claims) ?: $this->picture;
|
||||
}
|
||||
|
||||
protected static function getUserDisplayName(string $displayNameClaims, ProvidesClaims $token): string
|
||||
protected static function getUserDisplayName(string $displayNameClaims, ProvidesClaims $claims): string
|
||||
{
|
||||
$displayNameClaimParts = explode('|', $displayNameClaims);
|
||||
|
||||
$displayName = [];
|
||||
foreach ($displayNameClaimParts as $claim) {
|
||||
$component = $token->getClaim(trim($claim)) ?? '';
|
||||
$component = $claims->getClaim(trim($claim)) ?? '';
|
||||
if ($component !== '') {
|
||||
$displayName[] = $component;
|
||||
}
|
||||
@@ -57,13 +59,13 @@ class OidcUserDetails
|
||||
return implode(' ', $displayName);
|
||||
}
|
||||
|
||||
protected static function getUserGroups(string $groupsClaim, ProvidesClaims $token): ?array
|
||||
protected static function getUserGroups(string $groupsClaim, ProvidesClaims $claims): ?array
|
||||
{
|
||||
if (empty($groupsClaim)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$groupsList = Arr::get($token->getAllClaims(), $groupsClaim);
|
||||
$groupsList = Arr::get($claims->getAllClaims(), $groupsClaim);
|
||||
if (!is_array($groupsList)) {
|
||||
return null;
|
||||
}
|
||||
@@ -72,4 +74,14 @@ class OidcUserDetails
|
||||
return is_string($val);
|
||||
}));
|
||||
}
|
||||
|
||||
protected static function getPicture(ProvidesClaims $claims): ?string
|
||||
{
|
||||
$picture = $claims->getClaim('picture');
|
||||
if (is_string($picture) && str_starts_with($picture, 'http')) {
|
||||
return $picture;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ class SocialDriverManager
|
||||
string $driverName,
|
||||
array $config,
|
||||
string $socialiteHandler,
|
||||
callable $configureForRedirect = null
|
||||
?callable $configureForRedirect = null
|
||||
) {
|
||||
$this->validDrivers[] = $driverName;
|
||||
config()->set('services.' . $driverName, $config);
|
||||
|
||||
@@ -71,6 +71,10 @@ class ActivityType
|
||||
const IMPORT_RUN = 'import_run';
|
||||
const IMPORT_DELETE = 'import_delete';
|
||||
|
||||
const SORT_RULE_CREATE = 'sort_rule_create';
|
||||
const SORT_RULE_UPDATE = 'sort_rule_update';
|
||||
const SORT_RULE_DELETE = 'sort_rule_delete';
|
||||
|
||||
/**
|
||||
* Get all the possible values.
|
||||
*/
|
||||
|
||||
@@ -4,6 +4,8 @@ namespace BookStack\Activity;
|
||||
|
||||
use BookStack\Activity\Models\Comment;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Exceptions\NotifyException;
|
||||
use BookStack\Exceptions\PrettyException;
|
||||
use BookStack\Facades\Activity as ActivityService;
|
||||
use BookStack\Util\HtmlDescriptionFilter;
|
||||
|
||||
@@ -20,7 +22,7 @@ class CommentRepo
|
||||
/**
|
||||
* Create a new comment on an entity.
|
||||
*/
|
||||
public function create(Entity $entity, string $html, ?int $parent_id): Comment
|
||||
public function create(Entity $entity, string $html, ?int $parentId, string $contentRef): Comment
|
||||
{
|
||||
$userId = user()->id;
|
||||
$comment = new Comment();
|
||||
@@ -29,7 +31,8 @@ class CommentRepo
|
||||
$comment->created_by = $userId;
|
||||
$comment->updated_by = $userId;
|
||||
$comment->local_id = $this->getNextLocalId($entity);
|
||||
$comment->parent_id = $parent_id;
|
||||
$comment->parent_id = $parentId;
|
||||
$comment->content_ref = preg_match('/^bkmrk-(.*?):\d+:(\d*-\d*)?$/', $contentRef) === 1 ? $contentRef : '';
|
||||
|
||||
$entity->comments()->save($comment);
|
||||
ActivityService::add(ActivityType::COMMENT_CREATE, $comment);
|
||||
@@ -52,6 +55,41 @@ class CommentRepo
|
||||
return $comment;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Archive an existing comment.
|
||||
*/
|
||||
public function archive(Comment $comment): Comment
|
||||
{
|
||||
if ($comment->parent_id) {
|
||||
throw new NotifyException('Only top-level comments can be archived.', '/', 400);
|
||||
}
|
||||
|
||||
$comment->archived = true;
|
||||
$comment->save();
|
||||
|
||||
ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
|
||||
|
||||
return $comment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Un-archive an existing comment.
|
||||
*/
|
||||
public function unarchive(Comment $comment): Comment
|
||||
{
|
||||
if ($comment->parent_id) {
|
||||
throw new NotifyException('Only top-level comments can be un-archived.', '/', 400);
|
||||
}
|
||||
|
||||
$comment->archived = false;
|
||||
$comment->save();
|
||||
|
||||
ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
|
||||
|
||||
return $comment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a comment from the system.
|
||||
*/
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace BookStack\Activity\Controllers;
|
||||
use BookStack\Activity\ActivityType;
|
||||
use BookStack\Activity\Models\Activity;
|
||||
use BookStack\Http\Controller;
|
||||
use BookStack\Sorting\SortUrl;
|
||||
use BookStack\Util\SimpleListOptions;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
@@ -65,6 +66,7 @@ class AuditLogController extends Controller
|
||||
'filters' => $filters,
|
||||
'listOptions' => $listOptions,
|
||||
'activityTypes' => $types,
|
||||
'filterSortUrl' => new SortUrl('settings/audit', array_filter($request->except('page')))
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
namespace BookStack\Activity\Controllers;
|
||||
|
||||
use BookStack\Activity\CommentRepo;
|
||||
use BookStack\Activity\Tools\CommentTree;
|
||||
use BookStack\Activity\Tools\CommentTreeNode;
|
||||
use BookStack\Entities\Queries\PageQueries;
|
||||
use BookStack\Http\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -26,6 +28,7 @@ class CommentController extends Controller
|
||||
$input = $this->validate($request, [
|
||||
'html' => ['required', 'string'],
|
||||
'parent_id' => ['nullable', 'integer'],
|
||||
'content_ref' => ['string'],
|
||||
]);
|
||||
|
||||
$page = $this->pageQueries->findVisibleById($pageId);
|
||||
@@ -40,14 +43,12 @@ class CommentController extends Controller
|
||||
|
||||
// Create a new comment.
|
||||
$this->checkPermission('comment-create-all');
|
||||
$comment = $this->commentRepo->create($page, $input['html'], $input['parent_id'] ?? null);
|
||||
$contentRef = $input['content_ref'] ?? '';
|
||||
$comment = $this->commentRepo->create($page, $input['html'], $input['parent_id'] ?? null, $contentRef);
|
||||
|
||||
return view('comments.comment-branch', [
|
||||
'readOnly' => false,
|
||||
'branch' => [
|
||||
'comment' => $comment,
|
||||
'children' => [],
|
||||
]
|
||||
'branch' => new CommentTreeNode($comment, 0, []),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -74,6 +75,46 @@ class CommentController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a comment as archived.
|
||||
*/
|
||||
public function archive(int $id)
|
||||
{
|
||||
$comment = $this->commentRepo->getById($id);
|
||||
$this->checkOwnablePermission('page-view', $comment->entity);
|
||||
if (!userCan('comment-update', $comment) && !userCan('comment-delete', $comment)) {
|
||||
$this->showPermissionError();
|
||||
}
|
||||
|
||||
$this->commentRepo->archive($comment);
|
||||
|
||||
$tree = new CommentTree($comment->entity);
|
||||
return view('comments.comment-branch', [
|
||||
'readOnly' => false,
|
||||
'branch' => $tree->getCommentNodeForId($id),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unmark a comment as archived.
|
||||
*/
|
||||
public function unarchive(int $id)
|
||||
{
|
||||
$comment = $this->commentRepo->getById($id);
|
||||
$this->checkOwnablePermission('page-view', $comment->entity);
|
||||
if (!userCan('comment-update', $comment) && !userCan('comment-delete', $comment)) {
|
||||
$this->showPermissionError();
|
||||
}
|
||||
|
||||
$this->commentRepo->unarchive($comment);
|
||||
|
||||
$tree = new CommentTree($comment->entity);
|
||||
return view('comments.comment-branch', [
|
||||
'readOnly' => false,
|
||||
'branch' => $tree->getCommentNodeForId($id),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a comment from the system.
|
||||
*/
|
||||
|
||||
@@ -19,6 +19,8 @@ use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
* @property int $entity_id
|
||||
* @property int $created_by
|
||||
* @property int $updated_by
|
||||
* @property string $content_ref
|
||||
* @property bool $archived
|
||||
*/
|
||||
class Comment extends Model implements Loggable
|
||||
{
|
||||
@@ -26,7 +28,6 @@ class Comment extends Model implements Loggable
|
||||
use HasCreatorAndUpdater;
|
||||
|
||||
protected $fillable = ['parent_id'];
|
||||
protected $appends = ['created', 'updated'];
|
||||
|
||||
/**
|
||||
* Get the entity that this comment belongs to.
|
||||
@@ -54,22 +55,6 @@ class Comment extends Model implements Loggable
|
||||
return $this->updated_at->timestamp > $this->created_at->timestamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get created date as a relative diff.
|
||||
*/
|
||||
public function getCreatedAttribute(): string
|
||||
{
|
||||
return $this->created_at->diffForHumans();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get updated date as a relative diff.
|
||||
*/
|
||||
public function getUpdatedAttribute(): string
|
||||
{
|
||||
return $this->updated_at->diffForHumans();
|
||||
}
|
||||
|
||||
public function logDescriptor(): string
|
||||
{
|
||||
return "Comment #{$this->local_id} (ID: {$this->id}) for {$this->entity_type} (ID: {$this->entity_id})";
|
||||
|
||||
@@ -9,7 +9,7 @@ class CommentTree
|
||||
{
|
||||
/**
|
||||
* The built nested tree structure array.
|
||||
* @var array{comment: Comment, depth: int, children: array}[]
|
||||
* @var CommentTreeNode[]
|
||||
*/
|
||||
protected array $tree;
|
||||
protected array $comments;
|
||||
@@ -28,7 +28,7 @@ class CommentTree
|
||||
|
||||
public function empty(): bool
|
||||
{
|
||||
return count($this->tree) === 0;
|
||||
return count($this->getActive()) === 0;
|
||||
}
|
||||
|
||||
public function count(): int
|
||||
@@ -36,9 +36,35 @@ class CommentTree
|
||||
return count($this->comments);
|
||||
}
|
||||
|
||||
public function get(): array
|
||||
public function getActive(): array
|
||||
{
|
||||
return $this->tree;
|
||||
return array_filter($this->tree, fn (CommentTreeNode $node) => !$node->comment->archived);
|
||||
}
|
||||
|
||||
public function activeThreadCount(): int
|
||||
{
|
||||
return count($this->getActive());
|
||||
}
|
||||
|
||||
public function getArchived(): array
|
||||
{
|
||||
return array_filter($this->tree, fn (CommentTreeNode $node) => $node->comment->archived);
|
||||
}
|
||||
|
||||
public function archivedThreadCount(): int
|
||||
{
|
||||
return count($this->getArchived());
|
||||
}
|
||||
|
||||
public function getCommentNodeForId(int $commentId): ?CommentTreeNode
|
||||
{
|
||||
foreach ($this->tree as $node) {
|
||||
if ($node->comment->id === $commentId) {
|
||||
return $node;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function canUpdateAny(): bool
|
||||
@@ -54,6 +80,7 @@ class CommentTree
|
||||
|
||||
/**
|
||||
* @param Comment[] $comments
|
||||
* @return CommentTreeNode[]
|
||||
*/
|
||||
protected function createTree(array $comments): array
|
||||
{
|
||||
@@ -77,26 +104,22 @@ class CommentTree
|
||||
|
||||
$tree = [];
|
||||
foreach ($childMap[0] ?? [] as $childId) {
|
||||
$tree[] = $this->createTreeForId($childId, 0, $byId, $childMap);
|
||||
$tree[] = $this->createTreeNodeForId($childId, 0, $byId, $childMap);
|
||||
}
|
||||
|
||||
return $tree;
|
||||
}
|
||||
|
||||
protected function createTreeForId(int $id, int $depth, array &$byId, array &$childMap): array
|
||||
protected function createTreeNodeForId(int $id, int $depth, array &$byId, array &$childMap): CommentTreeNode
|
||||
{
|
||||
$childIds = $childMap[$id] ?? [];
|
||||
$children = [];
|
||||
|
||||
foreach ($childIds as $childId) {
|
||||
$children[] = $this->createTreeForId($childId, $depth + 1, $byId, $childMap);
|
||||
$children[] = $this->createTreeNodeForId($childId, $depth + 1, $byId, $childMap);
|
||||
}
|
||||
|
||||
return [
|
||||
'comment' => $byId[$id],
|
||||
'depth' => $depth,
|
||||
'children' => $children,
|
||||
];
|
||||
return new CommentTreeNode($byId[$id], $depth, $children);
|
||||
}
|
||||
|
||||
protected function loadComments(): array
|
||||
|
||||
23
app/Activity/Tools/CommentTreeNode.php
Normal file
23
app/Activity/Tools/CommentTreeNode.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Tools;
|
||||
|
||||
use BookStack\Activity\Models\Comment;
|
||||
|
||||
class CommentTreeNode
|
||||
{
|
||||
public Comment $comment;
|
||||
public int $depth;
|
||||
|
||||
/**
|
||||
* @var CommentTreeNode[]
|
||||
*/
|
||||
public array $children;
|
||||
|
||||
public function __construct(Comment $comment, int $depth, array $children)
|
||||
{
|
||||
$this->comment = $comment;
|
||||
$this->depth = $depth;
|
||||
$this->children = $children;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace BookStack\Api;
|
||||
|
||||
use BookStack\App\AppVersion;
|
||||
use BookStack\Http\ApiController;
|
||||
use Exception;
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
@@ -25,7 +26,7 @@ class ApiDocsGenerator
|
||||
*/
|
||||
public static function generateConsideringCache(): Collection
|
||||
{
|
||||
$appVersion = trim(file_get_contents(base_path('version')));
|
||||
$appVersion = AppVersion::get();
|
||||
$cacheKey = 'api-docs::' . $appVersion;
|
||||
$isProduction = config('app.env') === 'production';
|
||||
$cacheVal = $isProduction ? Cache::get($cacheKey) : null;
|
||||
|
||||
24
app/App/AppVersion.php
Normal file
24
app/App/AppVersion.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\App;
|
||||
|
||||
class AppVersion
|
||||
{
|
||||
protected static string $version = '';
|
||||
|
||||
/**
|
||||
* Get the application's version number from its top-level `version` text file.
|
||||
*/
|
||||
public static function get(): string
|
||||
{
|
||||
if (!empty(static::$version)) {
|
||||
return static::$version;
|
||||
}
|
||||
|
||||
$versionFile = base_path('version');
|
||||
$version = trim(file_get_contents($versionFile));
|
||||
static::$version = $version;
|
||||
|
||||
return $version;
|
||||
}
|
||||
}
|
||||
@@ -42,4 +42,12 @@ class EventServiceProvider extends ServiceProvider
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides the registration of Laravel's default email verification system
|
||||
*/
|
||||
protected function configureEmailVerification(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,5 +85,12 @@ class RouteServiceProvider extends ServiceProvider
|
||||
RateLimiter::for('public', function (Request $request) {
|
||||
return Limit::perMinute(10)->by($request->ip());
|
||||
});
|
||||
|
||||
RateLimiter::for('exports', function (Request $request) {
|
||||
$user = user();
|
||||
$attempts = $user->isGuest() ? 4 : 10;
|
||||
$key = $user->isGuest() ? $request->ip() : $user->id;
|
||||
return Limit::perMinute($attempts)->by($key);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
31
app/App/SystemApiController.php
Normal file
31
app/App/SystemApiController.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\App;
|
||||
|
||||
use BookStack\Http\ApiController;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class SystemApiController extends ApiController
|
||||
{
|
||||
/**
|
||||
* Read details regarding the BookStack instance.
|
||||
* Some details may be null where not set, like the app logo for example.
|
||||
*/
|
||||
public function read(): JsonResponse
|
||||
{
|
||||
$logoSetting = setting('app-logo', '');
|
||||
if ($logoSetting === 'none') {
|
||||
$logo = null;
|
||||
} else {
|
||||
$logo = $logoSetting ? url($logoSetting) : url('/logo.png');
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'version' => AppVersion::get(),
|
||||
'instance_id' => setting('instance-id'),
|
||||
'app_name' => setting('app-name'),
|
||||
'app_logo' => $logo,
|
||||
'base_url' => url('/'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
<?php
|
||||
|
||||
use BookStack\App\AppVersion;
|
||||
use BookStack\App\Model;
|
||||
use BookStack\Facades\Theme;
|
||||
use BookStack\Permissions\PermissionApplicator;
|
||||
use BookStack\Settings\SettingService;
|
||||
use BookStack\Users\Models\User;
|
||||
@@ -12,12 +14,7 @@ use BookStack\Users\Models\User;
|
||||
*/
|
||||
function versioned_asset(string $file = ''): string
|
||||
{
|
||||
static $version = null;
|
||||
|
||||
if (is_null($version)) {
|
||||
$versionFile = base_path('version');
|
||||
$version = trim(file_get_contents($versionFile));
|
||||
}
|
||||
$version = AppVersion::get();
|
||||
|
||||
$additional = '';
|
||||
if (config('app.env') === 'development') {
|
||||
@@ -42,9 +39,9 @@ function user(): User
|
||||
* Check if the current user has a permission. If an ownable element
|
||||
* is passed in the jointPermissions are checked against that particular item.
|
||||
*/
|
||||
function userCan(string $permission, Model $ownable = null): bool
|
||||
function userCan(string $permission, ?Model $ownable = null): bool
|
||||
{
|
||||
if ($ownable === null) {
|
||||
if (is_null($ownable)) {
|
||||
return user()->can($permission);
|
||||
}
|
||||
|
||||
@@ -70,7 +67,7 @@ function userCanOnAny(string $action, string $entityClass = ''): bool
|
||||
*
|
||||
* @return mixed|SettingService
|
||||
*/
|
||||
function setting(string $key = null, $default = null)
|
||||
function setting(?string $key = null, mixed $default = null): mixed
|
||||
{
|
||||
$settingService = app()->make(SettingService::class);
|
||||
|
||||
@@ -88,43 +85,10 @@ function setting(string $key = null, $default = null)
|
||||
*/
|
||||
function theme_path(string $path = ''): ?string
|
||||
{
|
||||
$theme = config('view.theme');
|
||||
|
||||
$theme = Theme::getTheme();
|
||||
if (!$theme) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return base_path('themes/' . $theme . ($path ? DIRECTORY_SEPARATOR . $path : $path));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a URL with multiple parameters for sorting purposes.
|
||||
* Works out the logic to set the correct sorting direction
|
||||
* Discards empty parameters and allows overriding.
|
||||
*/
|
||||
function sortUrl(string $path, array $data, array $overrideData = []): string
|
||||
{
|
||||
$queryStringSections = [];
|
||||
$queryData = array_merge($data, $overrideData);
|
||||
|
||||
// Change sorting direction is already sorted on current attribute
|
||||
if (isset($overrideData['sort']) && $overrideData['sort'] === $data['sort']) {
|
||||
$queryData['order'] = ($data['order'] === 'asc') ? 'desc' : 'asc';
|
||||
} elseif (isset($overrideData['sort'])) {
|
||||
$queryData['order'] = 'asc';
|
||||
}
|
||||
|
||||
foreach ($queryData as $name => $value) {
|
||||
$trimmedVal = trim($value);
|
||||
if ($trimmedVal === '') {
|
||||
continue;
|
||||
}
|
||||
$queryStringSections[] = urlencode($name) . '=' . urlencode($trimmedVal);
|
||||
}
|
||||
|
||||
if (count($queryStringSections) === 0) {
|
||||
return url($path);
|
||||
}
|
||||
|
||||
return url($path . '?' . implode('&', $queryStringSections));
|
||||
}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Broadcasting configuration options.
|
||||
*
|
||||
* Changes to these config files are not supported by BookStack and may break upon updates.
|
||||
* Configuration should be altered via the `.env` file or environment variables.
|
||||
* Do not edit this file unless you're happy to maintain any changes yourself.
|
||||
*/
|
||||
|
||||
return [
|
||||
|
||||
// Default Broadcaster
|
||||
// This option controls the default broadcaster that will be used by the
|
||||
// framework when an event needs to be broadcast. This can be set to
|
||||
// any of the connections defined in the "connections" array below.
|
||||
'default' => 'null',
|
||||
|
||||
// Broadcast Connections
|
||||
// Here you may define all of the broadcast connections that will be used
|
||||
// to broadcast events to other systems or over websockets. Samples of
|
||||
// each available type of connection are provided inside this array.
|
||||
'connections' => [
|
||||
|
||||
// Default options removed since we don't use broadcasting.
|
||||
|
||||
'log' => [
|
||||
'driver' => 'log',
|
||||
],
|
||||
|
||||
'null' => [
|
||||
'driver' => 'null',
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
@@ -35,10 +35,6 @@ return [
|
||||
// Available caches stores
|
||||
'stores' => [
|
||||
|
||||
'apc' => [
|
||||
'driver' => 'apc',
|
||||
],
|
||||
|
||||
'array' => [
|
||||
'driver' => 'array',
|
||||
'serialize' => false,
|
||||
@@ -49,6 +45,7 @@ return [
|
||||
'table' => 'cache',
|
||||
'connection' => null,
|
||||
'lock_connection' => null,
|
||||
'lock_table' => null,
|
||||
],
|
||||
|
||||
'file' => [
|
||||
|
||||
@@ -40,12 +40,16 @@ if (env('REDIS_SERVERS', false)) {
|
||||
|
||||
// MYSQL
|
||||
// Split out port from host if set
|
||||
$mysql_host = env('DB_HOST', 'localhost');
|
||||
$mysql_host_exploded = explode(':', $mysql_host);
|
||||
$mysql_port = env('DB_PORT', 3306);
|
||||
if (count($mysql_host_exploded) > 1) {
|
||||
$mysql_host = $mysql_host_exploded[0];
|
||||
$mysql_port = intval($mysql_host_exploded[1]);
|
||||
$mysqlHost = env('DB_HOST', 'localhost');
|
||||
$mysqlHostExploded = explode(':', $mysqlHost);
|
||||
$mysqlPort = env('DB_PORT', 3306);
|
||||
$mysqlHostIpv6 = str_starts_with($mysqlHost, '[');
|
||||
if ($mysqlHostIpv6 && str_contains($mysqlHost, ']:')) {
|
||||
$mysqlHost = implode(':', array_slice($mysqlHostExploded, 0, -1));
|
||||
$mysqlPort = intval(end($mysqlHostExploded));
|
||||
} else if (!$mysqlHostIpv6 && count($mysqlHostExploded) > 1) {
|
||||
$mysqlHost = $mysqlHostExploded[0];
|
||||
$mysqlPort = intval($mysqlHostExploded[1]);
|
||||
}
|
||||
|
||||
return [
|
||||
@@ -61,12 +65,12 @@ return [
|
||||
'mysql' => [
|
||||
'driver' => 'mysql',
|
||||
'url' => env('DATABASE_URL'),
|
||||
'host' => $mysql_host,
|
||||
'host' => $mysqlHost,
|
||||
'database' => env('DB_DATABASE', 'forge'),
|
||||
'username' => env('DB_USERNAME', 'forge'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'unix_socket' => env('DB_SOCKET', ''),
|
||||
'port' => $mysql_port,
|
||||
'port' => $mysqlPort,
|
||||
'charset' => 'utf8mb4',
|
||||
'collation' => 'utf8mb4_unicode_ci',
|
||||
// Prefixes are only semi-supported and may be unstable
|
||||
@@ -88,7 +92,7 @@ return [
|
||||
'database' => 'bookstack-test',
|
||||
'username' => env('MYSQL_USER', 'bookstack-test'),
|
||||
'password' => env('MYSQL_PASSWORD', 'bookstack-test'),
|
||||
'port' => $mysql_port,
|
||||
'port' => $mysqlPort,
|
||||
'charset' => 'utf8mb4',
|
||||
'collation' => 'utf8mb4_unicode_ci',
|
||||
'prefix' => '',
|
||||
|
||||
@@ -114,6 +114,7 @@ return [
|
||||
* @var array
|
||||
*/
|
||||
'allowed_protocols' => [
|
||||
"data://" => ["rules" => []],
|
||||
'file://' => ['rules' => []],
|
||||
'http://' => ['rules' => []],
|
||||
'https://' => ['rules' => []],
|
||||
|
||||
@@ -32,20 +32,22 @@ return [
|
||||
'local' => [
|
||||
'driver' => 'local',
|
||||
'root' => public_path(),
|
||||
'visibility' => 'public',
|
||||
'serve' => false,
|
||||
'throw' => true,
|
||||
'directory_visibility' => 'public',
|
||||
],
|
||||
|
||||
'local_secure_attachments' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('uploads/files/'),
|
||||
'serve' => false,
|
||||
'throw' => true,
|
||||
],
|
||||
|
||||
'local_secure_images' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('uploads/images/'),
|
||||
'visibility' => 'public',
|
||||
'serve' => false,
|
||||
'throw' => true,
|
||||
],
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ return [
|
||||
'password' => env('MAIL_PASSWORD'),
|
||||
'verify_peer' => env('MAIL_VERIFY_SSL', true),
|
||||
'timeout' => null,
|
||||
'local_domain' => env('MAIL_EHLO_DOMAIN'),
|
||||
'local_domain' => null,
|
||||
'tls_required' => ($mailEncryption === 'tls' || $mailEncryption === 'ssl'),
|
||||
],
|
||||
|
||||
@@ -64,12 +64,4 @@ return [
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
// Email markdown configuration
|
||||
'markdown' => [
|
||||
'theme' => 'default',
|
||||
'paths' => [
|
||||
resource_path('views/vendor/mail'),
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
@@ -47,6 +47,12 @@ return [
|
||||
// Multiple values can be provided comma seperated.
|
||||
'additional_scopes' => env('OIDC_ADDITIONAL_SCOPES', null),
|
||||
|
||||
// Enable fetching of the user's avatar from the 'picture' claim on login.
|
||||
// Will only be fetched if the user doesn't already have an avatar image assigned.
|
||||
// This can be a security risk due to performing server-side fetching (with up to 3 redirects) of
|
||||
// data from external URLs. Only enable if you trust the OIDC auth provider to provide safe URLs for user images.
|
||||
'fetch_avatar' => env('OIDC_FETCH_AVATAR', false),
|
||||
|
||||
// Group sync options
|
||||
// Enable syncing, upon login, of OIDC groups to BookStack roles
|
||||
'user_to_groups' => env('OIDC_USER_TO_GROUPS', false),
|
||||
|
||||
@@ -23,6 +23,7 @@ return [
|
||||
|
||||
'database' => [
|
||||
'driver' => 'database',
|
||||
'connection' => null,
|
||||
'table' => 'jobs',
|
||||
'queue' => 'default',
|
||||
'retry_after' => 90,
|
||||
|
||||
99
app/Console/Commands/AssignSortRuleCommand.php
Normal file
99
app/Console/Commands/AssignSortRuleCommand.php
Normal file
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Console\Commands;
|
||||
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Sorting\BookSorter;
|
||||
use BookStack\Sorting\SortRule;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class AssignSortRuleCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'bookstack:assign-sort-rule
|
||||
{sort-rule=0: ID of the sort rule to apply}
|
||||
{--all-books : Apply to all books in the system}
|
||||
{--books-without-sort : Apply to only books without a sort rule already assigned}
|
||||
{--books-with-sort= : Apply to only books with the sort rule of given id}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Assign a sort rule to content in the system';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(BookSorter $sorter): int
|
||||
{
|
||||
$sortRuleId = intval($this->argument('sort-rule')) ?? 0;
|
||||
if ($sortRuleId === 0) {
|
||||
return $this->listSortRules();
|
||||
}
|
||||
|
||||
$rule = SortRule::query()->find($sortRuleId);
|
||||
if ($this->option('all-books')) {
|
||||
$query = Book::query();
|
||||
} else if ($this->option('books-without-sort')) {
|
||||
$query = Book::query()->whereNull('sort_rule_id');
|
||||
} else if ($this->option('books-with-sort')) {
|
||||
$sortId = intval($this->option('books-with-sort')) ?: 0;
|
||||
if (!$sortId) {
|
||||
$this->error("Provided --books-with-sort option value is invalid");
|
||||
return 1;
|
||||
}
|
||||
$query = Book::query()->where('sort_rule_id', $sortId);
|
||||
} else {
|
||||
$this->error("No option provided to specify target. Run with the -h option to see all available options.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!$rule) {
|
||||
$this->error("Sort rule of provided id {$sortRuleId} not found!");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$count = $query->clone()->count();
|
||||
$this->warn("This will apply sort rule [{$rule->id}: {$rule->name}] to {$count} book(s) and run the sort on each.");
|
||||
$confirmed = $this->confirm("Are you sure you want to continue?");
|
||||
|
||||
if (!$confirmed) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
$processed = 0;
|
||||
$query->chunkById(10, function ($books) use ($rule, $sorter, $count, &$processed) {
|
||||
$max = min($count, ($processed + 10));
|
||||
$this->info("Applying to {$processed}-{$max} of {$count} books");
|
||||
foreach ($books as $book) {
|
||||
$book->sort_rule_id = $rule->id;
|
||||
$book->save();
|
||||
$sorter->runBookAutoSort($book);
|
||||
}
|
||||
$processed = $max;
|
||||
});
|
||||
|
||||
$this->info("Sort applied to {$processed} book(s)!");
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
protected function listSortRules(): int
|
||||
{
|
||||
|
||||
$rules = SortRule::query()->orderBy('id', 'asc')->get();
|
||||
$this->error("Sort rule ID required!");
|
||||
$this->warn("\nAvailable sort rules:");
|
||||
foreach ($rules as $rule) {
|
||||
$this->info("{$rule->id}: {$rule->name}");
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -70,7 +70,7 @@ class BookController extends Controller
|
||||
/**
|
||||
* Show the form for creating a new book.
|
||||
*/
|
||||
public function create(string $shelfSlug = null)
|
||||
public function create(?string $shelfSlug = null)
|
||||
{
|
||||
$this->checkPermission('book-create-all');
|
||||
|
||||
@@ -93,7 +93,7 @@ class BookController extends Controller
|
||||
* @throws ImageUploadException
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function store(Request $request, string $shelfSlug = null)
|
||||
public function store(Request $request, ?string $shelfSlug = null)
|
||||
{
|
||||
$this->checkPermission('book-create-all');
|
||||
$validated = $this->validate($request, [
|
||||
|
||||
@@ -17,6 +17,7 @@ use BookStack\Entities\Tools\PageContent;
|
||||
use BookStack\Entities\Tools\PageEditActivity;
|
||||
use BookStack\Entities\Tools\PageEditorData;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use BookStack\Exceptions\NotifyException;
|
||||
use BookStack\Exceptions\PermissionsException;
|
||||
use BookStack\Http\Controller;
|
||||
use BookStack\References\ReferenceFetcher;
|
||||
@@ -41,7 +42,7 @@ class PageController extends Controller
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function create(string $bookSlug, string $chapterSlug = null)
|
||||
public function create(string $bookSlug, ?string $chapterSlug = null)
|
||||
{
|
||||
if ($chapterSlug) {
|
||||
$parent = $this->entityQueries->chapters->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
|
||||
@@ -69,7 +70,7 @@ class PageController extends Controller
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function createAsGuest(Request $request, string $bookSlug, string $chapterSlug = null)
|
||||
public function createAsGuest(Request $request, string $bookSlug, ?string $chapterSlug = null)
|
||||
{
|
||||
$this->validate($request, [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
@@ -196,7 +197,7 @@ class PageController extends Controller
|
||||
public function edit(Request $request, string $bookSlug, string $pageSlug)
|
||||
{
|
||||
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
|
||||
$this->checkOwnablePermission('page-update', $page);
|
||||
$this->checkOwnablePermission('page-update', $page, $page->getUrl());
|
||||
|
||||
$editorData = new PageEditorData($page, $this->entityQueries, $request->query('editor', ''));
|
||||
if ($editorData->getWarnings()) {
|
||||
|
||||
@@ -43,7 +43,6 @@ class PageRevisionController extends Controller
|
||||
->selectRaw("IF(markdown = '', false, true) as is_markdown")
|
||||
->with(['page.book', 'createdBy'])
|
||||
->reorder('id', $listOptions->getOrder())
|
||||
->reorder('created_at', $listOptions->getOrder())
|
||||
->paginate(50);
|
||||
|
||||
$this->setPageTitle(trans('entities.pages_revisions_named', ['pageName' => $page->getShortName()]));
|
||||
@@ -52,6 +51,7 @@ class PageRevisionController extends Controller
|
||||
'revisions' => $revisions,
|
||||
'page' => $page,
|
||||
'listOptions' => $listOptions,
|
||||
'oldestRevisionId' => $page->revisions()->min('id'),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use BookStack\Sorting\SortRule;
|
||||
use BookStack\Uploads\Image;
|
||||
use Exception;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@@ -16,12 +17,14 @@ use Illuminate\Support\Collection;
|
||||
* @property string $description
|
||||
* @property int $image_id
|
||||
* @property ?int $default_template_id
|
||||
* @property ?int $sort_rule_id
|
||||
* @property Image|null $cover
|
||||
* @property \Illuminate\Database\Eloquent\Collection $chapters
|
||||
* @property \Illuminate\Database\Eloquent\Collection $pages
|
||||
* @property \Illuminate\Database\Eloquent\Collection $directPages
|
||||
* @property \Illuminate\Database\Eloquent\Collection $shelves
|
||||
* @property ?Page $defaultTemplate
|
||||
* @property ?SortRule $sortRule
|
||||
*/
|
||||
class Book extends Entity implements HasCoverImage
|
||||
{
|
||||
@@ -82,6 +85,14 @@ class Book extends Entity implements HasCoverImage
|
||||
return $this->belongsTo(Page::class, 'default_template_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the sort set assigned to this book, if existing.
|
||||
*/
|
||||
public function sortRule(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SortRule::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all pages within this book.
|
||||
*/
|
||||
|
||||
@@ -18,7 +18,7 @@ class QueryPopular
|
||||
) {
|
||||
}
|
||||
|
||||
public function run(int $count, int $page, array $filterModels = null): Collection
|
||||
public function run(int $count, int $page, array $filterModels): Collection
|
||||
{
|
||||
$query = $this->permissions
|
||||
->restrictEntityRelationQuery(View::query(), 'views', 'viewable_id', 'viewable_type')
|
||||
@@ -26,7 +26,7 @@ class QueryPopular
|
||||
->groupBy('viewable_id', 'viewable_type')
|
||||
->orderBy('view_count', 'desc');
|
||||
|
||||
if ($filterModels) {
|
||||
if (!empty($filterModels)) {
|
||||
$query->whereIn('viewable_type', $this->entityProvider->getMorphClasses($filterModels));
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace BookStack\Entities\Repos;
|
||||
|
||||
use BookStack\Activity\TagRepo;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\BookChild;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\HasCoverImage;
|
||||
@@ -12,6 +13,7 @@ use BookStack\Entities\Queries\PageQueries;
|
||||
use BookStack\Exceptions\ImageUploadException;
|
||||
use BookStack\References\ReferenceStore;
|
||||
use BookStack\References\ReferenceUpdater;
|
||||
use BookStack\Sorting\BookSorter;
|
||||
use BookStack\Uploads\ImageRepo;
|
||||
use BookStack\Util\HtmlDescriptionFilter;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
@@ -24,6 +26,7 @@ class BaseRepo
|
||||
protected ReferenceUpdater $referenceUpdater,
|
||||
protected ReferenceStore $referenceStore,
|
||||
protected PageQueries $pageQueries,
|
||||
protected BookSorter $bookSorter,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -134,6 +137,18 @@ class BaseRepo
|
||||
$entity->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort the parent of the given entity, if any auto sort actions are set for it.
|
||||
* Typical ran during create/update/insert events.
|
||||
*/
|
||||
public function sortParent(Entity $entity): void
|
||||
{
|
||||
if ($entity instanceof BookChild) {
|
||||
$book = $entity->book;
|
||||
$this->bookSorter->runBookAutoSort($book);
|
||||
}
|
||||
}
|
||||
|
||||
protected function updateDescription(Entity $entity, array $input): void
|
||||
{
|
||||
if (!in_array(HasHtmlDescription::class, class_uses($entity))) {
|
||||
|
||||
@@ -8,6 +8,7 @@ use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Tools\TrashCan;
|
||||
use BookStack\Exceptions\ImageUploadException;
|
||||
use BookStack\Facades\Activity;
|
||||
use BookStack\Sorting\SortRule;
|
||||
use BookStack\Uploads\ImageRepo;
|
||||
use Exception;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
@@ -33,6 +34,12 @@ class BookRepo
|
||||
$this->baseRepo->updateDefaultTemplate($book, intval($input['default_template_id'] ?? null));
|
||||
Activity::add(ActivityType::BOOK_CREATE, $book);
|
||||
|
||||
$defaultBookSortSetting = intval(setting('sorting-book-default', '0'));
|
||||
if ($defaultBookSortSetting && SortRule::query()->find($defaultBookSortSetting)) {
|
||||
$book->sort_rule_id = $defaultBookSortSetting;
|
||||
$book->save();
|
||||
}
|
||||
|
||||
return $book;
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ class ChapterRepo
|
||||
$this->baseRepo->updateDefaultTemplate($chapter, intval($input['default_template_id'] ?? null));
|
||||
Activity::add(ActivityType::CHAPTER_CREATE, $chapter);
|
||||
|
||||
$this->baseRepo->sortParent($chapter);
|
||||
|
||||
return $chapter;
|
||||
}
|
||||
|
||||
@@ -50,6 +52,8 @@ class ChapterRepo
|
||||
|
||||
Activity::add(ActivityType::CHAPTER_UPDATE, $chapter);
|
||||
|
||||
$this->baseRepo->sortParent($chapter);
|
||||
|
||||
return $chapter;
|
||||
}
|
||||
|
||||
@@ -88,6 +92,8 @@ class ChapterRepo
|
||||
$chapter->rebuildPermissions();
|
||||
Activity::add(ActivityType::CHAPTER_MOVE, $chapter);
|
||||
|
||||
$this->baseRepo->sortParent($chapter);
|
||||
|
||||
return $parent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,7 @@ class PageRepo
|
||||
$draft->refresh();
|
||||
|
||||
Activity::add(ActivityType::PAGE_CREATE, $draft);
|
||||
$this->baseRepo->sortParent($draft);
|
||||
|
||||
return $draft;
|
||||
}
|
||||
@@ -128,6 +129,7 @@ class PageRepo
|
||||
}
|
||||
|
||||
Activity::add(ActivityType::PAGE_UPDATE, $page);
|
||||
$this->baseRepo->sortParent($page);
|
||||
|
||||
return $page;
|
||||
}
|
||||
@@ -243,6 +245,8 @@ class PageRepo
|
||||
Activity::add(ActivityType::PAGE_RESTORE, $page);
|
||||
Activity::add(ActivityType::REVISION_RESTORE, $revision);
|
||||
|
||||
$this->baseRepo->sortParent($page);
|
||||
|
||||
return $page;
|
||||
}
|
||||
|
||||
@@ -272,6 +276,8 @@ class PageRepo
|
||||
|
||||
Activity::add(ActivityType::PAGE_MOVE, $page);
|
||||
|
||||
$this->baseRepo->sortParent($page);
|
||||
|
||||
return $parent;
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ class RevisionRepo
|
||||
/**
|
||||
* Store a new revision in the system for the given page.
|
||||
*/
|
||||
public function storeNewForPage(Page $page, string $summary = null): PageRevision
|
||||
public function storeNewForPage(Page $page, ?string $summary = null): PageRevision
|
||||
{
|
||||
$revision = new PageRevision();
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Queries\EntityQueries;
|
||||
use BookStack\Sorting\BookSortMap;
|
||||
use BookStack\Sorting\BookSortMapItem;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class BookContents
|
||||
@@ -103,211 +105,4 @@ class BookContents
|
||||
|
||||
return $query->where('book_id', '=', $this->book->id)->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort the books content using the given sort map.
|
||||
* Returns a list of books that were involved in the operation.
|
||||
*
|
||||
* @returns Book[]
|
||||
*/
|
||||
public function sortUsingMap(BookSortMap $sortMap): array
|
||||
{
|
||||
// Load models into map
|
||||
$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
|
||||
foreach ($sortMapItems as $item) {
|
||||
$this->applySortUpdates($item, $modelMap);
|
||||
}
|
||||
|
||||
/** @var Book[] $booksInvolved */
|
||||
$booksInvolved = array_values(array_filter($modelMap, function (string $key) {
|
||||
return str_starts_with($key, 'book:');
|
||||
}, 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. Changes where permissions are lacking will
|
||||
* be skipped and not throw an error.
|
||||
*
|
||||
* @param array<string, Entity> $modelMap
|
||||
*/
|
||||
protected function applySortUpdates(BookSortMapItem $sortMapItem, array $modelMap): void
|
||||
{
|
||||
/** @var BookChild $model */
|
||||
$model = $modelMap[$sortMapItem->type . ':' . $sortMapItem->id] ?? null;
|
||||
if (!$model) {
|
||||
return;
|
||||
}
|
||||
|
||||
$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($newBook->id);
|
||||
}
|
||||
|
||||
if ($model instanceof Page && $chapterChanged) {
|
||||
$model->chapter_id = $newChapter->id ?? 0;
|
||||
}
|
||||
|
||||
if ($priorityChanged) {
|
||||
$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 instanceof Chapter && $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.
|
||||
*
|
||||
* @return array<string, Entity>
|
||||
*/
|
||||
protected function loadModelsFromSortMap(BookSortMap $sortMap): array
|
||||
{
|
||||
$modelMap = [];
|
||||
$ids = [
|
||||
'chapter' => [],
|
||||
'page' => [],
|
||||
'book' => [],
|
||||
];
|
||||
|
||||
foreach ($sortMap->all() as $sortMapItem) {
|
||||
$ids[$sortMapItem->type][] = $sortMapItem->id;
|
||||
$ids['book'][] = $sortMapItem->parentBookId;
|
||||
if ($sortMapItem->parentChapterId) {
|
||||
$ids['chapter'][] = $sortMapItem->parentChapterId;
|
||||
}
|
||||
}
|
||||
|
||||
$pages = $this->queries->pages->visibleForList()->whereIn('id', array_unique($ids['page']))->get();
|
||||
/** @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 = $this->queries->chapters->visibleForList()->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 = $this->queries->books->visibleForList()->whereIn('id', array_unique($ids['book']))->get();
|
||||
/** @var Book $book */
|
||||
foreach ($books as $book) {
|
||||
$modelMap['book:' . $book->id] = $book;
|
||||
}
|
||||
|
||||
return $modelMap;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace BookStack\Exceptions;
|
||||
|
||||
use BookStack\App\AppVersion;
|
||||
use Illuminate\Contracts\Foundation\ExceptionRenderer;
|
||||
|
||||
class BookStackExceptionHandlerPage implements ExceptionRenderer
|
||||
@@ -30,9 +31,7 @@ class BookStackExceptionHandlerPage implements ExceptionRenderer
|
||||
return [
|
||||
'PHP Version' => phpversion(),
|
||||
'BookStack Version' => $this->safeReturn(function () {
|
||||
$versionFile = base_path('version');
|
||||
|
||||
return trim(file_get_contents($versionFile));
|
||||
return AppVersion::get();
|
||||
}, 'unknown'),
|
||||
'Theme Configured' => $this->safeReturn(function () {
|
||||
return config('view.theme');
|
||||
|
||||
7
app/Exceptions/DrawioPngReaderException.php
Normal file
7
app/Exceptions/DrawioPngReaderException.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Exceptions;
|
||||
|
||||
class DrawioPngReaderException extends \Exception
|
||||
{
|
||||
}
|
||||
@@ -16,6 +16,7 @@ class BookExportController extends Controller
|
||||
protected ExportFormatter $exportFormatter,
|
||||
) {
|
||||
$this->middleware('can:content-export');
|
||||
$this->middleware('throttle:exports');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,6 +76,6 @@ class BookExportController extends Controller
|
||||
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
|
||||
$zip = $builder->buildForBook($book);
|
||||
|
||||
return $this->download()->streamedDirectly(fopen($zip, 'r'), $bookSlug . '.zip', filesize($zip));
|
||||
return $this->download()->streamedFileDirectly($zip, $bookSlug . '.zip', true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ class ChapterExportController extends Controller
|
||||
protected ExportFormatter $exportFormatter,
|
||||
) {
|
||||
$this->middleware('can:content-export');
|
||||
$this->middleware('throttle:exports');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,6 +82,6 @@ class ChapterExportController extends Controller
|
||||
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
|
||||
$zip = $builder->buildForChapter($chapter);
|
||||
|
||||
return $this->download()->streamedDirectly(fopen($zip, 'r'), $chapterSlug . '.zip', filesize($zip));
|
||||
return $this->download()->streamedFileDirectly($zip, $chapterSlug . '.zip', true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ class PageExportController extends Controller
|
||||
protected ExportFormatter $exportFormatter,
|
||||
) {
|
||||
$this->middleware('can:content-export');
|
||||
$this->middleware('throttle:exports');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,6 +86,6 @@ class PageExportController extends Controller
|
||||
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
|
||||
$zip = $builder->buildForPage($page);
|
||||
|
||||
return $this->download()->streamedDirectly(fopen($zip, 'r'), $pageSlug . '.zip', filesize($zip));
|
||||
return $this->download()->streamedFileDirectly($zip, $pageSlug . '.zip', true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,18 +90,28 @@ class PdfGenerator
|
||||
$process = Process::fromShellCommandline($command);
|
||||
$process->setTimeout($timeout);
|
||||
|
||||
$cleanup = function () use ($inputHtml, $outputPdf) {
|
||||
foreach ([$inputHtml, $outputPdf] as $file) {
|
||||
if (file_exists($file)) {
|
||||
unlink($file);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
$process->run();
|
||||
} catch (ProcessTimedOutException $e) {
|
||||
$cleanup();
|
||||
throw new PdfExportException("PDF Export via command failed due to timeout at {$timeout} second(s)");
|
||||
}
|
||||
|
||||
if (!$process->isSuccessful()) {
|
||||
$cleanup();
|
||||
throw new PdfExportException("PDF Export via command failed with exit code {$process->getExitCode()}, stdout: {$process->getOutput()}, stderr: {$process->getErrorOutput()}");
|
||||
}
|
||||
|
||||
$pdfContents = file_get_contents($outputPdf);
|
||||
unlink($outputPdf);
|
||||
$cleanup();
|
||||
|
||||
if ($pdfContents === false) {
|
||||
throw new PdfExportException("PDF Export via command failed, unable to read PDF output file");
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace BookStack\Exports\ZipExports;
|
||||
|
||||
use BookStack\App\AppVersion;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Page;
|
||||
@@ -70,7 +71,7 @@ class ZipExportBuilder
|
||||
$this->data['exported_at'] = date(DATE_ATOM);
|
||||
$this->data['instance'] = [
|
||||
'id' => setting('instance-id', ''),
|
||||
'version' => trim(file_get_contents(base_path('version'))),
|
||||
'version' => AppVersion::get(),
|
||||
];
|
||||
|
||||
$zipFile = tempnam(sys_get_temp_dir(), 'bszip-');
|
||||
@@ -84,10 +85,27 @@ class ZipExportBuilder
|
||||
$zip->addEmptyDir('files');
|
||||
|
||||
$toRemove = [];
|
||||
$this->files->extractEach(function ($filePath, $fileRef) use ($zip, &$toRemove) {
|
||||
$zip->addFile($filePath, "files/$fileRef");
|
||||
$toRemove[] = $filePath;
|
||||
});
|
||||
$addedNames = [];
|
||||
|
||||
try {
|
||||
$this->files->extractEach(function ($filePath, $fileRef) use ($zip, &$toRemove, &$addedNames) {
|
||||
$entryName = "files/$fileRef";
|
||||
$zip->addFile($filePath, $entryName);
|
||||
$toRemove[] = $filePath;
|
||||
$addedNames[] = $entryName;
|
||||
});
|
||||
} catch (\Exception $exception) {
|
||||
// Cleanup the files we've processed so far and respond back with error
|
||||
foreach ($toRemove as $file) {
|
||||
unlink($file);
|
||||
}
|
||||
foreach ($addedNames as $name) {
|
||||
$zip->deleteName($name);
|
||||
}
|
||||
$zip->close();
|
||||
unlink($zipFile);
|
||||
throw new ZipExportException("Failed to add files for ZIP export, received error: " . $exception->getMessage());
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
|
||||
|
||||
@@ -49,13 +49,13 @@ abstract class Controller extends BaseController
|
||||
* On a permission error redirect to home and display.
|
||||
* the error as a notification.
|
||||
*
|
||||
* @return never
|
||||
* @throws NotifyException
|
||||
*/
|
||||
protected function showPermissionError()
|
||||
protected function showPermissionError(string $redirectLocation = '/'): never
|
||||
{
|
||||
$message = request()->wantsJson() ? trans('errors.permissionJson') : trans('errors.permission');
|
||||
|
||||
throw new NotifyException($message, '/', 403);
|
||||
throw new NotifyException($message, $redirectLocation, 403);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,10 +81,10 @@ abstract class Controller extends BaseController
|
||||
/**
|
||||
* Check the current user's permissions against an ownable item otherwise throw an exception.
|
||||
*/
|
||||
protected function checkOwnablePermission(string $permission, Model $ownable): void
|
||||
protected function checkOwnablePermission(string $permission, Model $ownable, string $redirectLocation = '/'): void
|
||||
{
|
||||
if (!userCan($permission, $ownable)) {
|
||||
$this->showPermissionError();
|
||||
$this->showPermissionError($redirectLocation);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,7 +163,7 @@ abstract class Controller extends BaseController
|
||||
*/
|
||||
protected function getImageValidationRules(): array
|
||||
{
|
||||
return ['image_extension', 'mimes:jpeg,png,gif,webp', 'max:' . (config('app.upload_limit') * 1000)];
|
||||
return ['image_extension', 'mimes:jpeg,png,gif,webp,avif', 'max:' . (config('app.upload_limit') * 1000)];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,7 +9,7 @@ use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
class DownloadResponseFactory
|
||||
{
|
||||
public function __construct(
|
||||
protected Request $request
|
||||
protected Request $request,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -35,6 +35,33 @@ class DownloadResponseFactory
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a response that downloads the given file via a stream.
|
||||
* Has the option to delete the provided file once the stream is closed.
|
||||
*/
|
||||
public function streamedFileDirectly(string $filePath, string $fileName, bool $deleteAfter = false): StreamedResponse
|
||||
{
|
||||
$fileSize = filesize($filePath);
|
||||
$stream = fopen($filePath, 'r');
|
||||
|
||||
if ($deleteAfter) {
|
||||
// Delete the given file if it still exists after the app terminates
|
||||
$callback = function () use ($filePath) {
|
||||
if (file_exists($filePath)) {
|
||||
unlink($filePath);
|
||||
}
|
||||
};
|
||||
|
||||
// We watch both app terminate and php shutdown to cover both normal app termination
|
||||
// as well as other potential scenarios (connection termination).
|
||||
app()->terminating($callback);
|
||||
register_shutdown_function($callback);
|
||||
}
|
||||
|
||||
return $this->streamedDirectly($stream, $fileName, $fileSize);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Create a file download response that provides the file with a content-type
|
||||
* correct for the file, in a way so the browser can show the content in browser,
|
||||
@@ -43,7 +70,7 @@ class DownloadResponseFactory
|
||||
public function streamedInline($stream, string $fileName, int $fileSize): StreamedResponse
|
||||
{
|
||||
$rangeStream = new RangeSupportedStream($stream, $fileSize, $this->request);
|
||||
$mime = $rangeStream->sniffMime();
|
||||
$mime = $rangeStream->sniffMime(pathinfo($fileName, PATHINFO_EXTENSION));
|
||||
$headers = array_merge($this->getHeaders($fileName, $fileSize, $mime), $rangeStream->getResponseHeaders());
|
||||
|
||||
return response()->stream(
|
||||
@@ -53,6 +80,22 @@ class DownloadResponseFactory
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a response that provides the given file via a stream with detected content-type.
|
||||
* Has the option to delete the provided file once the stream is closed.
|
||||
*/
|
||||
public function streamedFileInline(string $filePath, ?string $fileName = null): StreamedResponse
|
||||
{
|
||||
$fileSize = filesize($filePath);
|
||||
$stream = fopen($filePath, 'r');
|
||||
|
||||
if ($fileName === null) {
|
||||
$fileName = basename($filePath);
|
||||
}
|
||||
|
||||
return $this->streamedInline($stream, $fileName, $fileSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the common headers to provide for a download response.
|
||||
*/
|
||||
|
||||
@@ -7,6 +7,13 @@ use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class PreventResponseCaching
|
||||
{
|
||||
/**
|
||||
* Paths to ignore when preventing response caching.
|
||||
*/
|
||||
protected array $ignoredPathPrefixes = [
|
||||
'theme/',
|
||||
];
|
||||
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
@@ -20,6 +27,13 @@ class PreventResponseCaching
|
||||
/** @var Response $response */
|
||||
$response = $next($request);
|
||||
|
||||
$path = $request->path();
|
||||
foreach ($this->ignoredPathPrefixes as $ignoredPath) {
|
||||
if (str_starts_with($path, $ignoredPath)) {
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
||||
$response->headers->set('Cache-Control', 'no-cache, no-store, private');
|
||||
$response->headers->set('Expires', 'Sun, 12 Jul 2015 19:01:00 GMT');
|
||||
|
||||
|
||||
@@ -32,12 +32,12 @@ class RangeSupportedStream
|
||||
/**
|
||||
* Sniff a mime type from the stream.
|
||||
*/
|
||||
public function sniffMime(): string
|
||||
public function sniffMime(string $extension = ''): string
|
||||
{
|
||||
$offset = min(2000, $this->fileSize);
|
||||
$this->sniffContent = fread($this->stream, $offset);
|
||||
|
||||
return (new WebSafeMimeSniffer())->sniff($this->sniffContent);
|
||||
return (new WebSafeMimeSniffer())->sniff($this->sniffContent, $extension);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,7 +16,13 @@ class SearchIndex
|
||||
/**
|
||||
* A list of delimiter characters used to break-up parsed content into terms for indexing.
|
||||
*/
|
||||
public static string $delimiters = " \n\t.,!?:;()[]{}<>`'\"";
|
||||
public static string $delimiters = " \n\t.-,!?:;()[]{}<>`'\"«»";
|
||||
|
||||
/**
|
||||
* A list of delimiter which could be commonly used within a single term and also indicate a break between terms.
|
||||
* The indexer will index the full term with these delimiters, plus the terms split via these delimiters.
|
||||
*/
|
||||
public static string $softDelimiters = ".-";
|
||||
|
||||
public function __construct(
|
||||
protected EntityProvider $entityProvider
|
||||
@@ -154,7 +160,9 @@ class SearchIndex
|
||||
/** @var DOMNode $child */
|
||||
foreach ($doc->getBodyChildren() as $child) {
|
||||
$nodeName = $child->nodeName;
|
||||
$termCounts = $this->textToTermCountMap(trim($child->textContent));
|
||||
$text = trim($child->textContent);
|
||||
$text = str_replace("\u{00A0}", ' ', $text);
|
||||
$termCounts = $this->textToTermCountMap($text);
|
||||
foreach ($termCounts as $term => $count) {
|
||||
$scoreChange = $count * ($elementScoreAdjustmentMap[$nodeName] ?? 1);
|
||||
$scoresByTerm[$term] = ($scoresByTerm[$term] ?? 0) + $scoreChange;
|
||||
@@ -196,15 +204,36 @@ class SearchIndex
|
||||
protected function textToTermCountMap(string $text): array
|
||||
{
|
||||
$tokenMap = []; // {TextToken => OccurrenceCount}
|
||||
$splitChars = static::$delimiters;
|
||||
$token = strtok($text, $splitChars);
|
||||
$softDelims = static::$softDelimiters;
|
||||
$tokenizer = new SearchTextTokenizer($text, static::$delimiters);
|
||||
$extendedToken = '';
|
||||
$extendedLen = 0;
|
||||
|
||||
$token = $tokenizer->next();
|
||||
|
||||
while ($token !== false) {
|
||||
if (!isset($tokenMap[$token])) {
|
||||
$tokenMap[$token] = 0;
|
||||
$delim = $tokenizer->previousDelimiter();
|
||||
|
||||
if ($delim && str_contains($softDelims, $delim) && $token !== '') {
|
||||
$extendedToken .= $delim . $token;
|
||||
$extendedLen++;
|
||||
} else {
|
||||
if ($extendedLen > 1) {
|
||||
$tokenMap[$extendedToken] = ($tokenMap[$extendedToken] ?? 0) + 1;
|
||||
}
|
||||
$extendedToken = $token;
|
||||
$extendedLen = 1;
|
||||
}
|
||||
$tokenMap[$token]++;
|
||||
$token = strtok($splitChars);
|
||||
|
||||
if ($token) {
|
||||
$tokenMap[$token] = ($tokenMap[$token] ?? 0) + 1;
|
||||
}
|
||||
|
||||
$token = $tokenizer->next();
|
||||
}
|
||||
|
||||
if ($extendedLen > 1) {
|
||||
$tokenMap[$extendedToken] = ($tokenMap[$extendedToken] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return $tokenMap;
|
||||
|
||||
@@ -181,7 +181,7 @@ class SearchOptions
|
||||
protected static function parseStandardTermString(string $termString): array
|
||||
{
|
||||
$terms = explode(' ', $termString);
|
||||
$indexDelimiters = SearchIndex::$delimiters;
|
||||
$indexDelimiters = implode('', array_diff(str_split(SearchIndex::$delimiters), str_split(SearchIndex::$softDelimiters)));
|
||||
$parsed = [
|
||||
'terms' => [],
|
||||
'exacts' => [],
|
||||
|
||||
70
app/Search/SearchTextTokenizer.php
Normal file
70
app/Search/SearchTextTokenizer.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Search;
|
||||
|
||||
/**
|
||||
* A custom text tokenizer which records & provides insight needed for our search indexing.
|
||||
* We used to use basic strtok() but this class does the following which that lacked:
|
||||
* - Tracks and provides the current/previous delimiter that we've stopped at.
|
||||
* - Returns empty tokens upon parsing a delimiter.
|
||||
*/
|
||||
class SearchTextTokenizer
|
||||
{
|
||||
protected int $currentIndex = 0;
|
||||
protected int $length;
|
||||
protected string $currentDelimiter = '';
|
||||
protected string $previousDelimiter = '';
|
||||
|
||||
public function __construct(
|
||||
protected string $text,
|
||||
protected string $delimiters = ' '
|
||||
) {
|
||||
$this->length = strlen($this->text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current delimiter to be found.
|
||||
*/
|
||||
public function currentDelimiter(): string
|
||||
{
|
||||
return $this->currentDelimiter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the previous delimiter found.
|
||||
*/
|
||||
public function previousDelimiter(): string
|
||||
{
|
||||
return $this->previousDelimiter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the next token between delimiters.
|
||||
* Returns false if there's no further tokens.
|
||||
*/
|
||||
public function next(): string|false
|
||||
{
|
||||
$token = '';
|
||||
|
||||
for ($i = $this->currentIndex; $i < $this->length; $i++) {
|
||||
$char = $this->text[$i];
|
||||
if (str_contains($this->delimiters, $char)) {
|
||||
$this->previousDelimiter = $this->currentDelimiter;
|
||||
$this->currentDelimiter = $char;
|
||||
$this->currentIndex = $i + 1;
|
||||
return $token;
|
||||
}
|
||||
|
||||
$token .= $char;
|
||||
}
|
||||
|
||||
if ($token) {
|
||||
$this->currentIndex = $this->length;
|
||||
$this->previousDelimiter = $this->currentDelimiter;
|
||||
$this->currentDelimiter = '';
|
||||
return $token;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace BookStack\Settings;
|
||||
|
||||
use BookStack\Activity\ActivityType;
|
||||
use BookStack\App\AppVersion;
|
||||
use BookStack\Entities\Tools\TrashCan;
|
||||
use BookStack\Http\Controller;
|
||||
use BookStack\References\ReferenceStore;
|
||||
@@ -19,14 +20,11 @@ class MaintenanceController extends Controller
|
||||
$this->checkPermission('settings-manage');
|
||||
$this->setPageTitle(trans('settings.maint'));
|
||||
|
||||
// Get application version
|
||||
$version = trim(file_get_contents(base_path('version')));
|
||||
|
||||
// Recycle bin details
|
||||
$recycleStats = $trashCan->getTrashedCounts();
|
||||
|
||||
return view('settings.maintenance', [
|
||||
'version' => $version,
|
||||
'version' => AppVersion::get(),
|
||||
'recycleStats' => $recycleStats,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace BookStack\Settings;
|
||||
|
||||
use BookStack\Activity\ActivityType;
|
||||
use BookStack\App\AppVersion;
|
||||
use BookStack\Http\Controller;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -26,12 +27,9 @@ class SettingController extends Controller
|
||||
$this->checkPermission('settings-manage');
|
||||
$this->setPageTitle(trans('settings.settings'));
|
||||
|
||||
// Get application version
|
||||
$version = trim(file_get_contents(base_path('version')));
|
||||
|
||||
return view('settings.categories.' . $category, [
|
||||
'category' => $category,
|
||||
'version' => $version,
|
||||
'version' => AppVersion::get(),
|
||||
'guestUser' => User::getGuest(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Controllers;
|
||||
namespace BookStack\Sorting;
|
||||
|
||||
use BookStack\Activity\ActivityType;
|
||||
use BookStack\Entities\Queries\BookQueries;
|
||||
use BookStack\Entities\Tools\BookContents;
|
||||
use BookStack\Entities\Tools\BookSortMap;
|
||||
use BookStack\Facades\Activity;
|
||||
use BookStack\Http\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -45,25 +44,40 @@ class BookSortController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts a book using a given mapping array.
|
||||
* Update the sort options of a book, setting the auto-sort and/or updating
|
||||
* child order via mapping.
|
||||
*/
|
||||
public function update(Request $request, string $bookSlug)
|
||||
public function update(Request $request, BookSorter $sorter, string $bookSlug)
|
||||
{
|
||||
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
|
||||
$this->checkOwnablePermission('book-update', $book);
|
||||
$loggedActivityForBook = false;
|
||||
|
||||
// Return if no map sent
|
||||
if (!$request->filled('sort-tree')) {
|
||||
return redirect($book->getUrl());
|
||||
// Sort via map
|
||||
if ($request->filled('sort-tree')) {
|
||||
$sortMap = BookSortMap::fromJson($request->get('sort-tree'));
|
||||
$booksInvolved = $sorter->sortUsingMap($sortMap);
|
||||
|
||||
// Rebuild permissions and add activity for involved books.
|
||||
foreach ($booksInvolved as $bookInvolved) {
|
||||
Activity::add(ActivityType::BOOK_SORT, $bookInvolved);
|
||||
if ($bookInvolved->id === $book->id) {
|
||||
$loggedActivityForBook = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$sortMap = BookSortMap::fromJson($request->get('sort-tree'));
|
||||
$bookContents = new BookContents($book);
|
||||
$booksInvolved = $bookContents->sortUsingMap($sortMap);
|
||||
|
||||
// Rebuild permissions and add activity for involved books.
|
||||
foreach ($booksInvolved as $bookInvolved) {
|
||||
Activity::add(ActivityType::BOOK_SORT, $bookInvolved);
|
||||
if ($request->filled('auto-sort')) {
|
||||
$sortSetId = intval($request->get('auto-sort')) ?: null;
|
||||
if ($sortSetId && SortRule::query()->find($sortSetId) === null) {
|
||||
$sortSetId = null;
|
||||
}
|
||||
$book->sort_rule_id = $sortSetId;
|
||||
$book->save();
|
||||
$sorter->runBookAutoSort($book);
|
||||
if (!$loggedActivityForBook) {
|
||||
Activity::add(ActivityType::BOOK_SORT, $book);
|
||||
}
|
||||
}
|
||||
|
||||
return redirect($book->getUrl());
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Tools;
|
||||
namespace BookStack\Sorting;
|
||||
|
||||
class BookSortMap
|
||||
{
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Tools;
|
||||
namespace BookStack\Sorting;
|
||||
|
||||
class BookSortMapItem
|
||||
{
|
||||
284
app/Sorting/BookSorter.php
Normal file
284
app/Sorting/BookSorter.php
Normal file
@@ -0,0 +1,284 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Sorting;
|
||||
|
||||
use BookStack\App\Model;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\BookChild;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Queries\EntityQueries;
|
||||
|
||||
class BookSorter
|
||||
{
|
||||
public function __construct(
|
||||
protected EntityQueries $queries,
|
||||
) {
|
||||
}
|
||||
|
||||
public function runBookAutoSortForAllWithSet(SortRule $set): void
|
||||
{
|
||||
$set->books()->chunk(50, function ($books) {
|
||||
foreach ($books as $book) {
|
||||
$this->runBookAutoSort($book);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the auto-sort for a book if the book has a sort set applied to it.
|
||||
* This does not consider permissions since the sort operations are centrally
|
||||
* managed by admins so considered permitted if existing and assigned.
|
||||
*/
|
||||
public function runBookAutoSort(Book $book): void
|
||||
{
|
||||
$set = $book->sortRule;
|
||||
if (!$set) {
|
||||
return;
|
||||
}
|
||||
|
||||
$sortFunctions = array_map(function (SortRuleOperation $op) {
|
||||
return $op->getSortFunction();
|
||||
}, $set->getOperations());
|
||||
|
||||
$chapters = $book->chapters()
|
||||
->with('pages:id,name,priority,created_at,updated_at,chapter_id')
|
||||
->get(['id', 'name', 'priority', 'created_at', 'updated_at']);
|
||||
|
||||
/** @var (Chapter|Book)[] $topItems */
|
||||
$topItems = [
|
||||
...$book->directPages()->get(['id', 'name', 'priority', 'created_at', 'updated_at']),
|
||||
...$chapters,
|
||||
];
|
||||
|
||||
foreach ($sortFunctions as $sortFunction) {
|
||||
usort($topItems, $sortFunction);
|
||||
}
|
||||
|
||||
foreach ($topItems as $index => $topItem) {
|
||||
$topItem->priority = $index + 1;
|
||||
$topItem::withoutTimestamps(fn () => $topItem->save());
|
||||
}
|
||||
|
||||
foreach ($chapters as $chapter) {
|
||||
$pages = $chapter->pages->all();
|
||||
foreach ($sortFunctions as $sortFunction) {
|
||||
usort($pages, $sortFunction);
|
||||
}
|
||||
|
||||
foreach ($pages as $index => $page) {
|
||||
$page->priority = $index + 1;
|
||||
$page::withoutTimestamps(fn () => $page->save());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Sort the books content using the given sort map.
|
||||
* Returns a list of books that were involved in the operation.
|
||||
*
|
||||
* @returns Book[]
|
||||
*/
|
||||
public function sortUsingMap(BookSortMap $sortMap): array
|
||||
{
|
||||
// Load models into map
|
||||
$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
|
||||
foreach ($sortMapItems as $item) {
|
||||
$this->applySortUpdates($item, $modelMap);
|
||||
}
|
||||
|
||||
/** @var Book[] $booksInvolved */
|
||||
$booksInvolved = array_values(array_filter($modelMap, function (string $key) {
|
||||
return str_starts_with($key, 'book:');
|
||||
}, 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. Changes where permissions are lacking will
|
||||
* be skipped and not throw an error.
|
||||
*
|
||||
* @param array<string, Entity> $modelMap
|
||||
*/
|
||||
protected function applySortUpdates(BookSortMapItem $sortMapItem, array $modelMap): void
|
||||
{
|
||||
/** @var BookChild $model */
|
||||
$model = $modelMap[$sortMapItem->type . ':' . $sortMapItem->id] ?? null;
|
||||
if (!$model) {
|
||||
return;
|
||||
}
|
||||
|
||||
$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($newBook->id);
|
||||
}
|
||||
|
||||
if ($model instanceof Page && $chapterChanged) {
|
||||
$model->chapter_id = $newChapter->id ?? 0;
|
||||
}
|
||||
|
||||
if ($priorityChanged) {
|
||||
$model->priority = $sortMapItem->sort;
|
||||
}
|
||||
|
||||
if ($chapterChanged || $priorityChanged) {
|
||||
$model::withoutTimestamps(fn () => $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 instanceof Chapter && $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.
|
||||
*
|
||||
* @return array<string, Entity>
|
||||
*/
|
||||
protected function loadModelsFromSortMap(BookSortMap $sortMap): array
|
||||
{
|
||||
$modelMap = [];
|
||||
$ids = [
|
||||
'chapter' => [],
|
||||
'page' => [],
|
||||
'book' => [],
|
||||
];
|
||||
|
||||
foreach ($sortMap->all() as $sortMapItem) {
|
||||
$ids[$sortMapItem->type][] = $sortMapItem->id;
|
||||
$ids['book'][] = $sortMapItem->parentBookId;
|
||||
if ($sortMapItem->parentChapterId) {
|
||||
$ids['chapter'][] = $sortMapItem->parentChapterId;
|
||||
}
|
||||
}
|
||||
|
||||
$pages = $this->queries->pages->visibleForList()->whereIn('id', array_unique($ids['page']))->get();
|
||||
/** @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 = $this->queries->chapters->visibleForList()->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 = $this->queries->books->visibleForList()->whereIn('id', array_unique($ids['book']))->get();
|
||||
/** @var Book $book */
|
||||
foreach ($books as $book) {
|
||||
$modelMap['book:' . $book->id] = $book;
|
||||
}
|
||||
|
||||
return $modelMap;
|
||||
}
|
||||
}
|
||||
63
app/Sorting/SortRule.php
Normal file
63
app/Sorting/SortRule.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Sorting;
|
||||
|
||||
use BookStack\Activity\Models\Loggable;
|
||||
use BookStack\Entities\Models\Book;
|
||||
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 $sequence
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
*/
|
||||
class SortRule extends Model implements Loggable
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* @return SortRuleOperation[]
|
||||
*/
|
||||
public function getOperations(): array
|
||||
{
|
||||
return SortRuleOperation::fromSequence($this->sequence);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param SortRuleOperation[] $options
|
||||
*/
|
||||
public function setOperations(array $options): void
|
||||
{
|
||||
$values = array_map(fn (SortRuleOperation $opt) => $opt->value, $options);
|
||||
$this->sequence = implode(',', $values);
|
||||
}
|
||||
|
||||
public function logDescriptor(): string
|
||||
{
|
||||
return "({$this->id}) {$this->name}";
|
||||
}
|
||||
|
||||
public function getUrl(): string
|
||||
{
|
||||
return url("/settings/sorting/rules/{$this->id}");
|
||||
}
|
||||
|
||||
public function books(): HasMany
|
||||
{
|
||||
return $this->hasMany(Book::class);
|
||||
}
|
||||
|
||||
public static function allByName(): Collection
|
||||
{
|
||||
return static::query()
|
||||
->withCount('books')
|
||||
->orderBy('name', 'asc')
|
||||
->get();
|
||||
}
|
||||
}
|
||||
114
app/Sorting/SortRuleController.php
Normal file
114
app/Sorting/SortRuleController.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Sorting;
|
||||
|
||||
use BookStack\Activity\ActivityType;
|
||||
use BookStack\Http\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SortRuleController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('can:settings-manage');
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$this->setPageTitle(trans('settings.sort_rule_create'));
|
||||
|
||||
return view('settings.sort-rules.create');
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$this->validate($request, [
|
||||
'name' => ['required', 'string', 'min:1', 'max:200'],
|
||||
'sequence' => ['required', 'string', 'min:1'],
|
||||
]);
|
||||
|
||||
$operations = SortRuleOperation::fromSequence($request->input('sequence'));
|
||||
if (count($operations) === 0) {
|
||||
return redirect()->withInput()->withErrors(['sequence' => 'No operations set.']);
|
||||
}
|
||||
|
||||
$rule = new SortRule();
|
||||
$rule->name = $request->input('name');
|
||||
$rule->setOperations($operations);
|
||||
$rule->save();
|
||||
|
||||
$this->logActivity(ActivityType::SORT_RULE_CREATE, $rule);
|
||||
|
||||
return redirect('/settings/sorting');
|
||||
}
|
||||
|
||||
public function edit(string $id)
|
||||
{
|
||||
$rule = SortRule::query()->findOrFail($id);
|
||||
|
||||
$this->setPageTitle(trans('settings.sort_rule_edit'));
|
||||
|
||||
return view('settings.sort-rules.edit', ['rule' => $rule]);
|
||||
}
|
||||
|
||||
public function update(string $id, Request $request, BookSorter $bookSorter)
|
||||
{
|
||||
$this->validate($request, [
|
||||
'name' => ['required', 'string', 'min:1', 'max:200'],
|
||||
'sequence' => ['required', 'string', 'min:1'],
|
||||
]);
|
||||
|
||||
$rule = SortRule::query()->findOrFail($id);
|
||||
$operations = SortRuleOperation::fromSequence($request->input('sequence'));
|
||||
if (count($operations) === 0) {
|
||||
return redirect($rule->getUrl())->withInput()->withErrors(['sequence' => 'No operations set.']);
|
||||
}
|
||||
|
||||
$rule->name = $request->input('name');
|
||||
$rule->setOperations($operations);
|
||||
$changedSequence = $rule->isDirty('sequence');
|
||||
$rule->save();
|
||||
|
||||
$this->logActivity(ActivityType::SORT_RULE_UPDATE, $rule);
|
||||
|
||||
if ($changedSequence) {
|
||||
$bookSorter->runBookAutoSortForAllWithSet($rule);
|
||||
}
|
||||
|
||||
return redirect('/settings/sorting');
|
||||
}
|
||||
|
||||
public function destroy(string $id, Request $request)
|
||||
{
|
||||
$rule = SortRule::query()->findOrFail($id);
|
||||
$confirmed = $request->input('confirm') === 'true';
|
||||
$booksAssigned = $rule->books()->count();
|
||||
$warnings = [];
|
||||
|
||||
if ($booksAssigned > 0) {
|
||||
if ($confirmed) {
|
||||
$rule->books()->update(['sort_rule_id' => null]);
|
||||
} else {
|
||||
$warnings[] = trans('settings.sort_rule_delete_warn_books', ['count' => $booksAssigned]);
|
||||
}
|
||||
}
|
||||
|
||||
$defaultBookSortSetting = intval(setting('sorting-book-default', '0'));
|
||||
if ($defaultBookSortSetting === intval($id)) {
|
||||
if ($confirmed) {
|
||||
setting()->remove('sorting-book-default');
|
||||
} else {
|
||||
$warnings[] = trans('settings.sort_rule_delete_warn_default');
|
||||
}
|
||||
}
|
||||
|
||||
if (count($warnings) > 0) {
|
||||
return redirect($rule->getUrl() . '#delete')->withErrors(['delete' => $warnings]);
|
||||
}
|
||||
|
||||
$rule->delete();
|
||||
$this->logActivity(ActivityType::SORT_RULE_DELETE, $rule);
|
||||
|
||||
return redirect('/settings/sorting');
|
||||
}
|
||||
}
|
||||
69
app/Sorting/SortRuleOperation.php
Normal file
69
app/Sorting/SortRuleOperation.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Sorting;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
enum SortRuleOperation: string
|
||||
{
|
||||
case NameAsc = 'name_asc';
|
||||
case NameDesc = 'name_desc';
|
||||
case NameNumericAsc = 'name_numeric_asc';
|
||||
case NameNumericDesc = 'name_numeric_desc';
|
||||
case CreatedDateAsc = 'created_date_asc';
|
||||
case CreatedDateDesc = 'created_date_desc';
|
||||
case UpdateDateAsc = 'updated_date_asc';
|
||||
case UpdateDateDesc = 'updated_date_desc';
|
||||
case ChaptersFirst = 'chapters_first';
|
||||
case ChaptersLast = 'chapters_last';
|
||||
|
||||
/**
|
||||
* Provide a translated label string for this option.
|
||||
*/
|
||||
public function getLabel(): string
|
||||
{
|
||||
$key = $this->value;
|
||||
$label = '';
|
||||
if (str_ends_with($key, '_asc')) {
|
||||
$key = substr($key, 0, -4);
|
||||
$label = trans('settings.sort_rule_op_asc');
|
||||
} elseif (str_ends_with($key, '_desc')) {
|
||||
$key = substr($key, 0, -5);
|
||||
$label = trans('settings.sort_rule_op_desc');
|
||||
}
|
||||
|
||||
$label = trans('settings.sort_rule_op_' . $key) . ' ' . $label;
|
||||
return trim($label);
|
||||
}
|
||||
|
||||
public function getSortFunction(): callable
|
||||
{
|
||||
$camelValue = Str::camel($this->value);
|
||||
return SortSetOperationComparisons::$camelValue(...);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return SortRuleOperation[]
|
||||
*/
|
||||
public static function allExcluding(array $operations): array
|
||||
{
|
||||
$all = SortRuleOperation::cases();
|
||||
$filtered = array_filter($all, function (SortRuleOperation $operation) use ($operations) {
|
||||
return !in_array($operation, $operations);
|
||||
});
|
||||
return array_values($filtered);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a set of operations from a string sequence representation.
|
||||
* (values seperated by commas).
|
||||
* @return SortRuleOperation[]
|
||||
*/
|
||||
public static function fromSequence(string $sequence): array
|
||||
{
|
||||
$strOptions = explode(',', $sequence);
|
||||
$options = array_map(fn ($val) => SortRuleOperation::tryFrom($val), $strOptions);
|
||||
return array_filter($options);
|
||||
}
|
||||
}
|
||||
72
app/Sorting/SortSetOperationComparisons.php
Normal file
72
app/Sorting/SortSetOperationComparisons.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Sorting;
|
||||
|
||||
use voku\helper\ASCII;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
|
||||
/**
|
||||
* Sort comparison function for each of the possible SortSetOperation values.
|
||||
* Method names should be camelCase names for the SortSetOperation enum value.
|
||||
*/
|
||||
class SortSetOperationComparisons
|
||||
{
|
||||
public static function nameAsc(Entity $a, Entity $b): int
|
||||
{
|
||||
return strtolower(ASCII::to_transliterate($a->name, null)) <=> strtolower(ASCII::to_transliterate($b->name, null));
|
||||
}
|
||||
|
||||
public static function nameDesc(Entity $a, Entity $b): int
|
||||
{
|
||||
return strtolower(ASCII::to_transliterate($b->name, null)) <=> strtolower(ASCII::to_transliterate($a->name, null));
|
||||
}
|
||||
|
||||
public static function nameNumericAsc(Entity $a, Entity $b): int
|
||||
{
|
||||
$numRegex = '/^\d+(\.\d+)?/';
|
||||
$aMatches = [];
|
||||
$bMatches = [];
|
||||
preg_match($numRegex, $a->name, $aMatches);
|
||||
preg_match($numRegex, $b->name, $bMatches);
|
||||
$aVal = floatval(($aMatches[0] ?? 0));
|
||||
$bVal = floatval(($bMatches[0] ?? 0));
|
||||
|
||||
return $aVal <=> $bVal;
|
||||
}
|
||||
|
||||
public static function nameNumericDesc(Entity $a, Entity $b): int
|
||||
{
|
||||
return -(static::nameNumericAsc($a, $b));
|
||||
}
|
||||
|
||||
public static function createdDateAsc(Entity $a, Entity $b): int
|
||||
{
|
||||
return $a->created_at->unix() <=> $b->created_at->unix();
|
||||
}
|
||||
|
||||
public static function createdDateDesc(Entity $a, Entity $b): int
|
||||
{
|
||||
return $b->created_at->unix() <=> $a->created_at->unix();
|
||||
}
|
||||
|
||||
public static function updatedDateAsc(Entity $a, Entity $b): int
|
||||
{
|
||||
return $a->updated_at->unix() <=> $b->updated_at->unix();
|
||||
}
|
||||
|
||||
public static function updatedDateDesc(Entity $a, Entity $b): int
|
||||
{
|
||||
return $b->updated_at->unix() <=> $a->updated_at->unix();
|
||||
}
|
||||
|
||||
public static function chaptersFirst(Entity $a, Entity $b): int
|
||||
{
|
||||
return ($b instanceof Chapter ? 1 : 0) - (($a instanceof Chapter) ? 1 : 0);
|
||||
}
|
||||
|
||||
public static function chaptersLast(Entity $a, Entity $b): int
|
||||
{
|
||||
return ($a instanceof Chapter ? 1 : 0) - (($b instanceof Chapter) ? 1 : 0);
|
||||
}
|
||||
}
|
||||
49
app/Sorting/SortUrl.php
Normal file
49
app/Sorting/SortUrl.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Sorting;
|
||||
|
||||
/**
|
||||
* Generate a URL with multiple parameters for sorting purposes.
|
||||
* Works out the logic to set the correct sorting direction
|
||||
* Discards empty parameters and allows overriding.
|
||||
*/
|
||||
class SortUrl
|
||||
{
|
||||
public function __construct(
|
||||
protected string $path,
|
||||
protected array $data,
|
||||
protected array $overrideData = []
|
||||
) {
|
||||
}
|
||||
|
||||
public function withOverrideData(array $overrideData = []): self
|
||||
{
|
||||
return new self($this->path, $this->data, $overrideData);
|
||||
}
|
||||
|
||||
public function build(): string
|
||||
{
|
||||
$queryStringSections = [];
|
||||
$queryData = array_merge($this->data, $this->overrideData);
|
||||
|
||||
// Change sorting direction if already sorted on current attribute
|
||||
if (isset($this->overrideData['sort']) && $this->overrideData['sort'] === $this->data['sort']) {
|
||||
$queryData['order'] = ($this->data['order'] === 'asc') ? 'desc' : 'asc';
|
||||
} elseif (isset($this->overrideData['sort'])) {
|
||||
$queryData['order'] = 'asc';
|
||||
}
|
||||
|
||||
foreach ($queryData as $name => $value) {
|
||||
$trimmedVal = trim($value);
|
||||
if ($trimmedVal !== '') {
|
||||
$queryStringSections[] = urlencode($name) . '=' . urlencode($trimmedVal);
|
||||
}
|
||||
}
|
||||
|
||||
if (count($queryStringSections) === 0) {
|
||||
return url($this->path);
|
||||
}
|
||||
|
||||
return url($this->path . '?' . implode('&', $queryStringSections));
|
||||
}
|
||||
}
|
||||
31
app/Theming/ThemeController.php
Normal file
31
app/Theming/ThemeController.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Theming;
|
||||
|
||||
use BookStack\Facades\Theme;
|
||||
use BookStack\Http\Controller;
|
||||
use BookStack\Util\FilePathNormalizer;
|
||||
|
||||
class ThemeController extends Controller
|
||||
{
|
||||
/**
|
||||
* Serve a public file from the configured theme.
|
||||
*/
|
||||
public function publicFile(string $theme, string $path)
|
||||
{
|
||||
$cleanPath = FilePathNormalizer::normalize($path);
|
||||
if ($theme !== Theme::getTheme() || !$cleanPath) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$filePath = theme_path("public/{$cleanPath}");
|
||||
if (!file_exists($filePath)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$response = $this->download()->streamedFileInline($filePath);
|
||||
$response->setMaxAge(86400);
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,15 @@ class ThemeService
|
||||
*/
|
||||
protected array $listeners = [];
|
||||
|
||||
/**
|
||||
* Get the currently configured theme.
|
||||
* Returns an empty string if not configured.
|
||||
*/
|
||||
public function getTheme(): string
|
||||
{
|
||||
return config('view.theme') ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen to a given custom theme event,
|
||||
* setting up the action to be ran when the event occurs.
|
||||
@@ -84,7 +93,7 @@ class ThemeService
|
||||
/**
|
||||
* @see SocialDriverManager::addSocialDriver
|
||||
*/
|
||||
public function addSocialDriver(string $driverName, array $config, string $socialiteHandler, callable $configureForRedirect = null): void
|
||||
public function addSocialDriver(string $driverName, array $config, string $socialiteHandler, ?callable $configureForRedirect = null): void
|
||||
{
|
||||
$driverManager = app()->make(SocialDriverManager::class);
|
||||
$driverManager->addSocialDriver($driverName, $config, $socialiteHandler, $configureForRedirect);
|
||||
|
||||
@@ -47,6 +47,7 @@ class LocaleManager
|
||||
'ja' => 'ja',
|
||||
'ka' => 'ka_GE',
|
||||
'ko' => 'ko_KR',
|
||||
'ku' => 'ku_TR',
|
||||
'lt' => 'lt_LT',
|
||||
'lv' => 'lv_LV',
|
||||
'nb' => 'nb_NO',
|
||||
|
||||
122
app/Uploads/DrawioPngReader.php
Normal file
122
app/Uploads/DrawioPngReader.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Uploads;
|
||||
|
||||
use BookStack\Exceptions\DrawioPngReaderException;
|
||||
|
||||
/**
|
||||
* Reads the PNG file format: https://www.w3.org/TR/2003/REC-PNG-20031110/
|
||||
* So that it can extract embedded drawing data for alternative use.
|
||||
*/
|
||||
class DrawioPngReader
|
||||
{
|
||||
/**
|
||||
* @param resource $fileStream
|
||||
*/
|
||||
public function __construct(
|
||||
protected $fileStream
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws DrawioPngReaderException
|
||||
*/
|
||||
public function extractDrawing(): string
|
||||
{
|
||||
$signature = fread($this->fileStream, 8);
|
||||
$pngSignature = "\x89\x50\x4E\x47\x0D\x0A\x1A\x0A";
|
||||
if ($signature !== $pngSignature) {
|
||||
throw new DrawioPngReaderException('File does not appear to be a valid PNG file');
|
||||
}
|
||||
|
||||
$offset = 8;
|
||||
$searching = true;
|
||||
|
||||
while ($searching) {
|
||||
fseek($this->fileStream, $offset);
|
||||
|
||||
$lengthBytes = $this->readData(4);
|
||||
$chunkTypeBytes = $this->readData(4);
|
||||
$length = unpack('Nvalue', $lengthBytes)['value'];
|
||||
|
||||
if ($chunkTypeBytes === 'tEXt') {
|
||||
fseek($this->fileStream, $offset + 8);
|
||||
$data = $this->readData($length);
|
||||
$crc = $this->readData(4);
|
||||
$drawingData = $this->readTextForDrawing($data);
|
||||
if ($drawingData !== null) {
|
||||
$crcResult = $this->calculateCrc($chunkTypeBytes . $data);
|
||||
if ($crc !== $crcResult) {
|
||||
throw new DrawioPngReaderException('Drawing data withing PNG file appears to be corrupted');
|
||||
}
|
||||
return $drawingData;
|
||||
}
|
||||
} else if ($chunkTypeBytes === 'IEND') {
|
||||
$searching = false;
|
||||
}
|
||||
|
||||
$offset += 12 + $length; // 12 = length + type + crc bytes
|
||||
}
|
||||
|
||||
throw new DrawioPngReaderException('Unable to find drawing data within PNG file');
|
||||
}
|
||||
|
||||
protected function readTextForDrawing(string $data): ?string
|
||||
{
|
||||
// Check the keyword is mxfile to ensure we're getting the right data
|
||||
if (!str_starts_with($data, "mxfile\u{0}")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract & cleanup the drawing text
|
||||
$drawingText = substr($data, 7);
|
||||
return urldecode($drawingText);
|
||||
}
|
||||
|
||||
protected function readData(int $length): string
|
||||
{
|
||||
$bytes = fread($this->fileStream, $length);
|
||||
if ($bytes === false || strlen($bytes) < $length) {
|
||||
throw new DrawioPngReaderException('Unable to find drawing data within PNG file');
|
||||
}
|
||||
return $bytes;
|
||||
}
|
||||
|
||||
protected function getCrcTable(): array
|
||||
{
|
||||
$table = [];
|
||||
|
||||
for ($n = 0; $n < 256; $n++) {
|
||||
$c = $n;
|
||||
for ($k = 0; $k < 8; $k++) {
|
||||
if ($c & 1) {
|
||||
$c = 0xedb88320 ^ ($c >> 1);
|
||||
} else {
|
||||
$c = $c >> 1;
|
||||
}
|
||||
}
|
||||
$table[$n] = $c;
|
||||
}
|
||||
|
||||
return $table;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate a CRC for the given bytes following:
|
||||
* https://www.w3.org/TR/2003/REC-PNG-20031110/#D-CRCAppendix
|
||||
*/
|
||||
protected function calculateCrc(string $bytes): string
|
||||
{
|
||||
$table = $this->getCrcTable();
|
||||
|
||||
$length = strlen($bytes);
|
||||
$c = 0xffffffff;
|
||||
|
||||
for ($n = 0; $n < $length; $n++) {
|
||||
$tableIndex = ($c ^ ord($bytes[$n])) & 0xff;
|
||||
$c = $table[$tableIndex] ^ ($c >> 8);
|
||||
}
|
||||
|
||||
return pack('N', $c ^ 0xffffffff);
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,12 @@
|
||||
namespace BookStack\Uploads;
|
||||
|
||||
use BookStack\Exceptions\FileUploadException;
|
||||
use BookStack\Util\FilePathNormalizer;
|
||||
use Exception;
|
||||
use Illuminate\Contracts\Filesystem\Filesystem as Storage;
|
||||
use Illuminate\Filesystem\FilesystemAdapter;
|
||||
use Illuminate\Filesystem\FilesystemManager;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use League\Flysystem\WhitespacePathNormalizer;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
||||
class FileStorage
|
||||
@@ -121,12 +120,13 @@ class FileStorage
|
||||
*/
|
||||
protected function adjustPathForStorageDisk(string $path): string
|
||||
{
|
||||
$path = (new WhitespacePathNormalizer())->normalizePath(str_replace('uploads/files/', '', $path));
|
||||
$trimmed = str_replace('uploads/files/', '', $path);
|
||||
$normalized = FilePathNormalizer::normalize($trimmed);
|
||||
|
||||
if ($this->getStorageDiskName() === 'local_secure_attachments') {
|
||||
return $path;
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
return 'uploads/files/' . $path;
|
||||
return 'uploads/files/' . $normalized;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,9 +49,9 @@ class ImageRepo
|
||||
string $type,
|
||||
int $page = 0,
|
||||
int $pageSize = 24,
|
||||
int $uploadedTo = null,
|
||||
string $search = null,
|
||||
callable $whereClause = null
|
||||
?int $uploadedTo = null,
|
||||
?string $search = null,
|
||||
?callable $whereClause = null
|
||||
): array {
|
||||
$imageQuery = Image::query()->where('type', '=', strtolower($type));
|
||||
|
||||
@@ -91,7 +91,7 @@ class ImageRepo
|
||||
$parentFilter = function (Builder $query) use ($filterType, $contextPage) {
|
||||
if ($filterType === 'page') {
|
||||
$query->where('uploaded_to', '=', $contextPage->id);
|
||||
} elseif ($filterType === 'book') {
|
||||
} else if ($filterType === 'book') {
|
||||
$validPageIds = $contextPage->book->pages()
|
||||
->scopes('visible')
|
||||
->pluck('id')
|
||||
@@ -109,8 +109,14 @@ class ImageRepo
|
||||
*
|
||||
* @throws ImageUploadException
|
||||
*/
|
||||
public function saveNew(UploadedFile $uploadFile, string $type, int $uploadedTo = 0, int $resizeWidth = null, int $resizeHeight = null, bool $keepRatio = true): Image
|
||||
{
|
||||
public function saveNew(
|
||||
UploadedFile $uploadFile,
|
||||
string $type,
|
||||
int $uploadedTo = 0,
|
||||
?int $resizeWidth = null,
|
||||
?int $resizeHeight = null,
|
||||
bool $keepRatio = true
|
||||
): Image {
|
||||
$image = $this->imageService->saveNewFromUpload($uploadFile, $type, $uploadedTo, $resizeWidth, $resizeHeight, $keepRatio);
|
||||
|
||||
if ($type !== 'system') {
|
||||
@@ -184,7 +190,7 @@ class ImageRepo
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function destroyImage(Image $image = null): void
|
||||
public function destroyImage(?Image $image = null): void
|
||||
{
|
||||
if ($image) {
|
||||
$this->imageService->destroy($image);
|
||||
|
||||
@@ -6,6 +6,7 @@ use BookStack\Exceptions\ImageUploadException;
|
||||
use Exception;
|
||||
use GuzzleHttp\Psr7\Utils;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Intervention\Image\Decoders\BinaryImageDecoder;
|
||||
use Intervention\Image\Drivers\Gd\Decoders\NativeObjectDecoder;
|
||||
use Intervention\Image\Drivers\Gd\Driver;
|
||||
@@ -93,8 +94,8 @@ class ImageResizer
|
||||
|
||||
$imageData = $disk->get($imagePath);
|
||||
|
||||
// Do not resize apng images where we're not cropping
|
||||
if ($keepRatio && $this->isApngData($image, $imageData)) {
|
||||
// Do not resize animated images where we're not cropping
|
||||
if ($keepRatio && $this->isAnimated($image, $imageData)) {
|
||||
Cache::put($thumbCacheKey, $image->path, static::THUMBNAIL_CACHE_TIME);
|
||||
|
||||
return $this->storage->getPublicUrl($image->path);
|
||||
@@ -158,7 +159,10 @@ class ImageResizer
|
||||
*/
|
||||
protected function interventionFromImageData(string $imageData, ?string $fileType): InterventionImage
|
||||
{
|
||||
$manager = new ImageManager(new Driver());
|
||||
$manager = new ImageManager(
|
||||
new Driver(),
|
||||
autoOrientation: false,
|
||||
);
|
||||
|
||||
// Ensure gif images are decoded natively instead of deferring to intervention GIF
|
||||
// handling since we don't need the added animation support.
|
||||
@@ -237,15 +241,50 @@ class ImageResizer
|
||||
/**
|
||||
* Check if the given image and image data is apng.
|
||||
*/
|
||||
protected function isApngData(Image $image, string &$imageData): bool
|
||||
protected function isApngData(string &$imageData): bool
|
||||
{
|
||||
$isPng = strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'png';
|
||||
if (!$isPng) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$initialHeader = substr($imageData, 0, strpos($imageData, 'IDAT'));
|
||||
|
||||
return str_contains($initialHeader, 'acTL');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given avif image data represents an animated image.
|
||||
* This is based up the answer here: https://stackoverflow.com/a/79457313
|
||||
*/
|
||||
protected function isAnimatedAvifData(string &$imageData): bool
|
||||
{
|
||||
$stszPos = strpos($imageData, 'stsz');
|
||||
if ($stszPos === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Look 12 bytes after the start of 'stsz'
|
||||
$start = $stszPos + 12;
|
||||
$end = $start + 4;
|
||||
if ($end > strlen($imageData) - 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$data = substr($imageData, $start, 4);
|
||||
$count = unpack('Nvalue', $data)['value'];
|
||||
return $count > 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given image is animated.
|
||||
*/
|
||||
protected function isAnimated(Image $image, string &$imageData): bool
|
||||
{
|
||||
$extension = strtolower(pathinfo($image->path, PATHINFO_EXTENSION));
|
||||
if ($extension === 'png') {
|
||||
return $this->isApngData($imageData);
|
||||
}
|
||||
|
||||
if ($extension === 'avif') {
|
||||
return $this->isAnimatedAvifData($imageData);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class ImageService
|
||||
{
|
||||
protected static array $supportedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
|
||||
protected static array $supportedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif'];
|
||||
|
||||
public function __construct(
|
||||
protected ImageStorage $storage,
|
||||
@@ -31,8 +31,8 @@ class ImageService
|
||||
UploadedFile $uploadedFile,
|
||||
string $type,
|
||||
int $uploadedTo = 0,
|
||||
int $resizeWidth = null,
|
||||
int $resizeHeight = null,
|
||||
?int $resizeWidth = null,
|
||||
?int $resizeHeight = null,
|
||||
bool $keepRatio = true,
|
||||
string $imageName = '',
|
||||
): Image {
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
|
||||
namespace BookStack\Uploads;
|
||||
|
||||
use BookStack\Util\FilePathNormalizer;
|
||||
use Illuminate\Contracts\Filesystem\Filesystem;
|
||||
use Illuminate\Filesystem\FilesystemAdapter;
|
||||
use League\Flysystem\WhitespacePathNormalizer;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use League\Flysystem\UnableToSetVisibility;
|
||||
use League\Flysystem\Visibility;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class ImageStorageDisk
|
||||
@@ -30,13 +33,14 @@ class ImageStorageDisk
|
||||
*/
|
||||
protected function adjustPathForDisk(string $path): string
|
||||
{
|
||||
$path = (new WhitespacePathNormalizer())->normalizePath(str_replace('uploads/images/', '', $path));
|
||||
$trimmed = str_replace('uploads/images/', '', $path);
|
||||
$normalized = FilePathNormalizer::normalize($trimmed);
|
||||
|
||||
if ($this->usingSecureImages()) {
|
||||
return $path;
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
return 'uploads/images/' . $path;
|
||||
return 'uploads/images/' . $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,12 +77,19 @@ class ImageStorageDisk
|
||||
$path = $this->adjustPathForDisk($path);
|
||||
$this->filesystem->put($path, $data);
|
||||
|
||||
// Set visibility when a non-AWS-s3, s3-like storage option is in use.
|
||||
// Done since this call can break s3-like services but desired for other image stores.
|
||||
// Attempting to set ACL during above put request requires different permissions
|
||||
// hence would technically be a breaking change for actual s3 usage.
|
||||
// Set public visibility to ensure public access on S3, or that the file is accessible
|
||||
// to other processes (like web-servers) for local file storage options.
|
||||
// We avoid attempting this for (non-AWS) s3-like systems (even in a try-catch) as
|
||||
// we've always avoided setting permissions for s3-like due to potential issues,
|
||||
// with docs advising setting pre-configured permissions instead.
|
||||
// We also don't do this as the default filesystem/driver level as that can technically
|
||||
// require different ACLs for S3, and this provides us more logical control.
|
||||
if ($makePublic && !$this->isS3Like()) {
|
||||
$this->filesystem->setVisibility($path, 'public');
|
||||
try {
|
||||
$this->filesystem->setVisibility($path, Visibility::PUBLIC);
|
||||
} catch (UnableToSetVisibility $e) {
|
||||
Log::warning("Unable to set visibility for image upload with relative path: {$path}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace BookStack\Uploads;
|
||||
use BookStack\Exceptions\HttpFetchException;
|
||||
use BookStack\Http\HttpRequestService;
|
||||
use BookStack\Users\Models\User;
|
||||
use BookStack\Util\WebSafeMimeSniffer;
|
||||
use Exception;
|
||||
use GuzzleHttp\Psr7\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -53,6 +54,33 @@ class UserAvatars
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign a new avatar image to the given user by fetching from a remote URL.
|
||||
*/
|
||||
public function assignToUserFromUrl(User $user, string $avatarUrl): void
|
||||
{
|
||||
try {
|
||||
$this->destroyAllForUser($user);
|
||||
$imageData = $this->getAvatarImageData($avatarUrl);
|
||||
|
||||
$mime = (new WebSafeMimeSniffer())->sniff($imageData);
|
||||
[$format, $type] = explode('/', $mime, 2);
|
||||
if ($format !== 'image' || !ImageService::isExtensionSupported($type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$avatar = $this->createAvatarImageFromData($user, $imageData, $type);
|
||||
$user->avatar()->associate($avatar);
|
||||
$user->save();
|
||||
} catch (Exception $e) {
|
||||
Log::error('Failed to save user avatar image from URL', [
|
||||
'exception' => $e->getMessage(),
|
||||
'url' => $avatarUrl,
|
||||
'user_id' => $user->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy all user avatars uploaded to the given user.
|
||||
*/
|
||||
@@ -105,7 +133,7 @@ class UserAvatars
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an image from url and returns it as a string of image data.
|
||||
* Get an image from a URL and return it as a string of image data.
|
||||
*
|
||||
* @throws HttpFetchException
|
||||
*/
|
||||
@@ -113,7 +141,19 @@ class UserAvatars
|
||||
{
|
||||
try {
|
||||
$client = $this->http->buildClient(5);
|
||||
$response = $client->sendRequest(new Request('GET', $url));
|
||||
$responseCount = 0;
|
||||
|
||||
do {
|
||||
$response = $client->sendRequest(new Request('GET', $url));
|
||||
$responseCount++;
|
||||
$isRedirect = ($response->getStatusCode() === 301 || $response->getStatusCode() === 302);
|
||||
$url = $response->getHeader('Location')[0] ?? '';
|
||||
} while ($responseCount < 3 && $isRedirect && is_string($url) && str_starts_with($url, 'http'));
|
||||
|
||||
if ($responseCount === 3) {
|
||||
throw new HttpFetchException("Failed to fetch image, max redirect limit of 3 tries reached. Last fetched URL: {$url}");
|
||||
}
|
||||
|
||||
if ($response->getStatusCode() !== 200) {
|
||||
throw new HttpFetchException(trans('errors.cannot_get_image_from_url', ['url' => $url]));
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ class UserApiController extends ApiController
|
||||
});
|
||||
}
|
||||
|
||||
protected function rules(int $userId = null): array
|
||||
protected function rules(?int $userId = null): array
|
||||
{
|
||||
return [
|
||||
'create' => [
|
||||
@@ -54,7 +54,7 @@ class UserApiController extends ApiController
|
||||
'string',
|
||||
'email',
|
||||
'min:2',
|
||||
(new Unique('users', 'email'))->ignore($userId ?? null),
|
||||
(new Unique('users', 'email'))->ignore($userId),
|
||||
],
|
||||
'external_auth_id' => ['string'],
|
||||
'language' => ['string', 'max:15', 'alpha_dash'],
|
||||
|
||||
@@ -45,6 +45,7 @@ use Illuminate\Support\Collection;
|
||||
* @property string $system_name
|
||||
* @property Collection $roles
|
||||
* @property Collection $mfaValues
|
||||
* @property ?Image $avatar
|
||||
*/
|
||||
class User extends Model implements AuthenticatableContract, CanResetPasswordContract, Loggable, Sluggable
|
||||
{
|
||||
|
||||
17
app/Util/FilePathNormalizer.php
Normal file
17
app/Util/FilePathNormalizer.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Util;
|
||||
|
||||
use League\Flysystem\WhitespacePathNormalizer;
|
||||
|
||||
/**
|
||||
* Utility to normalize (potentially) user provided file paths
|
||||
* to avoid things like directory traversal.
|
||||
*/
|
||||
class FilePathNormalizer
|
||||
{
|
||||
public static function normalize(string $path): string
|
||||
{
|
||||
return (new WhitespacePathNormalizer())->normalizePath($path);
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,16 @@ namespace BookStack\Util;
|
||||
|
||||
use BookStack\Exceptions\HttpFetchException;
|
||||
|
||||
/**
|
||||
* Validate the host we're connecting to when making a server-side-request.
|
||||
* Will use the given hosts config if given during construction otherwise
|
||||
* will look to the app configured config.
|
||||
*/
|
||||
class SsrUrlValidator
|
||||
{
|
||||
protected string $config;
|
||||
|
||||
public function __construct(string $config = null)
|
||||
public function __construct(?string $config = null)
|
||||
{
|
||||
$this->config = $config ?? config('app.ssr_hosts') ?? '';
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ class WebSafeMimeSniffer
|
||||
/**
|
||||
* @var string[]
|
||||
*/
|
||||
protected $safeMimes = [
|
||||
protected array $safeMimes = [
|
||||
'application/json',
|
||||
'application/octet-stream',
|
||||
'application/pdf',
|
||||
@@ -48,16 +48,28 @@ class WebSafeMimeSniffer
|
||||
'video/av1',
|
||||
];
|
||||
|
||||
protected array $textTypesByExtension = [
|
||||
'css' => 'text/css',
|
||||
'js' => 'text/javascript',
|
||||
'json' => 'application/json',
|
||||
'csv' => 'text/csv',
|
||||
];
|
||||
|
||||
/**
|
||||
* Sniff the mime-type from the given file content while running the result
|
||||
* through an allow-list to ensure a web-safe result.
|
||||
* Takes the content as a reference since the value may be quite large.
|
||||
* Accepts an optional $extension which can be used for further guessing.
|
||||
*/
|
||||
public function sniff(string &$content): string
|
||||
public function sniff(string &$content, string $extension = ''): string
|
||||
{
|
||||
$fInfo = new finfo(FILEINFO_MIME_TYPE);
|
||||
$mime = $fInfo->buffer($content) ?: 'application/octet-stream';
|
||||
|
||||
if ($mime === 'text/plain' && $extension) {
|
||||
$mime = $this->textTypesByExtension[$extension] ?? 'text/plain';
|
||||
}
|
||||
|
||||
if (in_array($mime, $this->safeMimes)) {
|
||||
return $mime;
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -8,7 +8,7 @@
|
||||
"license": "MIT",
|
||||
"type": "project",
|
||||
"require": {
|
||||
"php": "^8.1.0",
|
||||
"php": "^8.2.0",
|
||||
"ext-curl": "*",
|
||||
"ext-dom": "*",
|
||||
"ext-fileinfo": "*",
|
||||
@@ -18,12 +18,11 @@
|
||||
"ext-xml": "*",
|
||||
"ext-zip": "*",
|
||||
"bacon/bacon-qr-code": "^3.0",
|
||||
"doctrine/dbal": "^3.5",
|
||||
"dompdf/dompdf": "^3.0",
|
||||
"dompdf/dompdf": "^3.1",
|
||||
"guzzlehttp/guzzle": "^7.4",
|
||||
"intervention/image": "^3.5",
|
||||
"knplabs/knp-snappy": "^1.5",
|
||||
"laravel/framework": "^10.48.23",
|
||||
"laravel/framework": "^v11.37",
|
||||
"laravel/socialite": "^5.10",
|
||||
"laravel/tinker": "^2.8",
|
||||
"league/commonmark": "^2.3",
|
||||
@@ -40,17 +39,17 @@
|
||||
"socialiteproviders/okta": "^4.2",
|
||||
"socialiteproviders/twitch": "^5.3",
|
||||
"ssddanbrown/htmldiff": "^1.0.2",
|
||||
"ssddanbrown/symfony-mailer": "6.4.x-dev"
|
||||
"ssddanbrown/symfony-mailer": "7.2.x-dev"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.21",
|
||||
"itsgoingd/clockwork": "^5.1",
|
||||
"mockery/mockery": "^1.5",
|
||||
"nunomaduro/collision": "^7.0",
|
||||
"larastan/larastan": "^2.7",
|
||||
"phpunit/phpunit": "^10.0",
|
||||
"nunomaduro/collision": "^8.1",
|
||||
"larastan/larastan": "^v3.0",
|
||||
"phpunit/phpunit": "^11.5",
|
||||
"squizlabs/php_codesniffer": "^3.7",
|
||||
"ssddanbrown/asserthtml": "^3.0"
|
||||
"ssddanbrown/asserthtml": "^3.1"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
@@ -104,7 +103,7 @@
|
||||
"preferred-install": "dist",
|
||||
"sort-packages": true,
|
||||
"platform": {
|
||||
"php": "8.1.0"
|
||||
"php": "8.2.0"
|
||||
}
|
||||
},
|
||||
"extra": {
|
||||
|
||||
2934
composer.lock
generated
2934
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,8 @@ class CommentFactory extends Factory
|
||||
'html' => $html,
|
||||
'parent_id' => null,
|
||||
'local_id' => 1,
|
||||
'content_ref' => '',
|
||||
'archived' => false,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,9 @@ class BookFactory extends Factory
|
||||
'name' => $this->faker->sentence(),
|
||||
'slug' => Str::random(10),
|
||||
'description' => $description,
|
||||
'description_html' => '<p>' . e($description) . '</p>'
|
||||
'description_html' => '<p>' . e($description) . '</p>',
|
||||
'sort_rule_id' => null,
|
||||
'default_template_id' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
30
database/factories/Sorting/SortRuleFactory.php
Normal file
30
database/factories/Sorting/SortRuleFactory.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories\Sorting;
|
||||
|
||||
use BookStack\Sorting\SortRule;
|
||||
use BookStack\Sorting\SortRuleOperation;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
class SortRuleFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* The name of the factory's corresponding model.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $model = SortRule::class;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
$cases = SortRuleOperation::cases();
|
||||
$op = $cases[array_rand($cases)];
|
||||
return [
|
||||
'name' => $op->name . ' Sort',
|
||||
'sequence' => $op->value,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -26,25 +26,19 @@ return new class extends Migration
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
$sm = Schema::getConnection()->getDoctrineSchemaManager();
|
||||
$prefix = DB::getTablePrefix();
|
||||
$pages = $sm->introspectTable($prefix . 'pages');
|
||||
$books = $sm->introspectTable($prefix . 'books');
|
||||
$chapters = $sm->introspectTable($prefix . 'chapters');
|
||||
|
||||
if ($pages->hasIndex('search')) {
|
||||
if (Schema::hasIndex('pages', 'search')) {
|
||||
Schema::table('pages', function (Blueprint $table) {
|
||||
$table->dropIndex('search');
|
||||
});
|
||||
}
|
||||
|
||||
if ($books->hasIndex('search')) {
|
||||
if (Schema::hasIndex('books', 'search')) {
|
||||
Schema::table('books', function (Blueprint $table) {
|
||||
$table->dropIndex('search');
|
||||
});
|
||||
}
|
||||
|
||||
if ($chapters->hasIndex('search')) {
|
||||
if (Schema::hasIndex('chapters', 'search')) {
|
||||
Schema::table('chapters', function (Blueprint $table) {
|
||||
$table->dropIndex('search');
|
||||
});
|
||||
|
||||
@@ -26,25 +26,19 @@ return new class extends Migration
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
$sm = Schema::getConnection()->getDoctrineSchemaManager();
|
||||
$prefix = DB::getTablePrefix();
|
||||
$pages = $sm->introspectTable($prefix . 'pages');
|
||||
$books = $sm->introspectTable($prefix . 'books');
|
||||
$chapters = $sm->introspectTable($prefix . 'chapters');
|
||||
|
||||
if ($pages->hasIndex('name_search')) {
|
||||
if (Schema::hasIndex('pages', 'name_search')) {
|
||||
Schema::table('pages', function (Blueprint $table) {
|
||||
$table->dropIndex('name_search');
|
||||
});
|
||||
}
|
||||
|
||||
if ($books->hasIndex('name_search')) {
|
||||
if (Schema::hasIndex('books', 'name_search')) {
|
||||
Schema::table('books', function (Blueprint $table) {
|
||||
$table->dropIndex('name_search');
|
||||
});
|
||||
}
|
||||
|
||||
if ($chapters->hasIndex('name_search')) {
|
||||
if (Schema::hasIndex('chapters', 'name_search')) {
|
||||
Schema::table('chapters', function (Blueprint $table) {
|
||||
$table->dropIndex('name_search');
|
||||
});
|
||||
|
||||
@@ -25,27 +25,21 @@ return new class extends Migration
|
||||
$table->index('score');
|
||||
});
|
||||
|
||||
$sm = Schema::getConnection()->getDoctrineSchemaManager();
|
||||
$prefix = DB::getTablePrefix();
|
||||
$pages = $sm->introspectTable($prefix . 'pages');
|
||||
$books = $sm->introspectTable($prefix . 'books');
|
||||
$chapters = $sm->introspectTable($prefix . 'chapters');
|
||||
|
||||
if ($pages->hasIndex('search')) {
|
||||
if (Schema::hasIndex('pages', 'search')) {
|
||||
Schema::table('pages', function (Blueprint $table) {
|
||||
$table->dropIndex('search');
|
||||
$table->dropIndex('name_search');
|
||||
});
|
||||
}
|
||||
|
||||
if ($books->hasIndex('search')) {
|
||||
if (Schema::hasIndex('books', 'search')) {
|
||||
Schema::table('books', function (Blueprint $table) {
|
||||
$table->dropIndex('search');
|
||||
$table->dropIndex('name_search');
|
||||
});
|
||||
}
|
||||
|
||||
if ($chapters->hasIndex('search')) {
|
||||
if (Schema::hasIndex('chapters', 'search')) {
|
||||
Schema::table('chapters', function (Blueprint $table) {
|
||||
$table->dropIndex('search');
|
||||
$table->dropIndex('name_search');
|
||||
|
||||
@@ -8,7 +8,7 @@ return new class extends Migration
|
||||
/**
|
||||
* Mapping of old polymorphic types to new simpler values.
|
||||
*/
|
||||
protected $changeMap = [
|
||||
protected array $changeMap = [
|
||||
'BookStack\\Bookshelf' => 'bookshelf',
|
||||
'BookStack\\Book' => 'book',
|
||||
'BookStack\\Chapter' => 'chapter',
|
||||
@@ -18,7 +18,7 @@ return new class extends Migration
|
||||
/**
|
||||
* Mapping of tables and columns that contain polymorphic types.
|
||||
*/
|
||||
protected $columnsByTable = [
|
||||
protected array $columnsByTable = [
|
||||
'activities' => 'entity_type',
|
||||
'comments' => 'entity_type',
|
||||
'deletions' => 'deletable_type',
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('sort_rules', function (Blueprint $table) {
|
||||
$table->increments('id');
|
||||
$table->string('name');
|
||||
$table->text('sequence');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('sort_rules');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('books', function (Blueprint $table) {
|
||||
$table->unsignedInteger('sort_rule_id')->nullable()->default(null);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('books', function (Blueprint $table) {
|
||||
$table->dropColumn('sort_rule_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('comments', function (Blueprint $table) {
|
||||
$table->string('content_ref');
|
||||
$table->boolean('archived')->index();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('comments', function (Blueprint $table) {
|
||||
$table->dropColumn('content_ref');
|
||||
$table->dropColumn('archived');
|
||||
});
|
||||
}
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user