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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -23,4 +23,5 @@ nbproject
 | 
			
		|||
.settings/
 | 
			
		||||
webpack-stats.json
 | 
			
		||||
.phpunit.result.cache
 | 
			
		||||
.DS_Store
 | 
			
		||||
.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