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.
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
# Note, if set to 'true' the page editor may still escape scripts.
ALLOW_CONTENT_SCRIPTS=false

View File

@ -196,3 +196,6 @@ Indrek Haav (IndrekHaav) :: Estonian
na3shkw :: Japanese
Giancarlo Di Massa (digitall-it) :: Italian
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
strategy:
matrix:
php: ['7.3', '7.4', '8.0']
php: ['7.3', '7.4', '8.0', '8.1']
steps:
- uses: actions/checkout@v1
- name: Setup PHP
uses: shivammathur/setup-php@b7d1d9c9a92d8d8463ce36d7f60da34d461724f8
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
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 'FLUSH PRIVILEGES;'
- name: Install composer dependencies & Test
- name: Install composer dependencies
run: composer install --prefer-dist --no-interaction --ansi
- name: Migrate and seed the database

View File

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

1
.gitignore vendored
View File

@ -24,3 +24,4 @@ nbproject
webpack-stats.json
.phpunit.result.cache
.DS_Store
phpstan.neon

View File

@ -61,7 +61,7 @@ class Activity extends Model
/**
* 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];
}

View File

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

View File

@ -90,8 +90,9 @@ class CommentRepo
*/
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;
use BookStack\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\MorphTo;
/**
* @property int $id
* @property string $name
* @property string $value
* @property int $order
*/
class Tag extends Model
{
use HasFactory;
protected $fillable = ['name', 'value', 'order'];
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\Entities\Models\Entity;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
@ -12,22 +13,54 @@ class TagRepo
protected $tag;
protected $permissionService;
/**
* TagRepo constructor.
*/
public function __construct(Tag $tag, PermissionService $ps)
public function __construct(PermissionService $ps)
{
$this->tag = $tag;
$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.
* If no search term is given the 50 most popular tag names are provided.
*/
public function getNameSuggestions(?string $searchTerm): Collection
{
$query = $this->tag->newQuery()
$query = Tag::query()
->select('*', DB::raw('count(*) as count'))
->groupBy('name');
@ -49,7 +82,7 @@ class TagRepo
*/
public function getValueSuggestions(?string $searchTerm, ?string $tagName): Collection
{
$query = $this->tag->newQuery()
$query = Tag::query()
->select('*', DB::raw('count(*) as count'))
->groupBy('value');
@ -90,9 +123,9 @@ class TagRepo
*/
protected function newInstanceFromInput(array $input): Tag
{
$name = trim($input['name']);
$value = isset($input['value']) ? trim($input['value']) : '';
return $this->tag->newInstance(['name' => $name, 'value' => $value]);
return new Tag([
'name' => trim($input['name']),
'value' => trim($input['value'] ?? ''),
]);
}
}

View File

@ -28,7 +28,7 @@ class ApiDocsGenerator
if (Cache::has($cacheKey) && config('app.env') === 'production') {
$docs = Cache::get($cacheKey);
} else {
$docs = (new static())->generate();
$docs = (new ApiDocsGenerator())->generate();
Cache::put($cacheKey, $docs, 60 * 24);
}
@ -55,10 +55,16 @@ class ApiDocsGenerator
{
return $routes->map(function (array $route) {
$exampleTypes = ['request', 'response'];
$fileTypes = ['json', 'http'];
foreach ($exampleTypes as $exampleType) {
$exampleFile = base_path("dev/api/{$exampleType}s/{$route['name']}.json");
$exampleContent = file_exists($exampleFile) ? file_get_contents($exampleFile) : null;
$route["example_{$exampleType}"] = $exampleContent;
foreach ($fileTypes as $fileType) {
$exampleFile = base_path("dev/api/{$exampleType}s/{$route['name']}." . $fileType);
if (file_exists($exampleFile)) {
$route["example_{$exampleType}"] = file_get_contents($exampleFile);
continue 2;
}
}
$route["example_{$exampleType}"] = null;
}
return $route;
@ -95,17 +101,14 @@ class ApiDocsGenerator
}
$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.
*/
protected function parseDescriptionFromMethodComment(string $comment)
protected function parseDescriptionFromMethodComment(string $comment): string
{
$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
{

View File

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

View File

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

View File

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

View File

@ -99,7 +99,7 @@ class Saml2Service
* @throws JsonDebugException
* @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
// 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\Model;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
@ -23,6 +24,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
*/
class Role extends Model implements Loggable
{
use HasFactory;
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.
*/
public static function getRole(string $displayName): ?Role
public static function getRole(string $displayName): ?self
{
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.
*/
public static function getSystemRole(string $systemName): ?Role
public static function getSystemRole(string $systemName): ?self
{
return static::query()->where('system_name', '=', $systemName)->first();
}
@ -116,7 +119,7 @@ class Role extends Model implements Loggable
}
/**
* @inheritdoc
* {@inheritdoc}
*/
public function logDescriptor(): string
{

View File

@ -21,7 +21,7 @@ class SocialAccount extends Model implements Loggable
}
/**
* @inheritDoc
* {@inheritdoc}
*/
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\CanResetPassword as CanResetPasswordContract;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
@ -43,6 +44,7 @@ use Illuminate\Support\Collection;
*/
class User extends Model implements AuthenticatableContract, CanResetPasswordContract, Loggable, Sluggable
{
use HasFactory;
use Authenticatable;
use CanResetPassword;
use Notifiable;
@ -90,7 +92,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
/**
* Returns the default public user.
*/
public static function getDefault(): User
public static function getDefault(): self
{
if (!is_null(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('role_permissions', 'permission_role.permission_id', '=', 'role_permissions.id')
->where('ru.user_id', '=', $this->id)
->get()
->pluck('name');
return $this->permissions;
@ -336,7 +337,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
}
/**
* @inheritdoc
* {@inheritdoc}
*/
public function logDescriptor(): string
{
@ -344,7 +345,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
}
/**
* @inheritDoc
* {@inheritdoc}
*/
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.
'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.
// <script> tags are escaped by default.
// Even when overridden the WYSIWYG editor may still escape script content.
@ -143,7 +146,6 @@ return [
// Class aliases, Registered on application start
'aliases' => [
// Laravel
'App' => Illuminate\Support\Facades\App::class,
'Arr' => Illuminate\Support\Arr::class,
@ -155,21 +157,23 @@ return [
'Config' => Illuminate\Support\Facades\Config::class,
'Cookie' => Illuminate\Support\Facades\Cookie::class,
'Crypt' => Illuminate\Support\Facades\Crypt::class,
'Date' => Illuminate\Support\Facades\Date::class,
'DB' => Illuminate\Support\Facades\DB::class,
'Eloquent' => Illuminate\Database\Eloquent\Model::class,
'Event' => Illuminate\Support\Facades\Event::class,
'File' => Illuminate\Support\Facades\File::class,
'Gate' => Illuminate\Support\Facades\Gate::class,
'Hash' => Illuminate\Support\Facades\Hash::class,
'Input' => Illuminate\Support\Facades\Input::class,
'Inspiring' => Illuminate\Foundation\Inspiring::class,
'Http' => Illuminate\Support\Facades\Http::class,
'Lang' => Illuminate\Support\Facades\Lang::class,
'Log' => Illuminate\Support\Facades\Log::class,
'Mail' => Illuminate\Support\Facades\Mail::class,
'Notification' => Illuminate\Support\Facades\Notification::class,
'Password' => Illuminate\Support\Facades\Password::class,
'Queue' => Illuminate\Support\Facades\Queue::class,
'RateLimiter' => Illuminate\Support\Facades\RateLimiter::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,
'Response' => Illuminate\Support\Facades\Response::class,
'Route' => Illuminate\Support\Facades\Route::class,
@ -180,6 +184,8 @@ return [
'URL' => Illuminate\Support\Facades\URL::class,
'Validator' => Illuminate\Support\Facades\Validator::class,
'View' => Illuminate\Support\Facades\View::class,
// Laravel Packages
'Socialite' => Laravel\Socialite\Facades\Socialite::class,
// Third Party

View File

@ -10,7 +10,6 @@
return [
// Method of authentication to use
// Options: standard, ldap, saml2, oidc
'method' => env('AUTH_METHOD', 'standard'),
@ -58,10 +57,16 @@ return [
'driver' => 'eloquent',
'model' => \BookStack\Auth\User::class,
],
'external' => [
'driver' => 'external-users',
'model' => \BookStack\Auth\User::class,
],
// 'users' => [
// 'driver' => 'database',
// 'table' => 'users',
// ],
],
// 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
use Illuminate\Support\Str;
/**
* Caching configuration options.
*
@ -39,12 +41,14 @@ return [
'array' => [
'driver' => 'array',
'serialize' => false,
],
'database' => [
'driver' => 'database',
'table' => 'cache',
'connection' => null,
'lock_connection' => null,
],
'file' => [
@ -54,18 +58,35 @@ return [
'memcached' => [
'driver' => 'memcached',
'servers' => env('CACHE_DRIVER') === 'memcached' ? $memcachedServers : [],
'options' => [
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
],
'servers' => $memcachedServers ?? [],
],
'redis' => [
'driver' => 'redis',
'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',
// Redis configuration to use if set
'redis' => env('REDIS_SERVERS', false) ? $redisConfig : [],
'redis' => $redisConfig ?? [],
];

View File

@ -25,9 +25,6 @@ return [
// file storage service, such as s3, to store publicly accessible assets.
'url' => env('STORAGE_URL', false),
// Default Cloud Filesystem Disk
'cloud' => 's3',
// Available filesystem disks
// Only local, local_secure & s3 are supported by BookStack
'disks' => [
@ -35,6 +32,7 @@ return [
'local' => [
'driver' => 'local',
'root' => public_path(),
'visibility' => 'public',
],
'local_secure_attachments' => [
@ -45,6 +43,7 @@ return [
'local_secure_images' => [
'driver' => 'local',
'root' => storage_path('uploads/images/'),
'visibility' => 'public',
],
'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,
],
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'username' => 'Laravel Log',
'emoji' => ':boom:',
'level' => 'critical',
],
'stderr' => [
'driver' => 'monolog',
'level' => 'debug',
'handler' => StreamHandler::class,
'with' => [
'stream' => 'php://stderr',
@ -99,6 +92,10 @@ return [
'testing' => [
'driver' => 'testing',
],
'emergency' => [
'path' => storage_path('logs/laravel.log'),
],
],
// Failed Login Message

View File

@ -11,6 +11,8 @@
return [
// 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
'driver' => env('MAIL_DRIVER', 'smtp'),

View File

@ -26,6 +26,7 @@ return [
'table' => 'jobs',
'queue' => 'default',
'retry_after' => 90,
'after_commit' => false,
],
'redis' => [
@ -34,13 +35,16 @@ return [
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => 90,
'block_for' => null,
'after_commit' => false,
],
],
// Failed queue job logging
'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 Illuminate\Console\Command;
use Symfony\Component\Console\Command\Command as SymfonyCommand;
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');
}
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) {
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'));
@ -61,7 +66,9 @@ class CreateAdmin extends Command
$name = $this->ask('Please specify an name for the new admin user');
}
if (mb_strlen($name) < 2) {
return $this->error('Invalid name provided');
$this->error('Invalid name provided');
return SymfonyCommand::FAILURE;
}
$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');
}
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]);
@ -79,5 +88,7 @@ class CreateAdmin extends Command
$user->save();
$this->info("Admin account with email \"{$user->email}\" successfully created!");
return SymfonyCommand::SUCCESS;
}
}

View File

@ -2,6 +2,7 @@
namespace BookStack\Console\Commands;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Tools\SearchIndex;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
@ -22,6 +23,9 @@ class RegenerateSearch extends Command
*/
protected $description = 'Re-index all content for searching';
/**
* @var SearchIndex
*/
protected $searchIndex;
/**
@ -45,8 +49,13 @@ class RegenerateSearch extends Command
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);
$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;
}
/** @var User $user */
$field = $id ? 'id' : 'email';
$value = $id ?: $email;
/** @var User $user */
$user = User::query()
->where($field, '=', $value)
->first();

View File

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

View File

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

View File

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

View File

@ -9,7 +9,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
/**
* @property Model deletable
* @property Model $deletable
*/
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
{
@ -32,7 +32,7 @@ class Deletion extends Model implements Loggable
/**
* 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([
'deleted_by' => user()->id,
@ -48,9 +48,13 @@ class Deletion extends Model implements Loggable
{
$deletable = $this->deletable()->first();
if ($deletable instanceof Entity) {
return "Deletion ({$this->id}) for {$deletable->getType()} ({$deletable->id}) {$deletable->name}";
}
return "Deletion ({$this->id})";
}
/**
* Get a URL for this specific deletion.
*/

View File

@ -106,7 +106,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
* Compares this entity to another given entity.
* 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];
}
@ -114,7 +114,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
/**
* 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)) {
return true;
@ -238,20 +238,12 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
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.
*/
public function getExcerpt(int $length = 100): string
{
$text = $this->getText();
$text = $this->{$this->textField} ?? '';
if (mb_strlen($text) > $length) {
$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
* relations such as shelves to books.
*/
public function getParent(): ?Entity
public function getParent(): ?self
{
if ($this instanceof Page) {
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
{
@ -310,7 +302,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
}
/**
* @inheritdoc
* {@inheritdoc}
*/
public function favourites(): MorphMany
{

View File

@ -3,12 +3,13 @@
namespace BookStack\Entities\Models;
use BookStack\Entities\Tools\PageContent;
use BookStack\Facades\Permissions;
use BookStack\Uploads\Attachment;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Permissions;
/**
* Class Page.
@ -25,6 +26,8 @@ use Permissions;
*/
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 $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.
*
* @return bool
*/
public function hasChapter()
public function hasChapter(): bool
{
return $this->chapter()->count() > 0;
}
@ -103,7 +104,7 @@ class Page extends BookChild
/**
* Get the url of this page.
*/
public function getUrl($path = ''): string
public function getUrl(string $path = ''): string
{
$parts = [
'books',
@ -129,7 +130,7 @@ class Page extends BookChild
/**
* Get this page for JSON display.
*/
public function forJsonDisplay(): Page
public function forJsonDisplay(): self
{
$refreshed = $this->refresh()->unsetRelations()->load(['tags', 'createdBy', 'updatedBy', 'ownedBy']);
$refreshed->setHidden(array_diff($refreshed->getHidden(), ['html', 'markdown']));

View File

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

View File

@ -124,7 +124,8 @@ class BookshelfRepo
$syncData = Book::visible()
->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)]];
});

View File

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

View File

@ -135,6 +135,12 @@ class PageContent
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
$imageName = 'embedded-image-' . Str::random(8) . '.' . $imageInfo['extension'];
@ -384,7 +390,7 @@ class PageContent
*/
protected function fetchSectionOfPage(Page $page, string $sectionId): string
{
$topLevelTags = ['table', 'ul', 'ol'];
$topLevelTags = ['table', 'ul', 'ol', 'pre'];
$doc = $this->loadDocumentFromHtml($page->html);
// 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();
$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]);
return trans('entities.pages_draft_edit_active.message', ['start' => $userMessage, 'time' => $timeMessage]);

View File

@ -2,26 +2,31 @@
namespace BookStack\Entities\Tools;
use BookStack\Actions\Tag;
use BookStack\Entities\EntityProvider;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Models\SearchTerm;
use DOMDocument;
use DOMNode;
use Illuminate\Support\Collection;
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
*/
protected $entityProvider;
public function __construct(SearchTerm $searchTerm, EntityProvider $entityProvider)
public function __construct(EntityProvider $entityProvider)
{
$this->searchTerm = $searchTerm;
$this->entityProvider = $entityProvider;
}
@ -31,14 +36,8 @@ class SearchIndex
public function indexEntity(Entity $entity)
{
$this->deleteEntityTerms($entity);
$nameTerms = $this->generateTermArrayFromText($entity->name, 5 * $entity->searchFactor);
$bodyTerms = $this->generateTermArrayFromText($entity->getText(), 1 * $entity->searchFactor);
$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);
$terms = $this->entityToTermDataArray($entity);
SearchTerm::query()->insert($terms);
}
/**
@ -46,40 +45,54 @@ class SearchIndex
*
* @param Entity[] $entities
*/
protected function indexEntities(array $entities)
public function indexEntities(array $entities)
{
$terms = [];
foreach ($entities as $entity) {
$nameTerms = $this->generateTermArrayFromText($entity->name, 5 * $entity->searchFactor);
$bodyTerms = $this->generateTermArrayFromText($entity->getText(), 1 * $entity->searchFactor);
foreach (array_merge($nameTerms, $bodyTerms) as $term) {
$term['entity_id'] = $entity->id;
$term['entity_type'] = $entity->getMorphClass();
$terms[] = $term;
}
$entityTerms = $this->entityToTermDataArray($entity);
array_push($terms, ...$entityTerms);
}
$chunkedTerms = array_chunk($terms, 500);
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.
* 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) {
$selectFields = ['id', 'name', $entityModel->textField];
$entityModel->newQuery()
->withTrashed()
->select($selectFields)
->chunk(1000, function (Collection $entities) {
$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()
->select($selectFields)
->with(['tags:id,name,value,entity_id,entity_type'])
->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}
$splitChars = " \n\t.,!?:;()[]{}<>`'\"";
$splitChars = static::$delimiters;
$token = strtok($text, $splitChars);
while ($token !== false) {
@ -108,14 +206,61 @@ class SearchIndex
$token = strtok($splitChars);
}
$terms = [];
foreach ($tokenMap as $token => $count) {
$terms[] = [
'term' => $token,
'score' => $count * $scoreAdjustment,
return $tokenMap;
}
/**
* 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.
*/
public static function fromString(string $search): SearchOptions
public static function fromString(string $search): self
{
$decoded = static::decode($search);
$instance = new static();
$instance = new SearchOptions();
foreach ($decoded as $type => $value) {
$instance->$type = $value;
}
@ -45,7 +45,7 @@ class SearchOptions
* Will look for a classic string term and use that
* 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')) {
return static::fromString('');
@ -55,17 +55,24 @@ class SearchOptions
return static::fromString($request->get('term'));
}
$instance = new static();
$instance = new SearchOptions();
$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'] ?? []);
foreach (($inputs['filters'] ?? []) as $filterKey => $filterVal) {
if (empty($filterVal)) {
continue;
}
$instance->filters[$filterKey] = $filterVal === 'true' ? '' : $filterVal;
}
if (isset($inputs['types']) && count($inputs['types']) < 4) {
$instance->filters['type'] = implode('|', $inputs['types']);
}
@ -102,11 +109,9 @@ class SearchOptions
}
// Parse standard terms
foreach (explode(' ', trim($searchString)) as $searchTerm) {
if ($searchTerm !== '') {
$terms['searches'][] = $searchTerm;
}
}
$parsedStandardTerms = static::parseStandardTermString($searchString);
array_push($terms['searches'], ...$parsedStandardTerms['terms']);
array_push($terms['exacts'], ...$parsedStandardTerms['exacts']);
// Split filter values out
$splitFilters = [];
@ -119,6 +124,33 @@ class SearchOptions
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.
*/

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\User;
use BookStack\Entities\EntityProvider;
use BookStack\Entities\Models\BookChild;
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\Collection as EloquentCollection;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Query\Builder;
use Illuminate\Database\Query\JoinClause;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use SplObjectStorage;
class SearchRunner
{
@ -20,11 +25,6 @@ class SearchRunner
*/
protected $entityProvider;
/**
* @var Connection
*/
protected $db;
/**
* @var PermissionService
*/
@ -37,17 +37,27 @@ class SearchRunner
*/
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->db = $db;
$this->permissionService = $permissionService;
$this->termAdjustmentCache = new SplObjectStorage();
}
/**
* Search all entities in the system.
* 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
{
@ -68,13 +78,18 @@ class SearchRunner
if (!in_array($entityType, $entityTypes)) {
continue;
}
$search = $this->searchEntityTable($searchOpts, $entityType, $page, $count, $action);
$entityTotal = $this->searchEntityTable($searchOpts, $entityType, $page, $count, $action, true);
if ($entityTotal > $page * $count) {
$entityModelInstance = $this->entityProvider->get($entityType);
$searchQuery = $this->buildQuery($searchOpts, $entityModelInstance, $action);
$entityTotal = $searchQuery->count();
$searchResults = $this->getPageOfDataFromQuery($searchQuery, $entityModelInstance, $page, $count);
if ($entityTotal > ($page * $count)) {
$hasMore = true;
}
$total += $entityTotal;
$results = $results->merge($search);
$results = $results->merge($searchResults);
}
return [
@ -99,7 +114,9 @@ class SearchRunner
if (!in_array($entityType, $entityTypes)) {
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);
}
@ -112,78 +129,199 @@ class SearchRunner
public function searchChapter(int $chapterId, string $searchString): Collection
{
$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');
}
/**
* Search across a particular entity type.
* Setting getCount = true will return the total
* matching instead of the items themselves.
*
* @return \Illuminate\Database\Eloquent\Collection|int|static[]
* Get a page of result data from the given query based on the provided page parameters.
*/
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);
if ($getCount) {
return $query->count();
$relations = ['tags'];
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.
*/
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);
$entitySelect = $entity->newQuery();
$entityQuery = $entityModelInstance->newQuery();
if ($entityModelInstance instanceof Page) {
$entityQuery->select($entityModelInstance::$listAttributes);
} else {
$entityQuery->select(['*']);
}
// Handle normal search terms
if (count($searchOpts->searches) > 0) {
$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);
}
$this->applyTermSearch($entityQuery, $searchOpts, $entityModelInstance);
// Handle exact term matching
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 . '%')
->orWhere($entity->textField, 'like', '%' . $inputTerm . '%');
->orWhere($entityModelInstance->textField, 'like', '%' . $inputTerm . '%');
});
}
// Handle tag searches
foreach ($searchOpts->tags as $inputTerm) {
$this->applyTagSearch($entitySelect, $inputTerm);
$this->applyTagSearch($entityQuery, $inputTerm);
}
// Handle filters
foreach ($searchOpts->filters as $filterTerm => $filterValue) {
$functionName = Str::camel('filter_' . $filterTerm);
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);
}
return join('|', $escapedOperators);
return implode('|', $escapedOperators);
}
/**
@ -234,44 +372,40 @@ class SearchRunner
/**
* Custom entity search filters.
*/
protected function filterUpdatedAfter(EloquentBuilder $query, Entity $model, $input)
protected function filterUpdatedAfter(EloquentBuilder $query, Entity $model, $input): void
{
try {
$date = date_create($input);
} catch (\Exception $e) {
return;
}
$query->where('updated_at', '>=', $date);
} catch (\Exception $e) {
}
}
protected function filterUpdatedBefore(EloquentBuilder $query, Entity $model, $input)
protected function filterUpdatedBefore(EloquentBuilder $query, Entity $model, $input): void
{
try {
$date = date_create($input);
} catch (\Exception $e) {
return;
}
$query->where('updated_at', '<', $date);
} catch (\Exception $e) {
}
}
protected function filterCreatedAfter(EloquentBuilder $query, Entity $model, $input)
protected function filterCreatedAfter(EloquentBuilder $query, Entity $model, $input): void
{
try {
$date = date_create($input);
} catch (\Exception $e) {
return;
}
$query->where('created_at', '>=', $date);
} catch (\Exception $e) {
}
}
protected function filterCreatedBefore(EloquentBuilder $query, Entity $model, $input)
{
try {
$date = date_create($input);
} catch (\Exception $e) {
return;
}
$query->where('created_at', '<', $date);
} catch (\Exception $e) {
}
}
protected function filterCreatedBy(EloquentBuilder $query, Entity $model, $input)
@ -348,9 +482,9 @@ class SearchRunner
*/
protected function sortByLastCommented(EloquentBuilder $query, Entity $model)
{
$commentsTable = $this->db->getTablePrefix() . 'comments';
$commentsTable = DB::getTablePrefix() . 'comments';
$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');
}

View File

@ -5,6 +5,7 @@ namespace BookStack\Entities\Tools;
use BookStack\Entities\EntityProvider;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Page;
use Illuminate\Support\Collection;
class SiblingFetcher
@ -18,18 +19,18 @@ class SiblingFetcher
$entities = [];
// Page in chapter
if ($entity->isA('page') && $entity->chapter) {
if ($entity instanceof Page && $entity->chapter) {
$entities = $entity->chapter->getVisiblePages();
}
// 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();
}
// Book
// 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);
if ($contextShelf) {
$entities = $contextShelf->visibleBooks()->get();
@ -38,8 +39,8 @@ class SiblingFetcher
}
}
// Shelve
if ($entity->isA('bookshelf')) {
// Shelf
if ($entity instanceof Bookshelf) {
$entities = Bookshelf::visible()->get();
}

View File

@ -4,13 +4,14 @@ namespace BookStack\Entities\Tools;
use BookStack\Entities\Models\BookChild;
use BookStack\Interfaces\Sluggable;
use BookStack\Model;
use Illuminate\Support\Str;
class SlugGenerator
{
/**
* 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
{
@ -38,6 +39,8 @@ class SlugGenerator
/**
* Check if a slug is already in-use for this
* type of model within the same parent.
*
* @param Sluggable&Model $model
*/
protected function slugInUse(string $slug, Sluggable $model): bool
{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -15,21 +15,6 @@ class AttachmentApiController extends ApiController
{
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)
{
$this->attachmentService = $attachmentService;
@ -61,7 +46,7 @@ class AttachmentApiController extends ApiController
public function create(Request $request)
{
$this->checkPermission('attachment-create-all');
$requestData = $this->validate($request, $this->rules['create']);
$requestData = $this->validate($request, $this->rules()['create']);
$pageId = $request->get('uploaded_to');
$page = Page::visible()->findOrFail($pageId);
@ -122,7 +107,7 @@ class AttachmentApiController extends ApiController
*/
public function update(Request $request, string $id)
{
$requestData = $this->validate($request, $this->rules['update']);
$requestData = $this->validate($request, $this->rules()['update']);
/** @var Attachment $attachment */
$attachment = Attachment::visible()->findOrFail($id);
@ -162,4 +147,22 @@ class AttachmentApiController extends ApiController
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 = [
'create' => [
'name' => 'required|string|max:255',
'description' => 'string|max:1000',
'tags' => 'array',
'name' => ['required', 'string', 'max:255'],
'description' => ['string', 'max:1000'],
'tags' => ['array'],
],
'update' => [
'name' => 'string|min:1|max:255',
'description' => 'string|max:1000',
'tags' => 'array',
'name' => ['string', 'min:1', 'max:255'],
'description' => ['string', 'max:1000'],
'tags' => ['array'],
],
];

View File

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

View File

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

View File

@ -16,20 +16,20 @@ class PageApiController extends ApiController
protected $rules = [
'create' => [
'book_id' => 'required_without:chapter_id|integer',
'chapter_id' => 'required_without:book_id|integer',
'name' => 'required|string|max:255',
'html' => 'required_without:markdown|string',
'markdown' => 'required_without:html|string',
'tags' => 'array',
'book_id' => ['required_without:chapter_id', 'integer'],
'chapter_id' => ['required_without:book_id', 'integer'],
'name' => ['required', 'string', 'max:255'],
'html' => ['required_without:markdown', 'string'],
'markdown' => ['required_without:html', 'string'],
'tags' => ['array'],
],
'update' => [
'book_id' => 'required|integer',
'chapter_id' => 'required|integer',
'name' => 'string|min:1|max:255',
'html' => 'string',
'markdown' => 'string',
'tags' => 'array',
'book_id' => ['required', 'integer'],
'chapter_id' => ['required', 'integer'],
'name' => ['string', 'min:1', 'max:255'],
'html' => ['string'],
'markdown' => ['string'],
'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)
{
$this->validate($request, [
'uploaded_to' => 'required|integer|exists:pages,id',
'file' => 'required|file',
'uploaded_to' => ['required', 'integer', 'exists:pages,id'],
'file' => array_merge(['required'], $this->attachmentService->getFileValidationRules()),
]);
$pageId = $request->get('uploaded_to');
@ -65,7 +65,7 @@ class AttachmentController extends Controller
public function uploadUpdate(Request $request, $attachmentId)
{
$this->validate($request, [
'file' => 'required|file',
'file' => array_merge(['required'], $this->attachmentService->getFileValidationRules()),
]);
/** @var Attachment $attachment */
@ -111,8 +111,8 @@ class AttachmentController extends Controller
try {
$this->validate($request, [
'attachment_edit_name' => 'required|string|min:1|max:255',
'attachment_edit_url' => 'string|min:1|max:255|safe_url',
'attachment_edit_name' => ['required', 'string', 'min:1', 'max:255'],
'attachment_edit_url' => ['string', 'min:1', 'max:255', 'safe_url'],
]);
} catch (ValidationException $exception) {
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 {
$this->validate($request, [
'attachment_link_uploaded_to' => 'required|integer|exists:pages,id',
'attachment_link_name' => 'required|string|min:1|max:255',
'attachment_link_url' => 'required|string|min:1|max:255|safe_url',
'attachment_link_uploaded_to' => ['required', 'integer', 'exists:pages,id'],
'attachment_link_name' => ['required', 'string', 'min:1', 'max:255'],
'attachment_link_url' => ['required', 'string', 'min:1', 'max:255', 'safe_url'],
]);
} catch (ValidationException $exception) {
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)
{
$this->validate($request, [
'order' => 'required|array',
'order' => ['required', 'array'],
]);
$page = $this->pageRepo->getById($pageId);
$this->checkOwnablePermission('page-update', $page);

View File

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

View File

@ -43,7 +43,9 @@ class ForgotPasswordController extends Controller
*/
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
// 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)
{
$rules = ['password' => 'required|string'];
$rules = ['password' => ['required', 'string']];
$authMethod = config('auth.method');
if ($authMethod === 'standard') {
$rules['email'] = 'required|email';
$rules['email'] = ['required', 'email'];
}
if ($authMethod === 'ldap') {
$rules['username'] = 'required|string';
$rules['email'] = 'email';
$rules['username'] = ['required', 'string'];
$rules['email'] = ['email'];
}
$request->validate($rules);

View File

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

View File

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

View File

@ -5,8 +5,7 @@ namespace BookStack\Http\Controllers\Auth;
use BookStack\Auth\Access\Saml2Service;
use BookStack\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Str;
use Illuminate\Support\Str;
class Saml2Controller extends Controller
{
@ -79,11 +78,6 @@ class Saml2Controller extends Controller
*/
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);
if (empty($samlResponse)) {
@ -114,7 +108,7 @@ class Saml2Controller extends Controller
$samlResponse = decrypt(cache()->pull($cacheKey));
} catch (\Exception $exception) {
}
$requestId = session()->pull('saml2_request_id', 'unset');
$requestId = session()->pull('saml2_request_id', null);
if (empty($acsId) || empty($samlResponse)) {
$this->showErrorNotification(trans('errors.saml_fail_authed', ['system' => config('saml2.name')]));

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -165,7 +165,7 @@ abstract class Controller extends BaseController
/**
* Log an activity in the system.
*
* @param string|Loggable
* @param string|Loggable $detail
*/
protected function logActivity(string $type, $detail = ''): void
{
@ -175,8 +175,8 @@ abstract class Controller extends BaseController
/**
* 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 \Exception
*/
protected function getValidatedModelFromRequest(Request $request): Favouritable
protected function getValidatedModelFromRequest(Request $request): Entity
{
$modelInfo = $this->validate($request, [
'type' => 'required|string',
'id' => 'required|integer',
'type' => ['required', 'string'],
'id' => ['required', 'integer'],
]);
if (!class_exists($modelInfo['type'])) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -20,9 +20,9 @@ class StatusController extends Controller
}),
'cache' => $this->trueWithoutError(function () {
$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 () {
$rand = Str::random();

View File

@ -17,6 +17,28 @@ class TagController extends Controller
$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.
*/

View File

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

View File

@ -74,18 +74,18 @@ class UserController extends Controller
{
$this->checkPermission('users-manage');
$validationRules = [
'name' => 'required',
'email' => 'required|email|unique:users,email',
'name' => ['required'],
'email' => ['required', 'email', 'unique:users,email'],
];
$authMethod = config('auth.method');
$sendInvite = ($request->get('send_invite', 'false') === 'true');
if ($authMethod === 'standard' && !$sendInvite) {
$validationRules['password'] = 'required|min:6';
$validationRules['password-confirm'] = 'required|same:password';
$validationRules['password'] = ['required', 'min:6'];
$validationRules['password-confirm'] = ['required', 'same:password'];
} elseif ($authMethod === 'ldap' || $authMethod === 'saml2' || $authMethod === 'openid') {
$validationRules['external_auth_id'] = 'required';
$validationRules['external_auth_id'] = ['required'];
}
$this->validate($request, $validationRules);
@ -156,11 +156,11 @@ class UserController extends Controller
$this->validate($request, [
'name' => 'min:2',
'email' => 'min:2|email|unique:users,email,' . $id,
'password' => 'min:6|required_with:password_confirm',
'password-confirm' => 'same:password|required_with:password',
'email' => ['min:2', 'email', 'unique:users,email,' . $id],
'password' => ['min:6', 'required_with:password_confirm'],
'password-confirm' => ['same:password', 'required_with:password'],
'setting' => 'array',
'profile_image' => 'nullable|' . $this->getImageValidationRules(),
'profile_image' => array_merge(['nullable'], $this->getImageValidationRules()),
]);
$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.
*/
protected $middleware = [
\BookStack\Http\Middleware\CheckForMaintenanceMode::class,
\BookStack\Http\Middleware\PreventRequestsDuringMaintenance::class,
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
\BookStack\Http\Middleware\TrimStrings::class,
\BookStack\Http\Middleware\TrustProxies::class,

View File

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

View File

@ -2,9 +2,9 @@
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.

View File

@ -2,43 +2,30 @@
namespace BookStack\Http\Middleware;
use BookStack\Providers\RouteServiceProvider;
use Closure;
use Illuminate\Contracts\Auth\Guard;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
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.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @param string|null ...$guards
*
* @return mixed
*/
public function handle($request, Closure $next)
public function handle(Request $request, Closure $next, ...$guards)
{
$requireConfirmation = setting('registration-confirmation');
if ($this->auth->check() && (!$requireConfirmation || ($requireConfirmation && $this->auth->user()->email_confirmed))) {
return redirect('/');
$guards = empty($guards) ? [null] : $guards;
foreach ($guards as $guard) {
if (Auth::guard($guard)->check()) {
return redirect(RouteServiceProvider::HOME);
}
}
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;
use Closure;
use Fideloper\Proxy\TrustProxies as Middleware;
use Illuminate\Http\Middleware\TrustProxies as Middleware;
use Illuminate\Http\Request;
class TrustProxies extends Middleware
@ -20,7 +20,7 @@ class TrustProxies extends Middleware
*
* @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.

View File

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

View File

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

View File

@ -16,6 +16,7 @@ use BookStack\Util\CspService;
use GuzzleHttp\Client;
use Illuminate\Contracts\Cache\Repository;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\URL;
@ -60,6 +61,9 @@ class AppServiceProvider extends ServiceProvider
// View Composers
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()
{
parent::boot();
}
}

View File

@ -2,11 +2,23 @@
namespace BookStack\Providers;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Route;
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.
*
@ -14,7 +26,6 @@ class RouteServiceProvider extends ServiceProvider
*
* @var string
*/
protected $namespace = 'BookStack\Http\Controllers';
/**
* Define your route model bindings, pattern filters, etc.
@ -23,18 +34,12 @@ class RouteServiceProvider extends ServiceProvider
*/
public function boot()
{
parent::boot();
}
$this->configureRateLimiting();
/**
* Define the routes for the application.
*
* @return void
*/
public function map()
{
$this->routes(function () {
$this->mapWebRoutes();
$this->mapApiRoutes();
});
}
/**
@ -71,4 +76,16 @@ class RouteServiceProvider extends ServiceProvider
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;
/**
* @property int created_by
* @property int updated_by
* @property int $created_by
* @property int $updated_by
*/
trait HasCreatorAndUpdater
{

View File

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

View File

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

View File

@ -4,9 +4,9 @@ namespace BookStack\Uploads;
use BookStack\Exceptions\FileUploadException;
use Exception;
use Illuminate\Contracts\Filesystem\Factory as FileSystem;
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\Str;
use League\Flysystem\Util;
@ -19,7 +19,7 @@ class AttachmentService
/**
* AttachmentService constructor.
*/
public function __construct(FileSystem $fileSystem)
public function __construct(FilesystemManager $fileSystem)
{
$this->fileSystem = $fileSystem;
}
@ -27,7 +27,7 @@ class AttachmentService
/**
* Get the storage that will be used for storing files.
*/
protected function getStorageDisk(): FileSystemInstance
protected function getStorageDisk(): Storage
{
return $this->fileSystem->disk($this->getStorageDiskName());
}
@ -233,4 +233,12 @@ class AttachmentService
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