Merge branch 'master' into release

This commit is contained in:
Dan Brown 2021-11-16 13:21:44 +00:00
commit 1755556468
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
378 changed files with 6959 additions and 2917 deletions

View File

@ -293,6 +293,10 @@ REVISION_LIMIT=50
# Set to -1 for unlimited recycle bin lifetime. # Set to -1 for unlimited recycle bin lifetime.
RECYCLE_BIN_LIFETIME=30 RECYCLE_BIN_LIFETIME=30
# File Upload Limit
# Maximum file size, in megabytes, that can be uploaded to the system.
FILE_UPLOAD_SIZE_LIMIT=50
# Allow <script> tags in page content # Allow <script> tags in page content
# Note, if set to 'true' the page editor may still escape scripts. # Note, if set to 'true' the page editor may still escape scripts.
ALLOW_CONTENT_SCRIPTS=false ALLOW_CONTENT_SCRIPTS=false

View File

@ -196,3 +196,6 @@ Indrek Haav (IndrekHaav) :: Estonian
na3shkw :: Japanese na3shkw :: Japanese
Giancarlo Di Massa (digitall-it) :: Italian Giancarlo Di Massa (digitall-it) :: Italian
M Nafis Al Mukhdi (mnafisalmukhdi1) :: Indonesian M Nafis Al Mukhdi (mnafisalmukhdi1) :: Indonesian
sulfo :: Danish
Raukze :: German
zygimantus :: Lithuanian

41
.github/workflows/phpstan.yml vendored Normal file
View File

@ -0,0 +1,41 @@
name: phpstan
on:
push:
branches-ignore:
- l10n_master
pull_request:
branches-ignore:
- l10n_master
jobs:
build:
runs-on: ubuntu-20.04
strategy:
matrix:
php: ['7.3']
steps:
- uses: actions/checkout@v1
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: gd, mbstring, json, curl, xml, mysql, ldap
- name: Get Composer Cache Directory
id: composer-cache
run: |
echo "::set-output name=dir::$(composer config cache-files-dir)"
- name: Cache composer packages
uses: actions/cache@v1
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ matrix.php }}
- name: Install composer dependencies
run: composer install --prefer-dist --no-interaction --ansi
- name: Run PHPStan
run: php${{ matrix.php }} ./vendor/bin/phpstan analyse --memory-limit=2G

View File

@ -13,12 +13,12 @@ jobs:
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
strategy: strategy:
matrix: matrix:
php: ['7.3', '7.4', '8.0'] php: ['7.3', '7.4', '8.0', '8.1']
steps: steps:
- uses: actions/checkout@v1 - uses: actions/checkout@v1
- name: Setup PHP - name: Setup PHP
uses: shivammathur/setup-php@b7d1d9c9a92d8d8463ce36d7f60da34d461724f8 uses: shivammathur/setup-php@v2
with: with:
php-version: ${{ matrix.php }} php-version: ${{ matrix.php }}
extensions: gd, mbstring, json, curl, xml, mysql, ldap extensions: gd, mbstring, json, curl, xml, mysql, ldap
@ -45,7 +45,7 @@ jobs:
mysql -uroot -proot -e "GRANT ALL ON \`bookstack-test\`.* TO 'bookstack-test'@'localhost';" mysql -uroot -proot -e "GRANT ALL ON \`bookstack-test\`.* TO 'bookstack-test'@'localhost';"
mysql -uroot -proot -e 'FLUSH PRIVILEGES;' mysql -uroot -proot -e 'FLUSH PRIVILEGES;'
- name: Install composer dependencies & Test - name: Install composer dependencies
run: composer install --prefer-dist --no-interaction --ansi run: composer install --prefer-dist --no-interaction --ansi
- name: Migrate and seed the database - name: Migrate and seed the database

View File

@ -13,12 +13,12 @@ jobs:
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
strategy: strategy:
matrix: matrix:
php: ['7.3', '7.4', '8.0'] php: ['7.3', '7.4', '8.0', '8.1']
steps: steps:
- uses: actions/checkout@v1 - uses: actions/checkout@v1
- name: Setup PHP - name: Setup PHP
uses: shivammathur/setup-php@b7d1d9c9a92d8d8463ce36d7f60da34d461724f8 uses: shivammathur/setup-php@v2
with: with:
php-version: ${{ matrix.php }} php-version: ${{ matrix.php }}
extensions: gd, mbstring, json, curl, xml, mysql, ldap extensions: gd, mbstring, json, curl, xml, mysql, ldap

3
.gitignore vendored
View File

@ -23,4 +23,5 @@ nbproject
.settings/ .settings/
webpack-stats.json webpack-stats.json
.phpunit.result.cache .phpunit.result.cache
.DS_Store .DS_Store
phpstan.neon

View File

@ -61,7 +61,7 @@ class Activity extends Model
/** /**
* Checks if another Activity matches the general information of another. * Checks if another Activity matches the general information of another.
*/ */
public function isSimilarTo(Activity $activityB): bool public function isSimilarTo(self $activityB): bool
{ {
return [$this->type, $this->entity_type, $this->entity_id] === [$activityB->type, $activityB->entity_type, $activityB->entity_id]; return [$this->type, $this->entity_type, $this->entity_id] === [$activityB->type, $activityB->entity_type, $activityB->entity_id];
} }

View File

@ -4,6 +4,7 @@ namespace BookStack\Actions;
use BookStack\Model; use BookStack\Model;
use BookStack\Traits\HasCreatorAndUpdater; use BookStack\Traits\HasCreatorAndUpdater;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\MorphTo;
/** /**
@ -15,6 +16,7 @@ use Illuminate\Database\Eloquent\Relations\MorphTo;
*/ */
class Comment extends Model class Comment extends Model
{ {
use HasFactory;
use HasCreatorAndUpdater; use HasCreatorAndUpdater;
protected $fillable = ['text', 'parent_id']; protected $fillable = ['text', 'parent_id'];

View File

@ -90,8 +90,9 @@ class CommentRepo
*/ */
protected function getNextLocalId(Entity $entity): int protected function getNextLocalId(Entity $entity): int
{ {
$comments = $entity->comments(false)->orderBy('local_id', 'desc')->first(); /** @var Comment $comment */
$comment = $entity->comments(false)->orderBy('local_id', 'desc')->first();
return ($comments->local_id ?? 0) + 1; return ($comment->local_id ?? 0) + 1;
} }
} }

View File

