Merge branch 'master' into release
This commit is contained in:
commit
1755556468
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -24,3 +24,4 @@ nbproject
|
|||
webpack-stats.json
|
||||
.phpunit.result.cache
|
||||
.DS_Store
|
||||
phpstan.neon
|
|
@ -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];
|
||||
}
|
||||
|
|
|
@ -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'];
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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'];
|
||||
|
||||
|
|
|
@ -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'] ?? ''),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -43,7 +43,7 @@ class ApiToken extends Model implements Loggable
|
|||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function logDescriptor(): string
|
||||
{
|
||||
|
|
|
@ -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 = [])
|
||||
{
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
{
|
||||
|
|
|
@ -21,7 +21,7 @@ class SocialAccount extends Model implements Loggable
|
|||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function logDescriptor(): string
|
||||
{
|
||||
|
|
|
@ -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
|
||||
{
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -10,7 +10,6 @@
|
|||
|
||||
return [
|
||||
|
||||
// Method of authentication to use
|
||||
// Options: standard, ldap, saml2, oidc
|
||||
'method' => env('AUTH_METHOD', 'standard'),
|
||||
|
||||
|
@ -45,7 +44,7 @@ return [
|
|||
'provider' => 'external',
|
||||
],
|
||||
'api' => [
|
||||
'driver' => 'api-token',
|
||||
'driver' => 'api-token',
|
||||
],
|
||||
],
|
||||
|
||||
|
@ -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,
|
||||
|
||||
];
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Caching configuration options.
|
||||
*
|
||||
|
@ -38,13 +40,15 @@ return [
|
|||
],
|
||||
|
||||
'array' => [
|
||||
'driver' => 'array',
|
||||
'driver' => 'array',
|
||||
'serialize' => false,
|
||||
],
|
||||
|
||||
'database' => [
|
||||
'driver' => 'database',
|
||||
'table' => 'cache',
|
||||
'connection' => null,
|
||||
'driver' => 'database',
|
||||
'table' => 'cache',
|
||||
'connection' => null,
|
||||
'lock_connection' => null,
|
||||
],
|
||||
|
||||
'file' => [
|
||||
|
@ -53,19 +57,36 @@ return [
|
|||
],
|
||||
|
||||
'memcached' => [
|
||||
'driver' => 'memcached',
|
||||
'servers' => env('CACHE_DRIVER') === 'memcached' ? $memcachedServers : [],
|
||||
'driver' => 'memcached',
|
||||
'options' => [
|
||||
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
|
||||
],
|
||||
'servers' => $memcachedServers ?? [],
|
||||
],
|
||||
|
||||
'redis' => [
|
||||
'driver' => 'redis',
|
||||
'connection' => 'default',
|
||||
'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'),
|
||||
|
||||
];
|
||||
|
|
|
@ -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,
|
||||
|
||||
];
|
|
@ -105,6 +105,6 @@ return [
|
|||
'migrations' => 'migrations',
|
||||
|
||||
// Redis configuration to use if set
|
||||
'redis' => env('REDIS_SERVERS', false) ? $redisConfig : [],
|
||||
'redis' => $redisConfig ?? [],
|
||||
|
||||
];
|
||||
|
|
|
@ -25,16 +25,14 @@ 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' => [
|
||||
|
||||
'local' => [
|
||||
'driver' => 'local',
|
||||
'root' => public_path(),
|
||||
'driver' => 'local',
|
||||
'root' => public_path(),
|
||||
'visibility' => 'public',
|
||||
],
|
||||
|
||||
'local_secure_attachments' => [
|
||||
|
@ -43,8 +41,9 @@ return [
|
|||
],
|
||||
|
||||
'local_secure_images' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('uploads/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'),
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'),
|
||||
|
||||
|
|
|
@ -22,25 +22,29 @@ return [
|
|||
],
|
||||
|
||||
'database' => [
|
||||
'driver' => 'database',
|
||||
'table' => 'jobs',
|
||||
'queue' => 'default',
|
||||
'retry_after' => 90,
|
||||
'driver' => 'database',
|
||||
'table' => 'jobs',
|
||||
'queue' => 'default',
|
||||
'retry_after' => 90,
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'redis' => [
|
||||
'driver' => 'redis',
|
||||
'connection' => 'default',
|
||||
'queue' => env('REDIS_QUEUE', 'default'),
|
||||
'retry_after' => 90,
|
||||
'block_for' => null,
|
||||
'driver' => 'redis',
|
||||
'connection' => 'default',
|
||||
'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',
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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'];
|
||||
|
|
|
@ -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'];
|
||||
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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,7 +48,11 @@ class Deletion extends Model implements Loggable
|
|||
{
|
||||
$deletable = $this->deletable()->first();
|
||||
|
||||
return "Deletion ({$this->id}) for {$deletable->getType()} ({$deletable->id}) {$deletable->name}";
|
||||
if ($deletable instanceof Entity) {
|
||||
return "Deletion ({$this->id}) for {$deletable->getType()} ({$deletable->id}) {$deletable->name}";
|
||||
}
|
||||
|
||||
return "Deletion ({$this->id})";
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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
|
||||
{
|
||||
|
|
|
@ -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']));
|
||||
|
|
|
@ -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
|
||||
{
|
||||
|
|
|
@ -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)]];
|
||||
});
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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]);
|
||||
|
|
|
@ -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];
|
||||
$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()
|
||||
->withTrashed()
|
||||
->select($selectFields)
|
||||
->chunk(1000, function (Collection $entities) {
|
||||
$this->indexEntities($entities->all());
|
||||
});
|
||||
->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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
$query->where('updated_at', '>=', $date);
|
||||
} catch (\Exception $e) {
|
||||
return;
|
||||
}
|
||||
$query->where('updated_at', '>=', $date);
|
||||
}
|
||||
|
||||
protected function filterUpdatedBefore(EloquentBuilder $query, Entity $model, $input)
|
||||
protected function filterUpdatedBefore(EloquentBuilder $query, Entity $model, $input): void
|
||||
{
|
||||
try {
|
||||
$date = date_create($input);
|
||||
$query->where('updated_at', '<', $date);
|
||||
} catch (\Exception $e) {
|
||||
return;
|
||||
}
|
||||
$query->where('updated_at', '<', $date);
|
||||
}
|
||||
|
||||
protected function filterCreatedAfter(EloquentBuilder $query, Entity $model, $input)
|
||||
protected function filterCreatedAfter(EloquentBuilder $query, Entity $model, $input): void
|
||||
{
|
||||
try {
|
||||
$date = date_create($input);
|
||||
$query->where('created_at', '>=', $date);
|
||||
} catch (\Exception $e) {
|
||||
return;
|
||||
}
|
||||
$query->where('created_at', '>=', $date);
|
||||
}
|
||||
|
||||
protected function filterCreatedBefore(EloquentBuilder $query, Entity $model, $input)
|
||||
{
|
||||
try {
|
||||
$date = date_create($input);
|
||||
$query->where('created_at', '<', $date);
|
||||
} catch (\Exception $e) {
|
||||
return;
|
||||
}
|
||||
$query->where('created_at', '<', $date);
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
{
|
||||
|
|
|
@ -323,6 +323,8 @@ class TrashCan
|
|||
if ($entity instanceof Bookshelf) {
|
||||
return $this->destroyShelf($entity);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -23,7 +23,7 @@ class NotifyException extends Exception implements Responsable
|
|||
/**
|
||||
* Send the response for this type of exception.
|
||||
*
|
||||
* @inheritdoc
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function toResponse($request)
|
||||
{
|
||||
|
|
|
@ -20,7 +20,7 @@ class PrettyException extends Exception implements Responsable
|
|||
/**
|
||||
* Render a response for when this exception occurs.
|
||||
*
|
||||
* @inheritdoc
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function toResponse($request)
|
||||
{
|
||||
|
|
|
@ -23,7 +23,7 @@ class StoppedAuthenticationException extends \Exception implements Responsable
|
|||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function toResponse($request)
|
||||
{
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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'],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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'],
|
||||
],
|
||||
];
|
||||
|
||||
|
|
|
@ -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'],
|
||||
],
|
||||
];
|
||||
|
||||
|
|
|
@ -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'],
|
||||
],
|
||||
];
|
||||
|
||||
|
|
|
@ -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'],
|
||||
],
|
||||
];
|
||||
|
||||
|
|
|
@ -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'],
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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,33 +54,23 @@ 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) {
|
||||
$this->showErrorNotification(trans('errors.email_confirmation_invalid'));
|
||||
} catch (UserTokenNotFoundException $exception) {
|
||||
$this->showErrorNotification(trans('errors.email_confirmation_invalid'));
|
||||
|
||||
return redirect('/register');
|
||||
}
|
||||
return redirect('/register');
|
||||
} catch (UserTokenExpiredException $exception) {
|
||||
$user = $this->userRepo->getById($exception->userId);
|
||||
$this->emailConfirmationService->sendConfirmation($user);
|
||||
$this->showErrorNotification(trans('errors.email_confirmation_expired'));
|
||||
|
||||
if ($exception instanceof UserTokenExpiredException) {
|
||||
$user = $this->userRepo->getById($exception->userId);
|
||||
$this->emailConfirmationService->sendConfirmation($user);
|
||||
$this->showErrorNotification(trans('errors.email_confirmation_expired'));
|
||||
|
||||
return redirect('/register/confirm');
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
return redirect('/register/confirm');
|
||||
}
|
||||
|
||||
$user = $this->userRepo->getById($userId);
|
||||
|
@ -92,22 +79,17 @@ class ConfirmEmailController extends Controller
|
|||
|
||||
$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'));
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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'));
|
||||
|
|
|
@ -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'],
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
@ -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')]));
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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', ''));
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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)];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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'])) {
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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',
|
||||
]);
|
||||
|
||||
|
|
|
@ -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'],
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -12,7 +12,7 @@ class CheckUserHasPermission
|
|||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @param \Closure $next
|
||||
* @param $permission
|
||||
* @param string $permission
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
|
|
|
@ -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.
|
|
@ -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);
|
||||
|
|
|
@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
{
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -30,6 +30,5 @@ class EventServiceProvider extends ServiceProvider
|
|||
*/
|
||||
public function boot()
|
||||
{
|
||||
parent::boot();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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->mapWebRoutes();
|
||||
$this->mapApiRoutes();
|
||||
$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());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
{
|
||||
|
|
|
@ -6,7 +6,7 @@ use BookStack\Auth\User;
|
|||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* @property int owned_by
|
||||
* @property int $owned_by
|
||||
*/
|
||||
trait HasOwner
|
||||
{
|
||||
|
|
|
@ -93,7 +93,7 @@ class Attachment extends Model
|
|||
|
||||
return $permissionService->filterRelatedEntity(
|
||||
Page::class,
|
||||
Attachment::query(),
|
||||
self::query(),
|
||||
'attachments',
|
||||
'uploaded_to'
|
||||
);
|
||||
|
|
|
@ -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
Loading…
Reference in New Issue