@ -3,10 +3,19 @@
namespace BookStack\Actions; namespace BookStack\Actions;
use BookStack\Model; use BookStack\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\MorphTo;
/**
* @property int $id
* @property string $name
* @property string $value
* @property int $order
*/
class Tag extends Model class Tag extends Model
{ {
use HasFactory;
protected $fillable = ['name', 'value', 'order']; protected $fillable = ['name', 'value', 'order'];
protected $hidden = ['id', 'entity_id', 'entity_type', 'created_at', 'updated_at']; protected $hidden = ['id', 'entity_id', 'entity_type', 'created_at', 'updated_at'];

View File

@ -4,6 +4,7 @@ namespace BookStack\Actions;
use BookStack\Auth\Permissions\PermissionService; use BookStack\Auth\Permissions\PermissionService;
use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Entity;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@ -12,22 +13,54 @@ class TagRepo
protected $tag; protected $tag;
protected $permissionService; protected $permissionService;
/** public function __construct(PermissionService $ps)
* TagRepo constructor.
*/
public function __construct(Tag $tag, PermissionService $ps)
{ {
$this->tag = $tag;
$this->permissionService = $ps; $this->permissionService = $ps;
} }
/**
* Start a query against all tags in the system.
*/
public function queryWithTotals(string $searchTerm, string $nameFilter): Builder
{
$query = Tag::query()
->select([
'name',
($searchTerm || $nameFilter) ? 'value' : DB::raw('COUNT(distinct value) as `values`'),
DB::raw('COUNT(id) as usages'),
DB::raw('SUM(IF(entity_type = \'BookStack\\\\Page\', 1, 0)) as page_count'),
DB::raw('SUM(IF(entity_type = \'BookStack\\\\Chapter\', 1, 0)) as chapter_count'),
DB::raw('SUM(IF(entity_type = \'BookStack\\\\Book\', 1, 0)) as book_count'),
DB::raw('SUM(IF(entity_type = \'BookStack\\\\BookShelf\', 1, 0)) as shelf_count'),
])
->orderBy($nameFilter ? 'value' : 'name');
if ($nameFilter) {
$query->where('name', '=', $nameFilter);
$query->groupBy('value');
} elseif ($searchTerm) {
$query->groupBy('name', 'value');
} else {
$query->groupBy('name');
}
if ($searchTerm) {
$query->where(function (Builder $query) use ($searchTerm) {
$query->where('name', 'like', '%' . $searchTerm . '%')
->orWhere('value', 'like', '%' . $searchTerm . '%');
});
}
return $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type');
}
/** /**
* Get tag name suggestions from scanning existing tag names. * Get tag name suggestions from scanning existing tag names.
* If no search term is given the 50 most popular tag names are provided. * If no search term is given the 50 most popular tag names are provided.
*/ */
public function getNameSuggestions(?string $searchTerm): Collection public function getNameSuggestions(?string $searchTerm): Collection
{ {
$query = $this->tag->newQuery() $query = Tag::query()
->select('*', DB::raw('count(*) as count')) ->select('*', DB::raw('count(*) as count'))
->groupBy('name'); ->groupBy('name');
@ -49,7 +82,7 @@ class TagRepo
*/ */
public function getValueSuggestions(?string $searchTerm, ?string $tagName): Collection public function getValueSuggestions(?string $searchTerm, ?string $tagName): Collection
{ {
$query = $this->tag->newQuery() $query = Tag::query()
->select('*', DB::raw('count(*) as count')) ->select('*', DB::raw('count(*) as count'))
->groupBy('value'); ->groupBy('value');
@ -90,9 +123,9 @@ class TagRepo
*/ */
protected function newInstanceFromInput(array $input): Tag protected function newInstanceFromInput(array $input): Tag
{ {
$name = trim($input['name']); return new Tag([
$value = isset($input['value']) ? trim($input['value']) : ''; 'name' => trim($input['name']),
'value' => trim($input['value'] ?? ''),
return $this->tag->newInstance(['name' => $name, 'value' => $value]); ]);
} }
} }

View File

@ -28,7 +28,7 @@ class ApiDocsGenerator
if (Cache::has($cacheKey) && config('app.env') === 'production') { if (Cache::has($cacheKey) && config('app.env') === 'production') {
$docs = Cache::get($cacheKey); $docs = Cache::get($cacheKey);
} else { } else {
$docs = (new static())->generate(); $docs = (new ApiDocsGenerator())->generate();
Cache::put($cacheKey, $docs, 60 * 24); Cache::put($cacheKey, $docs, 60 * 24);
} }
@ -55,10 +55,16 @@ class ApiDocsGenerator
{ {
return $routes->map(function (array $route) { return $routes->map(function (array $route) {
$exampleTypes = ['request', 'response']; $exampleTypes = ['request', 'response'];
$fileTypes = ['json', 'http'];
foreach ($exampleTypes as $exampleType) { foreach ($exampleTypes as $exampleType) {
$exampleFile = base_path("dev/api/{$exampleType}s/{$route['name']}.json"); foreach ($fileTypes as $fileType) {
$exampleContent = file_exists($exampleFile) ? file_get_contents($exampleFile) : null; $exampleFile = base_path("dev/api/{$exampleType}s/{$route['name']}." . $fileType);
$route["example_{$exampleType}"] = $exampleContent; if (file_exists($exampleFile)) {
$route["example_{$exampleType}"] = file_get_contents($exampleFile);
continue 2;
}
}
$route["example_{$exampleType}"] = null;
} }
return $route; return $route;
@ -95,17 +101,14 @@ class ApiDocsGenerator
} }
$rules = $class->getValdationRules()[$methodName] ?? []; $rules = $class->getValdationRules()[$methodName] ?? [];
foreach ($rules as $param => $ruleString) {
$rules[$param] = explode('|', $ruleString);
}
return count($rules) > 0 ? $rules : null; return empty($rules) ? null : $rules;
} }
/** /**
* Parse out the description text from a class method comment. * Parse out the description text from a class method comment.
*/ */
protected function parseDescriptionFromMethodComment(string $comment) protected function parseDescriptionFromMethodComment(string $comment): string
{ {
$matches = []; $matches = [];
preg_match_all('/^\s*?\*\s((?![@\s]).*?)$/m', $comment, $matches); preg_match_all('/^\s*?\*\s((?![@\s]).*?)$/m', $comment, $matches);

View File

@ -43,7 +43,7 @@ class ApiToken extends Model implements Loggable
} }
/** /**
* @inheritdoc * {@inheritdoc}
*/ */
public function logDescriptor(): string public function logDescriptor(): string
{ {

View File

@ -42,7 +42,7 @@ class ApiTokenGuard implements Guard
} }
/** /**
* @inheritDoc * {@inheritdoc}
*/ */
public function user() public function user()
{ {
@ -152,7 +152,7 @@ class ApiTokenGuard implements Guard
} }
/** /**
* @inheritDoc * {@inheritdoc}
*/ */
public function validate(array $credentials = []) public function validate(array $credentials = [])
{ {

View File

@ -94,7 +94,7 @@ class LdapSessionGuard extends ExternalBaseSessionGuard
} }
// Attach avatar if non-existent // Attach avatar if non-existent
if (is_null($user->avatar)) { if (!$user->avatar()->exists()) {
$this->ldapService->saveAndAttachAvatar($user, $userDetails); $this->ldapService->saveAndAttachAvatar($user, $userDetails);
} }

View File

@ -10,14 +10,11 @@ namespace BookStack\Auth\Access;
class Ldap class Ldap
{ {
/** /**
* Connect to a LDAP server. * Connect to an LDAP server.
*
* @param string $hostName
* @param int $port
* *
* @return resource * @return resource
*/ */
public function connect($hostName, $port) public function connect(string $hostName, int $port)
{ {
return ldap_connect($hostName, $port); return ldap_connect($hostName, $port);
} }
@ -26,12 +23,9 @@ class Ldap
* Set the value of a LDAP option for the given connection. * Set the value of a LDAP option for the given connection.
* *
* @param resource $ldapConnection * @param resource $ldapConnection
* @param int $option
* @param mixed $value * @param mixed $value
*
* @return bool
*/ */
public function setOption($ldapConnection, $option, $value) public function setOption($ldapConnection, int $option, $value): bool
{ {
return ldap_set_option($ldapConnection, $option, $value); return ldap_set_option($ldapConnection, $option, $value);
} }
@ -47,12 +41,9 @@ class Ldap
/** /**
* Set the version number for the given ldap connection. * Set the version number for the given ldap connection.
* *
* @param $ldapConnection * @param resource $ldapConnection
* @param $version
*
* @return bool
*/ */
public function setVersion($ldapConnection, $version) public function setVersion($ldapConnection, int $version): bool
{ {
return $this->setOption($ldapConnection, LDAP_OPT_PROTOCOL_VERSION, $version); return $this->setOption($ldapConnection, LDAP_OPT_PROTOCOL_VERSION, $version);
} }

View File

@ -99,7 +99,7 @@ class Saml2Service
* @throws JsonDebugException * @throws JsonDebugException
* @throws UserRegistrationException * @throws UserRegistrationException
*/ */
public function processAcsResponse(string $requestId, string $samlResponse): ?User public function processAcsResponse(?string $requestId, string $samlResponse): ?User
{ {
// The SAML2 toolkit expects the response to be within the $_POST superglobal // The SAML2 toolkit expects the response to be within the $_POST superglobal
// so we need to manually put it back there at this point. // so we need to manually put it back there at this point.

View File

@ -7,6 +7,7 @@ use BookStack\Auth\Permissions\RolePermission;
use BookStack\Interfaces\Loggable; use BookStack\Interfaces\Loggable;
use BookStack\Model; use BookStack\Model;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
@ -23,6 +24,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
*/ */
class Role extends Model implements Loggable class Role extends Model implements Loggable
{ {
use HasFactory;
protected $fillable = ['display_name', 'description', 'external_auth_id']; protected $fillable = ['display_name', 'description', 'external_auth_id'];
/** /**
@ -83,7 +86,7 @@ class Role extends Model implements Loggable
/** /**
* Get the role of the specified display name. * Get the role of the specified display name.
*/ */
public static function getRole(string $displayName): ?Role public static function getRole(string $displayName): ?self
{ {
return static::query()->where('display_name', '=', $displayName)->first(); return static::query()->where('display_name', '=', $displayName)->first();
} }
@ -91,7 +94,7 @@ class Role extends Model implements Loggable
/** /**
* Get the role object for the specified system role. * Get the role object for the specified system role.
*/ */
public static function getSystemRole(string $systemName): ?Role public static function getSystemRole(string $systemName): ?self
{ {
return static::query()->where('system_name', '=', $systemName)->first(); return static::query()->where('system_name', '=', $systemName)->first();
} }
@ -116,7 +119,7 @@ class Role extends Model implements Loggable
} }
/** /**
* @inheritdoc * {@inheritdoc}
*/ */
public function logDescriptor(): string public function logDescriptor(): string
{ {

View File

@ -21,7 +21,7 @@ class SocialAccount extends Model implements Loggable
} }
/** /**
* @inheritDoc * {@inheritdoc}
*/ */
public function logDescriptor(): string public function logDescriptor(): string
{ {

View File

@ -18,6 +18,7 @@ use Illuminate\Auth\Passwords\CanResetPassword;
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract; use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract; use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
@ -43,6 +44,7 @@ use Illuminate\Support\Collection;
*/ */
class User extends Model implements AuthenticatableContract, CanResetPasswordContract, Loggable, Sluggable class User extends Model implements AuthenticatableContract, CanResetPasswordContract, Loggable, Sluggable
{ {
use HasFactory;
use Authenticatable; use Authenticatable;
use CanResetPassword; use CanResetPassword;
use Notifiable; use Notifiable;
@ -90,7 +92,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
/** /**
* Returns the default public user. * Returns the default public user.
*/ */
public static function getDefault(): User public static function getDefault(): self
{ {
if (!is_null(static::$defaultUser)) { if (!is_null(static::$defaultUser)) {
return static::$defaultUser; return static::$defaultUser;
@ -176,7 +178,6 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
->leftJoin('permission_role', 'ru.role_id', '=', 'permission_role.role_id') ->leftJoin('permission_role', 'ru.role_id', '=', 'permission_role.role_id')
->leftJoin('role_permissions', 'permission_role.permission_id', '=', 'role_permissions.id') ->leftJoin('role_permissions', 'permission_role.permission_id', '=', 'role_permissions.id')
->where('ru.user_id', '=', $this->id) ->where('ru.user_id', '=', $this->id)
->get()
->pluck('name'); ->pluck('name');
return $this->permissions; return $this->permissions;
@ -336,7 +337,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
} }
/** /**
* @inheritdoc * {@inheritdoc}
*/ */
public function logDescriptor(): string public function logDescriptor(): string
{ {
@ -344,7 +345,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
} }
/** /**
* @inheritDoc * {@inheritdoc}
*/ */
public function refreshSlug(): string public function refreshSlug(): string
{ {

14
app/Config/app.php Executable file → Normal file
View File

@ -31,6 +31,9 @@ return [
// Set to -1 for unlimited recycle bin lifetime. // Set to -1 for unlimited recycle bin lifetime.
'recycle_bin_lifetime' => env('RECYCLE_BIN_LIFETIME', 30), 'recycle_bin_lifetime' => env('RECYCLE_BIN_LIFETIME', 30),
// The limit for all uploaded files, including images and attachments in MB.
'upload_limit' => env('FILE_UPLOAD_SIZE_LIMIT', 50),
// Allow <script> tags to entered within page content. // Allow <script> tags to entered within page content.
// <script> tags are escaped by default. // <script> tags are escaped by default.
// Even when overridden the WYSIWYG editor may still escape script content. // Even when overridden the WYSIWYG editor may still escape script content.
@ -143,7 +146,6 @@ return [
// Class aliases, Registered on application start // Class aliases, Registered on application start
'aliases' => [ 'aliases' => [
// Laravel // Laravel
'App' => Illuminate\Support\Facades\App::class, 'App' => Illuminate\Support\Facades\App::class,
'Arr' => Illuminate\Support\Arr::class, 'Arr' => Illuminate\Support\Arr::class,
@ -155,21 +157,23 @@ return [
'Config' => Illuminate\Support\Facades\Config::class, 'Config' => Illuminate\Support\Facades\Config::class,
'Cookie' => Illuminate\Support\Facades\Cookie::class, 'Cookie' => Illuminate\Support\Facades\Cookie::class,
'Crypt' => Illuminate\Support\Facades\Crypt::class, 'Crypt' => Illuminate\Support\Facades\Crypt::class,
'Date' => Illuminate\Support\Facades\Date::class,
'DB' => Illuminate\Support\Facades\DB::class, 'DB' => Illuminate\Support\Facades\DB::class,
'Eloquent' => Illuminate\Database\Eloquent\Model::class, 'Eloquent' => Illuminate\Database\Eloquent\Model::class,
'Event' => Illuminate\Support\Facades\Event::class, 'Event' => Illuminate\Support\Facades\Event::class,
'File' => Illuminate\Support\Facades\File::class, 'File' => Illuminate\Support\Facades\File::class,
'Gate' => Illuminate\Support\Facades\Gate::class,
'Hash' => Illuminate\Support\Facades\Hash::class, 'Hash' => Illuminate\Support\Facades\Hash::class,
'Input' => Illuminate\Support\Facades\Input::class, 'Http' => Illuminate\Support\Facades\Http::class,
'Inspiring' => Illuminate\Foundation\Inspiring::class,
'Lang' => Illuminate\Support\Facades\Lang::class, 'Lang' => Illuminate\Support\Facades\Lang::class,
'Log' => Illuminate\Support\Facades\Log::class, 'Log' => Illuminate\Support\Facades\Log::class,
'Mail' => Illuminate\Support\Facades\Mail::class, 'Mail' => Illuminate\Support\Facades\Mail::class,
'Notification' => Illuminate\Support\Facades\Notification::class, 'Notification' => Illuminate\Support\Facades\Notification::class,
'Password' => Illuminate\Support\Facades\Password::class, 'Password' => Illuminate\Support\Facades\Password::class,
'Queue' => Illuminate\Support\Facades\Queue::class, 'Queue' => Illuminate\Support\Facades\Queue::class,
'RateLimiter' => Illuminate\Support\Facades\RateLimiter::class,
'Redirect' => Illuminate\Support\Facades\Redirect::class, 'Redirect' => Illuminate\Support\Facades\Redirect::class,
'Redis' => Illuminate\Support\Facades\Redis::class, // 'Redis' => Illuminate\Support\Facades\Redis::class,
'Request' => Illuminate\Support\Facades\Request::class, 'Request' => Illuminate\Support\Facades\Request::class,
'Response' => Illuminate\Support\Facades\Response::class, 'Response' => Illuminate\Support\Facades\Response::class,
'Route' => Illuminate\Support\Facades\Route::class, 'Route' => Illuminate\Support\Facades\Route::class,
@ -180,6 +184,8 @@ return [
'URL' => Illuminate\Support\Facades\URL::class, 'URL' => Illuminate\Support\Facades\URL::class,
'Validator' => Illuminate\Support\Facades\Validator::class, 'Validator' => Illuminate\Support\Facades\Validator::class,
'View' => Illuminate\Support\Facades\View::class, 'View' => Illuminate\Support\Facades\View::class,
// Laravel Packages
'Socialite' => Laravel\Socialite\Facades\Socialite::class, 'Socialite' => Laravel\Socialite\Facades\Socialite::class,
// Third Party // Third Party

View File

@ -10,7 +10,6 @@
return [ return [
// Method of authentication to use
// Options: standard, ldap, saml2, oidc // Options: standard, ldap, saml2, oidc
'method' => env('AUTH_METHOD', 'standard'), 'method' => env('AUTH_METHOD', 'standard'),
@ -45,7 +44,7 @@ return [
'provider' => 'external', 'provider' => 'external',
], ],
'api' => [ 'api' => [
'driver' => 'api-token', 'driver' => 'api-token',
], ],
], ],
@ -58,10 +57,16 @@ return [
'driver' => 'eloquent', 'driver' => 'eloquent',
'model' => \BookStack\Auth\User::class, 'model' => \BookStack\Auth\User::class,
], ],
'external' => [ 'external' => [
'driver' => 'external-users', 'driver' => 'external-users',
'model' => \BookStack\Auth\User::class, 'model' => \BookStack\Auth\User::class,
], ],
// 'users' => [
// 'driver' => 'database',
// 'table' => 'users',
// ],
], ],
// Resetting Passwords // Resetting Passwords
@ -78,4 +83,10 @@ return [
], ],
], ],
// Password Confirmation Timeout
// Here you may define the amount of seconds before a password confirmation
// times out and the user is prompted to re-enter their password via the
// confirmation screen. By default, the timeout lasts for three hours.
'password_timeout' => 10800,
]; ];

View File

@ -1,5 +1,7 @@
<?php <?php
use Illuminate\Support\Str;
/** /**
* Caching configuration options. * Caching configuration options.
* *
@ -38,13 +40,15 @@ return [
], ],
'array' => [ 'array' => [
'driver' => 'array', 'driver' => 'array',
'serialize' => false,
], ],
'database' => [ 'database' => [
'driver' => 'database', 'driver' => 'database',
'table' => 'cache', 'table' => 'cache',
'connection' => null, 'connection' => null,
'lock_connection' => null,
], ],
'file' => [ 'file' => [
@ -53,19 +57,36 @@ return [
], ],
'memcached' => [ 'memcached' => [
'driver' => 'memcached', 'driver' => 'memcached',
'servers' => env('CACHE_DRIVER') === 'memcached' ? $memcachedServers : [], 'options' => [
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
],
'servers' => $memcachedServers ?? [],
], ],
'redis' => [ 'redis' => [
'driver' => 'redis', 'driver' => 'redis',
'connection' => 'default', 'connection' => 'default',
'lock_connection' => 'default',
],
'octane' => [
'driver' => 'octane',
], ],
], ],
// Cache key prefix /*
// Used to prevent collisions in shared cache systems. |--------------------------------------------------------------------------
'prefix' => env('CACHE_PREFIX', 'bookstack_cache'), | Cache Key Prefix
|--------------------------------------------------------------------------
|
| When utilizing a RAM based store such as APC or Memcached, there might
| be other applications utilizing the same cache. So, we'll specify a
| value to get prefixed to all our keys so we can avoid collisions.
|
*/
'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_') . '_cache'),
]; ];

415
app/Config/clockwork.php Normal file
View File

@ -0,0 +1,415 @@
<?php
return [
/*
|------------------------------------------------------------------------------------------------------------------
| Enable Clockwork
|------------------------------------------------------------------------------------------------------------------
|
| Clockwork is enabled by default only when your application is in debug mode. Here you can explicitly enable or
| disable Clockwork. When disabled, no data is collected and the api and web ui are inactive.
|
*/
'enable' => env('CLOCKWORK_ENABLE', false),
/*
|------------------------------------------------------------------------------------------------------------------
| Features
|------------------------------------------------------------------------------------------------------------------
|
| You can enable or disable various Clockwork features here. Some features have additional settings (eg. slow query
| threshold for database queries).
|
*/
'features' => [
// Cache usage stats and cache queries including results
'cache' => [
'enabled' => true,
// Collect cache queries
'collect_queries' => true,
// Collect values from cache queries (high performance impact with a very high number of queries)
'collect_values' => false,
],
// Database usage stats and queries
'database' => [
'enabled' => true,
// Collect database queries (high performance impact with a very high number of queries)
'collect_queries' => true,
// Collect details of models updates (high performance impact with a lot of model updates)
'collect_models_actions' => true,
// Collect details of retrieved models (very high performance impact with a lot of models retrieved)
'collect_models_retrieved' => false,
// Query execution time threshold in miliseconds after which the query will be marked as slow
'slow_threshold' => null,
// Collect only slow database queries
'slow_only' => false,
// Detect and report duplicate (N+1) queries
'detect_duplicate_queries' => false,
],
// Dispatched events
'events' => [
'enabled' => true,
// Ignored events (framework events are ignored by default)
'ignored_events' => [
// App\Events\UserRegistered::class,
// 'user.registered'
],
],
// Laravel log (you can still log directly to Clockwork with laravel log disabled)
'log' => [
'enabled' => true,
],
// Sent notifications
'notifications' => [
'enabled' => true,
],
// Performance metrics
'performance' => [
// Allow collecting of client metrics. Requires separate clockwork-browser npm package.
'client_metrics' => true,
],
// Dispatched queue jobs
'queue' => [
'enabled' => true,
],
// Redis commands
'redis' => [
'enabled' => true,
],
// Routes list
'routes' => [
'enabled' => false,
// Collect only routes from particular namespaces (only application routes by default)
'only_namespaces' => ['App'],
],
// Rendered views
'views' => [
'enabled' => true,
// Collect views including view data (high performance impact with a high number of views)
'collect_data' => false,
// Use Twig profiler instead of Laravel events for apps using laravel-twigbridge (more precise, but does
// not support collecting view data)
'use_twig_profiler' => false,
],
],
/*
|------------------------------------------------------------------------------------------------------------------
| Enable web UI
|------------------------------------------------------------------------------------------------------------------
|
| Clockwork comes with a web UI accessibla via http://your.app/clockwork. Here you can enable or disable this
| feature. You can also set a custom path for the web UI.
|
*/
'web' => true,
/*
|------------------------------------------------------------------------------------------------------------------
| Enable toolbar
|------------------------------------------------------------------------------------------------------------------
|
| Clockwork can show a toolbar with basic metrics on all responses. Here you can enable or disable this feature.
| Requires a separate clockwork-browser npm library.
| For installation instructions see https://underground.works/clockwork/#docs-viewing-data
|
*/
'toolbar' => true,
/*
|------------------------------------------------------------------------------------------------------------------
| HTTP requests collection
|------------------------------------------------------------------------------------------------------------------
|
| Clockwork collects data about HTTP requests to your app. Here you can choose which requests should be collected.
|
*/
'requests' => [
// With on-demand mode enabled, Clockwork will only profile requests when the browser extension is open or you
// manually pass a "clockwork-profile" cookie or get/post data key.
// Optionally you can specify a "secret" that has to be passed as the value to enable profiling.
'on_demand' => false,
// Collect only errors (requests with HTTP 4xx and 5xx responses)
'errors_only' => false,
// Response time threshold in miliseconds after which the request will be marked as slow
'slow_threshold' => null,
// Collect only slow requests
'slow_only' => false,
// Sample the collected requests (eg. set to 100 to collect only 1 in 100 requests)
'sample' => false,
// List of URIs that should not be collected
'except' => [
'/horizon/.*', // Laravel Horizon requests
'/telescope/.*', // Laravel Telescope requests
'/_debugbar/.*', // Laravel DebugBar requests
],
// List of URIs that should be collected, any other URI will not be collected if not empty
'only' => [
// '/api/.*'
],
// Don't collect OPTIONS requests, mostly used in the CSRF pre-flight requests and are rarely of interest
'except_preflight' => true,
],
/*
|------------------------------------------------------------------------------------------------------------------
| Artisan commands collection
|------------------------------------------------------------------------------------------------------------------
|
| Clockwork can collect data about executed artisan commands. Here you can enable and configure which commands
| should be collected.
|
*/
'artisan' => [
// Enable or disable collection of executed Artisan commands
'collect' => false,
// List of commands that should not be collected (built-in commands are not collected by default)
'except' => [
// 'inspire'
],
// List of commands that should be collected, any other command will not be collected if not empty
'only' => [
// 'inspire'
],
// Enable or disable collection of command output
'collect_output' => false,
// Enable or disable collection of built-in Laravel commands
'except_laravel_commands' => true,
],
/*
|------------------------------------------------------------------------------------------------------------------
| Queue jobs collection
|------------------------------------------------------------------------------------------------------------------
|
| Clockwork can collect data about executed queue jobs. Here you can enable and configure which queue jobs should
| be collected.
|
*/
'queue' => [
// Enable or disable collection of executed queue jobs
'collect' => false,
// List of queue jobs that should not be collected
'except' => [
// App\Jobs\ExpensiveJob::class
],
// List of queue jobs that should be collected, any other queue job will not be collected if not empty
'only' => [
// App\Jobs\BuggyJob::class
],
],
/*
|------------------------------------------------------------------------------------------------------------------
| Tests collection
|------------------------------------------------------------------------------------------------------------------
|
| Clockwork can collect data about executed tests. Here you can enable and configure which tests should be
| collected.
|
*/
'tests' => [
// Enable or disable collection of ran tests
'collect' => false,
// List of tests that should not be collected
'except' => [
// Tests\Unit\ExampleTest::class
],
],
/*
|------------------------------------------------------------------------------------------------------------------
| Enable data collection when Clockwork is disabled
|------------------------------------------------------------------------------------------------------------------
|
| You can enable this setting to collect data even when Clockwork is disabled. Eg. for future analysis.
|
*/
'collect_data_always' => false,
/*
|------------------------------------------------------------------------------------------------------------------
| Metadata storage
|------------------------------------------------------------------------------------------------------------------
|
| Configure how is the metadata collected by Clockwork stored. Two options are available:
| - files - A simple fast storage implementation storing data in one-per-request files.
| - sql - Stores requests in a sql database. Supports MySQL, Postgresql, Sqlite and requires PDO.
|
*/
'storage' => 'files',
// Path where the Clockwork metadata is stored
'storage_files_path' => storage_path('clockwork'),
// Compress the metadata files using gzip, trading a little bit of performance for lower disk usage
'storage_files_compress' => false,
// SQL database to use, can be a name of database configured in database.php or a path to a sqlite file
'storage_sql_database' => storage_path('clockwork.sqlite'),
// SQL table name to use, the table is automatically created and udpated when needed
'storage_sql_table' => 'clockwork',
// Maximum lifetime of collected metadata in minutes, older requests will automatically be deleted, false to disable
'storage_expiration' => 60 * 24 * 7,
/*
|------------------------------------------------------------------------------------------------------------------
| Authentication
|------------------------------------------------------------------------------------------------------------------
|
| Clockwork can be configured to require authentication before allowing access to the collected data. This might be
| useful when the application is publicly accessible. Setting to true will enable a simple authentication with a
| pre-configured password. You can also pass a class name of a custom implementation.
|
*/
'authentication' => false,
// Password for the simple authentication
'authentication_password' => 'VerySecretPassword',
/*
|------------------------------------------------------------------------------------------------------------------
| Stack traces collection
|------------------------------------------------------------------------------------------------------------------
|
| Clockwork can collect stack traces for log messages and certain data like database queries. Here you can set
| whether to collect stack traces, limit the number of collected frames and set further configuration. Collecting
| long stack traces considerably increases metadata size.
|
*/
'stack_traces' => [
// Enable or disable collecting of stack traces
'enabled' => true,
// Limit the number of frames to be collected
'limit' => 10,
// List of vendor names to skip when determining caller, common vendors are automatically added
'skip_vendors' => [
// 'phpunit'
],
// List of namespaces to skip when determining caller
'skip_namespaces' => [
// 'Laravel'
],
// List of class names to skip when determining caller
'skip_classes' => [
// App\CustomLog::class
],
],
/*
|------------------------------------------------------------------------------------------------------------------
| Serialization
|------------------------------------------------------------------------------------------------------------------
|
| Clockwork serializes the collected data to json for storage and transfer. Here you can configure certain aspects
| of serialization. Serialization has a large effect on the cpu time and memory usage.
|
*/
// Maximum depth of serialized multi-level arrays and objects
'serialization_depth' => 10,
// A list of classes that will never be serialized (eg. a common service container class)
'serialization_blackbox' => [
\Illuminate\Container\Container::class,
\Illuminate\Foundation\Application::class,
],
/*
|------------------------------------------------------------------------------------------------------------------
| Register helpers
|------------------------------------------------------------------------------------------------------------------
|
| Clockwork comes with a "clock" global helper function. You can use this helper to quickly log something and to
| access the Clockwork instance.
|
*/
'register_helpers' => true,
/*
|------------------------------------------------------------------------------------------------------------------
| Send Headers for AJAX request
|------------------------------------------------------------------------------------------------------------------
|
| When trying to collect data the AJAX method can sometimes fail if it is missing required headers. For example, an
| API might require a version number using Accept headers to route the HTTP request to the correct codebase.
|
*/
'headers' => [
// 'Accept' => 'application/vnd.com.whatever.v1+json',
],
/*
|------------------------------------------------------------------------------------------------------------------
| Server-Timing
|------------------------------------------------------------------------------------------------------------------
|
| Clockwork supports the W3C Server Timing specification, which allows for collecting a simple performance metrics
| in a cross-browser way. Eg. in Chrome, your app, database and timeline event timings will be shown in the Dev
| Tools network tab. This setting specifies the max number of timeline events that will be sent. Setting to false
| will disable the feature.
|
*/
'server_timing' => 10,
];

View File

@ -105,6 +105,6 @@ return [
'migrations' => 'migrations', 'migrations' => 'migrations',
// Redis configuration to use if set // Redis configuration to use if set
'redis' => env('REDIS_SERVERS', false) ? $redisConfig : [], 'redis' => $redisConfig ?? [],
]; ];

View File

@ -25,16 +25,14 @@ return [
// file storage service, such as s3, to store publicly accessible assets. // file storage service, such as s3, to store publicly accessible assets.
'url' => env('STORAGE_URL', false), 'url' => env('STORAGE_URL', false),
// Default Cloud Filesystem Disk
'cloud' => 's3',
// Available filesystem disks // Available filesystem disks
// Only local, local_secure & s3 are supported by BookStack // Only local, local_secure & s3 are supported by BookStack
'disks' => [ 'disks' => [
'local' => [ 'local' => [
'driver' => 'local', 'driver' => 'local',
'root' => public_path(), 'root' => public_path(),
'visibility' => 'public',
], ],
'local_secure_attachments' => [ 'local_secure_attachments' => [
@ -43,8 +41,9 @@ return [
], ],
'local_secure_images' => [ 'local_secure_images' => [
'driver' => 'local', 'driver' => 'local',
'root' => storage_path('uploads/images/'), 'root' => storage_path('uploads/images/'),
'visibility' => 'public',
], ],
's3' => [ 's3' => [
@ -59,4 +58,12 @@ return [
], ],
// Symbolic Links
// Here you may configure the symbolic links that will be created when the
// `storage:link` Artisan command is executed. The array keys should be
// the locations of the links and the values should be their targets.
'links' => [
public_path('storage') => storage_path('app/public'),
],
]; ];

View File

@ -49,16 +49,9 @@ return [
'days' => 7, 'days' => 7,
], ],
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'username' => 'Laravel Log',
'emoji' => ':boom:',
'level' => 'critical',
],
'stderr' => [ 'stderr' => [
'driver' => 'monolog', 'driver' => 'monolog',
'level' => 'debug',
'handler' => StreamHandler::class, 'handler' => StreamHandler::class,
'with' => [ 'with' => [
'stream' => 'php://stderr', 'stream' => 'php://stderr',
@ -99,6 +92,10 @@ return [
'testing' => [ 'testing' => [
'driver' => 'testing', 'driver' => 'testing',
], ],
'emergency' => [
'path' => storage_path('logs/laravel.log'),
],
], ],
// Failed Login Message // Failed Login Message

View File

@ -11,6 +11,8 @@
return [ return [
// Mail driver to use. // Mail driver to use.
// From Laravel 7+ this is MAIL_MAILER in laravel.
// Kept as MAIL_DRIVER in BookStack to prevent breaking change.
// Options: smtp, sendmail, log, array // Options: smtp, sendmail, log, array
'driver' => env('MAIL_DRIVER', 'smtp'), 'driver' => env('MAIL_DRIVER', 'smtp'),

View File

@ -22,25 +22,29 @@ return [
], ],
'database' => [ 'database' => [
'driver' => 'database', 'driver' => 'database',
'table' => 'jobs', 'table' => 'jobs',
'queue' => 'default', 'queue' => 'default',
'retry_after' => 90, 'retry_after' => 90,
'after_commit' => false,
], ],
'redis' => [ 'redis' => [
'driver' => 'redis', 'driver' => 'redis',
'connection' => 'default', 'connection' => 'default',
'queue' => env('REDIS_QUEUE', 'default'), 'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => 90, 'retry_after' => 90,
'block_for' => null, 'block_for' => null,
'after_commit' => false,
], ],
], ],
// Failed queue job logging // Failed queue job logging
'failed' => [ 'failed' => [
'database' => 'mysql', 'table' => 'failed_jobs', 'driver' => 'database-uuids',
'database' => 'mysql',
'table' => 'failed_jobs',
], ],
]; ];

View File

@ -4,6 +4,7 @@ namespace BookStack\Console\Commands;
use BookStack\Auth\UserRepo; use BookStack\Auth\UserRepo;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Symfony\Component\Console\Command\Command as SymfonyCommand;
class CreateAdmin extends Command class CreateAdmin extends Command
{ {
@ -49,11 +50,15 @@ class CreateAdmin extends Command
$email = $this->ask('Please specify an email address for the new admin user'); $email = $this->ask('Please specify an email address for the new admin user');
} }
if (mb_strlen($email) < 5 || !filter_var($email, FILTER_VALIDATE_EMAIL)) { if (mb_strlen($email) < 5 || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
return $this->error('Invalid email address provided'); $this->error('Invalid email address provided');
return SymfonyCommand::FAILURE;
} }
if ($this->userRepo->getByEmail($email) !== null) { if ($this->userRepo->getByEmail($email) !== null) {
return $this->error('A user with the provided email already exists!'); $this->error('A user with the provided email already exists!');
return SymfonyCommand::FAILURE;
} }
$name = trim($this->option('name')); $name = trim($this->option('name'));
@ -61,7 +66,9 @@ class CreateAdmin extends Command
$name = $this->ask('Please specify an name for the new admin user'); $name = $this->ask('Please specify an name for the new admin user');
} }
if (mb_strlen($name) < 2) { if (mb_strlen($name) < 2) {
return $this->error('Invalid name provided'); $this->error('Invalid name provided');
return SymfonyCommand::FAILURE;
} }
$password = trim($this->option('password')); $password = trim($this->option('password'));
@ -69,7 +76,9 @@ class CreateAdmin extends Command
$password = $this->secret('Please specify a password for the new admin user'); $password = $this->secret('Please specify a password for the new admin user');
} }
if (mb_strlen($password) < 5) { if (mb_strlen($password) < 5) {
return $this->error('Invalid password provided, Must be at least 5 characters'); $this->error('Invalid password provided, Must be at least 5 characters');
return SymfonyCommand::FAILURE;
} }
$user = $this->userRepo->create(['email' => $email, 'name' => $name, 'password' => $password]); $user = $this->userRepo->create(['email' => $email, 'name' => $name, 'password' => $password]);
@ -79,5 +88,7 @@ class CreateAdmin extends Command
$user->save(); $user->save();
$this->info("Admin account with email \"{$user->email}\" successfully created!"); $this->info("Admin account with email \"{$user->email}\" successfully created!");
return SymfonyCommand::SUCCESS;
} }
} }

View File

@ -2,6 +2,7 @@
namespace BookStack\Console\Commands; namespace BookStack\Console\Commands;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Tools\SearchIndex; use BookStack\Entities\Tools\SearchIndex;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@ -22,6 +23,9 @@ class RegenerateSearch extends Command
*/ */
protected $description = 'Re-index all content for searching'; protected $description = 'Re-index all content for searching';
/**
* @var SearchIndex
*/
protected $searchIndex; protected $searchIndex;
/** /**
@ -45,8 +49,13 @@ class RegenerateSearch extends Command
DB::setDefaultConnection($this->option('database')); DB::setDefaultConnection($this->option('database'));
} }
$this->searchIndex->indexAllEntities(); $this->searchIndex->indexAllEntities(function (Entity $model, int $processed, int $total) {
$this->info('Indexed ' . class_basename($model) . ' entries (' . $processed . '/' . $total . ')');
});
DB::setDefaultConnection($connection); DB::setDefaultConnection($connection);
$this->comment('Search index regenerated'); $this->line('Search index regenerated!');
return static::SUCCESS;
} }
} }

View File

@ -49,9 +49,10 @@ class ResetMfa extends Command
return 1; return 1;
} }
/** @var User $user */
$field = $id ? 'id' : 'email'; $field = $id ? 'id' : 'email';
$value = $id ?: $email; $value = $id ?: $email;
/** @var User $user */
$user = User::query() $user = User::query()
->where($field, '=', $value) ->where($field, '=', $value)
->first(); ->first();

View File

@ -4,6 +4,7 @@ namespace BookStack\Entities\Models;
use BookStack\Uploads\Image; use BookStack\Uploads\Image;
use Exception; use Exception;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
@ -21,7 +22,9 @@ use Illuminate\Support\Collection;
*/ */
class Book extends Entity implements HasCoverImage class Book extends Entity implements HasCoverImage
{ {
public $searchFactor = 2; use HasFactory;
public $searchFactor = 1.2;
protected $fillable = ['name', 'description']; protected $fillable = ['name', 'description'];
protected $hidden = ['restricted', 'pivot', 'image_id', 'deleted_at']; protected $hidden = ['restricted', 'pivot', 'image_id', 'deleted_at'];

View File

@ -3,14 +3,17 @@
namespace BookStack\Entities\Models; namespace BookStack\Entities\Models;
use BookStack\Uploads\Image; use BookStack\Uploads\Image;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Bookshelf extends Entity implements HasCoverImage class Bookshelf extends Entity implements HasCoverImage
{ {
use HasFactory;
protected $table = 'bookshelves'; protected $table = 'bookshelves';
public $searchFactor = 3; public $searchFactor = 1.2;
protected $fillable = ['name', 'description', 'image_id']; protected $fillable = ['name', 'description', 'image_id'];

View File

@ -2,29 +2,29 @@
namespace BookStack\Entities\Models; namespace BookStack\Entities\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
/** /**
* Class Chapter. * Class Chapter.
* *
* @property Collection<Page> $pages * @property Collection<Page> $pages
* @property mixed description * @property string $description
*/ */
class Chapter extends BookChild class Chapter extends BookChild
{ {
public $searchFactor = 1.3; use HasFactory;
public $searchFactor = 1.2;
protected $fillable = ['name', 'description', 'priority', 'book_id']; protected $fillable = ['name', 'description', 'priority', 'book_id'];
protected $hidden = ['restricted', 'pivot', 'deleted_at']; protected $hidden = ['restricted', 'pivot', 'deleted_at'];
/** /**
* Get the pages that this chapter contains. * Get the pages that this chapter contains.
*
* @param string $dir
*
* @return mixed
*/ */
public function pages($dir = 'ASC') public function pages(string $dir = 'ASC'): HasMany
{ {
return $this->hasMany(Page::class)->orderBy('priority', $dir); return $this->hasMany(Page::class)->orderBy('priority', $dir);
} }
@ -32,7 +32,7 @@ class Chapter extends BookChild
/** /**
* Get the url of this chapter. * Get the url of this chapter.
*/ */
public function getUrl($path = ''): string public function getUrl(string $path = ''): string
{ {
$parts = [ $parts = [
'books', 'books',

View File

@ -9,7 +9,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\MorphTo;
/** /**
* @property Model deletable * @property Model $deletable
*/ */
class Deletion extends Model implements Loggable class Deletion extends Model implements Loggable
{ {
@ -22,7 +22,7 @@ class Deletion extends Model implements Loggable
} }
/** /**
* The the user that performed the deletion. * Get the user that performed the deletion.
*/ */
public function deleter(): BelongsTo public function deleter(): BelongsTo
{ {
@ -32,7 +32,7 @@ class Deletion extends Model implements Loggable
/** /**
* Create a new deletion record for the provided entity. * Create a new deletion record for the provided entity.
*/ */
public static function createForEntity(Entity $entity): Deletion public static function createForEntity(Entity $entity): self
{ {
$record = (new self())->forceFill([ $record = (new self())->forceFill([
'deleted_by' => user()->id, 'deleted_by' => user()->id,
@ -48,7 +48,11 @@ class Deletion extends Model implements Loggable
{ {
$deletable = $this->deletable()->first(); $deletable = $this->deletable()->first();
return "Deletion ({$this->id}) for {$deletable->getType()} ({$deletable->id}) {$deletable->name}"; if ($deletable instanceof Entity) {
return "Deletion ({$this->id}) for {$deletable->getType()} ({$deletable->id}) {$deletable->name}";
}
return "Deletion ({$this->id})";
} }
/** /**

View File

@ -106,7 +106,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
* Compares this entity to another given entity. * Compares this entity to another given entity.
* Matches by comparing class and id. * Matches by comparing class and id.
*/ */
public function matches(Entity $entity): bool public function matches(self $entity): bool
{ {
return [get_class($this), $this->id] === [get_class($entity), $entity->id]; return [get_class($this), $this->id] === [get_class($entity), $entity->id];
} }
@ -114,7 +114,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
/** /**
* Checks if the current entity matches or contains the given. * Checks if the current entity matches or contains the given.
*/ */
public function matchesOrContains(Entity $entity): bool public function matchesOrContains(self $entity): bool
{ {
if ($this->matches($entity)) { if ($this->matches($entity)) {
return true; return true;
@ -238,20 +238,12 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
return mb_substr($this->name, 0, $length - 3) . '...'; return mb_substr($this->name, 0, $length - 3) . '...';
} }
/**
* Get the body text of this entity.
*/
public function getText(): string
{
return $this->{$this->textField} ?? '';
}
/** /**
* Get an excerpt of this entity's descriptive content to the specified length. * Get an excerpt of this entity's descriptive content to the specified length.
*/ */
public function getExcerpt(int $length = 100): string public function getExcerpt(int $length = 100): string
{ {
$text = $this->getText(); $text = $this->{$this->textField} ?? '';
if (mb_strlen($text) > $length) { if (mb_strlen($text) > $length) {
$text = mb_substr($text, 0, $length - 3) . '...'; $text = mb_substr($text, 0, $length - 3) . '...';
@ -270,7 +262,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
* This is the "static" parent and does not include dynamic * This is the "static" parent and does not include dynamic
* relations such as shelves to books. * relations such as shelves to books.
*/ */
public function getParent(): ?Entity public function getParent(): ?self
{ {
if ($this instanceof Page) { if ($this instanceof Page) {
return $this->chapter_id ? $this->chapter()->withTrashed()->first() : $this->book()->withTrashed()->first(); return $this->chapter_id ? $this->chapter()->withTrashed()->first() : $this->book()->withTrashed()->first();
@ -300,7 +292,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
} }
/** /**
* @inheritdoc * {@inheritdoc}
*/ */
public function refreshSlug(): string public function refreshSlug(): string
{ {
@ -310,7 +302,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
} }
/** /**
* @inheritdoc * {@inheritdoc}
*/ */
public function favourites(): MorphMany public function favourites(): MorphMany
{ {

View File

@ -3,12 +3,13 @@
namespace BookStack\Entities\Models; namespace BookStack\Entities\Models;
use BookStack\Entities\Tools\PageContent; use BookStack\Entities\Tools\PageContent;
use BookStack\Facades\Permissions;
use BookStack\Uploads\Attachment; use BookStack\Uploads\Attachment;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Permissions;
/** /**
* Class Page. * Class Page.
@ -25,6 +26,8 @@ use Permissions;
*/ */
class Page extends BookChild class Page extends BookChild
{ {
use HasFactory;
public static $listAttributes = ['name', 'id', 'slug', 'book_id', 'chapter_id', 'draft', 'template', 'text', 'created_at', 'updated_at', 'priority']; public static $listAttributes = ['name', 'id', 'slug', 'book_id', 'chapter_id', 'draft', 'template', 'text', 'created_at', 'updated_at', 'priority'];
public static $contentAttributes = ['name', 'id', 'slug', 'book_id', 'chapter_id', 'draft', 'template', 'html', 'text', 'created_at', 'updated_at', 'priority']; public static $contentAttributes = ['name', 'id', 'slug', 'book_id', 'chapter_id', 'draft', 'template', 'html', 'text', 'created_at', 'updated_at', 'priority'];
@ -61,10 +64,8 @@ class Page extends BookChild
/** /**
* Check if this page has a chapter. * Check if this page has a chapter.
*
* @return bool
*/ */
public function hasChapter() public function hasChapter(): bool
{ {
return $this->chapter()->count() > 0; return $this->chapter()->count() > 0;
} }
@ -103,7 +104,7 @@ class Page extends BookChild
/** /**
* Get the url of this page. * Get the url of this page.
*/ */
public function getUrl($path = ''): string public function getUrl(string $path = ''): string
{ {
$parts = [ $parts = [
'books', 'books',
@ -129,7 +130,7 @@ class Page extends BookChild
/** /**
* Get this page for JSON display. * Get this page for JSON display.
*/ */
public function forJsonDisplay(): Page public function forJsonDisplay(): self
{ {
$refreshed = $this->refresh()->unsetRelations()->load(['tags', 'createdBy', 'updatedBy', 'ownedBy']); $refreshed = $this->refresh()->unsetRelations()->load(['tags', 'createdBy', 'updatedBy', 'ownedBy']);
$refreshed->setHidden(array_diff($refreshed->getHidden(), ['html', 'markdown'])); $refreshed->setHidden(array_diff($refreshed->getHidden(), ['html', 'markdown']));

View File

@ -22,6 +22,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* @property string $html * @property string $html
* @property int $revision_number * @property int $revision_number
* @property Page $page * @property Page $page
* @property-read ?User $createdBy
*/ */
class PageRevision extends Model class PageRevision extends Model
{ {

View File

@ -124,7 +124,8 @@ class BookshelfRepo
$syncData = Book::visible() $syncData = Book::visible()
->whereIn('id', $bookIds) ->whereIn('id', $bookIds)
->get(['id'])->pluck('id')->mapWithKeys(function ($bookId) use ($numericIDs) { ->pluck('id')
->mapWithKeys(function ($bookId) use ($numericIDs) {
return [$bookId => ['order' => $numericIDs->search($bookId)]]; return [$bookId => ['order' => $numericIDs->search($bookId)]];
}); });

View File

@ -157,8 +157,8 @@ class PageRepo
*/ */
public function publishDraft(Page $draft, array $input): Page public function publishDraft(Page $draft, array $input): Page
{ {
$this->baseRepo->update($draft, $input);
$this->updateTemplateStatusAndContentFromInput($draft, $input); $this->updateTemplateStatusAndContentFromInput($draft, $input);
$this->baseRepo->update($draft, $input);
$draft->draft = false; $draft->draft = false;
$draft->revision_count = 1; $draft->revision_count = 1;
@ -252,9 +252,7 @@ class PageRepo
{ {
// If the page itself is a draft simply update that // If the page itself is a draft simply update that
if ($page->draft) { if ($page->draft) {
if (isset($input['html'])) { $this->updateTemplateStatusAndContentFromInput($page, $input);
(new PageContent($page))->setNewHTML($input['html']);
}
$page->fill($input); $page->fill($input);
$page->save(); $page->save();

View File

@ -135,6 +135,12 @@ class PageContent
return ''; return '';
} }
// Validate that the content is not over our upload limit
$uploadLimitBytes = (config('app.upload_limit') * 1000000);
if (strlen($imageInfo['data']) > $uploadLimitBytes) {
return '';
}
// Save image from data with a random name // Save image from data with a random name
$imageName = 'embedded-image-' . Str::random(8) . '.' . $imageInfo['extension']; $imageName = 'embedded-image-' . Str::random(8) . '.' . $imageInfo['extension'];
@ -384,7 +390,7 @@ class PageContent
*/ */
protected function fetchSectionOfPage(Page $page, string $sectionId): string protected function fetchSectionOfPage(Page $page, string $sectionId): string
{ {
$topLevelTags = ['table', 'ul', 'ol']; $topLevelTags = ['table', 'ul', 'ol', 'pre'];
$doc = $this->loadDocumentFromHtml($page->html); $doc = $this->loadDocumentFromHtml($page->html);
// Search included content for the id given and blank out if not exists. // Search included content for the id given and blank out if not exists.

View File

@ -35,7 +35,13 @@ class PageEditActivity
$pageDraftEdits = $this->activePageEditingQuery(60)->get(); $pageDraftEdits = $this->activePageEditingQuery(60)->get();
$count = $pageDraftEdits->count(); $count = $pageDraftEdits->count();
$userMessage = $count > 1 ? trans('entities.pages_draft_edit_active.start_a', ['count' => $count]) : trans('entities.pages_draft_edit_active.start_b', ['userName' => $pageDraftEdits->first()->createdBy->name]); $userMessage = trans('entities.pages_draft_edit_active.start_a', ['count' => $count]);
if ($count === 1) {
/** @var PageRevision $firstDraft */
$firstDraft = $pageDraftEdits->first();
$userMessage = trans('entities.pages_draft_edit_active.start_b', ['userName' => $firstDraft->createdBy->name ?? '']);
}
$timeMessage = trans('entities.pages_draft_edit_active.time_b', ['minCount'=> 60]); $timeMessage = trans('entities.pages_draft_edit_active.time_b', ['minCount'=> 60]);
return trans('entities.pages_draft_edit_active.message', ['start' => $userMessage, 'time' => $timeMessage]); return trans('entities.pages_draft_edit_active.message', ['start' => $userMessage, 'time' => $timeMessage]);

View File

@ -2,26 +2,31 @@
namespace BookStack\Entities\Tools; namespace BookStack\Entities\Tools;
use BookStack\Actions\Tag;
use BookStack\Entities\EntityProvider; use BookStack\Entities\EntityProvider;
use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Models\SearchTerm; use BookStack\Entities\Models\SearchTerm;
use DOMDocument;
use DOMNode;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
class SearchIndex class SearchIndex
{ {
/** /**
* @var SearchTerm * A list of delimiter characters used to break-up parsed content into terms for indexing.
*
* @var string
*/ */
protected $searchTerm; public static $delimiters = " \n\t.,!?:;()[]{}<>`'\"";
/** /**
* @var EntityProvider * @var EntityProvider
*/ */
protected $entityProvider; protected $entityProvider;
public function __construct(SearchTerm $searchTerm, EntityProvider $entityProvider) public function __construct(EntityProvider $entityProvider)
{ {
$this->searchTerm = $searchTerm;
$this->entityProvider = $entityProvider; $this->entityProvider = $entityProvider;
} }
@ -31,14 +36,8 @@ class SearchIndex
public function indexEntity(Entity $entity) public function indexEntity(Entity $entity)
{ {
$this->deleteEntityTerms($entity); $this->deleteEntityTerms($entity);
$nameTerms = $this->generateTermArrayFromText($entity->name, 5 * $entity->searchFactor); $terms = $this->entityToTermDataArray($entity);
$bodyTerms = $this->generateTermArrayFromText($entity->getText(), 1 * $entity->searchFactor); SearchTerm::query()->insert($terms);
$terms = array_merge($nameTerms, $bodyTerms);
foreach ($terms as $index => $term) {
$terms[$index]['entity_type'] = $entity->getMorphClass();
$terms[$index]['entity_id'] = $entity->id;
}
$this->searchTerm->newQuery()->insert($terms);
} }
/** /**
@ -46,40 +45,54 @@ class SearchIndex
* *
* @param Entity[] $entities * @param Entity[] $entities
*/ */
protected function indexEntities(array $entities) public function indexEntities(array $entities)
{ {
$terms = []; $terms = [];
foreach ($entities as $entity) { foreach ($entities as $entity) {
$nameTerms = $this->generateTermArrayFromText($entity->name, 5 * $entity->searchFactor); $entityTerms = $this->entityToTermDataArray($entity);
$bodyTerms = $this->generateTermArrayFromText($entity->getText(), 1 * $entity->searchFactor); array_push($terms, ...$entityTerms);
foreach (array_merge($nameTerms, $bodyTerms) as $term) {
$term['entity_id'] = $entity->id;
$term['entity_type'] = $entity->getMorphClass();
$terms[] = $term;
}
} }
$chunkedTerms = array_chunk($terms, 500); $chunkedTerms = array_chunk($terms, 500);
foreach ($chunkedTerms as $termChunk) { foreach ($chunkedTerms as $termChunk) {
$this->searchTerm->newQuery()->insert($termChunk); SearchTerm::query()->insert($termChunk);
} }
} }
/** /**
* Delete and re-index the terms for all entities in the system. * Delete and re-index the terms for all entities in the system.
* Can take a callback which is used for reporting progress.
* Callback receives three arguments:
* - An instance of the model being processed
* - The number that have been processed so far.
* - The total number of that model to be processed.
*
* @param callable(Entity, int, int)|null $progressCallback
*/ */
public function indexAllEntities() public function indexAllEntities(?callable $progressCallback = null)
{ {
$this->searchTerm->newQuery()->truncate(); SearchTerm::query()->truncate();
foreach ($this->entityProvider->all() as $entityModel) { foreach ($this->entityProvider->all() as $entityModel) {
$selectFields = ['id', 'name', $entityModel->textField]; $indexContentField = $entityModel instanceof Page ? 'html' : 'description';
$selectFields = ['id', 'name', $indexContentField];
$total = $entityModel->newQuery()->withTrashed()->count();
$chunkSize = 250;
$processed = 0;
$chunkCallback = function (Collection $entities) use ($progressCallback, &$processed, $total, $chunkSize, $entityModel) {
$this->indexEntities($entities->all());
$processed = min($processed + $chunkSize, $total);
if (is_callable($progressCallback)) {
$progressCallback($entityModel, $processed, $total);
}
};
$entityModel->newQuery() $entityModel->newQuery()
->withTrashed()
->select($selectFields) ->select($selectFields)
->chunk(1000, function (Collection $entities) { ->with(['tags:id,name,value,entity_id,entity_type'])
$this->indexEntities($entities->all()); ->chunk($chunkSize, $chunkCallback);
});
} }
} }
@ -92,12 +105,97 @@ class SearchIndex
} }
/** /**
* Create a scored term array from the given text. * Create a scored term array from the given text, where the keys are the terms
* and the values are their scores.
*
* @returns array<string, int>
*/ */
protected function generateTermArrayFromText(string $text, int $scoreAdjustment = 1): array protected function generateTermScoreMapFromText(string $text, int $scoreAdjustment = 1): array
{
$termMap = $this->textToTermCountMap($text);
foreach ($termMap as $term => $count) {
$termMap[$term] = $count * $scoreAdjustment;
}
return $termMap;
}
/**
* Create a scored term array from the given HTML, where the keys are the terms
* and the values are their scores.
*
* @returns array<string, int>
*/
protected function generateTermScoreMapFromHtml(string $html): array
{
if (empty($html)) {
return [];
}
$scoresByTerm = [];
$elementScoreAdjustmentMap = [
'h1' => 10,
'h2' => 5,
'h3' => 4,
'h4' => 3,
'h5' => 2,
'h6' => 1.5,
];
$html = '<body>' . $html . '</body>';
libxml_use_internal_errors(true);
$doc = new DOMDocument();
$doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
$topElems = $doc->documentElement->childNodes->item(0)->childNodes;
/** @var DOMNode $child */
foreach ($topElems as $child) {
$nodeName = $child->nodeName;
$termCounts = $this->textToTermCountMap(trim($child->textContent));
foreach ($termCounts as $term => $count) {
$scoreChange = $count * ($elementScoreAdjustmentMap[$nodeName] ?? 1);
$scoresByTerm[$term] = ($scoresByTerm[$term] ?? 0) + $scoreChange;
}
}
return $scoresByTerm;
}
/**
* Create a scored term map from the given set of entity tags.
*
* @param Tag[] $tags
*
* @returns array<string, int>
*/
protected function generateTermScoreMapFromTags(array $tags): array
{
$scoreMap = [];
$names = [];
$values = [];
foreach ($tags as $tag) {
$names[] = $tag->name;
$values[] = $tag->value;
}
$nameMap = $this->generateTermScoreMapFromText(implode(' ', $names), 3);
$valueMap = $this->generateTermScoreMapFromText(implode(' ', $values), 5);
return $this->mergeTermScoreMaps($nameMap, $valueMap);
}
/**
* For the given text, return an array where the keys are the unique term words
* and the values are the frequency of that term.
*
* @returns array<string, int>
*/
protected function textToTermCountMap(string $text): array
{ {
$tokenMap = []; // {TextToken => OccurrenceCount} $tokenMap = []; // {TextToken => OccurrenceCount}
$splitChars = " \n\t.,!?:;()[]{}<>`'\""; $splitChars = static::$delimiters;
$token = strtok($text, $splitChars); $token = strtok($text, $splitChars);
while ($token !== false) { while ($token !== false) {
@ -108,14 +206,61 @@ class SearchIndex
$token = strtok($splitChars); $token = strtok($splitChars);
} }
$terms = []; return $tokenMap;
foreach ($tokenMap as $token => $count) { }
$terms[] = [
'term' => $token, /**
'score' => $count * $scoreAdjustment, * For the given entity, Generate an array of term data details.
* Is the raw term data, not instances of SearchTerm models.
*
* @returns array{term: string, score: float, entity_id: int, entity_type: string}[]
*/
protected function entityToTermDataArray(Entity $entity): array
{
$nameTermsMap = $this->generateTermScoreMapFromText($entity->name, 40 * $entity->searchFactor);
$tagTermsMap = $this->generateTermScoreMapFromTags($entity->tags->all());
if ($entity instanceof Page) {
$bodyTermsMap = $this->generateTermScoreMapFromHtml($entity->html);
} else {
$bodyTermsMap = $this->generateTermScoreMapFromText($entity->description ?? '', $entity->searchFactor);
}
$mergedScoreMap = $this->mergeTermScoreMaps($nameTermsMap, $bodyTermsMap, $tagTermsMap);
$dataArray = [];
$entityId = $entity->id;
$entityType = $entity->getMorphClass();
foreach ($mergedScoreMap as $term => $score) {
$dataArray[] = [
'term' => $term,
'score' => $score,
'entity_type' => $entityType,
'entity_id' => $entityId,
]; ];
} }
return $terms; return $dataArray;
}
/**
* For the given term data arrays, Merge their contents by term
* while combining any scores.
*
* @param array<string, int>[] ...$scoreMaps
*
* @returns array<string, int>
*/
protected function mergeTermScoreMaps(...$scoreMaps): array
{
$mergedMap = [];
foreach ($scoreMaps as $scoreMap) {
foreach ($scoreMap as $term => $score) {
$mergedMap[$term] = ($mergedMap[$term] ?? 0) + $score;
}
}
return $mergedMap;
} }
} }

View File

@ -29,10 +29,10 @@ class SearchOptions
/** /**
* Create a new instance from a search string. * Create a new instance from a search string.
*/ */
public static function fromString(string $search): SearchOptions public static function fromString(string $search): self
{ {
$decoded = static::decode($search); $decoded = static::decode($search);
$instance = new static(); $instance = new SearchOptions();
foreach ($decoded as $type => $value) { foreach ($decoded as $type => $value) {
$instance->$type = $value; $instance->$type = $value;
} }
@ -45,7 +45,7 @@ class SearchOptions
* Will look for a classic string term and use that * Will look for a classic string term and use that
* Otherwise we'll use the details from an advanced search form. * Otherwise we'll use the details from an advanced search form.
*/ */
public static function fromRequest(Request $request): SearchOptions public static function fromRequest(Request $request): self
{ {
if (!$request->has('search') && !$request->has('term')) { if (!$request->has('search') && !$request->has('term')) {
return static::fromString(''); return static::fromString('');
@ -55,17 +55,24 @@ class SearchOptions
return static::fromString($request->get('term')); return static::fromString($request->get('term'));
} }
$instance = new static(); $instance = new SearchOptions();
$inputs = $request->only(['search', 'types', 'filters', 'exact', 'tags']); $inputs = $request->only(['search', 'types', 'filters', 'exact', 'tags']);
$instance->searches = explode(' ', $inputs['search'] ?? []);
$instance->exacts = array_filter($inputs['exact'] ?? []); $parsedStandardTerms = static::parseStandardTermString($inputs['search'] ?? '');
$instance->searches = $parsedStandardTerms['terms'];
$instance->exacts = $parsedStandardTerms['exacts'];
array_push($instance->exacts, ...array_filter($inputs['exact'] ?? []));
$instance->tags = array_filter($inputs['tags'] ?? []); $instance->tags = array_filter($inputs['tags'] ?? []);
foreach (($inputs['filters'] ?? []) as $filterKey => $filterVal) { foreach (($inputs['filters'] ?? []) as $filterKey => $filterVal) {
if (empty($filterVal)) { if (empty($filterVal)) {
continue; continue;
} }
$instance->filters[$filterKey] = $filterVal === 'true' ? '' : $filterVal; $instance->filters[$filterKey] = $filterVal === 'true' ? '' : $filterVal;
} }
if (isset($inputs['types']) && count($inputs['types']) < 4) { if (isset($inputs['types']) && count($inputs['types']) < 4) {
$instance->filters['type'] = implode('|', $inputs['types']); $instance->filters['type'] = implode('|', $inputs['types']);
} }
@ -102,11 +109,9 @@ class SearchOptions
} }
// Parse standard terms // Parse standard terms
foreach (explode(' ', trim($searchString)) as $searchTerm) { $parsedStandardTerms = static::parseStandardTermString($searchString);
if ($searchTerm !== '') { array_push($terms['searches'], ...$parsedStandardTerms['terms']);
$terms['searches'][] = $searchTerm; array_push($terms['exacts'], ...$parsedStandardTerms['exacts']);
}
}
// Split filter values out // Split filter values out
$splitFilters = []; $splitFilters = [];
@ -119,6 +124,33 @@ class SearchOptions
return $terms; return $terms;
} }
/**
* Parse a standard search term string into individual search terms and
* extract any exact terms searches to be made.
*
* @return array{terms: array<string>, exacts: array<string>}
*/
protected static function parseStandardTermString(string $termString): array
{
$terms = explode(' ', $termString);
$indexDelimiters = SearchIndex::$delimiters;
$parsed = [
'terms' => [],
'exacts' => [],
];
foreach ($terms as $searchTerm) {
if ($searchTerm === '') {
continue;
}
$parsedList = (strpbrk($searchTerm, $indexDelimiters) === false) ? 'terms' : 'exacts';
$parsed[$parsedList][] = $searchTerm;
}
return $parsed;
}
/** /**
* Encode this instance to a search string. * Encode this instance to a search string.
*/ */

View File

@ -0,0 +1,236 @@
<?php
namespace BookStack\Entities\Tools;
use BookStack\Actions\Tag;
use BookStack\Entities\Models\Entity;
use Illuminate\Support\HtmlString;
class SearchResultsFormatter
{
/**
* For the given array of entities, Prepare the models to be shown in search result
* output. This sets a series of additional attributes.
*
* @param Entity[] $results
*/
public function format(array $results, SearchOptions $options): void
{
foreach ($results as $result) {
$this->setSearchPreview($result, $options);
}
}
/**
* Update the given entity model to set attributes used for previews of the item
* primarily within search result lists.
*/
protected function setSearchPreview(Entity $entity, SearchOptions $options)
{
$textProperty = $entity->textField;
$textContent = $entity->$textProperty;
$terms = array_merge($options->exacts, $options->searches);
$originalContentByNewAttribute = [
'preview_name' => $entity->name,
'preview_content' => $textContent,
];
foreach ($originalContentByNewAttribute as $attributeName => $content) {
$targetLength = ($attributeName === 'preview_name') ? 0 : 260;
$matchRefs = $this->getMatchPositions($content, $terms);
$mergedRefs = $this->sortAndMergeMatchPositions($matchRefs);
$formatted = $this->formatTextUsingMatchPositions($mergedRefs, $content, $targetLength);
$entity->setAttribute($attributeName, new HtmlString($formatted));
}
$tags = $entity->relationLoaded('tags') ? $entity->tags->all() : [];
$this->highlightTagsContainingTerms($tags, $terms);
}
/**
* Highlight tags which match the given terms.
*
* @param Tag[] $tags
* @param string[] $terms
*/
protected function highlightTagsContainingTerms(array $tags, array $terms): void
{
foreach ($tags as $tag) {
$tagName = strtolower($tag->name);
$tagValue = strtolower($tag->value);
foreach ($terms as $term) {
$termLower = strtolower($term);
if (strpos($tagName, $termLower) !== false) {
$tag->setAttribute('highlight_name', true);
}
if (strpos($tagValue, $termLower) !== false) {
$tag->setAttribute('highlight_value', true);
}
}
}
}
/**
* Get positions of the given terms within the given text.
* Is in the array format of [int $startIndex => int $endIndex] where the indexes
* are positions within the provided text.
*
* @return array<int, int>
*/
protected function getMatchPositions(string $text, array $terms): array
{
$matchRefs = [];
$text = strtolower($text);
foreach ($terms as $term) {
$offset = 0;
$term = strtolower($term);
$pos = strpos($text, $term, $offset);
while ($pos !== false) {
$end = $pos + strlen($term);
$matchRefs[$pos] = $end;
$offset = $end;
$pos = strpos($text, $term, $offset);
}
}
return $matchRefs;
}
/**
* Sort the given match positions before merging them where they're
* adjacent or where they overlap.
*
* @param array<int, int> $matchPositions
*
* @return array<int, int>
*/
protected function sortAndMergeMatchPositions(array $matchPositions): array
{
ksort($matchPositions);
$mergedRefs = [];
$lastStart = 0;
$lastEnd = 0;
foreach ($matchPositions as $start => $end) {
if ($start > $lastEnd) {
$mergedRefs[$start] = $end;
$lastStart = $start;
$lastEnd = $end;
} elseif ($end > $lastEnd) {
$mergedRefs[$lastStart] = $end;
$lastEnd = $end;
}
}
return $mergedRefs;
}
/**
* Format the given original text, returning a version where terms are highlighted within.
* Returned content is in HTML text format.
* A given $targetLength of 0 asserts no target length limit.
*
* This is a complex function but written to be relatively efficient, going through the term matches in order
* so that we're only doing a one-time loop through of the matches. There is no further searching
* done within here.
*/
protected function formatTextUsingMatchPositions(array $matchPositions, string $originalText, int $targetLength): string
{
$maxEnd = strlen($originalText);
$fetchAll = ($targetLength === 0);
$contextLength = ($fetchAll ? 0 : 32);
$firstStart = null;
$lastEnd = 0;
$content = '';
$contentTextLength = 0;
if ($fetchAll) {
$targetLength = $maxEnd * 2;
}
foreach ($matchPositions as $start => $end) {
// Get our outer text ranges for the added context we want to show upon the result.
$contextStart = max($start - $contextLength, 0, $lastEnd);
$contextEnd = min($end + $contextLength, $maxEnd);
// Adjust the start if we're going to be touching the previous match.
$startDiff = $start - $lastEnd;
if ($startDiff < 0) {
$contextStart = $start;
// Trims off '$startDiff' number of characters to bring it back to the start
// if this current match zone.
$content = substr($content, 0, strlen($content) + $startDiff);
$contentTextLength += $startDiff;
}
// Add ellipsis between results
if (!$fetchAll && $contextStart !== 0 && $contextStart !== $start) {
$content .= ' ...';
$contentTextLength += 4;
} elseif ($fetchAll) {
// Or fill in gap since the previous match
$fillLength = $contextStart - $lastEnd;
$content .= e(substr($originalText, $lastEnd, $fillLength));
$contentTextLength += $fillLength;
}
// Add our content including the bolded matching text
$content .= e(substr($originalText, $contextStart, $start - $contextStart));
$contentTextLength += $start - $contextStart;
$content .= '<strong>' . e(substr($originalText, $start, $end - $start)) . '</strong>';
$contentTextLength += $end - $start;
$content .= e(substr($originalText, $end, $contextEnd - $end));
$contentTextLength += $contextEnd - $end;
// Update our last end position
$lastEnd = $contextEnd;
// Update the first start position if it's not already been set
if (is_null($firstStart)) {
$firstStart = $contextStart;
}
// Stop if we're near our target
if ($contentTextLength >= $targetLength - 10) {
break;
}
}
// Just copy out the content if we haven't moved along anywhere.
if ($lastEnd === 0) {
$content = e(substr($originalText, 0, $targetLength));
$contentTextLength = $targetLength;
$lastEnd = $targetLength;
}
// Pad out the end if we're low
$remainder = $targetLength - $contentTextLength;
if ($remainder > 10) {
$padEndLength = min($maxEnd - $lastEnd, $remainder);
$content .= e(substr($originalText, $lastEnd, $padEndLength));
$lastEnd += $padEndLength;
$contentTextLength += $padEndLength;
}
// Pad out the start if we're still low
$remainder = $targetLength - $contentTextLength;
$firstStart = $firstStart ?: 0;
if (!$fetchAll && $remainder > 10 && $firstStart !== 0) {
$padStart = max(0, $firstStart - $remainder);
$content = ($padStart === 0 ? '' : '...') . e(substr($originalText, $padStart, $firstStart - $padStart)) . substr($content, 4);
}
// Add ellipsis if we're not at the end
if ($lastEnd < $maxEnd) {
$content .= '...';
}
return $content;
}
}

View File

@ -5,13 +5,18 @@ namespace BookStack\Entities\Tools;
use BookStack\Auth\Permissions\PermissionService; use BookStack\Auth\Permissions\PermissionService;
use BookStack\Auth\User; use BookStack\Auth\User;
use BookStack\Entities\EntityProvider; use BookStack\Entities\EntityProvider;
use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Entity;
use Illuminate\Database\Connection; use BookStack\Entities\Models\Page;
use BookStack\Entities\Models\SearchTerm;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder; use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Query\Builder; use Illuminate\Database\Query\Builder;
use Illuminate\Database\Query\JoinClause;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use SplObjectStorage;
class SearchRunner class SearchRunner
{ {
@ -20,11 +25,6 @@ class SearchRunner
*/ */
protected $entityProvider; protected $entityProvider;
/**
* @var Connection
*/
protected $db;
/** /**
* @var PermissionService * @var PermissionService
*/ */
@ -37,17 +37,27 @@ class SearchRunner
*/ */
protected $queryOperators = ['<=', '>=', '=', '<', '>', 'like', '!=']; protected $queryOperators = ['<=', '>=', '=', '<', '>', 'like', '!='];
public function __construct(EntityProvider $entityProvider, Connection $db, PermissionService $permissionService) /**
* Retain a cache of score adjusted terms for specific search options.
* From PHP>=8 this can be made into a WeakMap instead.
*
* @var SplObjectStorage
*/
protected $termAdjustmentCache;
public function __construct(EntityProvider $entityProvider, PermissionService $permissionService)
{ {
$this->entityProvider = $entityProvider; $this->entityProvider = $entityProvider;
$this->db = $db;
$this->permissionService = $permissionService; $this->permissionService = $permissionService;
$this->termAdjustmentCache = new SplObjectStorage();
} }
/** /**
* Search all entities in the system. * Search all entities in the system.
* The provided count is for each entity to search, * The provided count is for each entity to search,
* Total returned could can be larger and not guaranteed. * Total returned could be larger and not guaranteed.
*
* @return array{total: int, count: int, has_more: bool, results: Entity[]}
*/ */
public function searchEntities(SearchOptions $searchOpts, string $entityType = 'all', int $page = 1, int $count = 20, string $action = 'view'): array public function searchEntities(SearchOptions $searchOpts, string $entityType = 'all', int $page = 1, int $count = 20, string $action = 'view'): array
{ {
@ -68,13 +78,18 @@ class SearchRunner
if (!in_array($entityType, $entityTypes)) { if (!in_array($entityType, $entityTypes)) {
continue; continue;
} }
$search = $this->searchEntityTable($searchOpts, $entityType, $page, $count, $action);
$entityTotal = $this->searchEntityTable($searchOpts, $entityType, $page, $count, $action, true); $entityModelInstance = $this->entityProvider->get($entityType);
if ($entityTotal > $page * $count) { $searchQuery = $this->buildQuery($searchOpts, $entityModelInstance, $action);
$entityTotal = $searchQuery->count();
$searchResults = $this->getPageOfDataFromQuery($searchQuery, $entityModelInstance, $page, $count);
if ($entityTotal > ($page * $count)) {
$hasMore = true; $hasMore = true;
} }
$total += $entityTotal; $total += $entityTotal;
$results = $results->merge($search); $results = $results->merge($searchResults);
} }
return [ return [
@ -99,7 +114,9 @@ class SearchRunner
if (!in_array($entityType, $entityTypes)) { if (!in_array($entityType, $entityTypes)) {
continue; continue;
} }
$search = $this->buildEntitySearchQuery($opts, $entityType)->where('book_id', '=', $bookId)->take(20)->get();
$entityModelInstance = $this->entityProvider->get($entityType);
$search = $this->buildQuery($opts, $entityModelInstance)->where('book_id', '=', $bookId)->take(20)->get();
$results = $results->merge($search); $results = $results->merge($search);
} }
@ -112,78 +129,199 @@ class SearchRunner
public function searchChapter(int $chapterId, string $searchString): Collection public function searchChapter(int $chapterId, string $searchString): Collection
{ {
$opts = SearchOptions::fromString($searchString); $opts = SearchOptions::fromString($searchString);
$pages = $this->buildEntitySearchQuery($opts, 'page')->where('chapter_id', '=', $chapterId)->take(20)->get(); $entityModelInstance = $this->entityProvider->get('page');
$pages = $this->buildQuery($opts, $entityModelInstance)->where('chapter_id', '=', $chapterId)->take(20)->get();
return $pages->sortByDesc('score'); return $pages->sortByDesc('score');
} }
/** /**
* Search across a particular entity type. * Get a page of result data from the given query based on the provided page parameters.
* Setting getCount = true will return the total
* matching instead of the items themselves.
*
* @return \Illuminate\Database\Eloquent\Collection|int|static[]
*/ */
protected function searchEntityTable(SearchOptions $searchOpts, string $entityType = 'page', int $page = 1, int $count = 20, string $action = 'view', bool $getCount = false) protected function getPageOfDataFromQuery(EloquentBuilder $query, Entity $entityModelInstance, int $page = 1, int $count = 20): EloquentCollection
{ {
$query = $this->buildEntitySearchQuery($searchOpts, $entityType, $action); $relations = ['tags'];
if ($getCount) {
return $query->count(); if ($entityModelInstance instanceof BookChild) {
$relations['book'] = function (BelongsTo $query) {
$query->visible();
};
} }
$query = $query->skip(($page - 1) * $count)->take($count); if ($entityModelInstance instanceof Page) {
$relations['chapter'] = function (BelongsTo $query) {
$query->visible();
};
}
return $query->get(); return $query->clone()
->with(array_filter($relations))
->skip(($page - 1) * $count)
->take($count)
->get();
} }
/** /**
* Create a search query for an entity. * Create a search query for an entity.
*/ */
protected function buildEntitySearchQuery(SearchOptions $searchOpts, string $entityType = 'page', string $action = 'view'): EloquentBuilder protected function buildQuery(SearchOptions $searchOpts, Entity $entityModelInstance, string $action = 'view'): EloquentBuilder
{ {
$entity = $this->entityProvider->get($entityType); $entityQuery = $entityModelInstance->newQuery();
$entitySelect = $entity->newQuery();
if ($entityModelInstance instanceof Page) {
$entityQuery->select($entityModelInstance::$listAttributes);
} else {
$entityQuery->select(['*']);
}
// Handle normal search terms // Handle normal search terms
if (count($searchOpts->searches) > 0) { $this->applyTermSearch($entityQuery, $searchOpts, $entityModelInstance);
$rawScoreSum = $this->db->raw('SUM(score) as score');
$subQuery = $this->db->table('search_terms')->select('entity_id', 'entity_type', $rawScoreSum);
$subQuery->where('entity_type', '=', $entity->getMorphClass());
$subQuery->where(function (Builder $query) use ($searchOpts) {
foreach ($searchOpts->searches as $inputTerm) {
$query->orWhere('term', 'like', $inputTerm . '%');
}
})->groupBy('entity_type', 'entity_id');
$entitySelect->join($this->db->raw('(' . $subQuery->toSql() . ') as s'), function (JoinClause $join) {
$join->on('id', '=', 'entity_id');
})->addSelect($entity->getTable() . '.*')
->selectRaw('s.score')
->orderBy('score', 'desc');
$entitySelect->mergeBindings($subQuery);
}
// Handle exact term matching // Handle exact term matching
foreach ($searchOpts->exacts as $inputTerm) { foreach ($searchOpts->exacts as $inputTerm) {
$entitySelect->where(function (EloquentBuilder $query) use ($inputTerm, $entity) { $entityQuery->where(function (EloquentBuilder $query) use ($inputTerm, $entityModelInstance) {
$query->where('name', 'like', '%' . $inputTerm . '%') $query->where('name', 'like', '%' . $inputTerm . '%')
->orWhere($entity->textField, 'like', '%' . $inputTerm . '%'); ->orWhere($entityModelInstance->textField, 'like', '%' . $inputTerm . '%');
}); });
} }
// Handle tag searches // Handle tag searches
foreach ($searchOpts->tags as $inputTerm) { foreach ($searchOpts->tags as $inputTerm) {
$this->applyTagSearch($entitySelect, $inputTerm); $this->applyTagSearch($entityQuery, $inputTerm);
} }
// Handle filters // Handle filters
foreach ($searchOpts->filters as $filterTerm => $filterValue) { foreach ($searchOpts->filters as $filterTerm => $filterValue) {
$functionName = Str::camel('filter_' . $filterTerm); $functionName = Str::camel('filter_' . $filterTerm);
if (method_exists($this, $functionName)) { if (method_exists($this, $functionName)) {
$this->$functionName($entitySelect, $entity, $filterValue); $this->$functionName($entityQuery, $entityModelInstance, $filterValue);
} }
} }
return $this->permissionService->enforceEntityRestrictions($entity, $entitySelect, $action); return $this->permissionService->enforceEntityRestrictions($entityModelInstance, $entityQuery, $action);
}
/**
* For the given search query, apply the queries for handling the regular search terms.
*/
protected function applyTermSearch(EloquentBuilder $entityQuery, SearchOptions $options, Entity $entity): void
{
$terms = $options->searches;
if (count($terms) === 0) {
return;
}
$scoredTerms = $this->getTermAdjustments($options);
$scoreSelect = $this->selectForScoredTerms($scoredTerms);
$subQuery = DB::table('search_terms')->select([
'entity_id',
'entity_type',
DB::raw($scoreSelect['statement']),
]);
$subQuery->addBinding($scoreSelect['bindings'], 'select');
$subQuery->where('entity_type', '=', $entity->getMorphClass());
$subQuery->where(function (Builder $query) use ($terms) {
foreach ($terms as $inputTerm) {
$query->orWhere('term', 'like', $inputTerm . '%');
}
});
$subQuery->groupBy('entity_type', 'entity_id');
$entityQuery->joinSub($subQuery, 's', 'id', '=', 'entity_id');
$entityQuery->addSelect('s.score');
$entityQuery->orderBy('score', 'desc');
}
/**
* Create a select statement, with prepared bindings, for the given
* set of scored search terms.
*
* @param array<string, float> $scoredTerms
*
* @return array{statement: string, bindings: string[]}
*/
protected function selectForScoredTerms(array $scoredTerms): array
{
// Within this we walk backwards to create the chain of 'if' statements
// so that each previous statement is used in the 'else' condition of
// the next (earlier) to be built. We start at '0' to have no score
// on no match (Should never actually get to this case).
$ifChain = '0';
$bindings = [];
foreach ($scoredTerms as $term => $score) {
$ifChain = 'IF(term like ?, score * ' . (float) $score . ', ' . $ifChain . ')';
$bindings[] = $term . '%';
}
return [
'statement' => 'SUM(' . $ifChain . ') as score',
'bindings' => array_reverse($bindings),
];
}
/**
* For the terms in the given search options, query their popularity across all
* search terms then provide that back as score adjustment multiplier applicable
* for their rarity. Returns an array of float multipliers, keyed by term.
*
* @return array<string, float>
*/
protected function getTermAdjustments(SearchOptions $options): array
{
if (isset($this->termAdjustmentCache[$options])) {
return $this->termAdjustmentCache[$options];
}
$termQuery = SearchTerm::query()->toBase();
$whenStatements = [];
$whenBindings = [];
foreach ($options->searches as $term) {
$whenStatements[] = 'WHEN term LIKE ? THEN ?';
$whenBindings[] = $term . '%';
$whenBindings[] = $term;
$termQuery->orWhere('term', 'like', $term . '%');
}
$case = 'CASE ' . implode(' ', $whenStatements) . ' END';
$termQuery->selectRaw($case . ' as term', $whenBindings);
$termQuery->selectRaw('COUNT(*) as count');
$termQuery->groupByRaw($case, $whenBindings);
$termCounts = $termQuery->pluck('count', 'term')->toArray();
$adjusted = $this->rawTermCountsToAdjustments($termCounts);
$this->termAdjustmentCache[$options] = $adjusted;
return $this->termAdjustmentCache[$options];
}
/**
* Convert counts of terms into a relative-count normalised multiplier.
*
* @param array<string, int> $termCounts
*
* @return array<string, int>
*/
protected function rawTermCountsToAdjustments(array $termCounts): array
{
if (empty($termCounts)) {
return [];
}
$multipliers = [];
$max = max(array_values($termCounts));
foreach ($termCounts as $term => $count) {
$percent = round($count / $max, 5);
$multipliers[$term] = 1.3 - $percent;
}
return $multipliers;
} }
/** /**
@ -196,7 +334,7 @@ class SearchRunner
$escapedOperators[] = preg_quote($operator); $escapedOperators[] = preg_quote($operator);
} }
return join('|', $escapedOperators); return implode('|', $escapedOperators);
} }
/** /**
@ -234,44 +372,40 @@ class SearchRunner
/** /**
* Custom entity search filters. * Custom entity search filters.
*/ */
protected function filterUpdatedAfter(EloquentBuilder $query, Entity $model, $input) protected function filterUpdatedAfter(EloquentBuilder $query, Entity $model, $input): void
{ {
try { try {
$date = date_create($input); $date = date_create($input);
$query->where('updated_at', '>=', $date);
} catch (\Exception $e) { } catch (\Exception $e) {
return;
} }
$query->where('updated_at', '>=', $date);
} }
protected function filterUpdatedBefore(EloquentBuilder $query, Entity $model, $input) protected function filterUpdatedBefore(EloquentBuilder $query, Entity $model, $input): void
{ {
try { try {
$date = date_create($input); $date = date_create($input);
$query->where('updated_at', '<', $date);
} catch (\Exception $e) { } catch (\Exception $e) {
return;
} }
$query->where('updated_at', '<', $date);
} }
protected function filterCreatedAfter(EloquentBuilder $query, Entity $model, $input) protected function filterCreatedAfter(EloquentBuilder $query, Entity $model, $input): void
{ {
try { try {
$date = date_create($input); $date = date_create($input);
$query->where('created_at', '>=', $date);
} catch (\Exception $e) { } catch (\Exception $e) {
return;
} }
$query->where('created_at', '>=', $date);
} }
protected function filterCreatedBefore(EloquentBuilder $query, Entity $model, $input) protected function filterCreatedBefore(EloquentBuilder $query, Entity $model, $input)
{ {
try { try {
$date = date_create($input); $date = date_create($input);
$query->where('created_at', '<', $date);
} catch (\Exception $e) { } catch (\Exception $e) {
return;
} }
$query->where('created_at', '<', $date);
} }
protected function filterCreatedBy(EloquentBuilder $query, Entity $model, $input) protected function filterCreatedBy(EloquentBuilder $query, Entity $model, $input)
@ -348,9 +482,9 @@ class SearchRunner
*/ */
protected function sortByLastCommented(EloquentBuilder $query, Entity $model) protected function sortByLastCommented(EloquentBuilder $query, Entity $model)
{ {
$commentsTable = $this->db->getTablePrefix() . 'comments'; $commentsTable = DB::getTablePrefix() . 'comments';
$morphClass = str_replace('\\', '\\\\', $model->getMorphClass()); $morphClass = str_replace('\\', '\\\\', $model->getMorphClass());
$commentQuery = $this->db->raw('(SELECT c1.entity_id, c1.entity_type, c1.created_at as last_commented FROM ' . $commentsTable . ' c1 LEFT JOIN ' . $commentsTable . ' c2 ON (c1.entity_id = c2.entity_id AND c1.entity_type = c2.entity_type AND c1.created_at < c2.created_at) WHERE c1.entity_type = \'' . $morphClass . '\' AND c2.created_at IS NULL) as comments'); $commentQuery = DB::raw('(SELECT c1.entity_id, c1.entity_type, c1.created_at as last_commented FROM ' . $commentsTable . ' c1 LEFT JOIN ' . $commentsTable . ' c2 ON (c1.entity_id = c2.entity_id AND c1.entity_type = c2.entity_type AND c1.created_at < c2.created_at) WHERE c1.entity_type = \'' . $morphClass . '\' AND c2.created_at IS NULL) as comments');
$query->join($commentQuery, $model->getTable() . '.id', '=', 'comments.entity_id')->orderBy('last_commented', 'desc'); $query->join($commentQuery, $model->getTable() . '.id', '=', 'comments.entity_id')->orderBy('last_commented', 'desc');
} }

View File

@ -5,6 +5,7 @@ namespace BookStack\Entities\Tools;
use BookStack\Entities\EntityProvider; use BookStack\Entities\EntityProvider;
use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf; use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Page;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
class SiblingFetcher class SiblingFetcher
@ -18,18 +19,18 @@ class SiblingFetcher
$entities = []; $entities = [];
// Page in chapter // Page in chapter
if ($entity->isA('page') && $entity->chapter) { if ($entity instanceof Page && $entity->chapter) {
$entities = $entity->chapter->getVisiblePages(); $entities = $entity->chapter->getVisiblePages();
} }
// Page in book or chapter // Page in book or chapter
if (($entity->isA('page') && !$entity->chapter) || $entity->isA('chapter')) { if (($entity instanceof Page && !$entity->chapter) || $entity->isA('chapter')) {
$entities = $entity->book->getDirectChildren(); $entities = $entity->book->getDirectChildren();
} }
// Book // Book
// Gets just the books in a shelf if shelf is in context // Gets just the books in a shelf if shelf is in context
if ($entity->isA('book')) { if ($entity instanceof Book) {
$contextShelf = (new ShelfContext())->getContextualShelfForBook($entity); $contextShelf = (new ShelfContext())->getContextualShelfForBook($entity);
if ($contextShelf) { if ($contextShelf) {
$entities = $contextShelf->visibleBooks()->get(); $entities = $contextShelf->visibleBooks()->get();
@ -38,8 +39,8 @@ class SiblingFetcher
} }
} }
// Shelve // Shelf
if ($entity->isA('bookshelf')) { if ($entity instanceof Bookshelf) {
$entities = Bookshelf::visible()->get(); $entities = Bookshelf::visible()->get();
} }

View File

@ -4,13 +4,14 @@ namespace BookStack\Entities\Tools;
use BookStack\Entities\Models\BookChild; use BookStack\Entities\Models\BookChild;
use BookStack\Interfaces\Sluggable; use BookStack\Interfaces\Sluggable;
use BookStack\Model;
use Illuminate\Support\Str; use Illuminate\Support\Str;
class SlugGenerator class SlugGenerator
{ {
/** /**
* Generate a fresh slug for the given entity. * Generate a fresh slug for the given entity.
* The slug will generated so it does not conflict within the same parent item. * The slug will be generated so that it doesn't conflict within the same parent item.
*/ */
public function generate(Sluggable $model): string public function generate(Sluggable $model): string
{ {
@ -38,6 +39,8 @@ class SlugGenerator
/** /**
* Check if a slug is already in-use for this * Check if a slug is already in-use for this
* type of model within the same parent. * type of model within the same parent.
*
* @param Sluggable&Model $model
*/ */
protected function slugInUse(string $slug, Sluggable $model): bool protected function slugInUse(string $slug, Sluggable $model): bool
{ {

View File

@ -323,6 +323,8 @@ class TrashCan
if ($entity instanceof Bookshelf) { if ($entity instanceof Bookshelf) {
return $this->destroyShelf($entity); return $this->destroyShelf($entity);
} }
return 0;
} }
/** /**

View File

@ -9,6 +9,7 @@ use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\HttpKernel\Exception\HttpException;
use Throwable;
class Handler extends ExceptionHandler class Handler extends ExceptionHandler
{ {
@ -27,6 +28,7 @@ class Handler extends ExceptionHandler
* @var array * @var array
*/ */
protected $dontFlash = [ protected $dontFlash = [
'current_password',
'password', 'password',
'password_confirmation', 'password_confirmation',
]; ];
@ -34,13 +36,13 @@ class Handler extends ExceptionHandler
/** /**
* Report or log an exception. * Report or log an exception.
* *
* @param Exception $exception * @param \Throwable $exception
* *
* @throws Exception * @throws \Throwable
* *
* @return void * @return void
*/ */
public function report(Exception $exception) public function report(Throwable $exception)
{ {
parent::report($exception); parent::report($exception);
} }
@ -53,7 +55,7 @@ class Handler extends ExceptionHandler
* *
* @return \Illuminate\Http\Response * @return \Illuminate\Http\Response
*/ */
public function render($request, Exception $e) public function render($request, Throwable $e)
{ {
if ($this->isApiRequest($request)) { if ($this->isApiRequest($request)) {
return $this->renderApiException($e); return $this->renderApiException($e);

View File

@ -23,7 +23,7 @@ class NotifyException extends Exception implements Responsable
/** /**
* Send the response for this type of exception. * Send the response for this type of exception.
* *
* @inheritdoc * {@inheritdoc}
*/ */
public function toResponse($request) public function toResponse($request)
{ {

View File

@ -20,7 +20,7 @@ class PrettyException extends Exception implements Responsable
/** /**
* Render a response for when this exception occurs. * Render a response for when this exception occurs.
* *
* @inheritdoc * {@inheritdoc}
*/ */
public function toResponse($request) public function toResponse($request)
{ {

View File

@ -23,7 +23,7 @@ class StoppedAuthenticationException extends \Exception implements Responsable
} }
/** /**
* @inheritdoc * {@inheritdoc}
*/ */
public function toResponse($request) public function toResponse($request)
{ {

View File

@ -24,9 +24,14 @@ abstract class ApiController extends Controller
/** /**
* Get the validation rules for this controller. * Get the validation rules for this controller.
* Defaults to a $rules property but can be a rules() method.
*/ */
public function getValdationRules(): array public function getValdationRules(): array
{ {
if (method_exists($this, 'rules')) {
return $this->rules();
}
return $this->rules; return $this->rules;
} }
} }

View File

@ -15,21 +15,6 @@ class AttachmentApiController extends ApiController
{ {
protected $attachmentService; protected $attachmentService;
protected $rules = [
'create' => [
'name' => 'required|min:1|max:255|string',
'uploaded_to' => 'required|integer|exists:pages,id',
'file' => 'required_without:link|file',
'link' => 'required_without:file|min:1|max:255|safe_url',
],
'update' => [
'name' => 'min:1|max:255|string',
'uploaded_to' => 'integer|exists:pages,id',
'file' => 'file',
'link' => 'min:1|max:255|safe_url',
],
];
public function __construct(AttachmentService $attachmentService) public function __construct(AttachmentService $attachmentService)
{ {
$this->attachmentService = $attachmentService; $this->attachmentService = $attachmentService;
@ -61,7 +46,7 @@ class AttachmentApiController extends ApiController
public function create(Request $request) public function create(Request $request)
{ {
$this->checkPermission('attachment-create-all'); $this->checkPermission('attachment-create-all');
$requestData = $this->validate($request, $this->rules['create']); $requestData = $this->validate($request, $this->rules()['create']);
$pageId = $request->get('uploaded_to'); $pageId = $request->get('uploaded_to');
$page = Page::visible()->findOrFail($pageId); $page = Page::visible()->findOrFail($pageId);
@ -122,7 +107,7 @@ class AttachmentApiController extends ApiController
*/ */
public function update(Request $request, string $id) public function update(Request $request, string $id)
{ {
$requestData = $this->validate($request, $this->rules['update']); $requestData = $this->validate($request, $this->rules()['update']);
/** @var Attachment $attachment */ /** @var Attachment $attachment */
$attachment = Attachment::visible()->findOrFail($id); $attachment = Attachment::visible()->findOrFail($id);
@ -162,4 +147,22 @@ class AttachmentApiController extends ApiController
return response('', 204); return response('', 204);
} }
protected function rules(): array
{
return [
'create' => [
'name' => ['required', 'min:1', 'max:255', 'string'],
'uploaded_to' => ['required', 'integer', 'exists:pages,id'],
'file' => array_merge(['required_without:link'], $this->attachmentService->getFileValidationRules()),
'link' => ['required_without:file', 'min:1', 'max:255', 'safe_url'],
],
'update' => [
'name' => ['min:1', 'max:255', 'string'],
'uploaded_to' => ['integer', 'exists:pages,id'],
'file' => $this->attachmentService->getFileValidationRules(),
'link' => ['min:1', 'max:255', 'safe_url'],
],
];
}
} }

View File

@ -13,14 +13,14 @@ class BookApiController extends ApiController
protected $rules = [ protected $rules = [
'create' => [ 'create' => [
'name' => 'required|string|max:255', 'name' => ['required', 'string', 'max:255'],
'description' => 'string|max:1000', 'description' => ['string', 'max:1000'],
'tags' => 'array', 'tags' => ['array'],
], ],
'update' => [ 'update' => [
'name' => 'string|min:1|max:255', 'name' => ['string', 'min:1', 'max:255'],
'description' => 'string|max:1000', 'description' => ['string', 'max:1000'],
'tags' => 'array', 'tags' => ['array'],
], ],
]; ];

View File

@ -18,14 +18,14 @@ class BookshelfApiController extends ApiController
protected $rules = [ protected $rules = [
'create' => [ 'create' => [
'name' => 'required|string|max:255', 'name' => ['required', 'string', 'max:255'],
'description' => 'string|max:1000', 'description' => ['string', 'max:1000'],
'books' => 'array', 'books' => ['array'],
], ],
'update' => [ 'update' => [
'name' => 'string|min:1|max:255', 'name' => ['string', 'min:1', 'max:255'],
'description' => 'string|max:1000', 'description' => ['string', 'max:1000'],
'books' => 'array', 'books' => ['array'],
], ],
]; ];

View File

@ -14,16 +14,16 @@ class ChapterApiController extends ApiController
protected $rules = [ protected $rules = [
'create' => [ 'create' => [
'book_id' => 'required|integer', 'book_id' => ['required', 'integer'],
'name' => 'required|string|max:255', 'name' => ['required', 'string', 'max:255'],
'description' => 'string|max:1000', 'description' => ['string', 'max:1000'],
'tags' => 'array', 'tags' => ['array'],
], ],
'update' => [ 'update' => [
'book_id' => 'integer', 'book_id' => ['integer'],
'name' => 'string|min:1|max:255', 'name' => ['string', 'min:1', 'max:255'],
'description' => 'string|max:1000', 'description' => ['string', 'max:1000'],
'tags' => 'array', 'tags' => ['array'],
], ],
]; ];

View File

@ -16,20 +16,20 @@ class PageApiController extends ApiController
protected $rules = [ protected $rules = [
'create' => [ 'create' => [
'book_id' => 'required_without:chapter_id|integer', 'book_id' => ['required_without:chapter_id', 'integer'],
'chapter_id' => 'required_without:book_id|integer', 'chapter_id' => ['required_without:book_id', 'integer'],
'name' => 'required|string|max:255', 'name' => ['required', 'string', 'max:255'],
'html' => 'required_without:markdown|string', 'html' => ['required_without:markdown', 'string'],
'markdown' => 'required_without:html|string', 'markdown' => ['required_without:html', 'string'],
'tags' => 'array', 'tags' => ['array'],
], ],
'update' => [ 'update' => [
'book_id' => 'required|integer', 'book_id' => ['required', 'integer'],
'chapter_id' => 'required|integer', 'chapter_id' => ['required', 'integer'],
'name' => 'string|min:1|max:255', 'name' => ['string', 'min:1', 'max:255'],
'html' => 'string', 'html' => ['string'],
'markdown' => 'string', 'markdown' => ['string'],
'tags' => 'array', 'tags' => ['array'],
], ],
]; ];

View File

@ -0,0 +1,65 @@
<?php
namespace BookStack\Http\Controllers\Api;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Tools\SearchOptions;
use BookStack\Entities\Tools\SearchRunner;
use Illuminate\Http\Request;
class SearchApiController extends ApiController
{
protected $searchRunner;
protected $rules = [
'all' => [
'query' => ['required'],
'page' => ['integer', 'min:1'],
'count' => ['integer', 'min:1', 'max:100'],
],
];
public function __construct(SearchRunner $searchRunner)
{
$this->searchRunner = $searchRunner;
}
/**
* Run a search query against all main content types (shelves, books, chapters & pages)
* in the system. Takes the same input as the main search bar within the BookStack
* interface as a 'query' parameter. See https://www.bookstackapp.com/docs/user/searching/
* for a full list of search term options. Results contain a 'type' property to distinguish
* between: bookshelf, book, chapter & page.
*
* The paging parameters and response format emulates a standard listing endpoint
* but standard sorting and filtering cannot be done on this endpoint. If a count value
* is provided this will only be taken as a suggestion. The results in the response
* may currently be up to 4x this value.
*/
public function all(Request $request)
{
$this->validate($request, $this->rules['all']);
$options = SearchOptions::fromString($request->get('query') ?? '');
$page = intval($request->get('page', '0')) ?: 1;
$count = min(intval($request->get('count', '0')) ?: 20, 100);
$results = $this->searchRunner->searchEntities($options, 'all', $page, $count);
/** @var Entity $result */
foreach ($results['results'] as $result) {
$result->setVisible([
'id', 'name', 'slug', 'book_id',
'chapter_id', 'draft', 'template',
'created_at', 'updated_at',
'tags', 'type',
]);
$result->setAttribute('type', $result->getType());
}
return response()->json([
'data' => $results['results'],
'total' => $results['total'],
]);
}
}

View File

@ -36,8 +36,8 @@ class AttachmentController extends Controller
public function upload(Request $request) public function upload(Request $request)
{ {
$this->validate($request, [ $this->validate($request, [
'uploaded_to' => 'required|integer|exists:pages,id', 'uploaded_to' => ['required', 'integer', 'exists:pages,id'],
'file' => 'required|file', 'file' => array_merge(['required'], $this->attachmentService->getFileValidationRules()),
]); ]);
$pageId = $request->get('uploaded_to'); $pageId = $request->get('uploaded_to');
@ -65,7 +65,7 @@ class AttachmentController extends Controller
public function uploadUpdate(Request $request, $attachmentId) public function uploadUpdate(Request $request, $attachmentId)
{ {
$this->validate($request, [ $this->validate($request, [
'file' => 'required|file', 'file' => array_merge(['required'], $this->attachmentService->getFileValidationRules()),
]); ]);
/** @var Attachment $attachment */ /** @var Attachment $attachment */
@ -111,8 +111,8 @@ class AttachmentController extends Controller
try { try {
$this->validate($request, [ $this->validate($request, [
'attachment_edit_name' => 'required|string|min:1|max:255', 'attachment_edit_name' => ['required', 'string', 'min:1', 'max:255'],
'attachment_edit_url' => 'string|min:1|max:255|safe_url', 'attachment_edit_url' => ['string', 'min:1', 'max:255', 'safe_url'],
]); ]);
} catch (ValidationException $exception) { } catch (ValidationException $exception) {
return response()->view('attachments.manager-edit-form', array_merge($request->only(['attachment_edit_name', 'attachment_edit_url']), [ return response()->view('attachments.manager-edit-form', array_merge($request->only(['attachment_edit_name', 'attachment_edit_url']), [
@ -146,9 +146,9 @@ class AttachmentController extends Controller
try { try {
$this->validate($request, [ $this->validate($request, [
'attachment_link_uploaded_to' => 'required|integer|exists:pages,id', 'attachment_link_uploaded_to' => ['required', 'integer', 'exists:pages,id'],
'attachment_link_name' => 'required|string|min:1|max:255', 'attachment_link_name' => ['required', 'string', 'min:1', 'max:255'],
'attachment_link_url' => 'required|string|min:1|max:255|safe_url', 'attachment_link_url' => ['required', 'string', 'min:1', 'max:255', 'safe_url'],
]); ]);
} catch (ValidationException $exception) { } catch (ValidationException $exception) {
return response()->view('attachments.manager-link-form', array_merge($request->only(['attachment_link_name', 'attachment_link_url']), [ return response()->view('attachments.manager-link-form', array_merge($request->only(['attachment_link_name', 'attachment_link_url']), [
@ -195,7 +195,7 @@ class AttachmentController extends Controller
public function sortForPage(Request $request, int $pageId) public function sortForPage(Request $request, int $pageId)
{ {
$this->validate($request, [ $this->validate($request, [
'order' => 'required|array', 'order' => ['required', 'array'],
]); ]);
$page = $this->pageRepo->getById($pageId); $page = $this->pageRepo->getById($pageId);
$this->checkOwnablePermission('page-update', $page); $this->checkOwnablePermission('page-update', $page);

View File

@ -10,10 +10,7 @@ use BookStack\Exceptions\UserTokenExpiredException;
use BookStack\Exceptions\UserTokenNotFoundException; use BookStack\Exceptions\UserTokenNotFoundException;
use BookStack\Http\Controllers\Controller; use BookStack\Http\Controllers\Controller;
use Exception; use Exception;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Routing\Redirector;
use Illuminate\View\View;
class ConfirmEmailController extends Controller class ConfirmEmailController extends Controller
{ {
@ -57,33 +54,23 @@ class ConfirmEmailController extends Controller
/** /**
* Confirms an email via a token and logs the user into the system. * Confirms an email via a token and logs the user into the system.
* *
* @param $token
*
* @throws ConfirmationEmailException * @throws ConfirmationEmailException
* @throws Exception * @throws Exception
*
* @return RedirectResponse|Redirector
*/ */
public function confirm($token) public function confirm(string $token)
{ {
try { try {
$userId = $this->emailConfirmationService->checkTokenAndGetUserId($token); $userId = $this->emailConfirmationService->checkTokenAndGetUserId($token);
} catch (Exception $exception) { } catch (UserTokenNotFoundException $exception) {
if ($exception instanceof UserTokenNotFoundException) { $this->showErrorNotification(trans('errors.email_confirmation_invalid'));
$this->showErrorNotification(trans('errors.email_confirmation_invalid'));
return redirect('/register'); return redirect('/register');
} } catch (UserTokenExpiredException $exception) {
$user = $this->userRepo->getById($exception->userId);
$this->emailConfirmationService->sendConfirmation($user);
$this->showErrorNotification(trans('errors.email_confirmation_expired'));
if ($exception instanceof UserTokenExpiredException) { return redirect('/register/confirm');
$user = $this->userRepo->getById($exception->userId);
$this->emailConfirmationService->sendConfirmation($user);
$this->showErrorNotification(trans('errors.email_confirmation_expired'));
return redirect('/register/confirm');
}
throw $exception;
} }
$user = $this->userRepo->getById($userId); $user = $this->userRepo->getById($userId);
@ -92,22 +79,17 @@ class ConfirmEmailController extends Controller
$this->emailConfirmationService->deleteByUser($user); $this->emailConfirmationService->deleteByUser($user);
$this->showSuccessNotification(trans('auth.email_confirm_success')); $this->showSuccessNotification(trans('auth.email_confirm_success'));
$this->loginService->login($user, auth()->getDefaultDriver());
return redirect('/'); return redirect('/login');
} }
/** /**
* Resend the confirmation email. * Resend the confirmation email.
*
* @param Request $request
*
* @return View
*/ */
public function resend(Request $request) public function resend(Request $request)
{ {
$this->validate($request, [ $this->validate($request, [
'email' => 'required|email|exists:users,email', 'email' => ['required', 'email', 'exists:users,email'],
]); ]);
$user = $this->userRepo->getByEmail($request->get('email')); $user = $this->userRepo->getByEmail($request->get('email'));

View File

@ -43,7 +43,9 @@ class ForgotPasswordController extends Controller
*/ */
public function sendResetLinkEmail(Request $request) public function sendResetLinkEmail(Request $request)
{ {
$this->validate($request, ['email' => 'required|email']); $this->validate($request, [
'email' => ['required', 'email'],
]);
// We will send the password reset link to this user. Once we have attempted // We will send the password reset link to this user. Once we have attempted
// to send the link, we will examine the response then see the message we // to send the link, we will examine the response then see the message we

View File

@ -176,16 +176,16 @@ class LoginController extends Controller
*/ */
protected function validateLogin(Request $request) protected function validateLogin(Request $request)
{ {
$rules = ['password' => 'required|string']; $rules = ['password' => ['required', 'string']];
$authMethod = config('auth.method'); $authMethod = config('auth.method');
if ($authMethod === 'standard') { if ($authMethod === 'standard') {
$rules['email'] = 'required|email'; $rules['email'] = ['required', 'email'];
} }
if ($authMethod === 'ldap') { if ($authMethod === 'ldap') {
$rules['username'] = 'required|string'; $rules['username'] = ['required', 'string'];
$rules['email'] = 'email'; $rules['email'] = ['email'];
} }
$request->validate($rules); $request->validate($rules);

View File

@ -73,8 +73,7 @@ class MfaBackupCodesController extends Controller
$this->validate($request, [ $this->validate($request, [
'code' => [ 'code' => [
'required', 'required', 'max:12', 'min:8',
'max:12', 'min:8',
function ($attribute, $value, $fail) use ($codeService, $codes) { function ($attribute, $value, $fail) use ($codeService, $codes) {
if (!$codeService->inputCodeExistsInSet($value, $codes)) { if (!$codeService->inputCodeExistsInSet($value, $codes)) {
$fail(trans('validation.backup_codes')); $fail(trans('validation.backup_codes'));

View File

@ -68,9 +68,9 @@ class RegisterController extends Controller
protected function validator(array $data) protected function validator(array $data)
{ {
return Validator::make($data, [ return Validator::make($data, [
'name' => 'required|min:2|max:255', 'name' => ['required', 'min:2', 'max:255'],
'email' => 'required|email|max:255|unique:users', 'email' => ['required', 'email', 'max:255', 'unique:users'],
'password' => 'required|min:8', 'password' => ['required', 'min:8'],
]); ]);
} }

View File

@ -5,8 +5,7 @@ namespace BookStack\Http\Controllers\Auth;
use BookStack\Auth\Access\Saml2Service; use BookStack\Auth\Access\Saml2Service;
use BookStack\Http\Controllers\Controller; use BookStack\Http\Controllers\Controller;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Str;
use Str;
class Saml2Controller extends Controller class Saml2Controller extends Controller
{ {
@ -79,11 +78,6 @@ class Saml2Controller extends Controller
*/ */
public function startAcs(Request $request) public function startAcs(Request $request)
{ {
// Note: This is a bit of a hack to prevent a session being stored
// on the response of this request. Within Laravel7+ this could instead
// be done via removing the StartSession middleware from the route.
config()->set('session.driver', 'array');
$samlResponse = $request->get('SAMLResponse', null); $samlResponse = $request->get('SAMLResponse', null);
if (empty($samlResponse)) { if (empty($samlResponse)) {
@ -114,7 +108,7 @@ class Saml2Controller extends Controller
$samlResponse = decrypt(cache()->pull($cacheKey)); $samlResponse = decrypt(cache()->pull($cacheKey));
} catch (\Exception $exception) { } catch (\Exception $exception) {
} }
$requestId = session()->pull('saml2_request_id', 'unset'); $requestId = session()->pull('saml2_request_id', null);
if (empty($acsId) || empty($samlResponse)) { if (empty($acsId) || empty($samlResponse)) {
$this->showErrorNotification(trans('errors.saml_fail_authed', ['system' => config('saml2.name')])); $this->showErrorNotification(trans('errors.saml_fail_authed', ['system' => config('saml2.name')]));

View File

@ -2,7 +2,6 @@
namespace BookStack\Http\Controllers\Auth; namespace BookStack\Http\Controllers\Auth;
use BookStack\Auth\Access\LoginService;
use BookStack\Auth\Access\UserInviteService; use BookStack\Auth\Access\UserInviteService;
use BookStack\Auth\UserRepo; use BookStack\Auth\UserRepo;
use BookStack\Exceptions\UserTokenExpiredException; use BookStack\Exceptions\UserTokenExpiredException;
@ -16,19 +15,17 @@ use Illuminate\Routing\Redirector;
class UserInviteController extends Controller class UserInviteController extends Controller
{ {
protected $inviteService; protected $inviteService;
protected $loginService;
protected $userRepo; protected $userRepo;
/** /**
* Create a new controller instance. * Create a new controller instance.
*/ */
public function __construct(UserInviteService $inviteService, LoginService $loginService, UserRepo $userRepo) public function __construct(UserInviteService $inviteService, UserRepo $userRepo)
{ {
$this->middleware('guest'); $this->middleware('guest');
$this->middleware('guard:standard'); $this->middleware('guard:standard');
$this->inviteService = $inviteService; $this->inviteService = $inviteService;
$this->loginService = $loginService;
$this->userRepo = $userRepo; $this->userRepo = $userRepo;
} }
@ -58,7 +55,7 @@ class UserInviteController extends Controller
public function setPassword(Request $request, string $token) public function setPassword(Request $request, string $token)
{ {
$this->validate($request, [ $this->validate($request, [
'password' => 'required|min:8', 'password' => ['required', 'min:8'],
]); ]);
try { try {
@ -73,10 +70,9 @@ class UserInviteController extends Controller
$user->save(); $user->save();
$this->inviteService->deleteByUser($user); $this->inviteService->deleteByUser($user);
$this->showSuccessNotification(trans('auth.user_invite_success', ['appName' => setting('app-name')])); $this->showSuccessNotification(trans('auth.user_invite_success_login', ['appName' => setting('app-name')]));
$this->loginService->login($user, auth()->getDefaultDriver());
return redirect('/'); return redirect('/login');
} }
/** /**

View File

@ -85,9 +85,9 @@ class BookController extends Controller
{ {
$this->checkPermission('book-create-all'); $this->checkPermission('book-create-all');
$this->validate($request, [ $this->validate($request, [
'name' => 'required|string|max:255', 'name' => ['required', 'string', 'max:255'],
'description' => 'string|max:1000', 'description' => ['string', 'max:1000'],
'image' => 'nullable|' . $this->getImageValidationRules(), 'image' => array_merge(['nullable'], $this->getImageValidationRules()),
]); ]);
$bookshelf = null; $bookshelf = null;
@ -156,9 +156,9 @@ class BookController extends Controller
$book = $this->bookRepo->getBySlug($slug); $book = $this->bookRepo->getBySlug($slug);
$this->checkOwnablePermission('book-update', $book); $this->checkOwnablePermission('book-update', $book);
$this->validate($request, [ $this->validate($request, [
'name' => 'required|string|max:255', 'name' => ['required', 'string', 'max:255'],
'description' => 'string|max:1000', 'description' => ['string', 'max:1000'],
'image' => 'nullable|' . $this->getImageValidationRules(), 'image' => array_merge(['nullable'], $this->getImageValidationRules()),
]); ]);
$book = $this->bookRepo->update($book, $request->all()); $book = $this->bookRepo->update($book, $request->all());

View File

@ -84,9 +84,9 @@ class BookshelfController extends Controller
{ {
$this->checkPermission('bookshelf-create-all'); $this->checkPermission('bookshelf-create-all');
$this->validate($request, [ $this->validate($request, [
'name' => 'required|string|max:255', 'name' => ['required', 'string', 'max:255'],
'description' => 'string|max:1000', 'description' => ['string', 'max:1000'],
'image' => 'nullable|' . $this->getImageValidationRules(), 'image' => array_merge(['nullable'], $this->getImageValidationRules()),
]); ]);
$bookIds = explode(',', $request->get('books', '')); $bookIds = explode(',', $request->get('books', ''));
@ -161,9 +161,9 @@ class BookshelfController extends Controller
$shelf = $this->bookshelfRepo->getBySlug($slug); $shelf = $this->bookshelfRepo->getBySlug($slug);
$this->checkOwnablePermission('bookshelf-update', $shelf); $this->checkOwnablePermission('bookshelf-update', $shelf);
$this->validate($request, [ $this->validate($request, [
'name' => 'required|string|max:255', 'name' => ['required', 'string', 'max:255'],
'description' => 'string|max:1000', 'description' => ['string', 'max:1000'],
'image' => 'nullable|' . $this->getImageValidationRules(), 'image' => array_merge(['nullable'], $this->getImageValidationRules()),
]); ]);
$bookIds = explode(',', $request->get('books', '')); $bookIds = explode(',', $request->get('books', ''));

View File

@ -47,7 +47,7 @@ class ChapterController extends Controller
public function store(Request $request, string $bookSlug) public function store(Request $request, string $bookSlug)
{ {
$this->validate($request, [ $this->validate($request, [
'name' => 'required|string|max:255', 'name' => ['required', 'string', 'max:255'],
]); ]);
$book = Book::visible()->where('slug', '=', $bookSlug)->firstOrFail(); $book = Book::visible()->where('slug', '=', $bookSlug)->firstOrFail();

View File

@ -24,8 +24,8 @@ class CommentController extends Controller
public function savePageComment(Request $request, int $pageId) public function savePageComment(Request $request, int $pageId)
{ {
$this->validate($request, [ $this->validate($request, [
'text' => 'required|string', 'text' => ['required', 'string'],
'parent_id' => 'nullable|integer', 'parent_id' => ['nullable', 'integer'],
]); ]);
$page = Page::visible()->find($pageId); $page = Page::visible()->find($pageId);
@ -53,7 +53,7 @@ class CommentController extends Controller
public function update(Request $request, int $commentId) public function update(Request $request, int $commentId)
{ {
$this->validate($request, [ $this->validate($request, [
'text' => 'required|string', 'text' => ['required', 'string'],
]); ]);
$comment = $this->commentRepo->getById($commentId); $comment = $this->commentRepo->getById($commentId);

View File

@ -165,7 +165,7 @@ abstract class Controller extends BaseController
/** /**
* Log an activity in the system. * Log an activity in the system.
* *
* @param string|Loggable * @param string|Loggable $detail
*/ */
protected function logActivity(string $type, $detail = ''): void protected function logActivity(string $type, $detail = ''): void
{ {
@ -175,8 +175,8 @@ abstract class Controller extends BaseController
/** /**
* Get the validation rules for image files. * Get the validation rules for image files.
*/ */
protected function getImageValidationRules(): string protected function getImageValidationRules(): array
{ {
return 'image_extension|mimes:jpeg,png,gif,webp'; return ['image_extension', 'mimes:jpeg,png,gif,webp', 'max:' . (config('app.upload_limit') * 1000)];
} }
} }

View File

@ -66,11 +66,11 @@ class FavouriteController extends Controller
* @throws \Illuminate\Validation\ValidationException * @throws \Illuminate\Validation\ValidationException
* @throws \Exception * @throws \Exception
*/ */
protected function getValidatedModelFromRequest(Request $request): Favouritable protected function getValidatedModelFromRequest(Request $request): Entity
{ {
$modelInfo = $this->validate($request, [ $modelInfo = $this->validate($request, [
'type' => 'required|string', 'type' => ['required', 'string'],
'id' => 'required|integer', 'id' => ['required', 'integer'],
]); ]);
if (!class_exists($modelInfo['type'])) { if (!class_exists($modelInfo['type'])) {

View File

@ -44,8 +44,8 @@ class DrawioImageController extends Controller
public function create(Request $request) public function create(Request $request)
{ {
$this->validate($request, [ $this->validate($request, [
'image' => 'required|string', 'image' => ['required', 'string'],
'uploaded_to' => 'required|integer', 'uploaded_to' => ['required', 'integer'],
]); ]);
$this->checkPermission('image-create-all'); $this->checkPermission('image-create-all');

View File

@ -51,7 +51,7 @@ class ImageController extends Controller
public function update(Request $request, string $id) public function update(Request $request, string $id)
{ {
$this->validate($request, [ $this->validate($request, [
'name' => 'required|min:2|string', 'name' => ['required', 'min:2', 'string'],
]); ]);
$image = $this->imageRepo->getById($id); $image = $this->imageRepo->getById($id);

View File

@ -60,7 +60,7 @@ class PageController extends Controller
public function createAsGuest(Request $request, string $bookSlug, string $chapterSlug = null) public function createAsGuest(Request $request, string $bookSlug, string $chapterSlug = null)
{ {
$this->validate($request, [ $this->validate($request, [
'name' => 'required|string|max:255', 'name' => ['required', 'string', 'max:255'],
]); ]);
$parent = $this->pageRepo->getParentFromSlugs($bookSlug, $chapterSlug); $parent = $this->pageRepo->getParentFromSlugs($bookSlug, $chapterSlug);
@ -107,7 +107,7 @@ class PageController extends Controller
public function store(Request $request, string $bookSlug, int $pageId) public function store(Request $request, string $bookSlug, int $pageId)
{ {
$this->validate($request, [ $this->validate($request, [
'name' => 'required|string|max:255', 'name' => ['required', 'string', 'max:255'],
]); ]);
$draftPage = $this->pageRepo->getById($pageId); $draftPage = $this->pageRepo->getById($pageId);
$this->checkOwnablePermission('page-create', $draftPage->getParent()); $this->checkOwnablePermission('page-create', $draftPage->getParent());
@ -176,7 +176,7 @@ class PageController extends Controller
{ {
$page = $this->pageRepo->getById($pageId); $page = $this->pageRepo->getById($pageId);
$page->setHidden(array_diff($page->getHidden(), ['html', 'markdown'])); $page->setHidden(array_diff($page->getHidden(), ['html', 'markdown']));
$page->addHidden(['book']); $page->makeHidden(['book']);
return response()->json($page); return response()->json($page);
} }
@ -234,7 +234,7 @@ class PageController extends Controller
public function update(Request $request, string $bookSlug, string $pageSlug) public function update(Request $request, string $bookSlug, string $pageSlug)
{ {
$this->validate($request, [ $this->validate($request, [
'name' => 'required|string|max:255', 'name' => ['required', 'string', 'max:255'],
]); ]);
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug); $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-update', $page); $this->checkOwnablePermission('page-update', $page);

View File

@ -48,7 +48,7 @@ class RoleController extends Controller
{ {
$this->checkPermission('user-roles-manage'); $this->checkPermission('user-roles-manage');
$this->validate($request, [ $this->validate($request, [
'display_name' => 'required|min:3|max:180', 'display_name' => ['required', 'min:3', 'max:180'],
'description' => 'max:180', 'description' => 'max:180',
]); ]);
@ -83,7 +83,7 @@ class RoleController extends Controller
{ {
$this->checkPermission('user-roles-manage'); $this->checkPermission('user-roles-manage');
$this->validate($request, [ $this->validate($request, [
'display_name' => 'required|min:3|max:180', 'display_name' => ['required', 'min:3', 'max:180'],
'description' => 'max:180', 'description' => 'max:180',
]); ]);

View File

@ -4,8 +4,8 @@ namespace BookStack\Http\Controllers;
use BookStack\Entities\Queries\Popular; use BookStack\Entities\Queries\Popular;
use BookStack\Entities\Tools\SearchOptions; use BookStack\Entities\Tools\SearchOptions;
use BookStack\Entities\Tools\SearchResultsFormatter;
use BookStack\Entities\Tools\SearchRunner; use BookStack\Entities\Tools\SearchRunner;
use BookStack\Entities\Tools\ShelfContext;
use BookStack\Entities\Tools\SiblingFetcher; use BookStack\Entities\Tools\SiblingFetcher;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@ -14,18 +14,15 @@ class SearchController extends Controller
protected $searchRunner; protected $searchRunner;
protected $entityContextManager; protected $entityContextManager;
public function __construct( public function __construct(SearchRunner $searchRunner)
SearchRunner $searchRunner, {
ShelfContext $entityContextManager
) {
$this->searchRunner = $searchRunner; $this->searchRunner = $searchRunner;
$this->entityContextManager = $entityContextManager;
} }
/** /**
* Searches all entities. * Searches all entities.
*/ */
public function search(Request $request) public function search(Request $request, SearchResultsFormatter $formatter)
{ {
$searchOpts = SearchOptions::fromRequest($request); $searchOpts = SearchOptions::fromRequest($request);
$fullSearchString = $searchOpts->toString(); $fullSearchString = $searchOpts->toString();
@ -35,6 +32,7 @@ class SearchController extends Controller
$nextPageLink = url('/search?term=' . urlencode($fullSearchString) . '&page=' . ($page + 1)); $nextPageLink = url('/search?term=' . urlencode($fullSearchString) . '&page=' . ($page + 1));
$results = $this->searchRunner->searchEntities($searchOpts, 'all', $page, 20); $results = $this->searchRunner->searchEntities($searchOpts, 'all', $page, 20);
$formatter->format($results['results']->all(), $searchOpts);
return view('search.all', [ return view('search.all', [
'entities' => $results['results'], 'entities' => $results['results'],

View File

@ -44,7 +44,7 @@ class SettingController extends Controller
$this->preventAccessInDemoMode(); $this->preventAccessInDemoMode();
$this->checkPermission('settings-manage'); $this->checkPermission('settings-manage');
$this->validate($request, [ $this->validate($request, [
'app_logo' => 'nullable|' . $this->getImageValidationRules(), 'app_logo' => array_merge(['nullable'], $this->getImageValidationRules()),
]); ]);
// Cycles through posted settings and update them // Cycles through posted settings and update them

View File

@ -20,9 +20,9 @@ class StatusController extends Controller
}), }),
'cache' => $this->trueWithoutError(function () { 'cache' => $this->trueWithoutError(function () {
$rand = Str::random(); $rand = Str::random();
Cache::set('status_test', $rand); Cache::add('status_test', $rand);
return Cache::get('status_test') === $rand; return Cache::pull('status_test') === $rand;
}), }),
'session' => $this->trueWithoutError(function () { 'session' => $this->trueWithoutError(function () {
$rand = Str::random(); $rand = Str::random();

View File

@ -17,6 +17,28 @@ class TagController extends Controller
$this->tagRepo = $tagRepo; $this->tagRepo = $tagRepo;
} }
/**
* Show a listing of existing tags in the system.
*/
public function index(Request $request)
{
$search = $request->get('search', '');
$nameFilter = $request->get('name', '');
$tags = $this->tagRepo
->queryWithTotals($search, $nameFilter)
->paginate(50)
->appends(array_filter([
'search' => $search,
'name' => $nameFilter,
]));
return view('tags.index', [
'tags' => $tags,
'search' => $search,
'nameFilter' => $nameFilter,
]);
}
/** /**
* Get tag name suggestions from a given search term. * Get tag name suggestions from a given search term.
*/ */

View File

@ -36,8 +36,8 @@ class UserApiTokenController extends Controller
$this->checkPermissionOrCurrentUser('users-manage', $userId); $this->checkPermissionOrCurrentUser('users-manage', $userId);
$this->validate($request, [ $this->validate($request, [
'name' => 'required|max:250', 'name' => ['required', 'max:250'],
'expires_at' => 'date_format:Y-m-d', 'expires_at' => ['date_format:Y-m-d'],
]); ]);
$user = User::query()->findOrFail($userId); $user = User::query()->findOrFail($userId);
@ -86,8 +86,8 @@ class UserApiTokenController extends Controller
public function update(Request $request, int $userId, int $tokenId) public function update(Request $request, int $userId, int $tokenId)
{ {
$this->validate($request, [ $this->validate($request, [
'name' => 'required|max:250', 'name' => ['required', 'max:250'],
'expires_at' => 'date_format:Y-m-d', 'expires_at' => ['date_format:Y-m-d'],
]); ]);
[$user, $token] = $this->checkPermissionAndFetchUserToken($userId, $tokenId); [$user, $token] = $this->checkPermissionAndFetchUserToken($userId, $tokenId);

View File

@ -74,18 +74,18 @@ class UserController extends Controller
{ {
$this->checkPermission('users-manage'); $this->checkPermission('users-manage');
$validationRules = [ $validationRules = [
'name' => 'required', 'name' => ['required'],
'email' => 'required|email|unique:users,email', 'email' => ['required', 'email', 'unique:users,email'],
]; ];
$authMethod = config('auth.method'); $authMethod = config('auth.method');
$sendInvite = ($request->get('send_invite', 'false') === 'true'); $sendInvite = ($request->get('send_invite', 'false') === 'true');
if ($authMethod === 'standard' && !$sendInvite) { if ($authMethod === 'standard' && !$sendInvite) {
$validationRules['password'] = 'required|min:6'; $validationRules['password'] = ['required', 'min:6'];
$validationRules['password-confirm'] = 'required|same:password'; $validationRules['password-confirm'] = ['required', 'same:password'];
} elseif ($authMethod === 'ldap' || $authMethod === 'saml2' || $authMethod === 'openid') { } elseif ($authMethod === 'ldap' || $authMethod === 'saml2' || $authMethod === 'openid') {
$validationRules['external_auth_id'] = 'required'; $validationRules['external_auth_id'] = ['required'];
} }
$this->validate($request, $validationRules); $this->validate($request, $validationRules);
@ -156,11 +156,11 @@ class UserController extends Controller
$this->validate($request, [ $this->validate($request, [
'name' => 'min:2', 'name' => 'min:2',
'email' => 'min:2|email|unique:users,email,' . $id, 'email' => ['min:2', 'email', 'unique:users,email,' . $id],
'password' => 'min:6|required_with:password_confirm', 'password' => ['min:6', 'required_with:password_confirm'],
'password-confirm' => 'same:password|required_with:password', 'password-confirm' => ['same:password', 'required_with:password'],
'setting' => 'array', 'setting' => 'array',
'profile_image' => 'nullable|' . $this->getImageValidationRules(), 'profile_image' => array_merge(['nullable'], $this->getImageValidationRules()),
]); ]);
$user = $this->userRepo->getById($id); $user = $this->userRepo->getById($id);

View File

@ -11,7 +11,7 @@ class Kernel extends HttpKernel
* These middleware are run during every request to your application. * These middleware are run during every request to your application.
*/ */
protected $middleware = [ protected $middleware = [
\BookStack\Http\Middleware\CheckForMaintenanceMode::class, \BookStack\Http\Middleware\PreventRequestsDuringMaintenance::class,
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class, \Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
\BookStack\Http\Middleware\TrimStrings::class, \BookStack\Http\Middleware\TrimStrings::class,
\BookStack\Http\Middleware\TrustProxies::class, \BookStack\Http\Middleware\TrustProxies::class,

View File

@ -12,7 +12,7 @@ class CheckUserHasPermission
* *
* @param \Illuminate\Http\Request $request * @param \Illuminate\Http\Request $request
* @param \Closure $next * @param \Closure $next
* @param $permission * @param string $permission
* *
* @return mixed * @return mixed
*/ */

View File

@ -2,9 +2,9 @@
namespace BookStack\Http\Middleware; namespace BookStack\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\CheckForMaintenanceMode as Middleware; use Illuminate\Foundation\Http\Middleware\PreventRequestsDuringMaintenance as Middleware;
class CheckForMaintenanceMode extends Middleware class PreventRequestsDuringMaintenance extends Middleware
{ {
/** /**
* The URIs that should be reachable while maintenance mode is enabled. * The URIs that should be reachable while maintenance mode is enabled.

View File

@ -2,43 +2,30 @@
namespace BookStack\Http\Middleware; namespace BookStack\Http\Middleware;
use BookStack\Providers\RouteServiceProvider;
use Closure; use Closure;
use Illuminate\Contracts\Auth\Guard; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class RedirectIfAuthenticated class RedirectIfAuthenticated
{ {
/**
* The Guard implementation.
*
* @var Guard
*/
protected $auth;
/**
* Create a new filter instance.
*
* @param Guard $auth
*
* @return void
*/
public function __construct(Guard $auth)
{
$this->auth = $auth;
}
/** /**
* Handle an incoming request. * Handle an incoming request.
* *
* @param \Illuminate\Http\Request $request * @param \Illuminate\Http\Request $request
* @param \Closure $next * @param \Closure $next
* @param string|null ...$guards
* *
* @return mixed * @return mixed
*/ */
public function handle($request, Closure $next) public function handle(Request $request, Closure $next, ...$guards)
{ {
$requireConfirmation = setting('registration-confirmation'); $guards = empty($guards) ? [null] : $guards;
if ($this->auth->check() && (!$requireConfirmation || ($requireConfirmation && $this->auth->user()->email_confirmed))) {
return redirect('/'); foreach ($guards as $guard) {
if (Auth::guard($guard)->check()) {
return redirect(RouteServiceProvider::HOME);
}
} }
return $next($request); return $next($request);

View File

@ -0,0 +1,20 @@
<?php
namespace BookStack\Http\Middleware;
use Illuminate\Http\Middleware\TrustHosts as Middleware;
class TrustHosts extends Middleware
{
/**
* Get the host patterns that should be trusted.
*
* @return array
*/
public function hosts()
{
return [
$this->allSubdomainsOfApplicationUrl(),
];
}
}

View File

@ -3,7 +3,7 @@
namespace BookStack\Http\Middleware; namespace BookStack\Http\Middleware;
use Closure; use Closure;
use Fideloper\Proxy\TrustProxies as Middleware; use Illuminate\Http\Middleware\TrustProxies as Middleware;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class TrustProxies extends Middleware class TrustProxies extends Middleware
@ -20,7 +20,7 @@ class TrustProxies extends Middleware
* *
* @var int * @var int
*/ */
protected $headers = Request::HEADER_X_FORWARDED_ALL; protected $headers = Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_HOST | Request::HEADER_X_FORWARDED_PORT | Request::HEADER_X_FORWARDED_PROTO | Request::HEADER_X_FORWARDED_AWS_ELB;
/** /**
* Handle the request, Set the correct user-configured proxy information. * Handle the request, Set the correct user-configured proxy information.

View File

@ -2,18 +2,12 @@
namespace BookStack\Interfaces; namespace BookStack\Interfaces;
use Illuminate\Database\Eloquent\Builder;
/** /**
* Interface Sluggable.
*
* Assigned to models that can have slugs. * Assigned to models that can have slugs.
* Must have the below properties. * Must have the below properties.
* *
* @property int $id * @property int $id
* @property string $name * @property string $name
*
* @method Builder newQuery
*/ */
interface Sluggable interface Sluggable
{ {

View File

@ -10,11 +10,9 @@ class Model extends EloquentModel
* Provides public access to get the raw attribute value from the model. * Provides public access to get the raw attribute value from the model.
* Used in areas where no mutations are required but performance is critical. * Used in areas where no mutations are required but performance is critical.
* *
* @param $key
*
* @return mixed * @return mixed
*/ */
public function getRawAttribute($key) public function getRawAttribute(string $key)
{ {
return parent::getAttributeFromArray($key); return parent::getAttributeFromArray($key);
} }

View File

@ -16,6 +16,7 @@ use BookStack\Util\CspService;
use GuzzleHttp\Client; use GuzzleHttp\Client;
use Illuminate\Contracts\Cache\Repository; use Illuminate\Contracts\Cache\Repository;
use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\URL;
@ -60,6 +61,9 @@ class AppServiceProvider extends ServiceProvider
// View Composers // View Composers
View::composer('entities.breadcrumbs', BreadcrumbsViewComposer::class); View::composer('entities.breadcrumbs', BreadcrumbsViewComposer::class);
// Set paginator to use bootstrap-style pagination
Paginator::useBootstrap();
} }
/** /**

View File

@ -30,6 +30,5 @@ class EventServiceProvider extends ServiceProvider
*/ */
public function boot() public function boot()
{ {
parent::boot();
} }
} }

View File

@ -2,11 +2,23 @@
namespace BookStack\Providers; namespace BookStack\Providers;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider; use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
class RouteServiceProvider extends ServiceProvider class RouteServiceProvider extends ServiceProvider
{ {
/**
* The path to the "home" route for your application.
*
* This is used by Laravel authentication to redirect users after login.
*
* @var string
*/
public const HOME = '/';
/** /**
* This namespace is applied to the controller routes in your routes file. * This namespace is applied to the controller routes in your routes file.
* *
@ -14,7 +26,6 @@ class RouteServiceProvider extends ServiceProvider
* *
* @var string * @var string
*/ */
protected $namespace = 'BookStack\Http\Controllers';
/** /**
* Define your route model bindings, pattern filters, etc. * Define your route model bindings, pattern filters, etc.
@ -23,18 +34,12 @@ class RouteServiceProvider extends ServiceProvider
*/ */
public function boot() public function boot()
{ {
parent::boot(); $this->configureRateLimiting();
}
/** $this->routes(function () {
* Define the routes for the application. $this->mapWebRoutes();
* $this->mapApiRoutes();
* @return void });
*/
public function map()
{
$this->mapWebRoutes();
$this->mapApiRoutes();
} }
/** /**
@ -71,4 +76,16 @@ class RouteServiceProvider extends ServiceProvider
require base_path('routes/api.php'); require base_path('routes/api.php');
}); });
} }
/**
* Configure the rate limiters for the application.
*
* @return void
*/
protected function configureRateLimiting()
{
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by(optional($request->user())->id ?: $request->ip());
});
}
} }

View File

@ -6,8 +6,8 @@ use BookStack\Auth\User;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
/** /**
* @property int created_by * @property int $created_by
* @property int updated_by * @property int $updated_by
*/ */
trait HasCreatorAndUpdater trait HasCreatorAndUpdater
{ {

View File

@ -6,7 +6,7 @@ use BookStack\Auth\User;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
/** /**
* @property int owned_by * @property int $owned_by
*/ */
trait HasOwner trait HasOwner
{ {

View File

@ -93,7 +93,7 @@ class Attachment extends Model
return $permissionService->filterRelatedEntity( return $permissionService->filterRelatedEntity(
Page::class, Page::class,
Attachment::query(), self::query(),
'attachments', 'attachments',
'uploaded_to' 'uploaded_to'
); );

View File

@ -4,9 +4,9 @@ namespace BookStack\Uploads;
use BookStack\Exceptions\FileUploadException; use BookStack\Exceptions\FileUploadException;
use Exception; use Exception;
use Illuminate\Contracts\Filesystem\Factory as FileSystem;
use Illuminate\Contracts\Filesystem\FileNotFoundException; use Illuminate\Contracts\Filesystem\FileNotFoundException;
use Illuminate\Contracts\Filesystem\Filesystem as FileSystemInstance; use Illuminate\Contracts\Filesystem\Filesystem as Storage;
use Illuminate\Filesystem\FilesystemManager;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use League\Flysystem\Util; use League\Flysystem\Util;
@ -19,7 +19,7 @@ class AttachmentService
/** /**
* AttachmentService constructor. * AttachmentService constructor.
*/ */
public function __construct(FileSystem $fileSystem) public function __construct(FilesystemManager $fileSystem)
{ {
$this->fileSystem = $fileSystem; $this->fileSystem = $fileSystem;
} }
@ -27,7 +27,7 @@ class AttachmentService
/** /**
* Get the storage that will be used for storing files. * Get the storage that will be used for storing files.
*/ */
protected function getStorageDisk(): FileSystemInstance protected function getStorageDisk(): Storage
{ {
return $this->fileSystem->disk($this->getStorageDiskName()); return $this->fileSystem->disk($this->getStorageDiskName());
} }
@ -233,4 +233,12 @@ class AttachmentService
return $attachmentPath; return $attachmentPath;
} }
/**
* Get the file validation rules for attachments.
*/
public function getFileValidationRules(): array
{
return ['file', 'max:' . (config('app.upload_limit') * 1000)];
}
} }

Some files were not shown because too many files have changed in this diff Show More