Merge branch 'master' into release
This commit is contained in:
		
						commit
						34854915b3
					
				| 
						 | 
				
			
			@ -37,6 +37,11 @@ APP_AUTO_LANG_PUBLIC=true
 | 
			
		|||
# Valid timezone values can be found here: https://www.php.net/manual/en/timezones.php
 | 
			
		||||
APP_TIMEZONE=UTC
 | 
			
		||||
 | 
			
		||||
# Application theme
 | 
			
		||||
# Used to specific a themes/<APP_THEME> folder where BookStack UI
 | 
			
		||||
# overrides can be made. Defaults to disabled.
 | 
			
		||||
APP_THEME=false
 | 
			
		||||
 | 
			
		||||
# Database details
 | 
			
		||||
# Host can contain a port (localhost:3306) or a separate DB_PORT option can be used.
 | 
			
		||||
DB_HOST=localhost
 | 
			
		||||
| 
						 | 
				
			
			@ -89,7 +94,7 @@ REDIS_SERVERS=127.0.0.1:6379:0
 | 
			
		|||
# Queue driver to use
 | 
			
		||||
# Queue not really currently used but may be configurable in the future.
 | 
			
		||||
# Would advise not to change this for now.
 | 
			
		||||
QUEUE_DRIVER=sync
 | 
			
		||||
QUEUE_CONNECTION=sync
 | 
			
		||||
 | 
			
		||||
# Storage system to use
 | 
			
		||||
# Can be 'local', 'local_secure' or 's3'
 | 
			
		||||
| 
						 | 
				
			
			@ -121,7 +126,7 @@ STORAGE_S3_ENDPOINT=https://my-custom-s3-compatible.service.com:8001
 | 
			
		|||
STORAGE_URL=false
 | 
			
		||||
 | 
			
		||||
# Authentication method to use
 | 
			
		||||
# Can be 'standard' or 'ldap'
 | 
			
		||||
# Can be 'standard', 'ldap' or 'saml2'
 | 
			
		||||
AUTH_METHOD=standard
 | 
			
		||||
 | 
			
		||||
# Social authentication configuration
 | 
			
		||||
| 
						 | 
				
			
			@ -191,6 +196,7 @@ LDAP_PASS=false
 | 
			
		|||
LDAP_USER_FILTER=false
 | 
			
		||||
LDAP_VERSION=false
 | 
			
		||||
LDAP_TLS_INSECURE=false
 | 
			
		||||
LDAP_ID_ATTRIBUTE=uid
 | 
			
		||||
LDAP_EMAIL_ATTRIBUTE=mail
 | 
			
		||||
LDAP_DISPLAY_NAME_ATTRIBUTE=cn
 | 
			
		||||
LDAP_FOLLOW_REFERRALS=true
 | 
			
		||||
| 
						 | 
				
			
			@ -201,6 +207,26 @@ LDAP_USER_TO_GROUPS=false
 | 
			
		|||
LDAP_GROUP_ATTRIBUTE="memberOf"
 | 
			
		||||
LDAP_REMOVE_FROM_GROUPS=false
 | 
			
		||||
 | 
			
		||||
# SAML authentication configuration
 | 
			
		||||
# Refer to https://www.bookstackapp.com/docs/admin/saml2-auth/
 | 
			
		||||
SAML2_NAME=SSO
 | 
			
		||||
SAML2_EMAIL_ATTRIBUTE=email
 | 
			
		||||
SAML2_DISPLAY_NAME_ATTRIBUTES=username
 | 
			
		||||
SAML2_EXTERNAL_ID_ATTRIBUTE=null
 | 
			
		||||
SAML2_IDP_ENTITYID=null
 | 
			
		||||
SAML2_IDP_SSO=null
 | 
			
		||||
SAML2_IDP_SLO=null
 | 
			
		||||
SAML2_IDP_x509=null
 | 
			
		||||
SAML2_ONELOGIN_OVERRIDES=null
 | 
			
		||||
SAML2_DUMP_USER_DETAILS=false
 | 
			
		||||
SAML2_AUTOLOAD_METADATA=false
 | 
			
		||||
 | 
			
		||||
# SAML group sync configuration
 | 
			
		||||
# Refer to https://www.bookstackapp.com/docs/admin/saml2-auth/
 | 
			
		||||
SAML2_USER_TO_GROUPS=false
 | 
			
		||||
SAML2_GROUP_ATTRIBUTE=group
 | 
			
		||||
SAML2_REMOVE_FROM_GROUPS=false
 | 
			
		||||
 | 
			
		||||
# Disable default third-party services such as Gravatar and Draw.IO
 | 
			
		||||
# Service-specific options will override this option
 | 
			
		||||
DISABLE_EXTERNAL_SERVICES=false
 | 
			
		||||
| 
						 | 
				
			
			@ -235,3 +261,9 @@ ALLOW_CONTENT_SCRIPTS=false
 | 
			
		|||
# Contents of the robots.txt file can be overridden, making this option obsolete.
 | 
			
		||||
ALLOW_ROBOTS=null
 | 
			
		||||
 | 
			
		||||
# The default and maximum item-counts for listing API requests.
 | 
			
		||||
API_DEFAULT_ITEM_COUNT=100
 | 
			
		||||
API_MAX_ITEM_COUNT=500
 | 
			
		||||
 | 
			
		||||
# The number of API requests that can be made per minute by a single user.
 | 
			
		||||
API_REQUESTS_PER_MIN=180
 | 
			
		||||
| 
						 | 
				
			
			@ -1,5 +1,5 @@
 | 
			
		|||
---
 | 
			
		||||
name: Bug report
 | 
			
		||||
name: Bug Report
 | 
			
		||||
about: Create a report to help us improve
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,5 +1,5 @@
 | 
			
		|||
---
 | 
			
		||||
name: Feature request
 | 
			
		||||
name: Feature Request
 | 
			
		||||
about: Suggest an idea for this project
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,13 @@
 | 
			
		|||
---
 | 
			
		||||
name: Language Request
 | 
			
		||||
about: Request a new language to be added to Crowdin for you to translate
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
### Language To Add
 | 
			
		||||
 | 
			
		||||
_Specify here the language you want to add._
 | 
			
		||||
 | 
			
		||||
----
 | 
			
		||||
 | 
			
		||||
_This issue template is to request a new language be added to our [Crowdin translation management project](https://crowdin.com/project/bookstack). Please don't use this template to request a new language that you are not prepared to provide translations for._   
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,73 @@
 | 
			
		|||
Name :: Languages
 | 
			
		||||
@robertlandes :: German
 | 
			
		||||
@SergioMendolia :: French
 | 
			
		||||
@NakaharaL :: Portuguese, Brazilian
 | 
			
		||||
@ReeseSebastian :: German
 | 
			
		||||
@arietimmerman :: Dutch
 | 
			
		||||
@diegoseso :: Spanish
 | 
			
		||||
@S64 :: Japanese
 | 
			
		||||
@JachuPL :: Polish
 | 
			
		||||
@Joorem :: French
 | 
			
		||||
@timoschwarzer :: German
 | 
			
		||||
@sanderdw :: Dutch
 | 
			
		||||
@lbguilherme :: Portuguese, Brazilian
 | 
			
		||||
@marcusforsberg :: Swedish
 | 
			
		||||
@artur-trzesiok :: Polish
 | 
			
		||||
@Alwaysin :: French
 | 
			
		||||
@msaus :: Japanese
 | 
			
		||||
@moucho :: Spanish
 | 
			
		||||
@vriic :: German
 | 
			
		||||
@DeehSlash :: Portuguese, Brazilian
 | 
			
		||||
@alex2702 :: German
 | 
			
		||||
@nicobubulle :: French
 | 
			
		||||
@kmoj86 :: Arabic
 | 
			
		||||
@houbaron :: Chinese Traditional; Chinese Simplified
 | 
			
		||||
@mullinsmikey :: Russian
 | 
			
		||||
@limkukhyun :: Korean
 | 
			
		||||
@CliffyPrime :: German
 | 
			
		||||
@kejjang :: Chinese Traditional
 | 
			
		||||
@TheLastOperator :: French
 | 
			
		||||
@qianmengnet :: Simplified Chinese
 | 
			
		||||
@ezzra :: German; German Informal
 | 
			
		||||
@vasiliev123 :: Polish
 | 
			
		||||
@Mant1kor :: Ukrainian
 | 
			
		||||
@Xiphoseer :: German; German Informal
 | 
			
		||||
@maantje :: Dutch
 | 
			
		||||
@cima :: Czech
 | 
			
		||||
@agvol :: Russian
 | 
			
		||||
@Hambern :: Swedish
 | 
			
		||||
@NootoNooto :: Dutch
 | 
			
		||||
@kostefun :: Russian
 | 
			
		||||
@lucaguindani :: French
 | 
			
		||||
@miles75 :: Hungarian
 | 
			
		||||
@danielroehrig-mm :: German
 | 
			
		||||
@oykenfurkan :: Turkish
 | 
			
		||||
@qligier :: French
 | 
			
		||||
@johnroyer :: Traditional Chinese
 | 
			
		||||
@artskoczylas :: Polish
 | 
			
		||||
@dellamina :: Italian
 | 
			
		||||
@jzoy :: Simplified Chinese
 | 
			
		||||
@ististudio :: Korean
 | 
			
		||||
@leomartinez :: Spanish Argentina
 | 
			
		||||
cipi1965 :: Italian
 | 
			
		||||
Mykola Ronik (Mantikor) :: Ukrainian
 | 
			
		||||
furkanoyk :: Turkish
 | 
			
		||||
m0uch0 :: Spanish
 | 
			
		||||
Maxim Zalata (zlatin) :: Russian; Ukrainian
 | 
			
		||||
nutsflag :: French
 | 
			
		||||
Leonardo Mario Martinez (leonardo.m.martinez) :: Spanish, Argentina
 | 
			
		||||
Rodrigo Saczuk Niz (rodrigoniz) :: Portuguese, Brazilian
 | 
			
		||||
叫钦叔就好 (254351722) :: Chinese Traditional; Chinese Simplified
 | 
			
		||||
aekramer :: Dutch
 | 
			
		||||
JachuPL :: Polish
 | 
			
		||||
milesteg :: Hungarian
 | 
			
		||||
Beenbag :: German
 | 
			
		||||
Lett3rs :: Danish
 | 
			
		||||
Julian (julian.henneberg) :: German; German Informal
 | 
			
		||||
3GNWn :: Danish
 | 
			
		||||
dbguichu :: Chinese Simplified
 | 
			
		||||
Randy Kim (hyunjun) :: Korean
 | 
			
		||||
Francesco M. Taurino (ftaurino) :: Italian
 | 
			
		||||
DanielFrederiksen :: Danish
 | 
			
		||||
Finn Wessel (19finnwessel6) :: German
 | 
			
		||||
Gustav Kånåhols (Kurbitz) :: Swedish
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,50 @@
 | 
			
		|||
name: phpunit
 | 
			
		||||
 | 
			
		||||
on:
 | 
			
		||||
  push:
 | 
			
		||||
    branches:
 | 
			
		||||
      - master
 | 
			
		||||
      - release
 | 
			
		||||
  pull_request:
 | 
			
		||||
    branches:
 | 
			
		||||
      - '*'
 | 
			
		||||
      - '*/*'
 | 
			
		||||
      - '!l10n_master'
 | 
			
		||||
 | 
			
		||||
jobs:
 | 
			
		||||
  build:
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    strategy:
 | 
			
		||||
      matrix:
 | 
			
		||||
        php: [7.2, 7.3]
 | 
			
		||||
    steps:
 | 
			
		||||
    - uses: actions/checkout@v1
 | 
			
		||||
 | 
			
		||||
    - 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: Setup Database
 | 
			
		||||
      run: |
 | 
			
		||||
        mysql -uroot -proot -e 'CREATE DATABASE IF NOT EXISTS `bookstack-test`;'
 | 
			
		||||
        mysql -uroot -proot -e "CREATE USER 'bookstack-test'@'localhost' IDENTIFIED BY 'bookstack-test';"
 | 
			
		||||
        mysql -uroot -proot -e "GRANT ALL ON \`bookstack-test\`.* TO 'bookstack-test'@'localhost';"
 | 
			
		||||
        mysql -uroot -proot -e 'FLUSH PRIVILEGES;'
 | 
			
		||||
 | 
			
		||||
    - name: Install composer dependencies & Test
 | 
			
		||||
      run: composer install --prefer-dist --no-interaction --ansi
 | 
			
		||||
 | 
			
		||||
    - name: Migrate and seed the database
 | 
			
		||||
      run: |
 | 
			
		||||
        php${{ matrix.php }} artisan migrate --force -n --database=mysql_testing
 | 
			
		||||
        php${{ matrix.php }} artisan db:seed --force -n --class=DummyContentSeeder --database=mysql_testing
 | 
			
		||||
 | 
			
		||||
    - name: phpunit
 | 
			
		||||
      run: php${{ matrix.php }} ./vendor/bin/phpunit
 | 
			
		||||
| 
						 | 
				
			
			@ -21,4 +21,6 @@ nbproject
 | 
			
		|||
.buildpath
 | 
			
		||||
.project
 | 
			
		||||
.settings/
 | 
			
		||||
webpack-stats.json
 | 
			
		||||
webpack-stats.json
 | 
			
		||||
.phpunit.result.cache
 | 
			
		||||
.DS_Store
 | 
			
		||||
							
								
								
									
										28
									
								
								.travis.yml
								
								
								
								
							
							
						
						
									
										28
									
								
								.travis.yml
								
								
								
								
							| 
						 | 
				
			
			@ -1,28 +0,0 @@
 | 
			
		|||
dist: trusty
 | 
			
		||||
sudo: false
 | 
			
		||||
language: php
 | 
			
		||||
php:
 | 
			
		||||
  - 7.0.20
 | 
			
		||||
  - 7.1.9
 | 
			
		||||
 | 
			
		||||
cache:
 | 
			
		||||
  directories:
 | 
			
		||||
    - $HOME/.composer/cache
 | 
			
		||||
 | 
			
		||||
before_script:
 | 
			
		||||
  - mysql -u root -e 'create database `bookstack-test`;'
 | 
			
		||||
  - mysql -u root -e "CREATE USER 'bookstack-test'@'localhost' IDENTIFIED BY 'bookstack-test';"
 | 
			
		||||
  - mysql -u root -e "GRANT ALL ON \`bookstack-test\`.* TO 'bookstack-test'@'localhost';"
 | 
			
		||||
  - mysql -u root -e "FLUSH PRIVILEGES;"
 | 
			
		||||
  - phpenv config-rm xdebug.ini
 | 
			
		||||
  - composer install --prefer-dist --no-interaction
 | 
			
		||||
  - php artisan clear-compiled -n
 | 
			
		||||
  - php artisan optimize -n
 | 
			
		||||
  - php artisan migrate --force -n --database=mysql_testing
 | 
			
		||||
  - php artisan db:seed --force -n --class=DummyContentSeeder --database=mysql_testing
 | 
			
		||||
 | 
			
		||||
after_failure:
 | 
			
		||||
  - cat storage/logs/laravel.log
 | 
			
		||||
 | 
			
		||||
script:
 | 
			
		||||
  - phpunit
 | 
			
		||||
| 
						 | 
				
			
			@ -3,13 +3,18 @@
 | 
			
		|||
namespace BookStack\Actions;
 | 
			
		||||
 | 
			
		||||
use BookStack\Auth\User;
 | 
			
		||||
use BookStack\Entities\Entity;
 | 
			
		||||
use BookStack\Model;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * @property string  key
 | 
			
		||||
 * @property \User   user
 | 
			
		||||
 * @property \Entity entity
 | 
			
		||||
 * @property string  extra
 | 
			
		||||
 * @property string $key
 | 
			
		||||
 * @property User $user
 | 
			
		||||
 * @property Entity $entity
 | 
			
		||||
 * @property string $extra
 | 
			
		||||
 * @property string $entity_type
 | 
			
		||||
 * @property int $entity_id
 | 
			
		||||
 * @property int $user_id
 | 
			
		||||
 * @property int $book_id
 | 
			
		||||
 */
 | 
			
		||||
class Activity extends Model
 | 
			
		||||
{
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,8 +1,8 @@
 | 
			
		|||
<?php namespace BookStack\Actions;
 | 
			
		||||
 | 
			
		||||
use BookStack\Auth\Permissions\PermissionService;
 | 
			
		||||
use BookStack\Entities\Book;
 | 
			
		||||
use BookStack\Entities\Entity;
 | 
			
		||||
use Session;
 | 
			
		||||
 | 
			
		||||
class ActivityService
 | 
			
		||||
{
 | 
			
		||||
| 
						 | 
				
			
			@ -12,7 +12,7 @@ class ActivityService
 | 
			
		|||
 | 
			
		||||
    /**
 | 
			
		||||
     * ActivityService constructor.
 | 
			
		||||
     * @param \BookStack\Actions\Activity $activity
 | 
			
		||||
     * @param Activity $activity
 | 
			
		||||
     * @param PermissionService $permissionService
 | 
			
		||||
     */
 | 
			
		||||
    public function __construct(Activity $activity, PermissionService $permissionService)
 | 
			
		||||
| 
						 | 
				
			
			@ -24,52 +24,57 @@ class ActivityService
 | 
			
		|||
 | 
			
		||||
    /**
 | 
			
		||||
     * Add activity data to database.
 | 
			
		||||
     * @param Entity $entity
 | 
			
		||||
     * @param        $activityKey
 | 
			
		||||
     * @param \BookStack\Entities\Entity $entity
 | 
			
		||||
     * @param string $activityKey
 | 
			
		||||
     * @param int $bookId
 | 
			
		||||
     * @param bool $extra
 | 
			
		||||
     */
 | 
			
		||||
    public function add(Entity $entity, $activityKey, $bookId = 0, $extra = false)
 | 
			
		||||
    public function add(Entity $entity, string $activityKey, int $bookId = null)
 | 
			
		||||
    {
 | 
			
		||||
        $activity = $this->activity->newInstance();
 | 
			
		||||
        $activity->user_id = $this->user->id;
 | 
			
		||||
        $activity->book_id = $bookId;
 | 
			
		||||
        $activity->key = strtolower($activityKey);
 | 
			
		||||
        if ($extra !== false) {
 | 
			
		||||
            $activity->extra = $extra;
 | 
			
		||||
        }
 | 
			
		||||
        $activity = $this->newActivityForUser($activityKey, $bookId);
 | 
			
		||||
        $entity->activity()->save($activity);
 | 
			
		||||
        $this->setNotification($activityKey);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Adds a activity history with a message & without binding to a entity.
 | 
			
		||||
     * @param            $activityKey
 | 
			
		||||
     * Adds a activity history with a message, without binding to a entity.
 | 
			
		||||
     * @param string $activityKey
 | 
			
		||||
     * @param string $message
 | 
			
		||||
     * @param int $bookId
 | 
			
		||||
     * @param bool|false $extra
 | 
			
		||||
     */
 | 
			
		||||
    public function addMessage($activityKey, $bookId = 0, $extra = false)
 | 
			
		||||
    public function addMessage(string $activityKey, string $message, int $bookId = null)
 | 
			
		||||
    {
 | 
			
		||||
        $this->activity->user_id = $this->user->id;
 | 
			
		||||
        $this->activity->book_id = $bookId;
 | 
			
		||||
        $this->activity->key = strtolower($activityKey);
 | 
			
		||||
        if ($extra !== false) {
 | 
			
		||||
            $this->activity->extra = $extra;
 | 
			
		||||
        }
 | 
			
		||||
        $this->activity->save();
 | 
			
		||||
        $this->newActivityForUser($activityKey, $bookId)->forceFill([
 | 
			
		||||
            'extra' => $message
 | 
			
		||||
        ])->save();
 | 
			
		||||
 | 
			
		||||
        $this->setNotification($activityKey);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get a new activity instance for the current user.
 | 
			
		||||
     * @param string $key
 | 
			
		||||
     * @param int|null $bookId
 | 
			
		||||
     * @return Activity
 | 
			
		||||
     */
 | 
			
		||||
    protected function newActivityForUser(string $key, int $bookId = null)
 | 
			
		||||
    {
 | 
			
		||||
        return $this->activity->newInstance()->forceFill([
 | 
			
		||||
            'key' => strtolower($key),
 | 
			
		||||
            'user_id' => $this->user->id,
 | 
			
		||||
            'book_id' => $bookId ?? 0,
 | 
			
		||||
        ]);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Removes the entity attachment from each of its activities
 | 
			
		||||
     * and instead uses the 'extra' field with the entities name.
 | 
			
		||||
     * Used when an entity is deleted.
 | 
			
		||||
     * @param Entity $entity
 | 
			
		||||
     * @param \BookStack\Entities\Entity $entity
 | 
			
		||||
     * @return mixed
 | 
			
		||||
     */
 | 
			
		||||
    public function removeEntity(Entity $entity)
 | 
			
		||||
    {
 | 
			
		||||
        // TODO - Rewrite to db query.
 | 
			
		||||
        $activities = $entity->activity;
 | 
			
		||||
        foreach ($activities as $activity) {
 | 
			
		||||
            $activity->extra = $entity->name;
 | 
			
		||||
| 
						 | 
				
			
			@ -90,7 +95,11 @@ class ActivityService
 | 
			
		|||
    {
 | 
			
		||||
        $activityList = $this->permissionService
 | 
			
		||||
            ->filterRestrictedEntityRelations($this->activity, 'activities', 'entity_id', 'entity_type')
 | 
			
		||||
            ->orderBy('created_at', 'desc')->with('user', 'entity')->skip($count * $page)->take($count)->get();
 | 
			
		||||
            ->orderBy('created_at', 'desc')
 | 
			
		||||
            ->with('user', 'entity')
 | 
			
		||||
            ->skip($count * $page)
 | 
			
		||||
            ->take($count)
 | 
			
		||||
            ->get();
 | 
			
		||||
 | 
			
		||||
        return $this->filterSimilar($activityList);
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			@ -98,7 +107,7 @@ class ActivityService
 | 
			
		|||
    /**
 | 
			
		||||
     * Gets the latest activity for an entity, Filtering out similar
 | 
			
		||||
     * items to prevent a message activity list.
 | 
			
		||||
     * @param Entity $entity
 | 
			
		||||
     * @param \BookStack\Entities\Entity $entity
 | 
			
		||||
     * @param int $count
 | 
			
		||||
     * @param int $page
 | 
			
		||||
     * @return array
 | 
			
		||||
| 
						 | 
				
			
			@ -171,7 +180,7 @@ class ActivityService
 | 
			
		|||
        $notificationTextKey = 'activities.' . $activityKey . '_notification';
 | 
			
		||||
        if (trans()->has($notificationTextKey)) {
 | 
			
		||||
            $message = trans($notificationTextKey);
 | 
			
		||||
            Session::flash('success', $message);
 | 
			
		||||
            session()->flash('success', $message);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,8 +1,10 @@
 | 
			
		|||
<?php namespace BookStack\Actions;
 | 
			
		||||
 | 
			
		||||
use BookStack\Auth\Permissions\PermissionService;
 | 
			
		||||
use BookStack\Entities\Book;
 | 
			
		||||
use BookStack\Entities\Entity;
 | 
			
		||||
use BookStack\Entities\EntityProvider;
 | 
			
		||||
use DB;
 | 
			
		||||
use Illuminate\Support\Collection;
 | 
			
		||||
 | 
			
		||||
class ViewService
 | 
			
		||||
| 
						 | 
				
			
			@ -13,8 +15,8 @@ class ViewService
 | 
			
		|||
 | 
			
		||||
    /**
 | 
			
		||||
     * ViewService constructor.
 | 
			
		||||
     * @param \BookStack\Actions\View $view
 | 
			
		||||
     * @param \BookStack\Auth\Permissions\PermissionService $permissionService
 | 
			
		||||
     * @param View $view
 | 
			
		||||
     * @param PermissionService $permissionService
 | 
			
		||||
     * @param EntityProvider $entityProvider
 | 
			
		||||
     */
 | 
			
		||||
    public function __construct(View $view, PermissionService $permissionService, EntityProvider $entityProvider)
 | 
			
		||||
| 
						 | 
				
			
			@ -26,7 +28,7 @@ class ViewService
 | 
			
		|||
 | 
			
		||||
    /**
 | 
			
		||||
     * Add a view to the given entity.
 | 
			
		||||
     * @param Entity $entity
 | 
			
		||||
     * @param \BookStack\Entities\Entity $entity
 | 
			
		||||
     * @return int
 | 
			
		||||
     */
 | 
			
		||||
    public function add(Entity $entity)
 | 
			
		||||
| 
						 | 
				
			
			@ -43,7 +45,7 @@ class ViewService
 | 
			
		|||
        }
 | 
			
		||||
 | 
			
		||||
        // Otherwise create new view count
 | 
			
		||||
        $entity->views()->save($this->view->create([
 | 
			
		||||
        $entity->views()->save($this->view->newInstance([
 | 
			
		||||
            'user_id' => $user->id,
 | 
			
		||||
            'views' => 1
 | 
			
		||||
        ]));
 | 
			
		||||
| 
						 | 
				
			
			@ -59,12 +61,12 @@ class ViewService
 | 
			
		|||
     * @param string $action - used for permission checking
 | 
			
		||||
     * @return Collection
 | 
			
		||||
     */
 | 
			
		||||
    public function getPopular(int $count = 10, int $page = 0, $filterModels = null, string $action = 'view')
 | 
			
		||||
    public function getPopular(int $count = 10, int $page = 0, array $filterModels = null, string $action = 'view')
 | 
			
		||||
    {
 | 
			
		||||
        $skipCount = $count * $page;
 | 
			
		||||
        $query = $this->permissionService
 | 
			
		||||
            ->filterRestrictedEntityRelations($this->view, 'views', 'viewable_id', 'viewable_type', $action)
 | 
			
		||||
            ->select('*', 'viewable_id', 'viewable_type', \DB::raw('SUM(views) as view_count'))
 | 
			
		||||
            ->select('*', 'viewable_id', 'viewable_type', DB::raw('SUM(views) as view_count'))
 | 
			
		||||
            ->groupBy('viewable_id', 'viewable_type')
 | 
			
		||||
            ->orderBy('view_count', 'desc');
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,125 @@
 | 
			
		|||
<?php namespace BookStack\Api;
 | 
			
		||||
 | 
			
		||||
use BookStack\Http\Controllers\Api\ApiController;
 | 
			
		||||
use Illuminate\Support\Collection;
 | 
			
		||||
use Illuminate\Support\Facades\Route;
 | 
			
		||||
use ReflectionClass;
 | 
			
		||||
use ReflectionException;
 | 
			
		||||
use ReflectionMethod;
 | 
			
		||||
 | 
			
		||||
class ApiDocsGenerator
 | 
			
		||||
{
 | 
			
		||||
 | 
			
		||||
    protected $reflectionClasses = [];
 | 
			
		||||
    protected $controllerClasses = [];
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Generate API documentation.
 | 
			
		||||
     */
 | 
			
		||||
    public function generate(): Collection
 | 
			
		||||
    {
 | 
			
		||||
        $apiRoutes = $this->getFlatApiRoutes();
 | 
			
		||||
        $apiRoutes = $this->loadDetailsFromControllers($apiRoutes);
 | 
			
		||||
        $apiRoutes = $this->loadDetailsFromFiles($apiRoutes);
 | 
			
		||||
        $apiRoutes = $apiRoutes->groupBy('base_model');
 | 
			
		||||
        return $apiRoutes;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Load any API details stored in static files.
 | 
			
		||||
     */
 | 
			
		||||
    protected function loadDetailsFromFiles(Collection $routes): Collection
 | 
			
		||||
    {
 | 
			
		||||
        return $routes->map(function (array $route) {
 | 
			
		||||
            $exampleTypes = ['request', 'response'];
 | 
			
		||||
            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;
 | 
			
		||||
            }
 | 
			
		||||
            return $route;
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Load any details we can fetch from the controller and its methods.
 | 
			
		||||
     */
 | 
			
		||||
    protected function loadDetailsFromControllers(Collection $routes): Collection
 | 
			
		||||
    {
 | 
			
		||||
        return $routes->map(function (array $route) {
 | 
			
		||||
            $method = $this->getReflectionMethod($route['controller'], $route['controller_method']);
 | 
			
		||||
            $comment = $method->getDocComment();
 | 
			
		||||
            $route['description'] = $comment ? $this->parseDescriptionFromMethodComment($comment) : null;
 | 
			
		||||
            $route['body_params'] = $this->getBodyParamsFromClass($route['controller'], $route['controller_method']);
 | 
			
		||||
            return $route;
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Load body params and their rules by inspecting the given class and method name.
 | 
			
		||||
     * @throws \Illuminate\Contracts\Container\BindingResolutionException
 | 
			
		||||
     */
 | 
			
		||||
    protected function getBodyParamsFromClass(string $className, string $methodName): ?array
 | 
			
		||||
    {
 | 
			
		||||
        /** @var ApiController $class */
 | 
			
		||||
        $class = $this->controllerClasses[$className] ?? null;
 | 
			
		||||
        if ($class === null) {
 | 
			
		||||
            $class = app()->make($className);
 | 
			
		||||
            $this->controllerClasses[$className] = $class;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $rules = $class->getValdationRules()[$methodName] ?? [];
 | 
			
		||||
        foreach ($rules as $param => $ruleString) {
 | 
			
		||||
            $rules[$param] = explode('|', $ruleString);
 | 
			
		||||
        }
 | 
			
		||||
        return count($rules) > 0 ? $rules : null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Parse out the description text from a class method comment.
 | 
			
		||||
     */
 | 
			
		||||
    protected function parseDescriptionFromMethodComment(string $comment)
 | 
			
		||||
    {
 | 
			
		||||
        $matches = [];
 | 
			
		||||
        preg_match_all('/^\s*?\*\s((?![@\s]).*?)$/m', $comment, $matches);
 | 
			
		||||
        return implode(' ', $matches[1] ?? []);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get a reflection method from the given class name and method name.
 | 
			
		||||
     * @throws ReflectionException
 | 
			
		||||
     */
 | 
			
		||||
    protected function getReflectionMethod(string $className, string $methodName): ReflectionMethod
 | 
			
		||||
    {
 | 
			
		||||
        $class = $this->reflectionClasses[$className] ?? null;
 | 
			
		||||
        if ($class === null) {
 | 
			
		||||
            $class = new ReflectionClass($className);
 | 
			
		||||
            $this->reflectionClasses[$className] = $class;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return $class->getMethod($methodName);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the system API routes, formatted into a flat collection.
 | 
			
		||||
     */
 | 
			
		||||
    protected function getFlatApiRoutes(): Collection
 | 
			
		||||
    {
 | 
			
		||||
        return collect(Route::getRoutes()->getRoutes())->filter(function ($route) {
 | 
			
		||||
            return strpos($route->uri, 'api/') === 0;
 | 
			
		||||
        })->map(function ($route) {
 | 
			
		||||
            [$controller, $controllerMethod] = explode('@', $route->action['uses']);
 | 
			
		||||
            $baseModelName = explode('.', explode('/', $route->uri)[1])[0];
 | 
			
		||||
            $shortName = $baseModelName . '-' . $controllerMethod;
 | 
			
		||||
            return [
 | 
			
		||||
                'name' => $shortName,
 | 
			
		||||
                'uri' => $route->uri,
 | 
			
		||||
                'method' => $route->methods[0],
 | 
			
		||||
                'controller' => $controller,
 | 
			
		||||
                'controller_method' => $controllerMethod,
 | 
			
		||||
                'base_model' => $baseModelName,
 | 
			
		||||
            ];
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,31 @@
 | 
			
		|||
<?php namespace BookStack\Api;
 | 
			
		||||
 | 
			
		||||
use BookStack\Auth\User;
 | 
			
		||||
use Illuminate\Database\Eloquent\Model;
 | 
			
		||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
 | 
			
		||||
use Illuminate\Support\Carbon;
 | 
			
		||||
 | 
			
		||||
class ApiToken extends Model
 | 
			
		||||
{
 | 
			
		||||
    protected $fillable = ['name', 'expires_at'];
 | 
			
		||||
    protected $casts = [
 | 
			
		||||
        'expires_at' => 'date:Y-m-d'
 | 
			
		||||
    ];
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the user that this token belongs to.
 | 
			
		||||
     */
 | 
			
		||||
    public function user(): BelongsTo
 | 
			
		||||
    {
 | 
			
		||||
        return $this->belongsTo(User::class);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the default expiry value for an API token.
 | 
			
		||||
     * Set to 100 years from now.
 | 
			
		||||
     */
 | 
			
		||||
    public static function defaultExpiry(): string
 | 
			
		||||
    {
 | 
			
		||||
        return Carbon::now()->addYears(100)->format('Y-m-d');
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,166 @@
 | 
			
		|||
<?php
 | 
			
		||||
 | 
			
		||||
namespace BookStack\Api;
 | 
			
		||||
 | 
			
		||||
use BookStack\Exceptions\ApiAuthException;
 | 
			
		||||
use Illuminate\Auth\GuardHelpers;
 | 
			
		||||
use Illuminate\Contracts\Auth\Authenticatable;
 | 
			
		||||
use Illuminate\Contracts\Auth\Guard;
 | 
			
		||||
use Illuminate\Support\Carbon;
 | 
			
		||||
use Illuminate\Support\Facades\Hash;
 | 
			
		||||
use Symfony\Component\HttpFoundation\Request;
 | 
			
		||||
 | 
			
		||||
class ApiTokenGuard implements Guard
 | 
			
		||||
{
 | 
			
		||||
 | 
			
		||||
    use GuardHelpers;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The request instance.
 | 
			
		||||
     */
 | 
			
		||||
    protected $request;
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The last auth exception thrown in this request.
 | 
			
		||||
     * @var ApiAuthException
 | 
			
		||||
     */
 | 
			
		||||
    protected $lastAuthException;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * ApiTokenGuard constructor.
 | 
			
		||||
     */
 | 
			
		||||
    public function __construct(Request $request)
 | 
			
		||||
    {
 | 
			
		||||
        $this->request = $request;
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    /**
 | 
			
		||||
     * @inheritDoc
 | 
			
		||||
     */
 | 
			
		||||
    public function user()
 | 
			
		||||
    {
 | 
			
		||||
        // Return the user if we've already retrieved them.
 | 
			
		||||
        // Effectively a request-instance cache for this method.
 | 
			
		||||
        if (!is_null($this->user)) {
 | 
			
		||||
            return $this->user;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $user = null;
 | 
			
		||||
        try {
 | 
			
		||||
            $user = $this->getAuthorisedUserFromRequest();
 | 
			
		||||
        } catch (ApiAuthException $exception) {
 | 
			
		||||
            $this->lastAuthException = $exception;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $this->user = $user;
 | 
			
		||||
        return $user;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Determine if current user is authenticated. If not, throw an exception.
 | 
			
		||||
     *
 | 
			
		||||
     * @return \Illuminate\Contracts\Auth\Authenticatable
 | 
			
		||||
     *
 | 
			
		||||
     * @throws ApiAuthException
 | 
			
		||||
     */
 | 
			
		||||
    public function authenticate()
 | 
			
		||||
    {
 | 
			
		||||
        if (! is_null($user = $this->user())) {
 | 
			
		||||
            return $user;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if ($this->lastAuthException) {
 | 
			
		||||
            throw $this->lastAuthException;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        throw new ApiAuthException('Unauthorized');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check the API token in the request and fetch a valid authorised user.
 | 
			
		||||
     * @throws ApiAuthException
 | 
			
		||||
     */
 | 
			
		||||
    protected function getAuthorisedUserFromRequest(): Authenticatable
 | 
			
		||||
    {
 | 
			
		||||
        $authToken = trim($this->request->headers->get('Authorization', ''));
 | 
			
		||||
        $this->validateTokenHeaderValue($authToken);
 | 
			
		||||
 | 
			
		||||
        [$id, $secret] = explode(':', str_replace('Token ', '', $authToken));
 | 
			
		||||
        $token = ApiToken::query()
 | 
			
		||||
            ->where('token_id', '=', $id)
 | 
			
		||||
            ->with(['user'])->first();
 | 
			
		||||
 | 
			
		||||
        $this->validateToken($token, $secret);
 | 
			
		||||
 | 
			
		||||
        return $token->user;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Validate the format of the token header value string.
 | 
			
		||||
     * @throws ApiAuthException
 | 
			
		||||
     */
 | 
			
		||||
    protected function validateTokenHeaderValue(string $authToken): void
 | 
			
		||||
    {
 | 
			
		||||
        if (empty($authToken)) {
 | 
			
		||||
            throw new ApiAuthException(trans('errors.api_no_authorization_found'));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (strpos($authToken, ':') === false || strpos($authToken, 'Token ') !== 0) {
 | 
			
		||||
            throw new ApiAuthException(trans('errors.api_bad_authorization_format'));
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Validate the given secret against the given token and ensure the token
 | 
			
		||||
     * currently has access to the instance API.
 | 
			
		||||
     * @throws ApiAuthException
 | 
			
		||||
     */
 | 
			
		||||
    protected function validateToken(?ApiToken $token, string $secret): void
 | 
			
		||||
    {
 | 
			
		||||
        if ($token === null) {
 | 
			
		||||
            throw new ApiAuthException(trans('errors.api_user_token_not_found'));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!Hash::check($secret, $token->secret)) {
 | 
			
		||||
            throw new ApiAuthException(trans('errors.api_incorrect_token_secret'));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $now = Carbon::now();
 | 
			
		||||
        if ($token->expires_at <= $now) {
 | 
			
		||||
            throw new ApiAuthException(trans('errors.api_user_token_expired'), 403);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!$token->user->can('access-api')) {
 | 
			
		||||
            throw new ApiAuthException(trans('errors.api_user_no_api_permission'), 403);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @inheritDoc
 | 
			
		||||
     */
 | 
			
		||||
    public function validate(array $credentials = [])
 | 
			
		||||
    {
 | 
			
		||||
        if (empty($credentials['id']) || empty($credentials['secret'])) {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $token = ApiToken::query()
 | 
			
		||||
            ->where('token_id', '=', $credentials['id'])
 | 
			
		||||
            ->with(['user'])->first();
 | 
			
		||||
 | 
			
		||||
        if ($token === null) {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return Hash::check($credentials['secret'], $token->secret);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * "Log out" the currently authenticated user.
 | 
			
		||||
     */
 | 
			
		||||
    public function logout()
 | 
			
		||||
    {
 | 
			
		||||
        $this->user = null;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,135 @@
 | 
			
		|||
<?php namespace BookStack\Api;
 | 
			
		||||
 | 
			
		||||
use Illuminate\Database\Eloquent\Builder;
 | 
			
		||||
use Illuminate\Database\Eloquent\Collection;
 | 
			
		||||
use Illuminate\Http\Request;
 | 
			
		||||
 | 
			
		||||
class ListingResponseBuilder
 | 
			
		||||
{
 | 
			
		||||
 | 
			
		||||
    protected $query;
 | 
			
		||||
    protected $request;
 | 
			
		||||
    protected $fields;
 | 
			
		||||
 | 
			
		||||
    protected $filterOperators = [
 | 
			
		||||
        'eq'   => '=',
 | 
			
		||||
        'ne'   => '!=',
 | 
			
		||||
        'gt'   => '>',
 | 
			
		||||
        'lt'   => '<',
 | 
			
		||||
        'gte'  => '>=',
 | 
			
		||||
        'lte'  => '<=',
 | 
			
		||||
        'like' => 'like'
 | 
			
		||||
    ];
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * ListingResponseBuilder constructor.
 | 
			
		||||
     */
 | 
			
		||||
    public function __construct(Builder $query, Request $request, array $fields)
 | 
			
		||||
    {
 | 
			
		||||
        $this->query = $query;
 | 
			
		||||
        $this->request = $request;
 | 
			
		||||
        $this->fields = $fields;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the response from this builder.
 | 
			
		||||
     */
 | 
			
		||||
    public function toResponse()
 | 
			
		||||
    {
 | 
			
		||||
        $data = $this->fetchData();
 | 
			
		||||
        $total = $this->query->count();
 | 
			
		||||
 | 
			
		||||
        return response()->json([
 | 
			
		||||
            'data' => $data,
 | 
			
		||||
            'total' => $total,
 | 
			
		||||
        ]);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Fetch the data to return in the response.
 | 
			
		||||
     */
 | 
			
		||||
    protected function fetchData(): Collection
 | 
			
		||||
    {
 | 
			
		||||
        $this->applyCountAndOffset($this->query);
 | 
			
		||||
        $this->applySorting($this->query);
 | 
			
		||||
        $this->applyFiltering($this->query);
 | 
			
		||||
 | 
			
		||||
        return $this->query->get($this->fields);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Apply any filtering operations found in the request.
 | 
			
		||||
     */
 | 
			
		||||
    protected function applyFiltering(Builder $query)
 | 
			
		||||
    {
 | 
			
		||||
        $requestFilters = $this->request->get('filter', []);
 | 
			
		||||
        if (!is_array($requestFilters)) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $queryFilters = collect($requestFilters)->map(function ($value, $key) {
 | 
			
		||||
            return $this->requestFilterToQueryFilter($key, $value);
 | 
			
		||||
        })->filter(function ($value) {
 | 
			
		||||
            return !is_null($value);
 | 
			
		||||
        })->values()->toArray();
 | 
			
		||||
 | 
			
		||||
        $query->where($queryFilters);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Convert a request filter query key/value pair into a [field, op, value] where condition.
 | 
			
		||||
     */
 | 
			
		||||
    protected function requestFilterToQueryFilter($fieldKey, $value): ?array
 | 
			
		||||
    {
 | 
			
		||||
        $splitKey = explode(':', $fieldKey);
 | 
			
		||||
        $field = $splitKey[0];
 | 
			
		||||
        $filterOperator = $splitKey[1] ?? 'eq';
 | 
			
		||||
 | 
			
		||||
        if (!in_array($field, $this->fields)) {
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!in_array($filterOperator, array_keys($this->filterOperators))) {
 | 
			
		||||
            $filterOperator = 'eq';
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $queryOperator = $this->filterOperators[$filterOperator];
 | 
			
		||||
        return [$field, $queryOperator, $value];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Apply sorting operations to the query from given parameters
 | 
			
		||||
     * otherwise falling back to the first given field, ascending.
 | 
			
		||||
     */
 | 
			
		||||
    protected function applySorting(Builder $query)
 | 
			
		||||
    {
 | 
			
		||||
        $defaultSortName = $this->fields[0];
 | 
			
		||||
        $direction = 'asc';
 | 
			
		||||
 | 
			
		||||
        $sort = $this->request->get('sort', '');
 | 
			
		||||
        if (strpos($sort, '-') === 0) {
 | 
			
		||||
            $direction = 'desc';
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $sortName = ltrim($sort, '+- ');
 | 
			
		||||
        if (!in_array($sortName, $this->fields)) {
 | 
			
		||||
            $sortName = $defaultSortName;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $query->orderBy($sortName, $direction);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Apply count and offset for paging, based on params from the request while falling
 | 
			
		||||
     * back to system defined default, taking the max limit into account.
 | 
			
		||||
     */
 | 
			
		||||
    protected function applyCountAndOffset(Builder $query)
 | 
			
		||||
    {
 | 
			
		||||
        $offset = max(0, $this->request->get('offset', 0));
 | 
			
		||||
        $maxCount = config('api.max_item_count');
 | 
			
		||||
        $count = $this->request->get('count', config('api.default_item_count'));
 | 
			
		||||
        $count = max(min($maxCount, $count), 1);
 | 
			
		||||
 | 
			
		||||
        $query->skip($offset)->take($count);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -13,7 +13,11 @@ class Application extends \Illuminate\Foundation\Application
 | 
			
		|||
     */
 | 
			
		||||
    public function configPath($path = '')
 | 
			
		||||
    {
 | 
			
		||||
        return $this->basePath.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'Config'.($path ? DIRECTORY_SEPARATOR.$path : $path);
 | 
			
		||||
        return $this->basePath
 | 
			
		||||
            . DIRECTORY_SEPARATOR
 | 
			
		||||
            . 'app'
 | 
			
		||||
            . DIRECTORY_SEPARATOR
 | 
			
		||||
            . 'Config'
 | 
			
		||||
            . ($path ? DIRECTORY_SEPARATOR.$path : $path);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -36,5 +36,4 @@ class EmailConfirmationService extends UserTokenService
 | 
			
		|||
        return setting('registration-confirmation')
 | 
			
		||||
            || setting('registration-restrict');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,81 @@
 | 
			
		|||
<?php namespace BookStack\Auth\Access;
 | 
			
		||||
 | 
			
		||||
use BookStack\Auth\Role;
 | 
			
		||||
use BookStack\Auth\User;
 | 
			
		||||
use Illuminate\Database\Eloquent\Builder;
 | 
			
		||||
 | 
			
		||||
class ExternalAuthService
 | 
			
		||||
{
 | 
			
		||||
    /**
 | 
			
		||||
     * Check a role against an array of group names to see if it matches.
 | 
			
		||||
     * Checked against role 'external_auth_id' if set otherwise the name of the role.
 | 
			
		||||
     */
 | 
			
		||||
    protected function roleMatchesGroupNames(Role $role, array $groupNames): bool
 | 
			
		||||
    {
 | 
			
		||||
        if ($role->external_auth_id) {
 | 
			
		||||
            return $this->externalIdMatchesGroupNames($role->external_auth_id, $groupNames);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $roleName = str_replace(' ', '-', trim(strtolower($role->display_name)));
 | 
			
		||||
        return in_array($roleName, $groupNames);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if the given external auth ID string matches one of the given group names.
 | 
			
		||||
     */
 | 
			
		||||
    protected function externalIdMatchesGroupNames(string $externalId, array $groupNames): bool
 | 
			
		||||
    {
 | 
			
		||||
        $externalAuthIds = explode(',', strtolower($externalId));
 | 
			
		||||
 | 
			
		||||
        foreach ($externalAuthIds as $externalAuthId) {
 | 
			
		||||
            if (in_array(trim($externalAuthId), $groupNames)) {
 | 
			
		||||
                return true;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Match an array of group names to BookStack system roles.
 | 
			
		||||
     * Formats group names to be lower-case and hyphenated.
 | 
			
		||||
     * @param array $groupNames
 | 
			
		||||
     * @return \Illuminate\Support\Collection
 | 
			
		||||
     */
 | 
			
		||||
    protected function matchGroupsToSystemsRoles(array $groupNames)
 | 
			
		||||
    {
 | 
			
		||||
        foreach ($groupNames as $i => $groupName) {
 | 
			
		||||
            $groupNames[$i] = str_replace(' ', '-', trim(strtolower($groupName)));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $roles = Role::query()->where(function (Builder $query) use ($groupNames) {
 | 
			
		||||
            $query->whereIn('name', $groupNames);
 | 
			
		||||
            foreach ($groupNames as $groupName) {
 | 
			
		||||
                $query->orWhere('external_auth_id', 'LIKE', '%' . $groupName . '%');
 | 
			
		||||
            }
 | 
			
		||||
        })->get();
 | 
			
		||||
 | 
			
		||||
        $matchedRoles = $roles->filter(function (Role $role) use ($groupNames) {
 | 
			
		||||
            return $this->roleMatchesGroupNames($role, $groupNames);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        return $matchedRoles->pluck('id');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Sync the groups to the user roles for the current user
 | 
			
		||||
     */
 | 
			
		||||
    public function syncWithGroups(User $user, array $userGroups): void
 | 
			
		||||
    {
 | 
			
		||||
        // Get the ids for the roles from the names
 | 
			
		||||
        $groupsAsRoles = $this->matchGroupsToSystemsRoles($userGroups);
 | 
			
		||||
 | 
			
		||||
        // Sync groups
 | 
			
		||||
        if ($this->config['remove_from_groups']) {
 | 
			
		||||
            $user->roles()->sync($groupsAsRoles);
 | 
			
		||||
            $user->attachDefaultRole();
 | 
			
		||||
        } else {
 | 
			
		||||
            $user->roles()->syncWithoutDetaching($groupsAsRoles);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,12 +1,11 @@
 | 
			
		|||
<?php
 | 
			
		||||
 | 
			
		||||
namespace BookStack\Providers;
 | 
			
		||||
namespace BookStack\Auth\Access;
 | 
			
		||||
 | 
			
		||||
use BookStack\Auth\Access\LdapService;
 | 
			
		||||
use Illuminate\Contracts\Auth\Authenticatable;
 | 
			
		||||
use Illuminate\Contracts\Auth\UserProvider;
 | 
			
		||||
 | 
			
		||||
class LdapUserProvider implements UserProvider
 | 
			
		||||
class ExternalBaseUserProvider implements UserProvider
 | 
			
		||||
{
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
| 
						 | 
				
			
			@ -16,21 +15,13 @@ class LdapUserProvider implements UserProvider
 | 
			
		|||
     */
 | 
			
		||||
    protected $model;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @var \BookStack\Auth\LdapService
 | 
			
		||||
     */
 | 
			
		||||
    protected $ldapService;
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * LdapUserProvider constructor.
 | 
			
		||||
     * @param             $model
 | 
			
		||||
     * @param \BookStack\Auth\LdapService $ldapService
 | 
			
		||||
     */
 | 
			
		||||
    public function __construct($model, LdapService $ldapService)
 | 
			
		||||
    public function __construct(string $model)
 | 
			
		||||
    {
 | 
			
		||||
        $this->model = $model;
 | 
			
		||||
        $this->ldapService = $ldapService;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
| 
						 | 
				
			
			@ -44,7 +35,6 @@ class LdapUserProvider implements UserProvider
 | 
			
		|||
        return new $class;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Retrieve a user by their unique identifier.
 | 
			
		||||
     *
 | 
			
		||||
| 
						 | 
				
			
			@ -65,12 +55,7 @@ class LdapUserProvider implements UserProvider
 | 
			
		|||
     */
 | 
			
		||||
    public function retrieveByToken($identifier, $token)
 | 
			
		||||
    {
 | 
			
		||||
        $model = $this->createModel();
 | 
			
		||||
 | 
			
		||||
        return $model->newQuery()
 | 
			
		||||
            ->where($model->getAuthIdentifierName(), $identifier)
 | 
			
		||||
            ->where($model->getRememberTokenName(), $token)
 | 
			
		||||
            ->first();
 | 
			
		||||
        return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -83,10 +68,7 @@ class LdapUserProvider implements UserProvider
 | 
			
		|||
     */
 | 
			
		||||
    public function updateRememberToken(Authenticatable $user, $token)
 | 
			
		||||
    {
 | 
			
		||||
        if ($user->exists) {
 | 
			
		||||
            $user->setRememberToken($token);
 | 
			
		||||
            $user->save();
 | 
			
		||||
        }
 | 
			
		||||
        //
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
| 
						 | 
				
			
			@ -97,27 +79,11 @@ class LdapUserProvider implements UserProvider
 | 
			
		|||
     */
 | 
			
		||||
    public function retrieveByCredentials(array $credentials)
 | 
			
		||||
    {
 | 
			
		||||
        // Get user via LDAP
 | 
			
		||||
        $userDetails = $this->ldapService->getUserDetails($credentials['username']);
 | 
			
		||||
        if ($userDetails === null) {
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Search current user base by looking up a uid
 | 
			
		||||
        $model = $this->createModel();
 | 
			
		||||
        $currentUser = $model->newQuery()
 | 
			
		||||
            ->where('external_auth_id', $userDetails['uid'])
 | 
			
		||||
        return $model->newQuery()
 | 
			
		||||
            ->where('external_auth_id', $credentials['external_auth_id'])
 | 
			
		||||
            ->first();
 | 
			
		||||
 | 
			
		||||
        if ($currentUser !== null) {
 | 
			
		||||
            return $currentUser;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $model->name = $userDetails['name'];
 | 
			
		||||
        $model->external_auth_id = $userDetails['uid'];
 | 
			
		||||
        $model->email = $userDetails['email'];
 | 
			
		||||
        $model->email_confirmed = false;
 | 
			
		||||
        return $model;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
| 
						 | 
				
			
			@ -129,6 +95,7 @@ class LdapUserProvider implements UserProvider
 | 
			
		|||
     */
 | 
			
		||||
    public function validateCredentials(Authenticatable $user, array $credentials)
 | 
			
		||||
    {
 | 
			
		||||
        return $this->ldapService->validateUserCredentials($user, $credentials['username'], $credentials['password']);
 | 
			
		||||
        // Should be done in the guard.
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,305 @@
 | 
			
		|||
<?php
 | 
			
		||||
 | 
			
		||||
namespace BookStack\Auth\Access\Guards;
 | 
			
		||||
 | 
			
		||||
use BookStack\Auth\Access\RegistrationService;
 | 
			
		||||
use Illuminate\Auth\GuardHelpers;
 | 
			
		||||
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
 | 
			
		||||
use Illuminate\Contracts\Auth\StatefulGuard;
 | 
			
		||||
use Illuminate\Contracts\Auth\UserProvider;
 | 
			
		||||
use Illuminate\Contracts\Session\Session;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Class BaseSessionGuard
 | 
			
		||||
 * A base implementation of a session guard. Is a copy of the default Laravel
 | 
			
		||||
 * guard with 'remember' functionality removed. Basic auth and event emission
 | 
			
		||||
 * has also been removed to keep this simple. Designed to be extended by external
 | 
			
		||||
 * Auth Guards.
 | 
			
		||||
 *
 | 
			
		||||
 * @package Illuminate\Auth
 | 
			
		||||
 */
 | 
			
		||||
class ExternalBaseSessionGuard implements StatefulGuard
 | 
			
		||||
{
 | 
			
		||||
    use GuardHelpers;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The name of the Guard. Typically "session".
 | 
			
		||||
     *
 | 
			
		||||
     * Corresponds to guard name in authentication configuration.
 | 
			
		||||
     *
 | 
			
		||||
     * @var string
 | 
			
		||||
     */
 | 
			
		||||
    protected $name;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The user we last attempted to retrieve.
 | 
			
		||||
     *
 | 
			
		||||
     * @var \Illuminate\Contracts\Auth\Authenticatable
 | 
			
		||||
     */
 | 
			
		||||
    protected $lastAttempted;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The session used by the guard.
 | 
			
		||||
     *
 | 
			
		||||
     * @var \Illuminate\Contracts\Session\Session
 | 
			
		||||
     */
 | 
			
		||||
    protected $session;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Indicates if the logout method has been called.
 | 
			
		||||
     *
 | 
			
		||||
     * @var bool
 | 
			
		||||
     */
 | 
			
		||||
    protected $loggedOut = false;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Service to handle common registration actions.
 | 
			
		||||
     *
 | 
			
		||||
     * @var RegistrationService
 | 
			
		||||
     */
 | 
			
		||||
    protected $registrationService;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Create a new authentication guard.
 | 
			
		||||
     *
 | 
			
		||||
     * @return void
 | 
			
		||||
     */
 | 
			
		||||
    public function __construct(string $name, UserProvider $provider, Session $session, RegistrationService $registrationService)
 | 
			
		||||
    {
 | 
			
		||||
        $this->name = $name;
 | 
			
		||||
        $this->session = $session;
 | 
			
		||||
        $this->provider = $provider;
 | 
			
		||||
        $this->registrationService = $registrationService;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the currently authenticated user.
 | 
			
		||||
     *
 | 
			
		||||
     * @return \Illuminate\Contracts\Auth\Authenticatable|null
 | 
			
		||||
     */
 | 
			
		||||
    public function user()
 | 
			
		||||
    {
 | 
			
		||||
        if ($this->loggedOut) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // If we've already retrieved the user for the current request we can just
 | 
			
		||||
        // return it back immediately. We do not want to fetch the user data on
 | 
			
		||||
        // every call to this method because that would be tremendously slow.
 | 
			
		||||
        if (! is_null($this->user)) {
 | 
			
		||||
            return $this->user;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $id = $this->session->get($this->getName());
 | 
			
		||||
 | 
			
		||||
        // First we will try to load the user using the
 | 
			
		||||
        // identifier in the session if one exists.
 | 
			
		||||
        if (! is_null($id)) {
 | 
			
		||||
            $this->user = $this->provider->retrieveById($id);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return $this->user;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the ID for the currently authenticated user.
 | 
			
		||||
     *
 | 
			
		||||
     * @return int|null
 | 
			
		||||
     */
 | 
			
		||||
    public function id()
 | 
			
		||||
    {
 | 
			
		||||
        if ($this->loggedOut) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return $this->user()
 | 
			
		||||
            ? $this->user()->getAuthIdentifier()
 | 
			
		||||
            : $this->session->get($this->getName());
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Log a user into the application without sessions or cookies.
 | 
			
		||||
     *
 | 
			
		||||
     * @param  array  $credentials
 | 
			
		||||
     * @return bool
 | 
			
		||||
     */
 | 
			
		||||
    public function once(array $credentials = [])
 | 
			
		||||
    {
 | 
			
		||||
        if ($this->validate($credentials)) {
 | 
			
		||||
            $this->setUser($this->lastAttempted);
 | 
			
		||||
 | 
			
		||||
            return true;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Log the given user ID into the application without sessions or cookies.
 | 
			
		||||
     *
 | 
			
		||||
     * @param  mixed  $id
 | 
			
		||||
     * @return \Illuminate\Contracts\Auth\Authenticatable|false
 | 
			
		||||
     */
 | 
			
		||||
    public function onceUsingId($id)
 | 
			
		||||
    {
 | 
			
		||||
        if (! is_null($user = $this->provider->retrieveById($id))) {
 | 
			
		||||
            $this->setUser($user);
 | 
			
		||||
 | 
			
		||||
            return $user;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Validate a user's credentials.
 | 
			
		||||
     *
 | 
			
		||||
     * @param  array  $credentials
 | 
			
		||||
     * @return bool
 | 
			
		||||
     */
 | 
			
		||||
    public function validate(array $credentials = [])
 | 
			
		||||
    {
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Attempt to authenticate a user using the given credentials.
 | 
			
		||||
     *
 | 
			
		||||
     * @param  array  $credentials
 | 
			
		||||
     * @param  bool  $remember
 | 
			
		||||
     * @return bool
 | 
			
		||||
     */
 | 
			
		||||
    public function attempt(array $credentials = [], $remember = false)
 | 
			
		||||
    {
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Log the given user ID into the application.
 | 
			
		||||
     *
 | 
			
		||||
     * @param  mixed  $id
 | 
			
		||||
     * @param  bool  $remember
 | 
			
		||||
     * @return \Illuminate\Contracts\Auth\Authenticatable|false
 | 
			
		||||
     */
 | 
			
		||||
    public function loginUsingId($id, $remember = false)
 | 
			
		||||
    {
 | 
			
		||||
        if (! is_null($user = $this->provider->retrieveById($id))) {
 | 
			
		||||
            $this->login($user, $remember);
 | 
			
		||||
 | 
			
		||||
            return $user;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Log a user into the application.
 | 
			
		||||
     *
 | 
			
		||||
     * @param  \Illuminate\Contracts\Auth\Authenticatable  $user
 | 
			
		||||
     * @param  bool  $remember
 | 
			
		||||
     * @return void
 | 
			
		||||
     */
 | 
			
		||||
    public function login(AuthenticatableContract $user, $remember = false)
 | 
			
		||||
    {
 | 
			
		||||
        $this->updateSession($user->getAuthIdentifier());
 | 
			
		||||
 | 
			
		||||
        $this->setUser($user);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Update the session with the given ID.
 | 
			
		||||
     *
 | 
			
		||||
     * @param  string  $id
 | 
			
		||||
     * @return void
 | 
			
		||||
     */
 | 
			
		||||
    protected function updateSession($id)
 | 
			
		||||
    {
 | 
			
		||||
        $this->session->put($this->getName(), $id);
 | 
			
		||||
 | 
			
		||||
        $this->session->migrate(true);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Log the user out of the application.
 | 
			
		||||
     *
 | 
			
		||||
     * @return void
 | 
			
		||||
     */
 | 
			
		||||
    public function logout()
 | 
			
		||||
    {
 | 
			
		||||
        $this->clearUserDataFromStorage();
 | 
			
		||||
 | 
			
		||||
        // Now we will clear the users out of memory so they are no longer available
 | 
			
		||||
        // as the user is no longer considered as being signed into this
 | 
			
		||||
        // application and should not be available here.
 | 
			
		||||
        $this->user = null;
 | 
			
		||||
 | 
			
		||||
        $this->loggedOut = true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Remove the user data from the session and cookies.
 | 
			
		||||
     *
 | 
			
		||||
     * @return void
 | 
			
		||||
     */
 | 
			
		||||
    protected function clearUserDataFromStorage()
 | 
			
		||||
    {
 | 
			
		||||
        $this->session->remove($this->getName());
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the last user we attempted to authenticate.
 | 
			
		||||
     *
 | 
			
		||||
     * @return \Illuminate\Contracts\Auth\Authenticatable
 | 
			
		||||
     */
 | 
			
		||||
    public function getLastAttempted()
 | 
			
		||||
    {
 | 
			
		||||
        return $this->lastAttempted;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get a unique identifier for the auth session value.
 | 
			
		||||
     *
 | 
			
		||||
     * @return string
 | 
			
		||||
     */
 | 
			
		||||
    public function getName()
 | 
			
		||||
    {
 | 
			
		||||
        return 'login_'.$this->name.'_'.sha1(static::class);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Determine if the user was authenticated via "remember me" cookie.
 | 
			
		||||
     *
 | 
			
		||||
     * @return bool
 | 
			
		||||
     */
 | 
			
		||||
    public function viaRemember()
 | 
			
		||||
    {
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Return the currently cached user.
 | 
			
		||||
     *
 | 
			
		||||
     * @return \Illuminate\Contracts\Auth\Authenticatable|null
 | 
			
		||||
     */
 | 
			
		||||
    public function getUser()
 | 
			
		||||
    {
 | 
			
		||||
        return $this->user;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Set the current user.
 | 
			
		||||
     *
 | 
			
		||||
     * @param  \Illuminate\Contracts\Auth\Authenticatable  $user
 | 
			
		||||
     * @return $this
 | 
			
		||||
     */
 | 
			
		||||
    public function setUser(AuthenticatableContract $user)
 | 
			
		||||
    {
 | 
			
		||||
        $this->user = $user;
 | 
			
		||||
 | 
			
		||||
        $this->loggedOut = false;
 | 
			
		||||
 | 
			
		||||
        return $this;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,114 @@
 | 
			
		|||
<?php
 | 
			
		||||
 | 
			
		||||
namespace BookStack\Auth\Access\Guards;
 | 
			
		||||
 | 
			
		||||
use BookStack\Auth\Access\LdapService;
 | 
			
		||||
use BookStack\Auth\Access\RegistrationService;
 | 
			
		||||
use BookStack\Auth\User;
 | 
			
		||||
use BookStack\Auth\UserRepo;
 | 
			
		||||
use BookStack\Exceptions\LdapException;
 | 
			
		||||
use BookStack\Exceptions\LoginAttemptException;
 | 
			
		||||
use BookStack\Exceptions\LoginAttemptEmailNeededException;
 | 
			
		||||
use BookStack\Exceptions\UserRegistrationException;
 | 
			
		||||
use Illuminate\Contracts\Auth\UserProvider;
 | 
			
		||||
use Illuminate\Contracts\Session\Session;
 | 
			
		||||
use Illuminate\Support\Facades\Hash;
 | 
			
		||||
use Illuminate\Support\Str;
 | 
			
		||||
 | 
			
		||||
class LdapSessionGuard extends ExternalBaseSessionGuard
 | 
			
		||||
{
 | 
			
		||||
 | 
			
		||||
    protected $ldapService;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * LdapSessionGuard constructor.
 | 
			
		||||
     */
 | 
			
		||||
    public function __construct($name,
 | 
			
		||||
        UserProvider $provider,
 | 
			
		||||
        Session $session,
 | 
			
		||||
        LdapService $ldapService,
 | 
			
		||||
        RegistrationService $registrationService
 | 
			
		||||
    )
 | 
			
		||||
    {
 | 
			
		||||
        $this->ldapService = $ldapService;
 | 
			
		||||
        parent::__construct($name, $provider, $session, $registrationService);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Validate a user's credentials.
 | 
			
		||||
     *
 | 
			
		||||
     * @param array $credentials
 | 
			
		||||
     * @return bool
 | 
			
		||||
     * @throws LdapException
 | 
			
		||||
     */
 | 
			
		||||
    public function validate(array $credentials = [])
 | 
			
		||||
    {
 | 
			
		||||
        $userDetails = $this->ldapService->getUserDetails($credentials['username']);
 | 
			
		||||
        $this->lastAttempted = $this->provider->retrieveByCredentials([
 | 
			
		||||
            'external_auth_id' => $userDetails['uid']
 | 
			
		||||
        ]);
 | 
			
		||||
 | 
			
		||||
        return $this->ldapService->validateUserCredentials($userDetails, $credentials['username'], $credentials['password']);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Attempt to authenticate a user using the given credentials.
 | 
			
		||||
     *
 | 
			
		||||
     * @param array $credentials
 | 
			
		||||
     * @param bool $remember
 | 
			
		||||
     * @return bool
 | 
			
		||||
     * @throws LoginAttemptEmailNeededException
 | 
			
		||||
     * @throws LoginAttemptException
 | 
			
		||||
     * @throws LdapException
 | 
			
		||||
     * @throws UserRegistrationException
 | 
			
		||||
     */
 | 
			
		||||
    public function attempt(array $credentials = [], $remember = false)
 | 
			
		||||
    {
 | 
			
		||||
        $username = $credentials['username'];
 | 
			
		||||
        $userDetails = $this->ldapService->getUserDetails($username);
 | 
			
		||||
        $this->lastAttempted = $user = $this->provider->retrieveByCredentials([
 | 
			
		||||
            'external_auth_id' => $userDetails['uid']
 | 
			
		||||
        ]);
 | 
			
		||||
 | 
			
		||||
        if (!$this->ldapService->validateUserCredentials($userDetails, $username, $credentials['password'])) {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (is_null($user)) {
 | 
			
		||||
            $user = $this->createNewFromLdapAndCreds($userDetails, $credentials);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Sync LDAP groups if required
 | 
			
		||||
        if ($this->ldapService->shouldSyncGroups()) {
 | 
			
		||||
            $this->ldapService->syncGroups($user, $username);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $this->login($user, $remember);
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Create a new user from the given ldap credentials and login credentials
 | 
			
		||||
     * @throws LoginAttemptEmailNeededException
 | 
			
		||||
     * @throws LoginAttemptException
 | 
			
		||||
     * @throws UserRegistrationException
 | 
			
		||||
     */
 | 
			
		||||
    protected function createNewFromLdapAndCreds(array $ldapUserDetails, array $credentials): User
 | 
			
		||||
    {
 | 
			
		||||
        $email = trim($ldapUserDetails['email'] ?: ($credentials['email'] ?? ''));
 | 
			
		||||
 | 
			
		||||
        if (empty($email)) {
 | 
			
		||||
            throw new LoginAttemptEmailNeededException();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $details = [
 | 
			
		||||
            'name' => $ldapUserDetails['name'],
 | 
			
		||||
            'email' => $ldapUserDetails['email'] ?: $credentials['email'],
 | 
			
		||||
            'external_auth_id' => $ldapUserDetails['uid'],
 | 
			
		||||
            'password' => Str::random(32),
 | 
			
		||||
        ];
 | 
			
		||||
 | 
			
		||||
        return $this->registrationService->registerUser($details, null, false);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,40 @@
 | 
			
		|||
<?php
 | 
			
		||||
 | 
			
		||||
namespace BookStack\Auth\Access\Guards;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Saml2 Session Guard
 | 
			
		||||
 *
 | 
			
		||||
 * The saml2 login process is async in nature meaning it does not fit very well
 | 
			
		||||
 * into the default laravel 'Guard' auth flow. Instead most of the logic is done
 | 
			
		||||
 * via the Saml2 controller & Saml2Service. This class provides a safer, thin
 | 
			
		||||
 * version of SessionGuard.
 | 
			
		||||
 *
 | 
			
		||||
 * @package BookStack\Auth\Access\Guards
 | 
			
		||||
 */
 | 
			
		||||
class Saml2SessionGuard extends ExternalBaseSessionGuard
 | 
			
		||||
{
 | 
			
		||||
    /**
 | 
			
		||||
     * Validate a user's credentials.
 | 
			
		||||
     *
 | 
			
		||||
     * @param array $credentials
 | 
			
		||||
     * @return bool
 | 
			
		||||
     */
 | 
			
		||||
    public function validate(array $credentials = [])
 | 
			
		||||
    {
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Attempt to authenticate a user using the given credentials.
 | 
			
		||||
     *
 | 
			
		||||
     * @param array $credentials
 | 
			
		||||
     * @param bool $remember
 | 
			
		||||
     * @return bool
 | 
			
		||||
     */
 | 
			
		||||
    public function attempt(array $credentials = [], $remember = false)
 | 
			
		||||
    {
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,37 +1,28 @@
 | 
			
		|||
<?php namespace BookStack\Auth\Access;
 | 
			
		||||
 | 
			
		||||
use BookStack\Auth\Access;
 | 
			
		||||
use BookStack\Auth\Role;
 | 
			
		||||
use BookStack\Auth\User;
 | 
			
		||||
use BookStack\Auth\UserRepo;
 | 
			
		||||
use BookStack\Exceptions\LdapException;
 | 
			
		||||
use Illuminate\Contracts\Auth\Authenticatable;
 | 
			
		||||
use Illuminate\Database\Eloquent\Builder;
 | 
			
		||||
use ErrorException;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Class LdapService
 | 
			
		||||
 * Handles any app-specific LDAP tasks.
 | 
			
		||||
 * @package BookStack\Services
 | 
			
		||||
 */
 | 
			
		||||
class LdapService
 | 
			
		||||
class LdapService extends ExternalAuthService
 | 
			
		||||
{
 | 
			
		||||
 | 
			
		||||
    protected $ldap;
 | 
			
		||||
    protected $ldapConnection;
 | 
			
		||||
    protected $config;
 | 
			
		||||
    protected $userRepo;
 | 
			
		||||
    protected $enabled;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * LdapService constructor.
 | 
			
		||||
     * @param Ldap $ldap
 | 
			
		||||
     * @param \BookStack\Auth\UserRepo $userRepo
 | 
			
		||||
     */
 | 
			
		||||
    public function __construct(Access\Ldap $ldap, UserRepo $userRepo)
 | 
			
		||||
    public function __construct(Ldap $ldap)
 | 
			
		||||
    {
 | 
			
		||||
        $this->ldap = $ldap;
 | 
			
		||||
        $this->config = config('services.ldap');
 | 
			
		||||
        $this->userRepo = $userRepo;
 | 
			
		||||
        $this->enabled = config('auth.method') === 'ldap';
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -45,13 +36,10 @@ class LdapService
 | 
			
		|||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Search for attributes for a specific user on the ldap
 | 
			
		||||
     * @param string $userName
 | 
			
		||||
     * @param array $attributes
 | 
			
		||||
     * @return null|array
 | 
			
		||||
     * Search for attributes for a specific user on the ldap.
 | 
			
		||||
     * @throws LdapException
 | 
			
		||||
     */
 | 
			
		||||
    private function getUserWithAttributes($userName, $attributes)
 | 
			
		||||
    private function getUserWithAttributes(string $userName, array $attributes): ?array
 | 
			
		||||
    {
 | 
			
		||||
        $ldapConnection = $this->getConnection();
 | 
			
		||||
        $this->bindSystemUser($ldapConnection);
 | 
			
		||||
| 
						 | 
				
			
			@ -73,16 +61,15 @@ class LdapService
 | 
			
		|||
    /**
 | 
			
		||||
     * Get the details of a user from LDAP using the given username.
 | 
			
		||||
     * User found via configurable user filter.
 | 
			
		||||
     * @param $userName
 | 
			
		||||
     * @return array|null
 | 
			
		||||
     * @throws LdapException
 | 
			
		||||
     */
 | 
			
		||||
    public function getUserDetails($userName)
 | 
			
		||||
    public function getUserDetails(string $userName): ?array
 | 
			
		||||
    {
 | 
			
		||||
        $idAttr = $this->config['id_attribute'];
 | 
			
		||||
        $emailAttr = $this->config['email_attribute'];
 | 
			
		||||
        $displayNameAttr = $this->config['display_name_attribute'];
 | 
			
		||||
 | 
			
		||||
        $user = $this->getUserWithAttributes($userName, ['cn', 'uid', 'dn', $emailAttr, $displayNameAttr]);
 | 
			
		||||
        $user = $this->getUserWithAttributes($userName, ['cn', 'dn', $idAttr, $emailAttr, $displayNameAttr]);
 | 
			
		||||
 | 
			
		||||
        if ($user === null) {
 | 
			
		||||
            return null;
 | 
			
		||||
| 
						 | 
				
			
			@ -90,7 +77,7 @@ class LdapService
 | 
			
		|||
 | 
			
		||||
        $userCn = $this->getUserResponseProperty($user, 'cn', null);
 | 
			
		||||
        return [
 | 
			
		||||
            'uid'   => $this->getUserResponseProperty($user, 'uid', $user['dn']),
 | 
			
		||||
            'uid'   => $this->getUserResponseProperty($user, $idAttr, $user['dn']),
 | 
			
		||||
            'name'  => $this->getUserResponseProperty($user, $displayNameAttr, $userCn),
 | 
			
		||||
            'dn'    => $user['dn'],
 | 
			
		||||
            'email' => $this->getUserResponseProperty($user, $emailAttr, null),
 | 
			
		||||
| 
						 | 
				
			
			@ -100,13 +87,10 @@ class LdapService
 | 
			
		|||
    /**
 | 
			
		||||
     * Get a property from an LDAP user response fetch.
 | 
			
		||||
     * Handles properties potentially being part of an array.
 | 
			
		||||
     * @param array $userDetails
 | 
			
		||||
     * @param string $propertyKey
 | 
			
		||||
     * @param $defaultValue
 | 
			
		||||
     * @return mixed
 | 
			
		||||
     */
 | 
			
		||||
    protected function getUserResponseProperty(array $userDetails, string $propertyKey, $defaultValue)
 | 
			
		||||
    {
 | 
			
		||||
        $propertyKey = strtolower($propertyKey);
 | 
			
		||||
        if (isset($userDetails[$propertyKey])) {
 | 
			
		||||
            return (is_array($userDetails[$propertyKey]) ? $userDetails[$propertyKey][0] : $userDetails[$propertyKey]);
 | 
			
		||||
        }
 | 
			
		||||
| 
						 | 
				
			
			@ -115,27 +99,19 @@ class LdapService
 | 
			
		|||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @param Authenticatable $user
 | 
			
		||||
     * @param string $username
 | 
			
		||||
     * @param string $password
 | 
			
		||||
     * @return bool
 | 
			
		||||
     * Check if the given credentials are valid for the given user.
 | 
			
		||||
     * @throws LdapException
 | 
			
		||||
     */
 | 
			
		||||
    public function validateUserCredentials(Authenticatable $user, $username, $password)
 | 
			
		||||
    public function validateUserCredentials(array $ldapUserDetails, string $username, string $password): bool
 | 
			
		||||
    {
 | 
			
		||||
        $ldapUser = $this->getUserDetails($username);
 | 
			
		||||
        if ($ldapUser === null) {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if ($ldapUser['uid'] !== $user->external_auth_id) {
 | 
			
		||||
        if ($ldapUserDetails === null) {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $ldapConnection = $this->getConnection();
 | 
			
		||||
        try {
 | 
			
		||||
            $ldapBind = $this->ldap->bind($ldapConnection, $ldapUser['dn'], $password);
 | 
			
		||||
        } catch (\ErrorException $e) {
 | 
			
		||||
            $ldapBind = $this->ldap->bind($ldapConnection, $ldapUserDetails['dn'], $password);
 | 
			
		||||
        } catch (ErrorException $e) {
 | 
			
		||||
            $ldapBind = false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -205,12 +181,10 @@ class LdapService
 | 
			
		|||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Parse a LDAP server string and return the host and port for
 | 
			
		||||
     * a connection. Is flexible to formats such as 'ldap.example.com:8069' or 'ldaps://ldap.example.com'
 | 
			
		||||
     * @param $serverString
 | 
			
		||||
     * @return array
 | 
			
		||||
     * Parse a LDAP server string and return the host and port for a connection.
 | 
			
		||||
     * Is flexible to formats such as 'ldap.example.com:8069' or 'ldaps://ldap.example.com'.
 | 
			
		||||
     */
 | 
			
		||||
    protected function parseServerString($serverString)
 | 
			
		||||
    protected function parseServerString(string $serverString): array
 | 
			
		||||
    {
 | 
			
		||||
        $serverNameParts = explode(':', $serverString);
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -227,11 +201,8 @@ class LdapService
 | 
			
		|||
 | 
			
		||||
    /**
 | 
			
		||||
     * Build a filter string by injecting common variables.
 | 
			
		||||
     * @param string $filterString
 | 
			
		||||
     * @param array $attrs
 | 
			
		||||
     * @return string
 | 
			
		||||
     */
 | 
			
		||||
    protected function buildFilter($filterString, array $attrs)
 | 
			
		||||
    protected function buildFilter(string $filterString, array $attrs): string
 | 
			
		||||
    {
 | 
			
		||||
        $newAttrs = [];
 | 
			
		||||
        foreach ($attrs as $key => $attrText) {
 | 
			
		||||
| 
						 | 
				
			
			@ -242,12 +213,10 @@ class LdapService
 | 
			
		|||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the groups a user is a part of on ldap
 | 
			
		||||
     * @param string $userName
 | 
			
		||||
     * @return array
 | 
			
		||||
     * Get the groups a user is a part of on ldap.
 | 
			
		||||
     * @throws LdapException
 | 
			
		||||
     */
 | 
			
		||||
    public function getUserGroups($userName)
 | 
			
		||||
    public function getUserGroups(string $userName): array
 | 
			
		||||
    {
 | 
			
		||||
        $groupsAttr = $this->config['group_attribute'];
 | 
			
		||||
        $user = $this->getUserWithAttributes($userName, [$groupsAttr]);
 | 
			
		||||
| 
						 | 
				
			
			@ -262,40 +231,36 @@ class LdapService
 | 
			
		|||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the parent groups of an array of groups
 | 
			
		||||
     * @param array $groupsArray
 | 
			
		||||
     * @param array $checked
 | 
			
		||||
     * @return array
 | 
			
		||||
     * Get the parent groups of an array of groups.
 | 
			
		||||
     * @throws LdapException
 | 
			
		||||
     */
 | 
			
		||||
    private function getGroupsRecursive($groupsArray, $checked)
 | 
			
		||||
    private function getGroupsRecursive(array $groupsArray, array $checked): array
 | 
			
		||||
    {
 | 
			
		||||
        $groups_to_add = [];
 | 
			
		||||
        $groupsToAdd = [];
 | 
			
		||||
        foreach ($groupsArray as $groupName) {
 | 
			
		||||
            if (in_array($groupName, $checked)) {
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            $groupsToAdd = $this->getGroupGroups($groupName);
 | 
			
		||||
            $groups_to_add = array_merge($groups_to_add, $groupsToAdd);
 | 
			
		||||
            $parentGroups = $this->getGroupGroups($groupName);
 | 
			
		||||
            $groupsToAdd = array_merge($groupsToAdd, $parentGroups);
 | 
			
		||||
            $checked[] = $groupName;
 | 
			
		||||
        }
 | 
			
		||||
        $groupsArray = array_unique(array_merge($groupsArray, $groups_to_add), SORT_REGULAR);
 | 
			
		||||
 | 
			
		||||
        if (!empty($groups_to_add)) {
 | 
			
		||||
            return $this->getGroupsRecursive($groupsArray, $checked);
 | 
			
		||||
        } else {
 | 
			
		||||
        $groupsArray = array_unique(array_merge($groupsArray, $groupsToAdd), SORT_REGULAR);
 | 
			
		||||
 | 
			
		||||
        if (empty($groupsToAdd)) {
 | 
			
		||||
            return $groupsArray;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return $this->getGroupsRecursive($groupsArray, $checked);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the parent groups of a single group
 | 
			
		||||
     * @param string $groupName
 | 
			
		||||
     * @return array
 | 
			
		||||
     * Get the parent groups of a single group.
 | 
			
		||||
     * @throws LdapException
 | 
			
		||||
     */
 | 
			
		||||
    private function getGroupGroups($groupName)
 | 
			
		||||
    private function getGroupGroups(string $groupName): array
 | 
			
		||||
    {
 | 
			
		||||
        $ldapConnection = $this->getConnection();
 | 
			
		||||
        $this->bindSystemUser($ldapConnection);
 | 
			
		||||
| 
						 | 
				
			
			@ -312,17 +277,14 @@ class LdapService
 | 
			
		|||
            return [];
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $groupGroups = $this->groupFilter($groups[0]);
 | 
			
		||||
        return $groupGroups;
 | 
			
		||||
        return $this->groupFilter($groups[0]);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Filter out LDAP CN and DN language in a ldap search return
 | 
			
		||||
     * Gets the base CN (common name) of the string
 | 
			
		||||
     * @param array $userGroupSearchResponse
 | 
			
		||||
     * @return array
 | 
			
		||||
     * Filter out LDAP CN and DN language in a ldap search return.
 | 
			
		||||
     * Gets the base CN (common name) of the string.
 | 
			
		||||
     */
 | 
			
		||||
    protected function groupFilter(array $userGroupSearchResponse)
 | 
			
		||||
    protected function groupFilter(array $userGroupSearchResponse): array
 | 
			
		||||
    {
 | 
			
		||||
        $groupsAttr = strtolower($this->config['group_attribute']);
 | 
			
		||||
        $ldapGroups = [];
 | 
			
		||||
| 
						 | 
				
			
			@ -343,73 +305,12 @@ class LdapService
 | 
			
		|||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Sync the LDAP groups to the user roles for the current user
 | 
			
		||||
     * @param \BookStack\Auth\User $user
 | 
			
		||||
     * @param string $username
 | 
			
		||||
     * Sync the LDAP groups to the user roles for the current user.
 | 
			
		||||
     * @throws LdapException
 | 
			
		||||
     */
 | 
			
		||||
    public function syncGroups(User $user, string $username)
 | 
			
		||||
    {
 | 
			
		||||
        $userLdapGroups = $this->getUserGroups($username);
 | 
			
		||||
 | 
			
		||||
        // Get the ids for the roles from the names
 | 
			
		||||
        $ldapGroupsAsRoles = $this->matchLdapGroupsToSystemsRoles($userLdapGroups);
 | 
			
		||||
 | 
			
		||||
        // Sync groups
 | 
			
		||||
        if ($this->config['remove_from_groups']) {
 | 
			
		||||
            $user->roles()->sync($ldapGroupsAsRoles);
 | 
			
		||||
            $this->userRepo->attachDefaultRole($user);
 | 
			
		||||
        } else {
 | 
			
		||||
            $user->roles()->syncWithoutDetaching($ldapGroupsAsRoles);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Match an array of group names from LDAP to BookStack system roles.
 | 
			
		||||
     * Formats LDAP group names to be lower-case and hyphenated.
 | 
			
		||||
     * @param array $groupNames
 | 
			
		||||
     * @return \Illuminate\Support\Collection
 | 
			
		||||
     */
 | 
			
		||||
    protected function matchLdapGroupsToSystemsRoles(array $groupNames)
 | 
			
		||||
    {
 | 
			
		||||
        foreach ($groupNames as $i => $groupName) {
 | 
			
		||||
            $groupNames[$i] = str_replace(' ', '-', trim(strtolower($groupName)));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $roles = Role::query()->where(function (Builder $query) use ($groupNames) {
 | 
			
		||||
            $query->whereIn('name', $groupNames);
 | 
			
		||||
            foreach ($groupNames as $groupName) {
 | 
			
		||||
                $query->orWhere('external_auth_id', 'LIKE', '%' . $groupName . '%');
 | 
			
		||||
            }
 | 
			
		||||
        })->get();
 | 
			
		||||
 | 
			
		||||
        $matchedRoles = $roles->filter(function (Role $role) use ($groupNames) {
 | 
			
		||||
            return $this->roleMatchesGroupNames($role, $groupNames);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        return $matchedRoles->pluck('id');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check a role against an array of group names to see if it matches.
 | 
			
		||||
     * Checked against role 'external_auth_id' if set otherwise the name of the role.
 | 
			
		||||
     * @param \BookStack\Auth\Role $role
 | 
			
		||||
     * @param array $groupNames
 | 
			
		||||
     * @return bool
 | 
			
		||||
     */
 | 
			
		||||
    protected function roleMatchesGroupNames(Role $role, array $groupNames)
 | 
			
		||||
    {
 | 
			
		||||
        if ($role->external_auth_id) {
 | 
			
		||||
            $externalAuthIds = explode(',', strtolower($role->external_auth_id));
 | 
			
		||||
            foreach ($externalAuthIds as $externalAuthId) {
 | 
			
		||||
                if (in_array(trim($externalAuthId), $groupNames)) {
 | 
			
		||||
                    return true;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $roleName = str_replace(' ', '-', trim(strtolower($role->display_name)));
 | 
			
		||||
        return in_array($roleName, $groupNames);
 | 
			
		||||
        $this->syncWithGroups($user, $userLdapGroups);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,118 @@
 | 
			
		|||
<?php namespace BookStack\Auth\Access;
 | 
			
		||||
 | 
			
		||||
use BookStack\Auth\SocialAccount;
 | 
			
		||||
use BookStack\Auth\User;
 | 
			
		||||
use BookStack\Auth\UserRepo;
 | 
			
		||||
use BookStack\Exceptions\UserRegistrationException;
 | 
			
		||||
use Exception;
 | 
			
		||||
 | 
			
		||||
class RegistrationService
 | 
			
		||||
{
 | 
			
		||||
 | 
			
		||||
    protected $userRepo;
 | 
			
		||||
    protected $emailConfirmationService;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * RegistrationService constructor.
 | 
			
		||||
     */
 | 
			
		||||
    public function __construct(UserRepo $userRepo, EmailConfirmationService $emailConfirmationService)
 | 
			
		||||
    {
 | 
			
		||||
        $this->userRepo = $userRepo;
 | 
			
		||||
        $this->emailConfirmationService = $emailConfirmationService;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check whether or not registrations are allowed in the app settings.
 | 
			
		||||
     * @throws UserRegistrationException
 | 
			
		||||
     */
 | 
			
		||||
    public function ensureRegistrationAllowed()
 | 
			
		||||
    {
 | 
			
		||||
        if (!$this->registrationAllowed()) {
 | 
			
		||||
            throw new UserRegistrationException(trans('auth.registrations_disabled'), '/login');
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if standard BookStack User registrations are currently allowed.
 | 
			
		||||
     * Does not prevent external-auth based registration.
 | 
			
		||||
     */
 | 
			
		||||
    protected function registrationAllowed(): bool
 | 
			
		||||
    {
 | 
			
		||||
        $authMethod = config('auth.method');
 | 
			
		||||
        $authMethodsWithRegistration = ['standard'];
 | 
			
		||||
        return in_array($authMethod, $authMethodsWithRegistration) && setting('registration-enabled');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The registrations flow for all users.
 | 
			
		||||
     * @throws UserRegistrationException
 | 
			
		||||
     */
 | 
			
		||||
    public function registerUser(array $userData, ?SocialAccount $socialAccount = null, bool $emailConfirmed = false): User
 | 
			
		||||
    {
 | 
			
		||||
        $userEmail = $userData['email'];
 | 
			
		||||
 | 
			
		||||
        // Email restriction
 | 
			
		||||
        $this->ensureEmailDomainAllowed($userEmail);
 | 
			
		||||
 | 
			
		||||
        // Ensure user does not already exist
 | 
			
		||||
        $alreadyUser = !is_null($this->userRepo->getByEmail($userEmail));
 | 
			
		||||
        if ($alreadyUser) {
 | 
			
		||||
            throw new UserRegistrationException(trans('errors.error_user_exists_different_creds', ['email' => $userEmail]));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Create the user
 | 
			
		||||
        $newUser = $this->userRepo->registerNew($userData, $emailConfirmed);
 | 
			
		||||
 | 
			
		||||
        // Assign social account if given
 | 
			
		||||
        if ($socialAccount) {
 | 
			
		||||
            $newUser->socialAccounts()->save($socialAccount);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Start email confirmation flow if required
 | 
			
		||||
        if ($this->emailConfirmationService->confirmationRequired() && !$emailConfirmed) {
 | 
			
		||||
            $newUser->save();
 | 
			
		||||
            $message = '';
 | 
			
		||||
 | 
			
		||||
            try {
 | 
			
		||||
                $this->emailConfirmationService->sendConfirmation($newUser);
 | 
			
		||||
            } catch (Exception $e) {
 | 
			
		||||
                $message = trans('auth.email_confirm_send_error');
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            throw new UserRegistrationException($message, '/register/confirm');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return $newUser;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Ensure that the given email meets any active email domain registration restrictions.
 | 
			
		||||
     * Throws if restrictions are active and the email does not match an allowed domain.
 | 
			
		||||
     * @throws UserRegistrationException
 | 
			
		||||
     */
 | 
			
		||||
    protected function ensureEmailDomainAllowed(string $userEmail): void
 | 
			
		||||
    {
 | 
			
		||||
        $registrationRestrict = setting('registration-restrict');
 | 
			
		||||
 | 
			
		||||
        if (!$registrationRestrict) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $restrictedEmailDomains = explode(',', str_replace(' ', '', $registrationRestrict));
 | 
			
		||||
        $userEmailDomain = $domain = mb_substr(mb_strrchr($userEmail, "@"), 1);
 | 
			
		||||
        if (!in_array($userEmailDomain, $restrictedEmailDomains)) {
 | 
			
		||||
            $redirect = $this->registrationAllowed() ? '/register' : '/login';
 | 
			
		||||
            throw new UserRegistrationException(trans('auth.registration_email_domain_invalid'), $redirect);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Alias to the UserRepo method of the same name.
 | 
			
		||||
     * Attaches the default system role, if configured, to the given user.
 | 
			
		||||
     */
 | 
			
		||||
    public function attachDefaultRole(User $user): void
 | 
			
		||||
    {
 | 
			
		||||
        $this->userRepo->attachDefaultRole($user);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,378 @@
 | 
			
		|||
<?php namespace BookStack\Auth\Access;
 | 
			
		||||
 | 
			
		||||
use BookStack\Auth\User;
 | 
			
		||||
use BookStack\Exceptions\JsonDebugException;
 | 
			
		||||
use BookStack\Exceptions\SamlException;
 | 
			
		||||
use BookStack\Exceptions\UserRegistrationException;
 | 
			
		||||
use Exception;
 | 
			
		||||
use Illuminate\Support\Str;
 | 
			
		||||
use OneLogin\Saml2\Auth;
 | 
			
		||||
use OneLogin\Saml2\Error;
 | 
			
		||||
use OneLogin\Saml2\IdPMetadataParser;
 | 
			
		||||
use OneLogin\Saml2\ValidationError;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Class Saml2Service
 | 
			
		||||
 * Handles any app-specific SAML tasks.
 | 
			
		||||
 */
 | 
			
		||||
class Saml2Service extends ExternalAuthService
 | 
			
		||||
{
 | 
			
		||||
    protected $config;
 | 
			
		||||
    protected $registrationService;
 | 
			
		||||
    protected $user;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Saml2Service constructor.
 | 
			
		||||
     */
 | 
			
		||||
    public function __construct(RegistrationService $registrationService, User $user)
 | 
			
		||||
    {
 | 
			
		||||
        $this->config = config('saml2');
 | 
			
		||||
        $this->registrationService = $registrationService;
 | 
			
		||||
        $this->user = $user;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Initiate a login flow.
 | 
			
		||||
     * @throws Error
 | 
			
		||||
     */
 | 
			
		||||
    public function login(): array
 | 
			
		||||
    {
 | 
			
		||||
        $toolKit = $this->getToolkit();
 | 
			
		||||
        $returnRoute = url('/saml2/acs');
 | 
			
		||||
        return [
 | 
			
		||||
            'url' => $toolKit->login($returnRoute, [], false, false, true),
 | 
			
		||||
            'id' => $toolKit->getLastRequestID(),
 | 
			
		||||
        ];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Initiate a logout flow.
 | 
			
		||||
     * @throws Error
 | 
			
		||||
     */
 | 
			
		||||
    public function logout(): array
 | 
			
		||||
    {
 | 
			
		||||
        $toolKit = $this->getToolkit();
 | 
			
		||||
        $returnRoute = url('/');
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
            $url = $toolKit->logout($returnRoute, [], null, null, true);
 | 
			
		||||
            $id = $toolKit->getLastRequestID();
 | 
			
		||||
        } catch (Error $error) {
 | 
			
		||||
            if ($error->getCode() !== Error::SAML_SINGLE_LOGOUT_NOT_SUPPORTED) {
 | 
			
		||||
                throw $error;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            $this->actionLogout();
 | 
			
		||||
            $url = '/';
 | 
			
		||||
            $id = null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return ['url' => $url, 'id' => $id];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Process the ACS response from the idp and return the
 | 
			
		||||
     * matching, or new if registration active, user matched to the idp.
 | 
			
		||||
     * Returns null if not authenticated.
 | 
			
		||||
     * @throws Error
 | 
			
		||||
     * @throws SamlException
 | 
			
		||||
     * @throws ValidationError
 | 
			
		||||
     * @throws JsonDebugException
 | 
			
		||||
     * @throws UserRegistrationException
 | 
			
		||||
     */
 | 
			
		||||
    public function processAcsResponse(?string $requestId): ?User
 | 
			
		||||
    {
 | 
			
		||||
        $toolkit = $this->getToolkit();
 | 
			
		||||
        $toolkit->processResponse($requestId);
 | 
			
		||||
        $errors = $toolkit->getErrors();
 | 
			
		||||
 | 
			
		||||
        if (!empty($errors)) {
 | 
			
		||||
            throw new Error(
 | 
			
		||||
                'Invalid ACS Response: '.implode(', ', $errors)
 | 
			
		||||
            );
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!$toolkit->isAuthenticated()) {
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $attrs = $toolkit->getAttributes();
 | 
			
		||||
        $id = $toolkit->getNameId();
 | 
			
		||||
 | 
			
		||||
        return $this->processLoginCallback($id, $attrs);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Process a response for the single logout service.
 | 
			
		||||
     * @throws Error
 | 
			
		||||
     */
 | 
			
		||||
    public function processSlsResponse(?string $requestId): ?string
 | 
			
		||||
    {
 | 
			
		||||
        $toolkit = $this->getToolkit();
 | 
			
		||||
        $redirect = $toolkit->processSLO(true, $requestId, false, null, true);
 | 
			
		||||
 | 
			
		||||
        $errors = $toolkit->getErrors();
 | 
			
		||||
 | 
			
		||||
        if (!empty($errors)) {
 | 
			
		||||
            throw new Error(
 | 
			
		||||
                'Invalid SLS Response: '.implode(', ', $errors)
 | 
			
		||||
            );
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $this->actionLogout();
 | 
			
		||||
        return $redirect;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Do the required actions to log a user out.
 | 
			
		||||
     */
 | 
			
		||||
    protected function actionLogout()
 | 
			
		||||
    {
 | 
			
		||||
        auth()->logout();
 | 
			
		||||
        session()->invalidate();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the metadata for this service provider.
 | 
			
		||||
     * @throws Error
 | 
			
		||||
     */
 | 
			
		||||
    public function metadata(): string
 | 
			
		||||
    {
 | 
			
		||||
        $toolKit = $this->getToolkit();
 | 
			
		||||
        $settings = $toolKit->getSettings();
 | 
			
		||||
        $metadata = $settings->getSPMetadata();
 | 
			
		||||
        $errors = $settings->validateMetadata($metadata);
 | 
			
		||||
 | 
			
		||||
        if (!empty($errors)) {
 | 
			
		||||
            throw new Error(
 | 
			
		||||
                'Invalid SP metadata: '.implode(', ', $errors),
 | 
			
		||||
                Error::METADATA_SP_INVALID
 | 
			
		||||
            );
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return $metadata;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Load the underlying Onelogin SAML2 toolkit.
 | 
			
		||||
     * @throws Error
 | 
			
		||||
     * @throws Exception
 | 
			
		||||
     */
 | 
			
		||||
    protected function getToolkit(): Auth
 | 
			
		||||
    {
 | 
			
		||||
        $settings = $this->config['onelogin'];
 | 
			
		||||
        $overrides = $this->config['onelogin_overrides'] ?? [];
 | 
			
		||||
 | 
			
		||||
        if ($overrides && is_string($overrides)) {
 | 
			
		||||
            $overrides = json_decode($overrides, true);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $metaDataSettings = [];
 | 
			
		||||
        if ($this->config['autoload_from_metadata']) {
 | 
			
		||||
            $metaDataSettings = IdPMetadataParser::parseRemoteXML($settings['idp']['entityId']);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $spSettings = $this->loadOneloginServiceProviderDetails();
 | 
			
		||||
        $settings = array_replace_recursive($settings, $spSettings, $metaDataSettings, $overrides);
 | 
			
		||||
        return new Auth($settings);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Load dynamic service provider options required by the onelogin toolkit.
 | 
			
		||||
     */
 | 
			
		||||
    protected function loadOneloginServiceProviderDetails(): array
 | 
			
		||||
    {
 | 
			
		||||
        $spDetails = [
 | 
			
		||||
            'entityId' => url('/saml2/metadata'),
 | 
			
		||||
            'assertionConsumerService' => [
 | 
			
		||||
                'url' => url('/saml2/acs'),
 | 
			
		||||
            ],
 | 
			
		||||
            'singleLogoutService' => [
 | 
			
		||||
                'url' => url('/saml2/sls')
 | 
			
		||||
            ],
 | 
			
		||||
        ];
 | 
			
		||||
 | 
			
		||||
        return [
 | 
			
		||||
            'baseurl' => url('/saml2'),
 | 
			
		||||
            'sp' => $spDetails
 | 
			
		||||
        ];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if groups should be synced.
 | 
			
		||||
     */
 | 
			
		||||
    protected function shouldSyncGroups(): bool
 | 
			
		||||
    {
 | 
			
		||||
        return $this->config['user_to_groups'] !== false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Calculate the display name
 | 
			
		||||
     */
 | 
			
		||||
    protected function getUserDisplayName(array $samlAttributes, string $defaultValue): string
 | 
			
		||||
    {
 | 
			
		||||
        $displayNameAttr = $this->config['display_name_attributes'];
 | 
			
		||||
 | 
			
		||||
        $displayName = [];
 | 
			
		||||
        foreach ($displayNameAttr as $dnAttr) {
 | 
			
		||||
            $dnComponent = $this->getSamlResponseAttribute($samlAttributes, $dnAttr, null);
 | 
			
		||||
            if ($dnComponent !== null) {
 | 
			
		||||
                $displayName[] = $dnComponent;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (count($displayName) == 0) {
 | 
			
		||||
            $displayName = $defaultValue;
 | 
			
		||||
        } else {
 | 
			
		||||
            $displayName = implode(' ', $displayName);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return $displayName;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the value to use as the external id saved in BookStack
 | 
			
		||||
     * used to link the user to an existing BookStack DB user.
 | 
			
		||||
     */
 | 
			
		||||
    protected function getExternalId(array $samlAttributes, string $defaultValue)
 | 
			
		||||
    {
 | 
			
		||||
        $userNameAttr = $this->config['external_id_attribute'];
 | 
			
		||||
        if ($userNameAttr === null) {
 | 
			
		||||
            return $defaultValue;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return $this->getSamlResponseAttribute($samlAttributes, $userNameAttr, $defaultValue);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Extract the details of a user from a SAML response.
 | 
			
		||||
     */
 | 
			
		||||
    protected function getUserDetails(string $samlID, $samlAttributes): array
 | 
			
		||||
    {
 | 
			
		||||
        $emailAttr = $this->config['email_attribute'];
 | 
			
		||||
        $externalId = $this->getExternalId($samlAttributes, $samlID);
 | 
			
		||||
 | 
			
		||||
        $defaultEmail = filter_var($samlID, FILTER_VALIDATE_EMAIL) ? $samlID : null;
 | 
			
		||||
        $email = $this->getSamlResponseAttribute($samlAttributes, $emailAttr, $defaultEmail);
 | 
			
		||||
 | 
			
		||||
        return [
 | 
			
		||||
            'external_id' => $externalId,
 | 
			
		||||
            'name' => $this->getUserDisplayName($samlAttributes, $externalId),
 | 
			
		||||
            'email' => $email,
 | 
			
		||||
            'saml_id' => $samlID,
 | 
			
		||||
        ];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the groups a user is a part of from the SAML response.
 | 
			
		||||
     */
 | 
			
		||||
    public function getUserGroups(array $samlAttributes): array
 | 
			
		||||
    {
 | 
			
		||||
        $groupsAttr = $this->config['group_attribute'];
 | 
			
		||||
        $userGroups = $samlAttributes[$groupsAttr] ?? null;
 | 
			
		||||
 | 
			
		||||
        if (!is_array($userGroups)) {
 | 
			
		||||
            $userGroups = [];
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return $userGroups;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     *  For an array of strings, return a default for an empty array,
 | 
			
		||||
     *  a string for an array with one element and the full array for
 | 
			
		||||
     *  more than one element.
 | 
			
		||||
     */
 | 
			
		||||
    protected function simplifyValue(array $data, $defaultValue)
 | 
			
		||||
    {
 | 
			
		||||
        switch (count($data)) {
 | 
			
		||||
            case 0:
 | 
			
		||||
                $data = $defaultValue;
 | 
			
		||||
                break;
 | 
			
		||||
            case 1:
 | 
			
		||||
                $data = $data[0];
 | 
			
		||||
                break;
 | 
			
		||||
        }
 | 
			
		||||
        return $data;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get a property from an SAML response.
 | 
			
		||||
     * Handles properties potentially being an array.
 | 
			
		||||
     */
 | 
			
		||||
    protected function getSamlResponseAttribute(array $samlAttributes, string $propertyKey, $defaultValue)
 | 
			
		||||
    {
 | 
			
		||||
        if (isset($samlAttributes[$propertyKey])) {
 | 
			
		||||
            return $this->simplifyValue($samlAttributes[$propertyKey], $defaultValue);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return $defaultValue;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the user from the database for the specified details.
 | 
			
		||||
     * @throws SamlException
 | 
			
		||||
     * @throws UserRegistrationException
 | 
			
		||||
     */
 | 
			
		||||
    protected function getOrRegisterUser(array $userDetails): ?User
 | 
			
		||||
    {
 | 
			
		||||
        $user = $this->user->newQuery()
 | 
			
		||||
          ->where('external_auth_id', '=', $userDetails['external_id'])
 | 
			
		||||
          ->first();
 | 
			
		||||
 | 
			
		||||
        if (is_null($user)) {
 | 
			
		||||
            $userData = [
 | 
			
		||||
                'name' => $userDetails['name'],
 | 
			
		||||
                'email' => $userDetails['email'],
 | 
			
		||||
                'password' => Str::random(32),
 | 
			
		||||
                'external_auth_id' => $userDetails['external_id'],
 | 
			
		||||
            ];
 | 
			
		||||
 | 
			
		||||
            $user = $this->registrationService->registerUser($userData, null, false);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return $user;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Process the SAML response for a user. Login the user when
 | 
			
		||||
     * they exist, optionally registering them automatically.
 | 
			
		||||
     * @throws SamlException
 | 
			
		||||
     * @throws JsonDebugException
 | 
			
		||||
     * @throws UserRegistrationException
 | 
			
		||||
     */
 | 
			
		||||
    public function processLoginCallback(string $samlID, array $samlAttributes): User
 | 
			
		||||
    {
 | 
			
		||||
        $userDetails = $this->getUserDetails($samlID, $samlAttributes);
 | 
			
		||||
        $isLoggedIn = auth()->check();
 | 
			
		||||
 | 
			
		||||
        if ($this->config['dump_user_details']) {
 | 
			
		||||
            throw new JsonDebugException([
 | 
			
		||||
                'id_from_idp' => $samlID,
 | 
			
		||||
                'attrs_from_idp' => $samlAttributes,
 | 
			
		||||
                'attrs_after_parsing' => $userDetails,
 | 
			
		||||
            ]);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if ($userDetails['email'] === null) {
 | 
			
		||||
            throw new SamlException(trans('errors.saml_no_email_address'));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if ($isLoggedIn) {
 | 
			
		||||
            throw new SamlException(trans('errors.saml_already_logged_in'), '/login');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $user = $this->getOrRegisterUser($userDetails);
 | 
			
		||||
        if ($user === null) {
 | 
			
		||||
            throw new SamlException(trans('errors.saml_user_not_registered', ['name' => $userDetails['external_id']]), '/login');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if ($this->shouldSyncGroups()) {
 | 
			
		||||
            $groups = $this->getUserGroups($samlAttributes);
 | 
			
		||||
            $this->syncWithGroups($user, $groups);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        auth()->login($user);
 | 
			
		||||
        return $user;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -5,8 +5,11 @@ use BookStack\Auth\UserRepo;
 | 
			
		|||
use BookStack\Exceptions\SocialDriverNotConfigured;
 | 
			
		||||
use BookStack\Exceptions\SocialSignInAccountNotUsed;
 | 
			
		||||
use BookStack\Exceptions\UserRegistrationException;
 | 
			
		||||
use Illuminate\Support\Str;
 | 
			
		||||
use Laravel\Socialite\Contracts\Factory as Socialite;
 | 
			
		||||
use Laravel\Socialite\Contracts\Provider;
 | 
			
		||||
use Laravel\Socialite\Contracts\User as SocialUser;
 | 
			
		||||
use Symfony\Component\HttpFoundation\RedirectResponse;
 | 
			
		||||
 | 
			
		||||
class SocialAuthService
 | 
			
		||||
{
 | 
			
		||||
| 
						 | 
				
			
			@ -19,9 +22,6 @@ class SocialAuthService
 | 
			
		|||
 | 
			
		||||
    /**
 | 
			
		||||
     * SocialAuthService constructor.
 | 
			
		||||
     * @param \BookStack\Auth\UserRepo      $userRepo
 | 
			
		||||
     * @param Socialite     $socialite
 | 
			
		||||
     * @param SocialAccount $socialAccount
 | 
			
		||||
     */
 | 
			
		||||
    public function __construct(UserRepo $userRepo, Socialite $socialite, SocialAccount $socialAccount)
 | 
			
		||||
    {
 | 
			
		||||
| 
						 | 
				
			
			@ -33,11 +33,9 @@ class SocialAuthService
 | 
			
		|||
 | 
			
		||||
    /**
 | 
			
		||||
     * Start the social login path.
 | 
			
		||||
     * @param string $socialDriver
 | 
			
		||||
     * @return \Symfony\Component\HttpFoundation\RedirectResponse
 | 
			
		||||
     * @throws SocialDriverNotConfigured
 | 
			
		||||
     */
 | 
			
		||||
    public function startLogIn($socialDriver)
 | 
			
		||||
    public function startLogIn(string $socialDriver): RedirectResponse
 | 
			
		||||
    {
 | 
			
		||||
        $driver = $this->validateDriver($socialDriver);
 | 
			
		||||
        return $this->getSocialDriver($driver)->redirect();
 | 
			
		||||
| 
						 | 
				
			
			@ -45,11 +43,9 @@ class SocialAuthService
 | 
			
		|||
 | 
			
		||||
    /**
 | 
			
		||||
     * Start the social registration process
 | 
			
		||||
     * @param string $socialDriver
 | 
			
		||||
     * @return \Symfony\Component\HttpFoundation\RedirectResponse
 | 
			
		||||
     * @throws SocialDriverNotConfigured
 | 
			
		||||
     */
 | 
			
		||||
    public function startRegister($socialDriver)
 | 
			
		||||
    public function startRegister(string $socialDriver): RedirectResponse
 | 
			
		||||
    {
 | 
			
		||||
        $driver = $this->validateDriver($socialDriver);
 | 
			
		||||
        return $this->getSocialDriver($driver)->redirect();
 | 
			
		||||
| 
						 | 
				
			
			@ -57,12 +53,9 @@ class SocialAuthService
 | 
			
		|||
 | 
			
		||||
    /**
 | 
			
		||||
     * Handle the social registration process on callback.
 | 
			
		||||
     * @param string $socialDriver
 | 
			
		||||
     * @param SocialUser $socialUser
 | 
			
		||||
     * @return SocialUser
 | 
			
		||||
     * @throws UserRegistrationException
 | 
			
		||||
     */
 | 
			
		||||
    public function handleRegistrationCallback(string $socialDriver, SocialUser $socialUser)
 | 
			
		||||
    public function handleRegistrationCallback(string $socialDriver, SocialUser $socialUser): SocialUser
 | 
			
		||||
    {
 | 
			
		||||
        // Check social account has not already been used
 | 
			
		||||
        if ($this->socialAccount->where('driver_id', '=', $socialUser->getId())->exists()) {
 | 
			
		||||
| 
						 | 
				
			
			@ -71,7 +64,7 @@ class SocialAuthService
 | 
			
		|||
 | 
			
		||||
        if ($this->userRepo->getByEmail($socialUser->getEmail())) {
 | 
			
		||||
            $email = $socialUser->getEmail();
 | 
			
		||||
            throw new UserRegistrationException(trans('errors.social_account_in_use', ['socialAccount'=>$socialDriver, 'email' => $email]), '/login');
 | 
			
		||||
            throw new UserRegistrationException(trans('errors.error_user_exists_different_creds', ['email' => $email]), '/login');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return $socialUser;
 | 
			
		||||
| 
						 | 
				
			
			@ -79,11 +72,9 @@ class SocialAuthService
 | 
			
		|||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the social user details via the social driver.
 | 
			
		||||
     * @param string $socialDriver
 | 
			
		||||
     * @return SocialUser
 | 
			
		||||
     * @throws SocialDriverNotConfigured
 | 
			
		||||
     */
 | 
			
		||||
    public function getSocialUser(string $socialDriver)
 | 
			
		||||
    public function getSocialUser(string $socialDriver): SocialUser
 | 
			
		||||
    {
 | 
			
		||||
        $driver = $this->validateDriver($socialDriver);
 | 
			
		||||
        return $this->socialite->driver($driver)->user();
 | 
			
		||||
| 
						 | 
				
			
			@ -91,12 +82,9 @@ class SocialAuthService
 | 
			
		|||
 | 
			
		||||
    /**
 | 
			
		||||
     * Handle the login process on a oAuth callback.
 | 
			
		||||
     * @param $socialDriver
 | 
			
		||||
     * @param SocialUser $socialUser
 | 
			
		||||
     * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
 | 
			
		||||
     * @throws SocialSignInAccountNotUsed
 | 
			
		||||
     */
 | 
			
		||||
    public function handleLoginCallback($socialDriver, SocialUser $socialUser)
 | 
			
		||||
    public function handleLoginCallback(string $socialDriver, SocialUser $socialUser)
 | 
			
		||||
    {
 | 
			
		||||
        $socialId = $socialUser->getId();
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -104,6 +92,7 @@ class SocialAuthService
 | 
			
		|||
        $socialAccount = $this->socialAccount->where('driver_id', '=', $socialId)->first();
 | 
			
		||||
        $isLoggedIn = auth()->check();
 | 
			
		||||
        $currentUser = user();
 | 
			
		||||
        $titleCaseDriver = Str::title($socialDriver);
 | 
			
		||||
 | 
			
		||||
        // When a user is not logged in and a matching SocialAccount exists,
 | 
			
		||||
        // Simply log the user into the application.
 | 
			
		||||
| 
						 | 
				
			
			@ -117,26 +106,26 @@ class SocialAuthService
 | 
			
		|||
        if ($isLoggedIn && $socialAccount === null) {
 | 
			
		||||
            $this->fillSocialAccount($socialDriver, $socialUser);
 | 
			
		||||
            $currentUser->socialAccounts()->save($this->socialAccount);
 | 
			
		||||
            session()->flash('success', trans('settings.users_social_connected', ['socialAccount' => title_case($socialDriver)]));
 | 
			
		||||
            session()->flash('success', trans('settings.users_social_connected', ['socialAccount' => $titleCaseDriver]));
 | 
			
		||||
            return redirect($currentUser->getEditUrl());
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // When a user is logged in and the social account exists and is already linked to the current user.
 | 
			
		||||
        if ($isLoggedIn && $socialAccount !== null && $socialAccount->user->id === $currentUser->id) {
 | 
			
		||||
            session()->flash('error', trans('errors.social_account_existing', ['socialAccount' => title_case($socialDriver)]));
 | 
			
		||||
            session()->flash('error', trans('errors.social_account_existing', ['socialAccount' => $titleCaseDriver]));
 | 
			
		||||
            return redirect($currentUser->getEditUrl());
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // When a user is logged in, A social account exists but the users do not match.
 | 
			
		||||
        if ($isLoggedIn && $socialAccount !== null && $socialAccount->user->id != $currentUser->id) {
 | 
			
		||||
            session()->flash('error', trans('errors.social_account_already_used_existing', ['socialAccount' => title_case($socialDriver)]));
 | 
			
		||||
            session()->flash('error', trans('errors.social_account_already_used_existing', ['socialAccount' => $titleCaseDriver]));
 | 
			
		||||
            return redirect($currentUser->getEditUrl());
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Otherwise let the user know this social account is not used by anyone.
 | 
			
		||||
        $message = trans('errors.social_account_not_used', ['socialAccount' => title_case($socialDriver)]);
 | 
			
		||||
        if (setting('registration-enabled')) {
 | 
			
		||||
            $message .= trans('errors.social_account_register_instructions', ['socialAccount' => title_case($socialDriver)]);
 | 
			
		||||
        $message = trans('errors.social_account_not_used', ['socialAccount' => $titleCaseDriver]);
 | 
			
		||||
        if (setting('registration-enabled') && config('auth.method') !== 'ldap' && config('auth.method') !== 'saml2') {
 | 
			
		||||
            $message .= trans('errors.social_account_register_instructions', ['socialAccount' => $titleCaseDriver]);
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        throw new SocialSignInAccountNotUsed($message, '/login');
 | 
			
		||||
| 
						 | 
				
			
			@ -144,20 +133,18 @@ class SocialAuthService
 | 
			
		|||
 | 
			
		||||
    /**
 | 
			
		||||
     * Ensure the social driver is correct and supported.
 | 
			
		||||
     *
 | 
			
		||||
     * @param $socialDriver
 | 
			
		||||
     * @return string
 | 
			
		||||
     * @throws SocialDriverNotConfigured
 | 
			
		||||
     */
 | 
			
		||||
    private function validateDriver($socialDriver)
 | 
			
		||||
    protected function validateDriver(string $socialDriver): string
 | 
			
		||||
    {
 | 
			
		||||
        $driver = trim(strtolower($socialDriver));
 | 
			
		||||
 | 
			
		||||
        if (!in_array($driver, $this->validSocialDrivers)) {
 | 
			
		||||
            abort(404, trans('errors.social_driver_not_found'));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!$this->checkDriverConfigured($driver)) {
 | 
			
		||||
            throw new SocialDriverNotConfigured(trans('errors.social_driver_not_configured', ['socialAccount' => title_case($socialDriver)]));
 | 
			
		||||
            throw new SocialDriverNotConfigured(trans('errors.social_driver_not_configured', ['socialAccount' => Str::title($socialDriver)]));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return $driver;
 | 
			
		||||
| 
						 | 
				
			
			@ -165,10 +152,8 @@ class SocialAuthService
 | 
			
		|||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check a social driver has been configured correctly.
 | 
			
		||||
     * @param $driver
 | 
			
		||||
     * @return bool
 | 
			
		||||
     */
 | 
			
		||||
    private function checkDriverConfigured($driver)
 | 
			
		||||
    protected function checkDriverConfigured(string $driver): bool
 | 
			
		||||
    {
 | 
			
		||||
        $lowerName = strtolower($driver);
 | 
			
		||||
        $configPrefix = 'services.' . $lowerName . '.';
 | 
			
		||||
| 
						 | 
				
			
			@ -178,55 +163,48 @@ class SocialAuthService
 | 
			
		|||
 | 
			
		||||
    /**
 | 
			
		||||
     * Gets the names of the active social drivers.
 | 
			
		||||
     * @return array
 | 
			
		||||
     */
 | 
			
		||||
    public function getActiveDrivers()
 | 
			
		||||
    public function getActiveDrivers(): array
 | 
			
		||||
    {
 | 
			
		||||
        $activeDrivers = [];
 | 
			
		||||
 | 
			
		||||
        foreach ($this->validSocialDrivers as $driverKey) {
 | 
			
		||||
            if ($this->checkDriverConfigured($driverKey)) {
 | 
			
		||||
                $activeDrivers[$driverKey] = $this->getDriverName($driverKey);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return $activeDrivers;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the presentational name for a driver.
 | 
			
		||||
     * @param $driver
 | 
			
		||||
     * @return mixed
 | 
			
		||||
     */
 | 
			
		||||
    public function getDriverName($driver)
 | 
			
		||||
    public function getDriverName(string $driver): string
 | 
			
		||||
    {
 | 
			
		||||
        return config('services.' . strtolower($driver) . '.name');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if the current config for the given driver allows auto-registration.
 | 
			
		||||
     * @param string $driver
 | 
			
		||||
     * @return bool
 | 
			
		||||
     */
 | 
			
		||||
    public function driverAutoRegisterEnabled(string $driver)
 | 
			
		||||
    public function driverAutoRegisterEnabled(string $driver): bool
 | 
			
		||||
    {
 | 
			
		||||
        return config('services.' . strtolower($driver) . '.auto_register') === true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if the current config for the given driver allow email address auto-confirmation.
 | 
			
		||||
     * @param string $driver
 | 
			
		||||
     * @return bool
 | 
			
		||||
     */
 | 
			
		||||
    public function driverAutoConfirmEmailEnabled(string $driver)
 | 
			
		||||
    public function driverAutoConfirmEmailEnabled(string $driver): bool
 | 
			
		||||
    {
 | 
			
		||||
        return config('services.' . strtolower($driver) . '.auto_confirm') === true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @param string $socialDriver
 | 
			
		||||
     * @param SocialUser $socialUser
 | 
			
		||||
     * @return SocialAccount
 | 
			
		||||
     * Fill and return a SocialAccount from the given driver name and SocialUser.
 | 
			
		||||
     */
 | 
			
		||||
    public function fillSocialAccount($socialDriver, $socialUser)
 | 
			
		||||
    public function fillSocialAccount(string $socialDriver, SocialUser $socialUser): SocialAccount
 | 
			
		||||
    {
 | 
			
		||||
        $this->socialAccount->fill([
 | 
			
		||||
            'driver'    => $socialDriver,
 | 
			
		||||
| 
						 | 
				
			
			@ -238,22 +216,17 @@ class SocialAuthService
 | 
			
		|||
 | 
			
		||||
    /**
 | 
			
		||||
     * Detach a social account from a user.
 | 
			
		||||
     * @param $socialDriver
 | 
			
		||||
     * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
 | 
			
		||||
     */
 | 
			
		||||
    public function detachSocialAccount($socialDriver)
 | 
			
		||||
    public function detachSocialAccount(string $socialDriver)
 | 
			
		||||
    {
 | 
			
		||||
        user()->socialAccounts()->where('driver', '=', $socialDriver)->delete();
 | 
			
		||||
        session()->flash('success', trans('settings.users_social_disconnected', ['socialAccount' => title_case($socialDriver)]));
 | 
			
		||||
        return redirect(user()->getEditUrl());
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Provide redirect options per service for the Laravel Socialite driver
 | 
			
		||||
     * @param $driverName
 | 
			
		||||
     * @return \Laravel\Socialite\Contracts\Provider
 | 
			
		||||
     */
 | 
			
		||||
    public function getSocialDriver(string $driverName)
 | 
			
		||||
    public function getSocialDriver(string $driverName): Provider
 | 
			
		||||
    {
 | 
			
		||||
        $driver = $this->socialite->driver($driverName);
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -19,5 +19,4 @@ class UserInviteService extends UserTokenService
 | 
			
		|||
        $token = $this->createTokenForUser($user);
 | 
			
		||||
        $user->notify(new UserInvite($token));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -5,6 +5,7 @@ use BookStack\Exceptions\UserTokenExpiredException;
 | 
			
		|||
use BookStack\Exceptions\UserTokenNotFoundException;
 | 
			
		||||
use Carbon\Carbon;
 | 
			
		||||
use Illuminate\Database\Connection as Database;
 | 
			
		||||
use Illuminate\Support\Str;
 | 
			
		||||
use stdClass;
 | 
			
		||||
 | 
			
		||||
class UserTokenService
 | 
			
		||||
| 
						 | 
				
			
			@ -73,9 +74,9 @@ class UserTokenService
 | 
			
		|||
     */
 | 
			
		||||
    protected function generateToken() : string
 | 
			
		||||
    {
 | 
			
		||||
        $token = str_random(24);
 | 
			
		||||
        $token = Str::random(24);
 | 
			
		||||
        while ($this->tokenExists($token)) {
 | 
			
		||||
            $token = str_random(25);
 | 
			
		||||
            $token = Str::random(25);
 | 
			
		||||
        }
 | 
			
		||||
        return $token;
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			@ -130,5 +131,4 @@ class UserTokenService
 | 
			
		|||
        return Carbon::now()->subHours($this->expiryTime)
 | 
			
		||||
            ->gt(new Carbon($tokenEntry->created_at));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -215,7 +215,6 @@ class PermissionService
 | 
			
		|||
     * @param Collection $books
 | 
			
		||||
     * @param array $roles
 | 
			
		||||
     * @param bool $deleteOld
 | 
			
		||||
     * @throws \Throwable
 | 
			
		||||
     */
 | 
			
		||||
    protected function buildJointPermissionsForBooks($books, $roles, $deleteOld = false)
 | 
			
		||||
    {
 | 
			
		||||
| 
						 | 
				
			
			@ -634,42 +633,40 @@ class PermissionService
 | 
			
		|||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the children of a book in an efficient single query, Filtered by the permission system.
 | 
			
		||||
     * @param integer $book_id
 | 
			
		||||
     * @param bool $filterDrafts
 | 
			
		||||
     * @param bool $fetchPageContent
 | 
			
		||||
     * @return QueryBuilder
 | 
			
		||||
     * Limited the given entity query so that the query will only
 | 
			
		||||
     * return items that the user has permission for the given ability.
 | 
			
		||||
     */
 | 
			
		||||
    public function bookChildrenQuery($book_id, $filterDrafts = false, $fetchPageContent = false)
 | 
			
		||||
    public function restrictEntityQuery(Builder $query, string $ability = 'view'): Builder
 | 
			
		||||
    {
 | 
			
		||||
        $entities = $this->entityProvider;
 | 
			
		||||
        $pageSelect = $this->db->table('pages')->selectRaw($entities->page->entityRawQuery($fetchPageContent))
 | 
			
		||||
            ->where('book_id', '=', $book_id)->where(function ($query) use ($filterDrafts) {
 | 
			
		||||
                $query->where('draft', '=', 0);
 | 
			
		||||
                if (!$filterDrafts) {
 | 
			
		||||
                    $query->orWhere(function ($query) {
 | 
			
		||||
                        $query->where('draft', '=', 1)->where('created_by', '=', $this->currentUser()->id);
 | 
			
		||||
                    });
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
        $chapterSelect = $this->db->table('chapters')->selectRaw($entities->chapter->entityRawQuery())->where('book_id', '=', $book_id);
 | 
			
		||||
        $query = $this->db->query()->select('*')->from($this->db->raw("({$pageSelect->toSql()} UNION {$chapterSelect->toSql()}) AS U"))
 | 
			
		||||
            ->mergeBindings($pageSelect)->mergeBindings($chapterSelect);
 | 
			
		||||
 | 
			
		||||
        // Add joint permission filter
 | 
			
		||||
        $whereQuery = $this->db->table('joint_permissions as jp')->selectRaw('COUNT(*)')
 | 
			
		||||
            ->whereRaw('jp.entity_id=U.id')->whereRaw('jp.entity_type=U.entity_type')
 | 
			
		||||
            ->where('jp.action', '=', 'view')->whereIn('jp.role_id', $this->getRoles())
 | 
			
		||||
            ->where(function ($query) {
 | 
			
		||||
                $query->where('jp.has_permission', '=', 1)->orWhere(function ($query) {
 | 
			
		||||
                    $query->where('jp.has_permission_own', '=', 1)->where('jp.created_by', '=', $this->currentUser()->id);
 | 
			
		||||
                });
 | 
			
		||||
            });
 | 
			
		||||
        $query->whereRaw("({$whereQuery->toSql()}) > 0")->mergeBindings($whereQuery);
 | 
			
		||||
 | 
			
		||||
        $query->orderBy('draft', 'desc')->orderBy('priority', 'asc');
 | 
			
		||||
        $this->clean();
 | 
			
		||||
        return  $query;
 | 
			
		||||
        return $query->where(function (Builder $parentQuery) use ($ability) {
 | 
			
		||||
            $parentQuery->whereHas('jointPermissions', function (Builder $permissionQuery) use ($ability) {
 | 
			
		||||
                $permissionQuery->whereIn('role_id', $this->getRoles())
 | 
			
		||||
                    ->where('action', '=', $ability)
 | 
			
		||||
                    ->where(function (Builder $query) {
 | 
			
		||||
                        $query->where('has_permission', '=', true)
 | 
			
		||||
                            ->orWhere(function (Builder $query) {
 | 
			
		||||
                                $query->where('has_permission_own', '=', true)
 | 
			
		||||
                                    ->where('created_by', '=', $this->currentUser()->id);
 | 
			
		||||
                            });
 | 
			
		||||
                    });
 | 
			
		||||
            });
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Extend the given page query to ensure draft items are not visible
 | 
			
		||||
     * unless created by the given user.
 | 
			
		||||
     */
 | 
			
		||||
    public function enforceDraftVisiblityOnQuery(Builder $query): Builder
 | 
			
		||||
    {
 | 
			
		||||
        return $query->where(function (Builder $query) {
 | 
			
		||||
            $query->where('draft', '=', false)
 | 
			
		||||
                ->orWhere(function (Builder $query) {
 | 
			
		||||
                    $query->where('draft', '=', true)
 | 
			
		||||
                        ->where('created_by', '=', $this->currentUser()->id);
 | 
			
		||||
                });
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
| 
						 | 
				
			
			@ -684,12 +681,11 @@ class PermissionService
 | 
			
		|||
        if (strtolower($entityType) === 'page') {
 | 
			
		||||
            // Prevent drafts being visible to others.
 | 
			
		||||
            $query = $query->where(function ($query) {
 | 
			
		||||
                $query->where('draft', '=', false);
 | 
			
		||||
                if ($this->currentUser()) {
 | 
			
		||||
                    $query->orWhere(function ($query) {
 | 
			
		||||
                        $query->where('draft', '=', true)->where('created_by', '=', $this->currentUser()->id);
 | 
			
		||||
                $query->where('draft', '=', false)
 | 
			
		||||
                    ->orWhere(function ($query) {
 | 
			
		||||
                        $query->where('draft', '=', true)
 | 
			
		||||
                            ->where('created_by', '=', $this->currentUser()->id);
 | 
			
		||||
                    });
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -3,6 +3,7 @@
 | 
			
		|||
use BookStack\Auth\Permissions;
 | 
			
		||||
use BookStack\Auth\Role;
 | 
			
		||||
use BookStack\Exceptions\PermissionsException;
 | 
			
		||||
use Illuminate\Support\Str;
 | 
			
		||||
 | 
			
		||||
class PermissionsRepo
 | 
			
		||||
{
 | 
			
		||||
| 
						 | 
				
			
			@ -66,7 +67,7 @@ class PermissionsRepo
 | 
			
		|||
        $role->name = str_replace(' ', '-', strtolower($roleData['display_name']));
 | 
			
		||||
        // Prevent duplicate names
 | 
			
		||||
        while ($this->role->where('name', '=', $role->name)->count() > 0) {
 | 
			
		||||
            $role->name .= strtolower(str_random(2));
 | 
			
		||||
            $role->name .= strtolower(Str::random(2));
 | 
			
		||||
        }
 | 
			
		||||
        $role->save();
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -136,7 +137,7 @@ class PermissionsRepo
 | 
			
		|||
        // Prevent deleting admin role or default registration role.
 | 
			
		||||
        if ($role->system_name && in_array($role->system_name, $this->systemRoles)) {
 | 
			
		||||
            throw new PermissionsException(trans('errors.role_system_cannot_be_deleted'));
 | 
			
		||||
        } else if ($role->id == setting('registration-role')) {
 | 
			
		||||
        } else if ($role->id === intval(setting('registration-role'))) {
 | 
			
		||||
            throw new PermissionsException(trans('errors.role_registration_default_cannot_delete'));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -4,6 +4,13 @@ use BookStack\Auth\Permissions\JointPermission;
 | 
			
		|||
use BookStack\Auth\Permissions\RolePermission;
 | 
			
		||||
use BookStack\Model;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Class Role
 | 
			
		||||
 * @property string $display_name
 | 
			
		||||
 * @property string $description
 | 
			
		||||
 * @property string $external_auth_id
 | 
			
		||||
 * @package BookStack\Auth
 | 
			
		||||
 */
 | 
			
		||||
class Role extends Model
 | 
			
		||||
{
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -65,7 +72,7 @@ class Role extends Model
 | 
			
		|||
     */
 | 
			
		||||
    public function detachPermission(RolePermission $permission)
 | 
			
		||||
    {
 | 
			
		||||
        $this->permissions()->detach($permission->id);
 | 
			
		||||
        $this->permissions()->detach([$permission->id]);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
| 
						 | 
				
			
			@ -75,7 +82,7 @@ class Role extends Model
 | 
			
		|||
     */
 | 
			
		||||
    public static function getRole($roleName)
 | 
			
		||||
    {
 | 
			
		||||
        return static::where('name', '=', $roleName)->first();
 | 
			
		||||
        return static::query()->where('name', '=', $roleName)->first();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
| 
						 | 
				
			
			@ -85,7 +92,7 @@ class Role extends Model
 | 
			
		|||
     */
 | 
			
		||||
    public static function getSystemRole($roleName)
 | 
			
		||||
    {
 | 
			
		||||
        return static::where('system_name', '=', $roleName)->first();
 | 
			
		||||
        return static::query()->where('system_name', '=', $roleName)->first();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
| 
						 | 
				
			
			@ -94,6 +101,15 @@ class Role extends Model
 | 
			
		|||
     */
 | 
			
		||||
    public static function visible()
 | 
			
		||||
    {
 | 
			
		||||
        return static::where('hidden', '=', false)->orderBy('name')->get();
 | 
			
		||||
        return static::query()->where('hidden', '=', false)->orderBy('name')->get();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the roles that can be restricted.
 | 
			
		||||
     * @return \Illuminate\Database\Eloquent\Builder[]|\Illuminate\Database\Eloquent\Collection
 | 
			
		||||
     */
 | 
			
		||||
    public static function restrictable()
 | 
			
		||||
    {
 | 
			
		||||
        return static::query()->where('system_name', '!=', 'admin')->get();
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,5 +1,6 @@
 | 
			
		|||
<?php namespace BookStack\Auth;
 | 
			
		||||
 | 
			
		||||
use BookStack\Api\ApiToken;
 | 
			
		||||
use BookStack\Model;
 | 
			
		||||
use BookStack\Notifications\ResetPassword;
 | 
			
		||||
use BookStack\Uploads\Image;
 | 
			
		||||
| 
						 | 
				
			
			@ -9,6 +10,7 @@ use Illuminate\Auth\Passwords\CanResetPassword;
 | 
			
		|||
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
 | 
			
		||||
use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;
 | 
			
		||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
 | 
			
		||||
use Illuminate\Database\Eloquent\Relations\HasMany;
 | 
			
		||||
use Illuminate\Notifications\Notifiable;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
| 
						 | 
				
			
			@ -45,7 +47,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
 | 
			
		|||
     * The attributes excluded from the model's JSON form.
 | 
			
		||||
     * @var array
 | 
			
		||||
     */
 | 
			
		||||
    protected $hidden = ['password', 'remember_token'];
 | 
			
		||||
    protected $hidden = ['password', 'remember_token', 'system_name', 'email_confirmed', 'external_auth_id', 'email'];
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * This holds the user's permissions when loaded.
 | 
			
		||||
| 
						 | 
				
			
			@ -53,13 +55,24 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
 | 
			
		|||
     */
 | 
			
		||||
    protected $permissions;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * This holds the default user when loaded.
 | 
			
		||||
     * @var null|User
 | 
			
		||||
     */
 | 
			
		||||
    protected static $defaultUser = null;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Returns the default public user.
 | 
			
		||||
     * @return User
 | 
			
		||||
     */
 | 
			
		||||
    public static function getDefault()
 | 
			
		||||
    {
 | 
			
		||||
        return static::where('system_name', '=', 'public')->first();
 | 
			
		||||
        if (!is_null(static::$defaultUser)) {
 | 
			
		||||
            return static::$defaultUser;
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        static::$defaultUser = static::where('system_name', '=', 'public')->first();
 | 
			
		||||
        return static::$defaultUser;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
| 
						 | 
				
			
			@ -103,6 +116,17 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
 | 
			
		|||
        return $this->roles->pluck('system_name')->contains($role);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Attach the default system role to this user.
 | 
			
		||||
     */
 | 
			
		||||
    public function attachDefaultRole(): void
 | 
			
		||||
    {
 | 
			
		||||
        $roleId = setting('registration-role');
 | 
			
		||||
        if ($roleId && $this->roles()->where('id', '=', $roleId)->count() === 0) {
 | 
			
		||||
            $this->roles()->attach($roleId);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get all permissions belonging to a the current user.
 | 
			
		||||
     * @param bool $cache
 | 
			
		||||
| 
						 | 
				
			
			@ -140,16 +164,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
 | 
			
		|||
     */
 | 
			
		||||
    public function attachRole(Role $role)
 | 
			
		||||
    {
 | 
			
		||||
        $this->attachRoleId($role->id);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Attach a role id to this user.
 | 
			
		||||
     * @param $id
 | 
			
		||||
     */
 | 
			
		||||
    public function attachRoleId($id)
 | 
			
		||||
    {
 | 
			
		||||
        $this->roles()->attach($id);
 | 
			
		||||
        $this->roles()->attach($role->id);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
| 
						 | 
				
			
			@ -207,19 +222,26 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
 | 
			
		|||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the url for editing this user.
 | 
			
		||||
     * @return string
 | 
			
		||||
     * Get the API tokens assigned to this user.
 | 
			
		||||
     */
 | 
			
		||||
    public function getEditUrl()
 | 
			
		||||
    public function apiTokens(): HasMany
 | 
			
		||||
    {
 | 
			
		||||
        return url('/settings/users/' . $this->id);
 | 
			
		||||
        return $this->hasMany(ApiToken::class);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the url for editing this user.
 | 
			
		||||
     */
 | 
			
		||||
    public function getEditUrl(string $path = ''): string
 | 
			
		||||
    {
 | 
			
		||||
        $uri = '/settings/users/' . $this->id . '/' . trim($path, '/');
 | 
			
		||||
        return url(rtrim($uri, '/'));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the url that links to this user's profile.
 | 
			
		||||
     * @return mixed
 | 
			
		||||
     */
 | 
			
		||||
    public function getProfileUrl()
 | 
			
		||||
    public function getProfileUrl(): string
 | 
			
		||||
    {
 | 
			
		||||
        return url('/user/' . $this->id);
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,39 +1,37 @@
 | 
			
		|||
<?php namespace BookStack\Auth;
 | 
			
		||||
 | 
			
		||||
use Activity;
 | 
			
		||||
use BookStack\Entities\Repos\EntityRepo;
 | 
			
		||||
use BookStack\Entities\Book;
 | 
			
		||||
use BookStack\Entities\Bookshelf;
 | 
			
		||||
use BookStack\Entities\Chapter;
 | 
			
		||||
use BookStack\Entities\Page;
 | 
			
		||||
use BookStack\Exceptions\NotFoundException;
 | 
			
		||||
use BookStack\Exceptions\UserUpdateException;
 | 
			
		||||
use BookStack\Uploads\Image;
 | 
			
		||||
use Exception;
 | 
			
		||||
use Illuminate\Database\Eloquent\Builder;
 | 
			
		||||
use Images;
 | 
			
		||||
use Log;
 | 
			
		||||
 | 
			
		||||
class UserRepo
 | 
			
		||||
{
 | 
			
		||||
 | 
			
		||||
    protected $user;
 | 
			
		||||
    protected $role;
 | 
			
		||||
    protected $entityRepo;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * UserRepo constructor.
 | 
			
		||||
     * @param User $user
 | 
			
		||||
     * @param Role $role
 | 
			
		||||
     * @param EntityRepo $entityRepo
 | 
			
		||||
     */
 | 
			
		||||
    public function __construct(User $user, Role $role, EntityRepo $entityRepo)
 | 
			
		||||
    public function __construct(User $user, Role $role)
 | 
			
		||||
    {
 | 
			
		||||
        $this->user = $user;
 | 
			
		||||
        $this->role = $role;
 | 
			
		||||
        $this->entityRepo = $entityRepo;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @param string $email
 | 
			
		||||
     * @return User|null
 | 
			
		||||
     * Get a user by their email address.
 | 
			
		||||
     */
 | 
			
		||||
    public function getByEmail($email)
 | 
			
		||||
    public function getByEmail(string $email): ?User
 | 
			
		||||
    {
 | 
			
		||||
        return $this->user->where('email', '=', $email)->first();
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			@ -79,31 +77,16 @@ class UserRepo
 | 
			
		|||
 | 
			
		||||
     /**
 | 
			
		||||
     * Creates a new user and attaches a role to them.
 | 
			
		||||
     * @param array $data
 | 
			
		||||
     * @param boolean $verifyEmail
 | 
			
		||||
     * @return \BookStack\Auth\User
 | 
			
		||||
     */
 | 
			
		||||
    public function registerNew(array $data, $verifyEmail = false)
 | 
			
		||||
    public function registerNew(array $data, bool $emailConfirmed = false): User
 | 
			
		||||
    {
 | 
			
		||||
        $user = $this->create($data, $verifyEmail);
 | 
			
		||||
        $this->attachDefaultRole($user);
 | 
			
		||||
        $user = $this->create($data, $emailConfirmed);
 | 
			
		||||
        $user->attachDefaultRole();
 | 
			
		||||
        $this->downloadAndAssignUserAvatar($user);
 | 
			
		||||
 | 
			
		||||
        return $user;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Give a user the default role. Used when creating a new user.
 | 
			
		||||
     * @param User $user
 | 
			
		||||
     */
 | 
			
		||||
    public function attachDefaultRole(User $user)
 | 
			
		||||
    {
 | 
			
		||||
        $roleId = setting('registration-role');
 | 
			
		||||
        if ($roleId !== false && $user->roles()->where('id', '=', $roleId)->count() === 0) {
 | 
			
		||||
            $user->attachRoleId($roleId);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Assign a user to a system-level role.
 | 
			
		||||
     * @param User $user
 | 
			
		||||
| 
						 | 
				
			
			@ -121,7 +104,7 @@ class UserRepo
 | 
			
		|||
 | 
			
		||||
    /**
 | 
			
		||||
     * Checks if the give user is the only admin.
 | 
			
		||||
     * @param \BookStack\Auth\User $user
 | 
			
		||||
     * @param User $user
 | 
			
		||||
     * @return bool
 | 
			
		||||
     */
 | 
			
		||||
    public function isOnlyAdmin(User $user)
 | 
			
		||||
| 
						 | 
				
			
			@ -173,28 +156,27 @@ class UserRepo
 | 
			
		|||
 | 
			
		||||
    /**
 | 
			
		||||
     * Create a new basic instance of user.
 | 
			
		||||
     * @param array $data
 | 
			
		||||
     * @param boolean $verifyEmail
 | 
			
		||||
     * @return \BookStack\Auth\User
 | 
			
		||||
     */
 | 
			
		||||
    public function create(array $data, $verifyEmail = false)
 | 
			
		||||
    public function create(array $data, bool $emailConfirmed = false): User
 | 
			
		||||
    {
 | 
			
		||||
        return $this->user->forceCreate([
 | 
			
		||||
            'name'     => $data['name'],
 | 
			
		||||
            'email'    => $data['email'],
 | 
			
		||||
            'password' => bcrypt($data['password']),
 | 
			
		||||
            'email_confirmed' => $verifyEmail
 | 
			
		||||
            'email_confirmed' => $emailConfirmed,
 | 
			
		||||
            'external_auth_id' => $data['external_auth_id'] ?? '',
 | 
			
		||||
        ]);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Remove the given user from storage, Delete all related content.
 | 
			
		||||
     * @param \BookStack\Auth\User $user
 | 
			
		||||
     * @param User $user
 | 
			
		||||
     * @throws Exception
 | 
			
		||||
     */
 | 
			
		||||
    public function destroy(User $user)
 | 
			
		||||
    {
 | 
			
		||||
        $user->socialAccounts()->delete();
 | 
			
		||||
        $user->apiTokens()->delete();
 | 
			
		||||
        $user->delete();
 | 
			
		||||
        
 | 
			
		||||
        // Delete user profile images
 | 
			
		||||
| 
						 | 
				
			
			@ -206,7 +188,7 @@ class UserRepo
 | 
			
		|||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the latest activity for a user.
 | 
			
		||||
     * @param \BookStack\Auth\User $user
 | 
			
		||||
     * @param User $user
 | 
			
		||||
     * @param int $count
 | 
			
		||||
     * @param int $page
 | 
			
		||||
     * @return array
 | 
			
		||||
| 
						 | 
				
			
			@ -218,36 +200,35 @@ class UserRepo
 | 
			
		|||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the recently created content for this given user.
 | 
			
		||||
     * @param \BookStack\Auth\User $user
 | 
			
		||||
     * @param int $count
 | 
			
		||||
     * @return mixed
 | 
			
		||||
     */
 | 
			
		||||
    public function getRecentlyCreated(User $user, $count = 20)
 | 
			
		||||
    public function getRecentlyCreated(User $user, int $count = 20): array
 | 
			
		||||
    {
 | 
			
		||||
        $createdByUserQuery = function (Builder $query) use ($user) {
 | 
			
		||||
            $query->where('created_by', '=', $user->id);
 | 
			
		||||
        $query = function (Builder $query) use ($user, $count) {
 | 
			
		||||
            return $query->orderBy('created_at', 'desc')
 | 
			
		||||
                ->where('created_by', '=', $user->id)
 | 
			
		||||
                ->take($count)
 | 
			
		||||
                ->get();
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        return [
 | 
			
		||||
            'pages'    => $this->entityRepo->getRecentlyCreated('page', $count, 0, $createdByUserQuery),
 | 
			
		||||
            'chapters' => $this->entityRepo->getRecentlyCreated('chapter', $count, 0, $createdByUserQuery),
 | 
			
		||||
            'books'    => $this->entityRepo->getRecentlyCreated('book', $count, 0, $createdByUserQuery),
 | 
			
		||||
            'shelves'  => $this->entityRepo->getRecentlyCreated('bookshelf', $count, 0, $createdByUserQuery)
 | 
			
		||||
            'pages'    => $query(Page::visible()->where('draft', '=', false)),
 | 
			
		||||
            'chapters' => $query(Chapter::visible()),
 | 
			
		||||
            'books'    => $query(Book::visible()),
 | 
			
		||||
            'shelves'  => $query(Bookshelf::visible()),
 | 
			
		||||
        ];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get asset created counts for the give user.
 | 
			
		||||
     * @param \BookStack\Auth\User $user
 | 
			
		||||
     * @return array
 | 
			
		||||
     */
 | 
			
		||||
    public function getAssetCounts(User $user)
 | 
			
		||||
    public function getAssetCounts(User $user): array
 | 
			
		||||
    {
 | 
			
		||||
        $createdBy = ['created_by' => $user->id];
 | 
			
		||||
        return [
 | 
			
		||||
            'pages'    => $this->entityRepo->getUserTotalCreated('page', $user),
 | 
			
		||||
            'chapters' => $this->entityRepo->getUserTotalCreated('chapter', $user),
 | 
			
		||||
            'books'    => $this->entityRepo->getUserTotalCreated('book', $user),
 | 
			
		||||
            'shelves'    => $this->entityRepo->getUserTotalCreated('bookshelf', $user),
 | 
			
		||||
            'pages'    =>  Page::visible()->where($createdBy)->count(),
 | 
			
		||||
            'chapters'    =>  Chapter::visible()->where($createdBy)->count(),
 | 
			
		||||
            'books'    =>  Book::visible()->where($createdBy)->count(),
 | 
			
		||||
            'shelves'    =>  Bookshelf::visible()->where($createdBy)->count(),
 | 
			
		||||
        ];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -260,16 +241,6 @@ class UserRepo
 | 
			
		|||
        return $this->role->newQuery()->orderBy('name', 'asc')->get();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get all the roles which can be given restricted access to
 | 
			
		||||
     * other entities in the system.
 | 
			
		||||
     * @return mixed
 | 
			
		||||
     */
 | 
			
		||||
    public function getRestrictableRoles()
 | 
			
		||||
    {
 | 
			
		||||
        return $this->role->where('system_name', '!=', 'admin')->get();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get an avatar image for a user and set it as their avatar.
 | 
			
		||||
     * Returns early if avatars disabled or not set in config.
 | 
			
		||||
| 
						 | 
				
			
			@ -288,7 +259,7 @@ class UserRepo
 | 
			
		|||
            $user->save();
 | 
			
		||||
            return true;
 | 
			
		||||
        } catch (Exception $e) {
 | 
			
		||||
            \Log::error('Failed to save user avatar image');
 | 
			
		||||
            Log::error('Failed to save user avatar image');
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,23 @@
 | 
			
		|||
<?php
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * API configuration options.
 | 
			
		||||
 *
 | 
			
		||||
 * Changes to these config files are not supported by BookStack and may break upon updates.
 | 
			
		||||
 * Configuration should be altered via the `.env` file or environment variables.
 | 
			
		||||
 * Do not edit this file unless you're happy to maintain any changes yourself.
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
return [
 | 
			
		||||
 | 
			
		||||
    // The default number of items that are returned in listing API requests.
 | 
			
		||||
    // This count can often be overridden, up the the max option, per-request via request options.
 | 
			
		||||
    'default_item_count' => env('API_DEFAULT_ITEM_COUNT', 100),
 | 
			
		||||
 | 
			
		||||
    // The maximum number of items that can be returned in a listing API request.
 | 
			
		||||
    'max_item_count' => env('API_MAX_ITEM_COUNT', 500),
 | 
			
		||||
 | 
			
		||||
    // The number of API requests that can be made per minute by a single user.
 | 
			
		||||
    'requests_per_minute' => env('API_REQUESTS_PER_MIN', 180)
 | 
			
		||||
 | 
			
		||||
];
 | 
			
		||||
| 
						 | 
				
			
			@ -52,11 +52,14 @@ return [
 | 
			
		|||
    'locale' => env('APP_LANG', 'en'),
 | 
			
		||||
 | 
			
		||||
    // Locales available
 | 
			
		||||
    'locales' => ['en', 'ar', 'de', 'de_informal', 'es', 'es_AR', 'fr', 'hu', 'nl', 'pt_BR', 'sk', 'cs', 'sv', 'kr', 'ja', 'pl', 'it', 'ru', 'uk', 'zh_CN', 'zh_TW'],
 | 
			
		||||
    'locales' => ['en', 'ar', 'da', 'de', 'de_informal', 'es', 'es_AR', 'fr', 'hu', 'nl', 'pt_BR', 'sk', 'cs', 'sv', 'ko', 'ja', 'pl', 'it', 'ru', 'uk', 'zh_CN', 'zh_TW', 'tr'],
 | 
			
		||||
 | 
			
		||||
    //  Application Fallback Locale
 | 
			
		||||
    'fallback_locale' => 'en',
 | 
			
		||||
 | 
			
		||||
    // Faker Locale
 | 
			
		||||
    'faker_locale' => 'en_GB',
 | 
			
		||||
 | 
			
		||||
    // Enable right-to-left text control.
 | 
			
		||||
    'rtl' => false,
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -72,10 +75,6 @@ return [
 | 
			
		|||
    // Encryption cipher
 | 
			
		||||
    'cipher' => 'AES-256-CBC',
 | 
			
		||||
 | 
			
		||||
    // Logging configuration
 | 
			
		||||
    // Options: single, daily, syslog, errorlog
 | 
			
		||||
    'log' => env('APP_LOGGING', 'single'),
 | 
			
		||||
 | 
			
		||||
    // Application Services Provides
 | 
			
		||||
    'providers' => [
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -107,7 +106,6 @@ return [
 | 
			
		|||
        Barryvdh\DomPDF\ServiceProvider::class,
 | 
			
		||||
        Barryvdh\Snappy\ServiceProvider::class,
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
        // BookStack replacement service providers (Extends Laravel)
 | 
			
		||||
        BookStack\Providers\PaginationServiceProvider::class,
 | 
			
		||||
        BookStack\Providers\TranslationServiceProvider::class,
 | 
			
		||||
| 
						 | 
				
			
			@ -137,6 +135,7 @@ return [
 | 
			
		|||
 | 
			
		||||
        // Laravel
 | 
			
		||||
        'App'       => Illuminate\Support\Facades\App::class,
 | 
			
		||||
        'Arr'       => Illuminate\Support\Arr::class,
 | 
			
		||||
        'Artisan'   => Illuminate\Support\Facades\Artisan::class,
 | 
			
		||||
        'Auth'      => Illuminate\Support\Facades\Auth::class,
 | 
			
		||||
        'Blade'     => Illuminate\Support\Facades\Blade::class,
 | 
			
		||||
| 
						 | 
				
			
			@ -166,6 +165,7 @@ return [
 | 
			
		|||
        'Schema'    => Illuminate\Support\Facades\Schema::class,
 | 
			
		||||
        'Session'   => Illuminate\Support\Facades\Session::class,
 | 
			
		||||
        'Storage'   => Illuminate\Support\Facades\Storage::class,
 | 
			
		||||
        'Str'       => Illuminate\Support\Str::class,
 | 
			
		||||
        'URL'       => Illuminate\Support\Facades\URL::class,
 | 
			
		||||
        'Validator' => Illuminate\Support\Facades\Validator::class,
 | 
			
		||||
        'View'      => Illuminate\Support\Facades\View::class,
 | 
			
		||||
| 
						 | 
				
			
			@ -181,6 +181,7 @@ return [
 | 
			
		|||
        'Setting'  => BookStack\Facades\Setting::class,
 | 
			
		||||
        'Views'    => BookStack\Facades\Views::class,
 | 
			
		||||
        'Images'   => BookStack\Facades\Images::class,
 | 
			
		||||
        'Permissions' => BookStack\Facades\Permissions::class,
 | 
			
		||||
 | 
			
		||||
    ],
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -11,14 +11,14 @@
 | 
			
		|||
return [
 | 
			
		||||
 | 
			
		||||
    // Method of authentication to use
 | 
			
		||||
    // Options: standard, ldap
 | 
			
		||||
    // Options: standard, ldap, saml2
 | 
			
		||||
    'method' => env('AUTH_METHOD', 'standard'),
 | 
			
		||||
 | 
			
		||||
    // Authentication Defaults
 | 
			
		||||
    // This option controls the default authentication "guard" and password
 | 
			
		||||
    // reset options for your application.
 | 
			
		||||
    'defaults' => [
 | 
			
		||||
        'guard' => 'web',
 | 
			
		||||
        'guard' => env('AUTH_METHOD', 'standard'),
 | 
			
		||||
        'passwords' => 'users',
 | 
			
		||||
    ],
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -26,16 +26,22 @@ return [
 | 
			
		|||
    // All authentication drivers have a user provider. This defines how the
 | 
			
		||||
    // users are actually retrieved out of your database or other storage
 | 
			
		||||
    // mechanisms used by this application to persist your user's data.
 | 
			
		||||
    // Supported: "session", "token"
 | 
			
		||||
    // Supported drivers: "session", "api-token", "ldap-session"
 | 
			
		||||
    'guards' => [
 | 
			
		||||
        'web' => [
 | 
			
		||||
        'standard' => [
 | 
			
		||||
            'driver' => 'session',
 | 
			
		||||
            'provider' => 'users',
 | 
			
		||||
        ],
 | 
			
		||||
 | 
			
		||||
        'ldap' => [
 | 
			
		||||
            'driver' => 'ldap-session',
 | 
			
		||||
            'provider' => 'external',
 | 
			
		||||
        ],
 | 
			
		||||
        'saml2' => [
 | 
			
		||||
            'driver' => 'saml2-session',
 | 
			
		||||
            'provider' => 'external',
 | 
			
		||||
        ],
 | 
			
		||||
        'api' => [
 | 
			
		||||
            'driver' => 'token',
 | 
			
		||||
            'provider' => 'users',
 | 
			
		||||
            'driver' => 'api-token',
 | 
			
		||||
        ],
 | 
			
		||||
    ],
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -43,17 +49,15 @@ return [
 | 
			
		|||
    // All authentication drivers have a user provider. This defines how the
 | 
			
		||||
    // users are actually retrieved out of your database or other storage
 | 
			
		||||
    // mechanisms used by this application to persist your user's data.
 | 
			
		||||
    // Supported: database, eloquent, ldap
 | 
			
		||||
    'providers' => [
 | 
			
		||||
        'users' => [
 | 
			
		||||
            'driver' => env('AUTH_METHOD', 'standard') === 'standard' ? 'eloquent' : env('AUTH_METHOD'),
 | 
			
		||||
            'driver' => 'eloquent',
 | 
			
		||||
            'model' => \BookStack\Auth\User::class,
 | 
			
		||||
        ],
 | 
			
		||||
        'external' => [
 | 
			
		||||
            'driver' => 'external-users',
 | 
			
		||||
            'model' => \BookStack\Auth\User::class,
 | 
			
		||||
        ],
 | 
			
		||||
 | 
			
		||||
        // 'users' => [
 | 
			
		||||
        //     'driver' => 'database',
 | 
			
		||||
        //     'table' => 'users',
 | 
			
		||||
        // ],
 | 
			
		||||
    ],
 | 
			
		||||
 | 
			
		||||
    // Resetting Passwords
 | 
			
		||||
| 
						 | 
				
			
			@ -69,4 +73,4 @@ return [
 | 
			
		|||
        ],
 | 
			
		||||
    ],
 | 
			
		||||
 | 
			
		||||
];
 | 
			
		||||
];
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -24,9 +24,13 @@ return [
 | 
			
		|||
 | 
			
		||||
        'pusher' => [
 | 
			
		||||
            'driver' => 'pusher',
 | 
			
		||||
            'key' => env('PUSHER_KEY'),
 | 
			
		||||
            'secret' => env('PUSHER_SECRET'),
 | 
			
		||||
            'key' => env('PUSHER_APP_KEY'),
 | 
			
		||||
            'secret' => env('PUSHER_APP_SECRET'),
 | 
			
		||||
            'app_id' => env('PUSHER_APP_ID'),
 | 
			
		||||
            'options' => [
 | 
			
		||||
                'cluster' => env('PUSHER_APP_CLUSTER'),
 | 
			
		||||
                'useTLS' => true,
 | 
			
		||||
            ],
 | 
			
		||||
        ],
 | 
			
		||||
 | 
			
		||||
        'redis' => [
 | 
			
		||||
| 
						 | 
				
			
			@ -38,6 +42,11 @@ return [
 | 
			
		|||
            'driver' => 'log',
 | 
			
		||||
        ],
 | 
			
		||||
 | 
			
		||||
        'null' => [
 | 
			
		||||
            'driver' => 'null',
 | 
			
		||||
        ],
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    ],
 | 
			
		||||
 | 
			
		||||
];
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -14,8 +14,12 @@ if (env('CACHE_DRIVER') === 'memcached') {
 | 
			
		|||
    $memcachedServers = explode(',', trim(env('MEMCACHED_SERVERS', '127.0.0.1:11211:100'), ','));
 | 
			
		||||
    foreach ($memcachedServers as $index => $memcachedServer) {
 | 
			
		||||
        $memcachedServerDetails = explode(':', $memcachedServer);
 | 
			
		||||
        if (count($memcachedServerDetails) < 2) $memcachedServerDetails[] = '11211';
 | 
			
		||||
        if (count($memcachedServerDetails) < 3) $memcachedServerDetails[] = '100';
 | 
			
		||||
        if (count($memcachedServerDetails) < 2) {
 | 
			
		||||
            $memcachedServerDetails[] = '11211';
 | 
			
		||||
        }
 | 
			
		||||
        if (count($memcachedServerDetails) < 3) {
 | 
			
		||||
            $memcachedServerDetails[] = '100';
 | 
			
		||||
        }
 | 
			
		||||
        $memcachedServers[$index] = array_combine($memcachedServerKeys, $memcachedServerDetails);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -62,6 +66,6 @@ return [
 | 
			
		|||
 | 
			
		||||
    // Cache key prefix
 | 
			
		||||
    // Used to prevent collisions in shared cache systems.
 | 
			
		||||
    'prefix' => env('CACHE_PREFIX', 'bookstack'),
 | 
			
		||||
    'prefix' => env('CACHE_PREFIX', 'bookstack_cache'),
 | 
			
		||||
 | 
			
		||||
];
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -11,10 +11,9 @@
 | 
			
		|||
// REDIS
 | 
			
		||||
// Split out configuration into an array
 | 
			
		||||
if (env('REDIS_SERVERS', false)) {
 | 
			
		||||
 | 
			
		||||
    $redisDefaults = ['host' => '127.0.0.1', 'port' => '6379', 'database' => '0', 'password' => null];
 | 
			
		||||
    $redisServers = explode(',', trim(env('REDIS_SERVERS', '127.0.0.1:6379:0'), ','));
 | 
			
		||||
    $redisConfig = [];
 | 
			
		||||
    $redisConfig = ['client' => 'predis'];
 | 
			
		||||
    $cluster = count($redisServers) > 1;
 | 
			
		||||
 | 
			
		||||
    if ($cluster) {
 | 
			
		||||
| 
						 | 
				
			
			@ -59,14 +58,9 @@ return [
 | 
			
		|||
    // Many of those shown here are unsupported by BookStack.
 | 
			
		||||
    'connections' => [
 | 
			
		||||
 | 
			
		||||
        'sqlite' => [
 | 
			
		||||
            'driver'   => 'sqlite',
 | 
			
		||||
            'database' => storage_path('database.sqlite'),
 | 
			
		||||
            'prefix'   => '',
 | 
			
		||||
        ],
 | 
			
		||||
 | 
			
		||||
        'mysql' => [
 | 
			
		||||
            'driver'    => 'mysql',
 | 
			
		||||
            'url' => env('DATABASE_URL'),
 | 
			
		||||
            'host'      => $mysql_host,
 | 
			
		||||
            'database'  => env('DB_DATABASE', 'forge'),
 | 
			
		||||
            'username'  => env('DB_USERNAME', 'forge'),
 | 
			
		||||
| 
						 | 
				
			
			@ -76,43 +70,28 @@ return [
 | 
			
		|||
            'charset'   => 'utf8mb4',
 | 
			
		||||
            'collation' => 'utf8mb4_unicode_ci',
 | 
			
		||||
            'prefix'    => '',
 | 
			
		||||
            'prefix_indexes' => true,
 | 
			
		||||
            'strict'    => false,
 | 
			
		||||
            'engine' => null,
 | 
			
		||||
            'options' => extension_loaded('pdo_mysql') ? array_filter([
 | 
			
		||||
                PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
 | 
			
		||||
            ]) : [],
 | 
			
		||||
        ],
 | 
			
		||||
 | 
			
		||||
        'mysql_testing' => [
 | 
			
		||||
            'driver'    => 'mysql',
 | 
			
		||||
            'url' => env('TEST_DATABASE_URL'),
 | 
			
		||||
            'host'      => '127.0.0.1',
 | 
			
		||||
            'database'  => 'bookstack-test',
 | 
			
		||||
            'username'  => env('MYSQL_USER', 'bookstack-test'),
 | 
			
		||||
            'password'  => env('MYSQL_PASSWORD', 'bookstack-test'),
 | 
			
		||||
            'charset'   => 'utf8',
 | 
			
		||||
            'collation' => 'utf8_unicode_ci',
 | 
			
		||||
            'charset'   => 'utf8mb4',
 | 
			
		||||
            'collation' => 'utf8mb4_unicode_ci',
 | 
			
		||||
            'prefix'    => '',
 | 
			
		||||
            'prefix_indexes' => true,
 | 
			
		||||
            'strict'    => false,
 | 
			
		||||
        ],
 | 
			
		||||
 | 
			
		||||
        'pgsql' => [
 | 
			
		||||
            'driver'   => 'pgsql',
 | 
			
		||||
            'host'     => env('DB_HOST', 'localhost'),
 | 
			
		||||
            'database' => env('DB_DATABASE', 'forge'),
 | 
			
		||||
            'username' => env('DB_USERNAME', 'forge'),
 | 
			
		||||
            'password' => env('DB_PASSWORD', ''),
 | 
			
		||||
            'charset'  => 'utf8',
 | 
			
		||||
            'prefix'   => '',
 | 
			
		||||
            'schema'   => 'public',
 | 
			
		||||
        ],
 | 
			
		||||
 | 
			
		||||
        'sqlsrv' => [
 | 
			
		||||
            'driver'   => 'sqlsrv',
 | 
			
		||||
            'host'     => env('DB_HOST', 'localhost'),
 | 
			
		||||
            'database' => env('DB_DATABASE', 'forge'),
 | 
			
		||||
            'username' => env('DB_USERNAME', 'forge'),
 | 
			
		||||
            'password' => env('DB_PASSWORD', ''),
 | 
			
		||||
            'charset'  => 'utf8',
 | 
			
		||||
            'prefix'   => '',
 | 
			
		||||
        ],
 | 
			
		||||
 | 
			
		||||
    ],
 | 
			
		||||
 | 
			
		||||
    // Migration Repository Table
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -79,6 +79,7 @@ return [
 | 
			
		|||
        'files'           => false, // Show the included files
 | 
			
		||||
        'config'          => false, // Display config settings
 | 
			
		||||
        'cache'           => false, // Display cache events
 | 
			
		||||
        'models'          => true, // Display models
 | 
			
		||||
    ],
 | 
			
		||||
 | 
			
		||||
     // Configure some DataCollectors
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -69,7 +69,7 @@ return [
 | 
			
		|||
         * should be an absolute path.
 | 
			
		||||
         * This is only checked on command line call by dompdf.php, but not by
 | 
			
		||||
         * direct class use like:
 | 
			
		||||
         * $dompdf = new DOMPDF();	$dompdf->load_html($htmldata); $dompdf->render(); $pdfdata = $dompdf->output();
 | 
			
		||||
         * $dompdf = new DOMPDF();  $dompdf->load_html($htmldata); $dompdf->render(); $pdfdata = $dompdf->output();
 | 
			
		||||
         */
 | 
			
		||||
        "DOMPDF_CHROOT" => realpath(base_path()),
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,37 @@
 | 
			
		|||
<?php
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Hashing configuration options.
 | 
			
		||||
 *
 | 
			
		||||
 * Changes to these config files are not supported by BookStack and may break upon updates.
 | 
			
		||||
 * Configuration should be altered via the `.env` file or environment variables.
 | 
			
		||||
 * Do not edit this file unless you're happy to maintain any changes yourself.
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
return [
 | 
			
		||||
 | 
			
		||||
    // Default Hash Driver
 | 
			
		||||
    // This option controls the default hash driver that will be used to hash
 | 
			
		||||
    // passwords for your application. By default, the bcrypt algorithm is used.
 | 
			
		||||
    // Supported: "bcrypt", "argon", "argon2id"
 | 
			
		||||
    'driver' => 'bcrypt',
 | 
			
		||||
 | 
			
		||||
    // Bcrypt Options
 | 
			
		||||
    // Here you may specify the configuration options that should be used when
 | 
			
		||||
    // passwords are hashed using the Bcrypt algorithm. This will allow you
 | 
			
		||||
    // to control the amount of time it takes to hash the given password.
 | 
			
		||||
    'bcrypt' => [
 | 
			
		||||
        'rounds' => env('BCRYPT_ROUNDS', 10),
 | 
			
		||||
    ],
 | 
			
		||||
 | 
			
		||||
    // Argon Options
 | 
			
		||||
    // Here you may specify the configuration options that should be used when
 | 
			
		||||
    // passwords are hashed using the Argon algorithm. These will allow you
 | 
			
		||||
    // to control the amount of time it takes to hash the given password.
 | 
			
		||||
    'argon' => [
 | 
			
		||||
        'memory' => 1024,
 | 
			
		||||
        'threads' => 2,
 | 
			
		||||
        'time' => 2,
 | 
			
		||||
    ],
 | 
			
		||||
 | 
			
		||||
];
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,82 @@
 | 
			
		|||
<?php
 | 
			
		||||
 | 
			
		||||
use Monolog\Handler\NullHandler;
 | 
			
		||||
use Monolog\Handler\StreamHandler;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Logging configuration options.
 | 
			
		||||
 *
 | 
			
		||||
 * Changes to these config files are not supported by BookStack and may break upon updates.
 | 
			
		||||
 * Configuration should be altered via the `.env` file or environment variables.
 | 
			
		||||
 * Do not edit this file unless you're happy to maintain any changes yourself.
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
return [
 | 
			
		||||
 | 
			
		||||
    // Default Log Channel
 | 
			
		||||
    // This option defines the default log channel that gets used when writing
 | 
			
		||||
    // messages to the logs. The name specified in this option should match
 | 
			
		||||
    // one of the channels defined in the "channels" configuration array.
 | 
			
		||||
    'default' => env('LOG_CHANNEL', 'single'),
 | 
			
		||||
 | 
			
		||||
    // Log Channels
 | 
			
		||||
    // Here you may configure the log channels for your application. Out of
 | 
			
		||||
    // the box, Laravel uses the Monolog PHP logging library. This gives
 | 
			
		||||
    // you a variety of powerful log handlers / formatters to utilize.
 | 
			
		||||
    // Available Drivers: "single", "daily", "slack", "syslog",
 | 
			
		||||
    //                    "errorlog", "monolog",
 | 
			
		||||
    //                    "custom", "stack"
 | 
			
		||||
    'channels' => [
 | 
			
		||||
        'stack' => [
 | 
			
		||||
            'driver' => 'stack',
 | 
			
		||||
            'channels' => ['daily'],
 | 
			
		||||
            'ignore_exceptions' => false,
 | 
			
		||||
        ],
 | 
			
		||||
 | 
			
		||||
        'single' => [
 | 
			
		||||
            'driver' => 'single',
 | 
			
		||||
            'path' => storage_path('logs/laravel.log'),
 | 
			
		||||
            'level' => 'debug',
 | 
			
		||||
            'days' => 14,
 | 
			
		||||
        ],
 | 
			
		||||
 | 
			
		||||
        'daily' => [
 | 
			
		||||
            'driver' => 'daily',
 | 
			
		||||
            'path' => storage_path('logs/laravel.log'),
 | 
			
		||||
            'level' => 'debug',
 | 
			
		||||
            'days' => 7,
 | 
			
		||||
        ],
 | 
			
		||||
 | 
			
		||||
        'slack' => [
 | 
			
		||||
            'driver' => 'slack',
 | 
			
		||||
            'url' => env('LOG_SLACK_WEBHOOK_URL'),
 | 
			
		||||
            'username' => 'Laravel Log',
 | 
			
		||||
            'emoji' => ':boom:',
 | 
			
		||||
            'level' => 'critical',
 | 
			
		||||
        ],
 | 
			
		||||
 | 
			
		||||
        'stderr' => [
 | 
			
		||||
            'driver' => 'monolog',
 | 
			
		||||
            'handler' => StreamHandler::class,
 | 
			
		||||
            'with' => [
 | 
			
		||||
                'stream' => 'php://stderr',
 | 
			
		||||
            ],
 | 
			
		||||
        ],
 | 
			
		||||
 | 
			
		||||
        'syslog' => [
 | 
			
		||||
            'driver' => 'syslog',
 | 
			
		||||
            'level' => 'debug',
 | 
			
		||||
        ],
 | 
			
		||||
 | 
			
		||||
        'errorlog' => [
 | 
			
		||||
            'driver' => 'errorlog',
 | 
			
		||||
            'level' => 'debug',
 | 
			
		||||
        ],
 | 
			
		||||
 | 
			
		||||
        'null' => [
 | 
			
		||||
            'driver' => 'monolog',
 | 
			
		||||
            'handler' => NullHandler::class,
 | 
			
		||||
        ],
 | 
			
		||||
    ],
 | 
			
		||||
 | 
			
		||||
];
 | 
			
		||||
| 
						 | 
				
			
			@ -23,7 +23,7 @@ return [
 | 
			
		|||
    // Global "From" address & name
 | 
			
		||||
    'from' => [
 | 
			
		||||
        'address' => env('MAIL_FROM', 'mail@bookstackapp.com'),
 | 
			
		||||
        'name' => env('MAIL_FROM_NAME','BookStack')
 | 
			
		||||
        'name' => env('MAIL_FROM_NAME', 'BookStack')
 | 
			
		||||
    ],
 | 
			
		||||
 | 
			
		||||
    // Email encryption protocol
 | 
			
		||||
| 
						 | 
				
			
			@ -46,4 +46,10 @@ return [
 | 
			
		|||
        ],
 | 
			
		||||
    ],
 | 
			
		||||
 | 
			
		||||
    // Log Channel
 | 
			
		||||
    // If you are using the "log" driver, you may specify the logging channel
 | 
			
		||||
    // if you prefer to keep mail messages separate from other log entries
 | 
			
		||||
    // for simpler reading. Otherwise, the default channel will be used.
 | 
			
		||||
    'log_channel' => env('MAIL_LOG_CHANNEL'),
 | 
			
		||||
 | 
			
		||||
];
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -12,11 +12,12 @@ return [
 | 
			
		|||
 | 
			
		||||
    // Default driver to use for the queue
 | 
			
		||||
    // Options: null, sync, redis
 | 
			
		||||
    'default' => env('QUEUE_DRIVER', 'sync'),
 | 
			
		||||
    'default' => env('QUEUE_CONNECTION', 'sync'),
 | 
			
		||||
 | 
			
		||||
    // Queue connection configuration
 | 
			
		||||
    'connections' => [
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
        'sync' => [
 | 
			
		||||
            'driver' => 'sync',
 | 
			
		||||
        ],
 | 
			
		||||
| 
						 | 
				
			
			@ -25,38 +26,15 @@ return [
 | 
			
		|||
            'driver' => 'database',
 | 
			
		||||
            'table' => 'jobs',
 | 
			
		||||
            'queue' => 'default',
 | 
			
		||||
            'expire' => 60,
 | 
			
		||||
        ],
 | 
			
		||||
 | 
			
		||||
        'beanstalkd' => [
 | 
			
		||||
            'driver' => 'beanstalkd',
 | 
			
		||||
            'host'   => 'localhost',
 | 
			
		||||
            'queue'  => 'default',
 | 
			
		||||
            'ttr'    => 60,
 | 
			
		||||
        ],
 | 
			
		||||
 | 
			
		||||
        'sqs' => [
 | 
			
		||||
            'driver' => 'sqs',
 | 
			
		||||
            'key'    => 'your-public-key',
 | 
			
		||||
            'secret' => 'your-secret-key',
 | 
			
		||||
            'queue'  => 'your-queue-url',
 | 
			
		||||
            'region' => 'us-east-1',
 | 
			
		||||
        ],
 | 
			
		||||
 | 
			
		||||
        'iron' => [
 | 
			
		||||
            'driver'  => 'iron',
 | 
			
		||||
            'host'    => 'mq-aws-us-east-1.iron.io',
 | 
			
		||||
            'token'   => 'your-token',
 | 
			
		||||
            'project' => 'your-project-id',
 | 
			
		||||
            'queue'   => 'your-queue-name',
 | 
			
		||||
            'encrypt' => true,
 | 
			
		||||
            'retry_after' => 90,
 | 
			
		||||
        ],
 | 
			
		||||
 | 
			
		||||
        'redis' => [
 | 
			
		||||
            'driver' => 'redis',
 | 
			
		||||
            'connection' => 'default',
 | 
			
		||||
            'queue'  => 'default',
 | 
			
		||||
            'expire' => 60,
 | 
			
		||||
            'queue' => env('REDIS_QUEUE', 'default'),
 | 
			
		||||
            'retry_after' => 90,
 | 
			
		||||
            'block_for' => null,
 | 
			
		||||
        ],
 | 
			
		||||
 | 
			
		||||
    ],
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,144 @@
 | 
			
		|||
<?php
 | 
			
		||||
 | 
			
		||||
return [
 | 
			
		||||
 | 
			
		||||
    // Display name, shown to users, for SAML2 option
 | 
			
		||||
    'name' => env('SAML2_NAME', 'SSO'),
 | 
			
		||||
 | 
			
		||||
    // Dump user details after a login request for debugging purposes
 | 
			
		||||
    'dump_user_details' => env('SAML2_DUMP_USER_DETAILS', false),
 | 
			
		||||
 | 
			
		||||
    // Attribute, within a SAML response, to find the user's email address
 | 
			
		||||
    'email_attribute' => env('SAML2_EMAIL_ATTRIBUTE', 'email'),
 | 
			
		||||
    // Attribute, within a SAML response, to find the user's display name
 | 
			
		||||
    'display_name_attributes' => explode('|', env('SAML2_DISPLAY_NAME_ATTRIBUTES', 'username')),
 | 
			
		||||
    // Attribute, within a SAML response, to use to connect a BookStack user to the SAML user.
 | 
			
		||||
    'external_id_attribute' => env('SAML2_EXTERNAL_ID_ATTRIBUTE', null),
 | 
			
		||||
 | 
			
		||||
    // Group sync options
 | 
			
		||||
    // Enable syncing, upon login, of SAML2 groups to BookStack groups
 | 
			
		||||
    'user_to_groups' => env('SAML2_USER_TO_GROUPS', false),
 | 
			
		||||
    // Attribute, within a SAML response, to find group names on
 | 
			
		||||
    'group_attribute' => env('SAML2_GROUP_ATTRIBUTE', 'group'),
 | 
			
		||||
    // When syncing groups, remove any groups that no longer match. Otherwise sync only adds new groups.
 | 
			
		||||
    'remove_from_groups' => env('SAML2_REMOVE_FROM_GROUPS', false),
 | 
			
		||||
 | 
			
		||||
    // Autoload IDP details from the metadata endpoint
 | 
			
		||||
    'autoload_from_metadata' => env('SAML2_AUTOLOAD_METADATA', false),
 | 
			
		||||
 | 
			
		||||
    // Overrides, in JSON format, to the configuration passed to underlying onelogin library.
 | 
			
		||||
    'onelogin_overrides' => env('SAML2_ONELOGIN_OVERRIDES', null),
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    'onelogin' => [
 | 
			
		||||
        // If 'strict' is True, then the PHP Toolkit will reject unsigned
 | 
			
		||||
        // or unencrypted messages if it expects them signed or encrypted
 | 
			
		||||
        // Also will reject the messages if not strictly follow the SAML
 | 
			
		||||
        // standard: Destination, NameId, Conditions ... are validated too.
 | 
			
		||||
        'strict' => true,
 | 
			
		||||
 | 
			
		||||
        // Enable debug mode (to print errors)
 | 
			
		||||
        'debug' => env('APP_DEBUG', false),
 | 
			
		||||
 | 
			
		||||
        // Set a BaseURL to be used instead of try to guess
 | 
			
		||||
        // the BaseURL of the view that process the SAML Message.
 | 
			
		||||
        // Ex. http://sp.example.com/
 | 
			
		||||
        //     http://example.com/sp/
 | 
			
		||||
        'baseurl' => null,
 | 
			
		||||
 | 
			
		||||
        // Service Provider Data that we are deploying
 | 
			
		||||
        'sp' => [
 | 
			
		||||
            // Identifier of the SP entity  (must be a URI)
 | 
			
		||||
            'entityId' => '',
 | 
			
		||||
 | 
			
		||||
            // Specifies info about where and how the <AuthnResponse> message MUST be
 | 
			
		||||
            // returned to the requester, in this case our SP.
 | 
			
		||||
            'assertionConsumerService' => [
 | 
			
		||||
                // URL Location where the <Response> from the IdP will be returned
 | 
			
		||||
                'url' => '',
 | 
			
		||||
                // SAML protocol binding to be used when returning the <Response>
 | 
			
		||||
                // message.  Onelogin Toolkit supports for this endpoint the
 | 
			
		||||
                // HTTP-POST binding only
 | 
			
		||||
                'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
 | 
			
		||||
            ],
 | 
			
		||||
 | 
			
		||||
            // Specifies info about where and how the <Logout Response> message MUST be
 | 
			
		||||
            // returned to the requester, in this case our SP.
 | 
			
		||||
            'singleLogoutService' => [
 | 
			
		||||
                // URL Location where the <Response> from the IdP will be returned
 | 
			
		||||
                'url' => '',
 | 
			
		||||
                // SAML protocol binding to be used when returning the <Response>
 | 
			
		||||
                // message.  Onelogin Toolkit supports for this endpoint the
 | 
			
		||||
                // HTTP-Redirect binding only
 | 
			
		||||
                'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
 | 
			
		||||
            ],
 | 
			
		||||
 | 
			
		||||
            // Specifies constraints on the name identifier to be used to
 | 
			
		||||
            // represent the requested subject.
 | 
			
		||||
            // Take a look on lib/Saml2/Constants.php to see the NameIdFormat supported
 | 
			
		||||
            'NameIDFormat' => 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
 | 
			
		||||
            // Usually x509cert and privateKey of the SP are provided by files placed at
 | 
			
		||||
            // the certs folder. But we can also provide them with the following parameters
 | 
			
		||||
            'x509cert' => '',
 | 
			
		||||
            'privateKey' => '',
 | 
			
		||||
        ],
 | 
			
		||||
        // Identity Provider Data that we want connect with our SP
 | 
			
		||||
        'idp' => [
 | 
			
		||||
            // Identifier of the IdP entity  (must be a URI)
 | 
			
		||||
            'entityId' => env('SAML2_IDP_ENTITYID', null),
 | 
			
		||||
            // SSO endpoint info of the IdP. (Authentication Request protocol)
 | 
			
		||||
            'singleSignOnService' => [
 | 
			
		||||
                // URL Target of the IdP where the SP will send the Authentication Request Message
 | 
			
		||||
                'url' => env('SAML2_IDP_SSO', null),
 | 
			
		||||
                // SAML protocol binding to be used when returning the <Response>
 | 
			
		||||
                // message.  Onelogin Toolkit supports for this endpoint the
 | 
			
		||||
                // HTTP-Redirect binding only
 | 
			
		||||
                'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
 | 
			
		||||
            ],
 | 
			
		||||
            // SLO endpoint info of the IdP.
 | 
			
		||||
            'singleLogoutService' => [
 | 
			
		||||
                // URL Location of the IdP where the SP will send the SLO Request
 | 
			
		||||
                'url' => env('SAML2_IDP_SLO', null),
 | 
			
		||||
                // URL location of the IdP where the SP will send the SLO Response (ResponseLocation)
 | 
			
		||||
                // if not set, url for the SLO Request will be used
 | 
			
		||||
                'responseUrl' => '',
 | 
			
		||||
                // SAML protocol binding to be used when returning the <Response>
 | 
			
		||||
                // message.  Onelogin Toolkit supports for this endpoint the
 | 
			
		||||
                // HTTP-Redirect binding only
 | 
			
		||||
                'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
 | 
			
		||||
            ],
 | 
			
		||||
            // Public x509 certificate of the IdP
 | 
			
		||||
            'x509cert' => env('SAML2_IDP_x509', null),
 | 
			
		||||
            /*
 | 
			
		||||
             *  Instead of use the whole x509cert you can use a fingerprint in
 | 
			
		||||
             *  order to validate the SAMLResponse, but we don't recommend to use
 | 
			
		||||
             *  that method on production since is exploitable by a collision
 | 
			
		||||
             *  attack.
 | 
			
		||||
             *  (openssl x509 -noout -fingerprint -in "idp.crt" to generate it,
 | 
			
		||||
             *   or add for example the -sha256 , -sha384 or -sha512 parameter)
 | 
			
		||||
             *
 | 
			
		||||
             *  If a fingerprint is provided, then the certFingerprintAlgorithm is required in order to
 | 
			
		||||
             *  let the toolkit know which Algorithm was used. Possible values: sha1, sha256, sha384 or sha512
 | 
			
		||||
             *  'sha1' is the default value.
 | 
			
		||||
             */
 | 
			
		||||
            // 'certFingerprint' => '',
 | 
			
		||||
            // 'certFingerprintAlgorithm' => 'sha1',
 | 
			
		||||
            /* In some scenarios the IdP uses different certificates for
 | 
			
		||||
             * signing/encryption, or is under key rollover phase and more
 | 
			
		||||
             * than one certificate is published on IdP metadata.
 | 
			
		||||
             * In order to handle that the toolkit offers that parameter.
 | 
			
		||||
             * (when used, 'x509cert' and 'certFingerprint' values are
 | 
			
		||||
             * ignored).
 | 
			
		||||
             */
 | 
			
		||||
            // 'x509certMulti' => array(
 | 
			
		||||
            //      'signing' => array(
 | 
			
		||||
            //          0 => '<cert1-string>',
 | 
			
		||||
            //      ),
 | 
			
		||||
            //      'encryption' => array(
 | 
			
		||||
            //          0 => '<cert2-string>',
 | 
			
		||||
            //      )
 | 
			
		||||
            // ),
 | 
			
		||||
        ],
 | 
			
		||||
    ],
 | 
			
		||||
 | 
			
		||||
];
 | 
			
		||||
| 
						 | 
				
			
			@ -22,23 +22,6 @@ return [
 | 
			
		|||
    // Callback URL for social authentication methods
 | 
			
		||||
    'callback_url' => env('APP_URL', false),
 | 
			
		||||
 | 
			
		||||
    'mailgun'  => [
 | 
			
		||||
        'domain' => '',
 | 
			
		||||
        'secret' => '',
 | 
			
		||||
    ],
 | 
			
		||||
 | 
			
		||||
    'ses'      => [
 | 
			
		||||
        'key'    => '',
 | 
			
		||||
        'secret' => '',
 | 
			
		||||
        'region' => 'us-east-1',
 | 
			
		||||
    ],
 | 
			
		||||
 | 
			
		||||
    'stripe'   => [
 | 
			
		||||
        'model'  => \BookStack\Auth\User::class,
 | 
			
		||||
        'key'    => '',
 | 
			
		||||
        'secret' => '',
 | 
			
		||||
    ],
 | 
			
		||||
 | 
			
		||||
    'github'   => [
 | 
			
		||||
        'client_id'     => env('GITHUB_APP_ID', false),
 | 
			
		||||
        'client_secret' => env('GITHUB_APP_SECRET', false),
 | 
			
		||||
| 
						 | 
				
			
			@ -98,8 +81,8 @@ return [
 | 
			
		|||
    'okta' => [
 | 
			
		||||
        'client_id' => env('OKTA_APP_ID'),
 | 
			
		||||
        'client_secret' => env('OKTA_APP_SECRET'),
 | 
			
		||||
        'redirect' => env('APP_URL') . '/login/service/okta/callback', 
 | 
			
		||||
        'base_url' => env('OKTA_BASE_URL'), 
 | 
			
		||||
        'redirect' => env('APP_URL') . '/login/service/okta/callback',
 | 
			
		||||
        'base_url' => env('OKTA_BASE_URL'),
 | 
			
		||||
        'name'          => 'Okta',
 | 
			
		||||
        'auto_register' => env('OKTA_AUTO_REGISTER', false),
 | 
			
		||||
        'auto_confirm' => env('OKTA_AUTO_CONFIRM_EMAIL', false),
 | 
			
		||||
| 
						 | 
				
			
			@ -140,13 +123,14 @@ return [
 | 
			
		|||
        'base_dn' => env('LDAP_BASE_DN', false),
 | 
			
		||||
        'user_filter' => env('LDAP_USER_FILTER', '(&(uid=${user}))'),
 | 
			
		||||
        'version' => env('LDAP_VERSION', false),
 | 
			
		||||
        'id_attribute' => env('LDAP_ID_ATTRIBUTE', 'uid'),
 | 
			
		||||
        'email_attribute' => env('LDAP_EMAIL_ATTRIBUTE', 'mail'),
 | 
			
		||||
        'display_name_attribute' => env('LDAP_DISPLAY_NAME_ATTRIBUTE', 'cn'),
 | 
			
		||||
        'follow_referrals' => env('LDAP_FOLLOW_REFERRALS', false),
 | 
			
		||||
		'user_to_groups' => env('LDAP_USER_TO_GROUPS',false),
 | 
			
		||||
		'group_attribute' => env('LDAP_GROUP_ATTRIBUTE', 'memberOf'),
 | 
			
		||||
		'remove_from_groups' => env('LDAP_REMOVE_FROM_GROUPS',false),
 | 
			
		||||
		'tls_insecure' => env('LDAP_TLS_INSECURE', false),
 | 
			
		||||
	]
 | 
			
		||||
        'user_to_groups' => env('LDAP_USER_TO_GROUPS', false),
 | 
			
		||||
        'group_attribute' => env('LDAP_GROUP_ATTRIBUTE', 'memberOf'),
 | 
			
		||||
        'remove_from_groups' => env('LDAP_REMOVE_FROM_GROUPS', false),
 | 
			
		||||
        'tls_insecure' => env('LDAP_TLS_INSECURE', false),
 | 
			
		||||
    ],
 | 
			
		||||
 | 
			
		||||
];
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -35,13 +35,18 @@ return [
 | 
			
		|||
    // Session database table, if database driver is in use
 | 
			
		||||
    'table' => 'sessions',
 | 
			
		||||
 | 
			
		||||
    // Session Cache Store
 | 
			
		||||
    // When using the "apc" or "memcached" session drivers, you may specify a
 | 
			
		||||
    // cache store that should be used for these sessions. This value must
 | 
			
		||||
    // correspond with one of the application's configured cache stores.
 | 
			
		||||
    'store' => null,
 | 
			
		||||
 | 
			
		||||
    // Session Sweeping Lottery
 | 
			
		||||
    // Some session drivers must manually sweep their storage location to get
 | 
			
		||||
    // rid of old sessions from storage. Here are the chances that it will
 | 
			
		||||
    // happen on a given request. By default, the odds are 2 out of 100.
 | 
			
		||||
    'lottery' => [2, 100],
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    // Session Cookie Name
 | 
			
		||||
    // Here you may change the name of the cookie used to identify a session
 | 
			
		||||
    // instance by ID. The name specified here will get used every time a
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -16,7 +16,12 @@ return [
 | 
			
		|||
    'app-editor'           => 'wysiwyg',
 | 
			
		||||
    'app-color'            => '#206ea7',
 | 
			
		||||
    'app-color-light'      => 'rgba(32,110,167,0.15)',
 | 
			
		||||
    'bookshelf-color'      => '#a94747',
 | 
			
		||||
    'book-color'           => '#077b70',
 | 
			
		||||
    'chapter-color'        => '#af4d0d',
 | 
			
		||||
    'page-color'           => '#206ea7',
 | 
			
		||||
    'page-draft-color'     => '#7e50b1',
 | 
			
		||||
    'app-custom-head'      => false,
 | 
			
		||||
    'registration-enabled' => false,
 | 
			
		||||
 | 
			
		||||
];
 | 
			
		||||
];
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,88 @@
 | 
			
		|||
<?php
 | 
			
		||||
 | 
			
		||||
namespace BookStack\Console\Commands;
 | 
			
		||||
 | 
			
		||||
use BookStack\Entities\Bookshelf;
 | 
			
		||||
use BookStack\Entities\Repos\BookshelfRepo;
 | 
			
		||||
use Illuminate\Console\Command;
 | 
			
		||||
 | 
			
		||||
class CopyShelfPermissions extends Command
 | 
			
		||||
{
 | 
			
		||||
    /**
 | 
			
		||||
     * The name and signature of the console command.
 | 
			
		||||
     *
 | 
			
		||||
     * @var string
 | 
			
		||||
     */
 | 
			
		||||
    protected $signature = 'bookstack:copy-shelf-permissions
 | 
			
		||||
                            {--a|all : Perform for all shelves in the system}
 | 
			
		||||
                            {--s|slug= : The slug for a shelf to target}
 | 
			
		||||
                            ';
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The console command description.
 | 
			
		||||
     *
 | 
			
		||||
     * @var string
 | 
			
		||||
     */
 | 
			
		||||
    protected $description = 'Copy shelf permissions to all child books.';
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @var BookshelfRepo
 | 
			
		||||
     */
 | 
			
		||||
    protected $bookshelfRepo;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Create a new command instance.
 | 
			
		||||
     *
 | 
			
		||||
     * @return void
 | 
			
		||||
     */
 | 
			
		||||
    public function __construct(BookshelfRepo $repo)
 | 
			
		||||
    {
 | 
			
		||||
        $this->bookshelfRepo = $repo;
 | 
			
		||||
        parent::__construct();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Execute the console command.
 | 
			
		||||
     *
 | 
			
		||||
     * @return mixed
 | 
			
		||||
     */
 | 
			
		||||
    public function handle()
 | 
			
		||||
    {
 | 
			
		||||
        $shelfSlug = $this->option('slug');
 | 
			
		||||
        $cascadeAll = $this->option('all');
 | 
			
		||||
        $shelves = null;
 | 
			
		||||
 | 
			
		||||
        if (!$cascadeAll && !$shelfSlug) {
 | 
			
		||||
            $this->error('Either a --slug or --all option must be provided.');
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if ($cascadeAll) {
 | 
			
		||||
            $continue = $this->confirm(
 | 
			
		||||
                'Permission settings for all shelves will be cascaded. '.
 | 
			
		||||
                        'Books assigned to multiple shelves will receive only the permissions of it\'s last processed shelf. '.
 | 
			
		||||
                        'Are you sure you want to proceed?'
 | 
			
		||||
            );
 | 
			
		||||
 | 
			
		||||
            if (!$continue && !$this->hasOption('no-interaction')) {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            $shelves = Bookshelf::query()->get(['id', 'restricted']);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if ($shelfSlug) {
 | 
			
		||||
            $shelves = Bookshelf::query()->where('slug', '=', $shelfSlug)->get(['id', 'restricted']);
 | 
			
		||||
            if ($shelves->count() === 0) {
 | 
			
		||||
                $this->info('No shelves found with the given slug.');
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        foreach ($shelves as $shelf) {
 | 
			
		||||
            $this->bookshelfRepo->copyDownPermissions($shelf, false);
 | 
			
		||||
            $this->info('Copied permissions for shelf [' . $shelf->id . ']');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $this->info('Permissions copied for ' . $shelves->count() . ' shelves.');
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,21 +1,25 @@
 | 
			
		|||
<?php namespace BookStack\Entities;
 | 
			
		||||
 | 
			
		||||
use BookStack\Uploads\Image;
 | 
			
		||||
use Exception;
 | 
			
		||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
 | 
			
		||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
 | 
			
		||||
use Illuminate\Database\Eloquent\Relations\HasMany;
 | 
			
		||||
use Illuminate\Support\Collection;
 | 
			
		||||
 | 
			
		||||
class Book extends Entity
 | 
			
		||||
/**
 | 
			
		||||
 * Class Book
 | 
			
		||||
 * @property string $description
 | 
			
		||||
 * @property int $image_id
 | 
			
		||||
 * @property Image|null $cover
 | 
			
		||||
 * @package BookStack\Entities
 | 
			
		||||
 */
 | 
			
		||||
class Book extends Entity implements HasCoverImage
 | 
			
		||||
{
 | 
			
		||||
    public $searchFactor = 2;
 | 
			
		||||
 | 
			
		||||
    protected $fillable = ['name', 'description', 'image_id'];
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the morph class for this model.
 | 
			
		||||
     * @return string
 | 
			
		||||
     */
 | 
			
		||||
    public function getMorphClass()
 | 
			
		||||
    {
 | 
			
		||||
        return 'BookStack\\Book';
 | 
			
		||||
    }
 | 
			
		||||
    protected $fillable = ['name', 'description'];
 | 
			
		||||
    protected $hidden = ['restricted'];
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the url for this book.
 | 
			
		||||
| 
						 | 
				
			
			@ -45,7 +49,7 @@ class Book extends Entity
 | 
			
		|||
 | 
			
		||||
        try {
 | 
			
		||||
            $cover = $this->cover ? url($this->cover->getThumb($width, $height, false)) : $default;
 | 
			
		||||
        } catch (\Exception $err) {
 | 
			
		||||
        } catch (Exception $err) {
 | 
			
		||||
            $cover = $default;
 | 
			
		||||
        }
 | 
			
		||||
        return $cover;
 | 
			
		||||
| 
						 | 
				
			
			@ -53,16 +57,23 @@ class Book extends Entity
 | 
			
		|||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the cover image of the book
 | 
			
		||||
     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
 | 
			
		||||
     */
 | 
			
		||||
    public function cover()
 | 
			
		||||
    public function cover(): BelongsTo
 | 
			
		||||
    {
 | 
			
		||||
        return $this->belongsTo(Image::class, 'image_id');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the type of the image model that is used when storing a cover image.
 | 
			
		||||
     */
 | 
			
		||||
    public function coverImageTypeKey(): string
 | 
			
		||||
    {
 | 
			
		||||
        return 'cover_book';
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get all pages within this book.
 | 
			
		||||
     * @return \Illuminate\Database\Eloquent\Relations\HasMany
 | 
			
		||||
     * @return HasMany
 | 
			
		||||
     */
 | 
			
		||||
    public function pages()
 | 
			
		||||
    {
 | 
			
		||||
| 
						 | 
				
			
			@ -71,7 +82,7 @@ class Book extends Entity
 | 
			
		|||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the direct child pages of this book.
 | 
			
		||||
     * @return \Illuminate\Database\Eloquent\Relations\HasMany
 | 
			
		||||
     * @return HasMany
 | 
			
		||||
     */
 | 
			
		||||
    public function directPages()
 | 
			
		||||
    {
 | 
			
		||||
| 
						 | 
				
			
			@ -80,7 +91,7 @@ class Book extends Entity
 | 
			
		|||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get all chapters within this book.
 | 
			
		||||
     * @return \Illuminate\Database\Eloquent\Relations\HasMany
 | 
			
		||||
     * @return HasMany
 | 
			
		||||
     */
 | 
			
		||||
    public function chapters()
 | 
			
		||||
    {
 | 
			
		||||
| 
						 | 
				
			
			@ -89,13 +100,24 @@ class Book extends Entity
 | 
			
		|||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the shelves this book is contained within.
 | 
			
		||||
     * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
 | 
			
		||||
     * @return BelongsToMany
 | 
			
		||||
     */
 | 
			
		||||
    public function shelves()
 | 
			
		||||
    {
 | 
			
		||||
        return $this->belongsToMany(Bookshelf::class, 'bookshelves_books', 'book_id', 'bookshelf_id');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the direct child items within this book.
 | 
			
		||||
     * @return Collection
 | 
			
		||||
     */
 | 
			
		||||
    public function getDirectChildren(): Collection
 | 
			
		||||
    {
 | 
			
		||||
        $pages = $this->directPages()->visible()->get();
 | 
			
		||||
        $chapters = $this->chapters()->visible()->get();
 | 
			
		||||
        return $pages->contact($chapters)->sortBy('priority')->sortByDesc('draft');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get an excerpt of this book's description to the specified length or less.
 | 
			
		||||
     * @param int $length
 | 
			
		||||
| 
						 | 
				
			
			@ -106,13 +128,4 @@ class Book extends Entity
 | 
			
		|||
        $description = $this->description;
 | 
			
		||||
        return mb_strlen($description) > $length ? mb_substr($description, 0, $length-3) . '...' : $description;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Return a generalised, common raw query that can be 'unioned' across entities.
 | 
			
		||||
     * @return string
 | 
			
		||||
     */
 | 
			
		||||
    public function entityRawQuery()
 | 
			
		||||
    {
 | 
			
		||||
        return "'BookStack\\\\Book' as entity_type, id, id as entity_id, slug, name, {$this->textField} as text,'' as html, '0' as book_id, '0' as priority, '0' as chapter_id, '0' as draft, created_by, updated_by, updated_at, created_at";
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,60 @@
 | 
			
		|||
<?php namespace BookStack\Entities;
 | 
			
		||||
 | 
			
		||||
use Illuminate\Database\Eloquent\Builder;
 | 
			
		||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Class BookChild
 | 
			
		||||
 * @property int $book_id
 | 
			
		||||
 * @property int $priority
 | 
			
		||||
 * @property Book $book
 | 
			
		||||
 * @method Builder whereSlugs(string $bookSlug, string $childSlug)
 | 
			
		||||
 */
 | 
			
		||||
class BookChild extends Entity
 | 
			
		||||
{
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Scope a query to find items where the the child has the given childSlug
 | 
			
		||||
     * where its parent has the bookSlug.
 | 
			
		||||
     */
 | 
			
		||||
    public function scopeWhereSlugs(Builder $query, string $bookSlug, string $childSlug)
 | 
			
		||||
    {
 | 
			
		||||
        return $query->with('book')
 | 
			
		||||
            ->whereHas('book', function (Builder $query) use ($bookSlug) {
 | 
			
		||||
                $query->where('slug', '=', $bookSlug);
 | 
			
		||||
            })
 | 
			
		||||
            ->where('slug', '=', $childSlug);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the book this page sits in.
 | 
			
		||||
     * @return BelongsTo
 | 
			
		||||
     */
 | 
			
		||||
    public function book(): BelongsTo
 | 
			
		||||
    {
 | 
			
		||||
        return $this->belongsTo(Book::class);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Change the book that this entity belongs to.
 | 
			
		||||
     */
 | 
			
		||||
    public function changeBook(int $newBookId): Entity
 | 
			
		||||
    {
 | 
			
		||||
        $this->book_id = $newBookId;
 | 
			
		||||
        $this->refreshSlug();
 | 
			
		||||
        $this->save();
 | 
			
		||||
        $this->refresh();
 | 
			
		||||
 | 
			
		||||
        // Update related activity
 | 
			
		||||
        $this->activity()->update(['book_id' => $newBookId]);
 | 
			
		||||
 | 
			
		||||
        // Update all child pages if a chapter
 | 
			
		||||
        if ($this instanceof Chapter) {
 | 
			
		||||
            foreach ($this->pages as $page) {
 | 
			
		||||
                $page->changeBook($newBookId);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return $this;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,8 +1,10 @@
 | 
			
		|||
<?php namespace BookStack\Entities;
 | 
			
		||||
 | 
			
		||||
use BookStack\Uploads\Image;
 | 
			
		||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
 | 
			
		||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
 | 
			
		||||
 | 
			
		||||
class Bookshelf extends Entity
 | 
			
		||||
class Bookshelf extends Entity implements HasCoverImage
 | 
			
		||||
{
 | 
			
		||||
    protected $table = 'bookshelves';
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -10,15 +12,6 @@ class Bookshelf extends Entity
 | 
			
		|||
 | 
			
		||||
    protected $fillable = ['name', 'description', 'image_id'];
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the morph class for this model.
 | 
			
		||||
     * @return string
 | 
			
		||||
     */
 | 
			
		||||
    public function getMorphClass()
 | 
			
		||||
    {
 | 
			
		||||
        return 'BookStack\\Bookshelf';
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the books in this shelf.
 | 
			
		||||
     * Should not be used directly since does not take into account permissions.
 | 
			
		||||
| 
						 | 
				
			
			@ -31,6 +24,14 @@ class Bookshelf extends Entity
 | 
			
		|||
            ->orderBy('order', 'asc');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Related books that are visible to the current user.
 | 
			
		||||
     */
 | 
			
		||||
    public function visibleBooks(): BelongsToMany
 | 
			
		||||
    {
 | 
			
		||||
        return $this->books()->visible();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the url for this bookshelf.
 | 
			
		||||
     * @param string|bool $path
 | 
			
		||||
| 
						 | 
				
			
			@ -68,13 +69,20 @@ class Bookshelf extends Entity
 | 
			
		|||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the cover image of the shelf
 | 
			
		||||
     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
 | 
			
		||||
     */
 | 
			
		||||
    public function cover()
 | 
			
		||||
    public function cover(): BelongsTo
 | 
			
		||||
    {
 | 
			
		||||
        return $this->belongsTo(Image::class, 'image_id');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the type of the image model that is used when storing a cover image.
 | 
			
		||||
     */
 | 
			
		||||
    public function coverImageTypeKey(): string
 | 
			
		||||
    {
 | 
			
		||||
        return 'cover_shelf';
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get an excerpt of this book's description to the specified length or less.
 | 
			
		||||
     * @param int $length
 | 
			
		||||
| 
						 | 
				
			
			@ -86,22 +94,27 @@ class Bookshelf extends Entity
 | 
			
		|||
        return mb_strlen($description) > $length ? mb_substr($description, 0, $length-3) . '...' : $description;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Return a generalised, common raw query that can be 'unioned' across entities.
 | 
			
		||||
     * @return string
 | 
			
		||||
     */
 | 
			
		||||
    public function entityRawQuery()
 | 
			
		||||
    {
 | 
			
		||||
        return "'BookStack\\\\BookShelf' as entity_type, id, id as entity_id, slug, name, {$this->textField} as text,'' as html, '0' as book_id, '0' as priority, '0' as chapter_id, '0' as draft, created_by, updated_by, updated_at, created_at";
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if this shelf contains the given book.
 | 
			
		||||
     * @param Book $book
 | 
			
		||||
     * @return bool
 | 
			
		||||
     */
 | 
			
		||||
    public function contains(Book $book)
 | 
			
		||||
    public function contains(Book $book): bool
 | 
			
		||||
    {
 | 
			
		||||
        return $this->books()->where('id', '=', $book->id)->count() > 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Add a book to the end of this shelf.
 | 
			
		||||
     * @param Book $book
 | 
			
		||||
     */
 | 
			
		||||
    public function appendBook(Book $book)
 | 
			
		||||
    {
 | 
			
		||||
        if ($this->contains($book)) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $maxOrder = $this->books()->max('order');
 | 
			
		||||
        $this->books()->attach($book->id, ['order' => $maxOrder + 1]);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,5 +1,6 @@
 | 
			
		|||
<?php namespace BookStack\Entities;
 | 
			
		||||
 | 
			
		||||
use BookStack\Entities\Managers\EntityContext;
 | 
			
		||||
use Illuminate\View\View;
 | 
			
		||||
 | 
			
		||||
class BreadcrumbsViewComposer
 | 
			
		||||
| 
						 | 
				
			
			@ -9,9 +10,9 @@ class BreadcrumbsViewComposer
 | 
			
		|||
 | 
			
		||||
    /**
 | 
			
		||||
     * BreadcrumbsViewComposer constructor.
 | 
			
		||||
     * @param EntityContextManager $entityContextManager
 | 
			
		||||
     * @param EntityContext $entityContextManager
 | 
			
		||||
     */
 | 
			
		||||
    public function __construct(EntityContextManager $entityContextManager)
 | 
			
		||||
    public function __construct(EntityContext $entityContextManager)
 | 
			
		||||
    {
 | 
			
		||||
        $this->entityContextManager = $entityContextManager;
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			@ -23,8 +24,9 @@ class BreadcrumbsViewComposer
 | 
			
		|||
    public function compose(View $view)
 | 
			
		||||
    {
 | 
			
		||||
        $crumbs = $view->getData()['crumbs'];
 | 
			
		||||
        if (array_first($crumbs) instanceof Book) {
 | 
			
		||||
            $shelf = $this->entityContextManager->getContextualShelfForBook(array_first($crumbs));
 | 
			
		||||
        $firstCrumb = $crumbs[0] ?? null;
 | 
			
		||||
        if ($firstCrumb instanceof Book) {
 | 
			
		||||
            $shelf = $this->entityContextManager->getContextualShelfForBook($firstCrumb);
 | 
			
		||||
            if ($shelf) {
 | 
			
		||||
                array_unshift($crumbs, $shelf);
 | 
			
		||||
                $view->with('crumbs', $crumbs);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,29 +1,18 @@
 | 
			
		|||
<?php namespace BookStack\Entities;
 | 
			
		||||
 | 
			
		||||
class Chapter extends Entity
 | 
			
		||||
use Illuminate\Support\Collection;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Class Chapter
 | 
			
		||||
 * @property Collection<Page> $pages
 | 
			
		||||
 * @package BookStack\Entities
 | 
			
		||||
 */
 | 
			
		||||
class Chapter extends BookChild
 | 
			
		||||
{
 | 
			
		||||
    public $searchFactor = 1.3;
 | 
			
		||||
 | 
			
		||||
    protected $fillable = ['name', 'description', 'priority', 'book_id'];
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the morph class for this model.
 | 
			
		||||
     * @return string
 | 
			
		||||
     */
 | 
			
		||||
    public function getMorphClass()
 | 
			
		||||
    {
 | 
			
		||||
        return 'BookStack\\Chapter';
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the book this chapter is within.
 | 
			
		||||
     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
 | 
			
		||||
     */
 | 
			
		||||
    public function book()
 | 
			
		||||
    {
 | 
			
		||||
        return $this->belongsTo(Book::class);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the pages that this chapter contains.
 | 
			
		||||
     * @param string $dir
 | 
			
		||||
| 
						 | 
				
			
			@ -62,15 +51,6 @@ class Chapter extends Entity
 | 
			
		|||
        return mb_strlen($description) > $length ? mb_substr($description, 0, $length-3) . '...' : $description;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Return a generalised, common raw query that can be 'unioned' across entities.
 | 
			
		||||
     * @return string
 | 
			
		||||
     */
 | 
			
		||||
    public function entityRawQuery()
 | 
			
		||||
    {
 | 
			
		||||
        return "'BookStack\\\\Chapter' as entity_type, id, id as entity_id, slug, name, {$this->textField} as text, '' as html, book_id, priority, '0' as chapter_id, '0' as draft, created_by, updated_by, updated_at, created_at";
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if this chapter has any child pages.
 | 
			
		||||
     * @return bool
 | 
			
		||||
| 
						 | 
				
			
			@ -79,4 +59,15 @@ class Chapter extends Entity
 | 
			
		|||
    {
 | 
			
		||||
        return count($this->pages) > 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the visible pages in this chapter.
 | 
			
		||||
     */
 | 
			
		||||
    public function getVisiblePages(): Collection
 | 
			
		||||
    {
 | 
			
		||||
        return $this->pages()->visible()
 | 
			
		||||
        ->orderBy('draft', 'desc')
 | 
			
		||||
        ->orderBy('priority', 'asc')
 | 
			
		||||
        ->get();
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -6,8 +6,11 @@ use BookStack\Actions\Tag;
 | 
			
		|||
use BookStack\Actions\View;
 | 
			
		||||
use BookStack\Auth\Permissions\EntityPermission;
 | 
			
		||||
use BookStack\Auth\Permissions\JointPermission;
 | 
			
		||||
use BookStack\Facades\Permissions;
 | 
			
		||||
use BookStack\Ownable;
 | 
			
		||||
use Carbon\Carbon;
 | 
			
		||||
use Illuminate\Database\Eloquent\Builder;
 | 
			
		||||
use Illuminate\Database\Eloquent\Collection;
 | 
			
		||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
| 
						 | 
				
			
			@ -15,7 +18,7 @@ use Illuminate\Database\Eloquent\Relations\MorphMany;
 | 
			
		|||
 * The base class for book-like items such as pages, chapters & books.
 | 
			
		||||
 * This is not a database model in itself but extended.
 | 
			
		||||
 *
 | 
			
		||||
 * @property integer $id
 | 
			
		||||
 * @property int $id
 | 
			
		||||
 * @property string $name
 | 
			
		||||
 * @property string $slug
 | 
			
		||||
 * @property Carbon $created_at
 | 
			
		||||
| 
						 | 
				
			
			@ -23,6 +26,11 @@ use Illuminate\Database\Eloquent\Relations\MorphMany;
 | 
			
		|||
 * @property int $created_by
 | 
			
		||||
 * @property int $updated_by
 | 
			
		||||
 * @property boolean $restricted
 | 
			
		||||
 * @property Collection $tags
 | 
			
		||||
 * @method static Entity|Builder visible()
 | 
			
		||||
 * @method static Entity|Builder hasPermission(string $permission)
 | 
			
		||||
 * @method static Builder withLastView()
 | 
			
		||||
 * @method static Builder withViewCount()
 | 
			
		||||
 *
 | 
			
		||||
 * @package BookStack\Entities
 | 
			
		||||
 */
 | 
			
		||||
| 
						 | 
				
			
			@ -40,14 +48,45 @@ class Entity extends Ownable
 | 
			
		|||
    public $searchFactor = 1.0;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the morph class for this model.
 | 
			
		||||
     * Set here since, due to folder changes, the namespace used
 | 
			
		||||
     * in the database no longer matches the class namespace.
 | 
			
		||||
     * @return string
 | 
			
		||||
     * Get the entities that are visible to the current user.
 | 
			
		||||
     */
 | 
			
		||||
    public function getMorphClass()
 | 
			
		||||
    public function scopeVisible(Builder $query)
 | 
			
		||||
    {
 | 
			
		||||
        return 'BookStack\\Entity';
 | 
			
		||||
        return $this->scopeHasPermission($query, 'view');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Scope the query to those entities that the current user has the given permission for.
 | 
			
		||||
     */
 | 
			
		||||
    public function scopeHasPermission(Builder $query, string $permission)
 | 
			
		||||
    {
 | 
			
		||||
        return Permissions::restrictEntityQuery($query, $permission);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Query scope to get the last view from the current user.
 | 
			
		||||
     */
 | 
			
		||||
    public function scopeWithLastView(Builder $query)
 | 
			
		||||
    {
 | 
			
		||||
        $viewedAtQuery = View::query()->select('updated_at')
 | 
			
		||||
            ->whereColumn('viewable_id', '=', $this->getTable() . '.id')
 | 
			
		||||
            ->where('viewable_type', '=', $this->getMorphClass())
 | 
			
		||||
            ->where('user_id', '=', user()->id)
 | 
			
		||||
            ->take(1);
 | 
			
		||||
 | 
			
		||||
        return $query->addSelect(['last_viewed_at' => $viewedAtQuery]);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Query scope to get the total view count of the entities.
 | 
			
		||||
     */
 | 
			
		||||
    public function scopeWithViewCount(Builder $query)
 | 
			
		||||
    {
 | 
			
		||||
        $viewCountQuery = View::query()->selectRaw('SUM(views) as view_count')
 | 
			
		||||
            ->whereColumn('viewable_id', '=', $this->getTable() . '.id')
 | 
			
		||||
            ->where('viewable_type', '=', $this->getMorphClass())->take(1);
 | 
			
		||||
 | 
			
		||||
        $query->addSelect(['view_count' => $viewCountQuery]);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
| 
						 | 
				
			
			@ -87,11 +126,12 @@ class Entity extends Ownable
 | 
			
		|||
 | 
			
		||||
    /**
 | 
			
		||||
     * Gets the activity objects for this entity.
 | 
			
		||||
     * @return \Illuminate\Database\Eloquent\Relations\MorphMany
 | 
			
		||||
     * @return MorphMany
 | 
			
		||||
     */
 | 
			
		||||
    public function activity()
 | 
			
		||||
    {
 | 
			
		||||
        return $this->morphMany(Activity::class, 'entity')->orderBy('created_at', 'desc');
 | 
			
		||||
        return $this->morphMany(Activity::class, 'entity')
 | 
			
		||||
            ->orderBy('created_at', 'desc');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
| 
						 | 
				
			
			@ -102,14 +142,9 @@ class Entity extends Ownable
 | 
			
		|||
        return $this->morphMany(View::class, 'viewable');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public function viewCountQuery()
 | 
			
		||||
    {
 | 
			
		||||
        return $this->views()->selectRaw('viewable_id, sum(views) as view_count')->groupBy('viewable_id');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the Tag models that have been user assigned to this entity.
 | 
			
		||||
     * @return \Illuminate\Database\Eloquent\Relations\MorphMany
 | 
			
		||||
     * @return MorphMany
 | 
			
		||||
     */
 | 
			
		||||
    public function tags()
 | 
			
		||||
    {
 | 
			
		||||
| 
						 | 
				
			
			@ -129,7 +164,7 @@ class Entity extends Ownable
 | 
			
		|||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the related search terms.
 | 
			
		||||
     * @return \Illuminate\Database\Eloquent\Relations\MorphMany
 | 
			
		||||
     * @return MorphMany
 | 
			
		||||
     */
 | 
			
		||||
    public function searchTerms()
 | 
			
		||||
    {
 | 
			
		||||
| 
						 | 
				
			
			@ -158,7 +193,7 @@ class Entity extends Ownable
 | 
			
		|||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the entity jointPermissions this is connected to.
 | 
			
		||||
     * @return \Illuminate\Database\Eloquent\Relations\MorphMany
 | 
			
		||||
     * @return MorphMany
 | 
			
		||||
     */
 | 
			
		||||
    public function jointPermissions()
 | 
			
		||||
    {
 | 
			
		||||
| 
						 | 
				
			
			@ -237,15 +272,6 @@ class Entity extends Ownable
 | 
			
		|||
        return trim($text);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Return a generalised, common raw query that can be 'unioned' across entities.
 | 
			
		||||
     * @return string
 | 
			
		||||
     */
 | 
			
		||||
    public function entityRawQuery()
 | 
			
		||||
    {
 | 
			
		||||
        return '';
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the url of this entity
 | 
			
		||||
     * @param $path
 | 
			
		||||
| 
						 | 
				
			
			@ -255,4 +281,32 @@ class Entity extends Ownable
 | 
			
		|||
    {
 | 
			
		||||
        return $path;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Rebuild the permissions for this entity.
 | 
			
		||||
     */
 | 
			
		||||
    public function rebuildPermissions()
 | 
			
		||||
    {
 | 
			
		||||
        /** @noinspection PhpUnhandledExceptionInspection */
 | 
			
		||||
        Permissions::buildJointPermissionsForEntity($this);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Index the current entity for search
 | 
			
		||||
     */
 | 
			
		||||
    public function indexForSearch()
 | 
			
		||||
    {
 | 
			
		||||
        $searchService = app()->make(SearchService::class);
 | 
			
		||||
        $searchService->indexEntity($this);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Generate and set a new URL slug for this model.
 | 
			
		||||
     */
 | 
			
		||||
    public function refreshSlug(): string
 | 
			
		||||
    {
 | 
			
		||||
        $generator = new SlugGenerator($this);
 | 
			
		||||
        $this->slug = $generator->generate();
 | 
			
		||||
        return $this->slug;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -39,11 +39,6 @@ class EntityProvider
 | 
			
		|||
 | 
			
		||||
    /**
 | 
			
		||||
     * EntityProvider constructor.
 | 
			
		||||
     * @param Bookshelf $bookshelf
 | 
			
		||||
     * @param Book $book
 | 
			
		||||
     * @param Chapter $chapter
 | 
			
		||||
     * @param Page $page
 | 
			
		||||
     * @param PageRevision $pageRevision
 | 
			
		||||
     */
 | 
			
		||||
    public function __construct(
 | 
			
		||||
        Bookshelf $bookshelf,
 | 
			
		||||
| 
						 | 
				
			
			@ -62,9 +57,8 @@ class EntityProvider
 | 
			
		|||
    /**
 | 
			
		||||
     * Fetch all core entity types as an associated array
 | 
			
		||||
     * with their basic names as the keys.
 | 
			
		||||
     * @return Entity[]
 | 
			
		||||
     */
 | 
			
		||||
    public function all()
 | 
			
		||||
    public function all(): array
 | 
			
		||||
    {
 | 
			
		||||
        return [
 | 
			
		||||
            'bookshelf' => $this->bookshelf,
 | 
			
		||||
| 
						 | 
				
			
			@ -76,10 +70,8 @@ class EntityProvider
 | 
			
		|||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get an entity instance by it's basic name.
 | 
			
		||||
     * @param string $type
 | 
			
		||||
     * @return Entity
 | 
			
		||||
     */
 | 
			
		||||
    public function get(string $type)
 | 
			
		||||
    public function get(string $type): Entity
 | 
			
		||||
    {
 | 
			
		||||
        $type = strtolower($type);
 | 
			
		||||
        return $this->all()[$type];
 | 
			
		||||
| 
						 | 
				
			
			@ -87,15 +79,9 @@ class EntityProvider
 | 
			
		|||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the morph classes, as an array, for a single or multiple types.
 | 
			
		||||
     * @param string|array $types
 | 
			
		||||
     * @return array<string>
 | 
			
		||||
     */
 | 
			
		||||
    public function getMorphClasses($types)
 | 
			
		||||
    public function getMorphClasses(array $types): array
 | 
			
		||||
    {
 | 
			
		||||
        if (is_string($types)) {
 | 
			
		||||
            $types = [$types];
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $morphClasses = [];
 | 
			
		||||
        foreach ($types as $type) {
 | 
			
		||||
            $model = $this->get($type);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,35 +1,34 @@
 | 
			
		|||
<?php namespace BookStack\Entities;
 | 
			
		||||
 | 
			
		||||
use BookStack\Entities\Repos\EntityRepo;
 | 
			
		||||
use BookStack\Entities\Managers\BookContents;
 | 
			
		||||
use BookStack\Entities\Managers\PageContent;
 | 
			
		||||
use BookStack\Uploads\ImageService;
 | 
			
		||||
use DomPDF;
 | 
			
		||||
use Exception;
 | 
			
		||||
use SnappyPDF;
 | 
			
		||||
use Throwable;
 | 
			
		||||
 | 
			
		||||
class ExportService
 | 
			
		||||
{
 | 
			
		||||
 | 
			
		||||
    protected $entityRepo;
 | 
			
		||||
    protected $imageService;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * ExportService constructor.
 | 
			
		||||
     * @param EntityRepo $entityRepo
 | 
			
		||||
     * @param ImageService $imageService
 | 
			
		||||
     */
 | 
			
		||||
    public function __construct(EntityRepo $entityRepo, ImageService $imageService)
 | 
			
		||||
    public function __construct(ImageService $imageService)
 | 
			
		||||
    {
 | 
			
		||||
        $this->entityRepo = $entityRepo;
 | 
			
		||||
        $this->imageService = $imageService;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Convert a page to a self-contained HTML file.
 | 
			
		||||
     * Includes required CSS & image content. Images are base64 encoded into the HTML.
 | 
			
		||||
     * @param \BookStack\Entities\Page $page
 | 
			
		||||
     * @return mixed|string
 | 
			
		||||
     * @throws \Throwable
 | 
			
		||||
     * @throws Throwable
 | 
			
		||||
     */
 | 
			
		||||
    public function pageToContainedHtml(Page $page)
 | 
			
		||||
    {
 | 
			
		||||
        $this->entityRepo->renderPage($page);
 | 
			
		||||
        $page->html = (new PageContent($page))->render();
 | 
			
		||||
        $pageHtml = view('pages/export', [
 | 
			
		||||
            'page' => $page
 | 
			
		||||
        ])->render();
 | 
			
		||||
| 
						 | 
				
			
			@ -38,15 +37,13 @@ class ExportService
 | 
			
		|||
 | 
			
		||||
    /**
 | 
			
		||||
     * Convert a chapter to a self-contained HTML file.
 | 
			
		||||
     * @param \BookStack\Entities\Chapter $chapter
 | 
			
		||||
     * @return mixed|string
 | 
			
		||||
     * @throws \Throwable
 | 
			
		||||
     * @throws Throwable
 | 
			
		||||
     */
 | 
			
		||||
    public function chapterToContainedHtml(Chapter $chapter)
 | 
			
		||||
    {
 | 
			
		||||
        $pages = $this->entityRepo->getChapterChildren($chapter);
 | 
			
		||||
        $pages = $chapter->getVisiblePages();
 | 
			
		||||
        $pages->each(function ($page) {
 | 
			
		||||
            $page->html = $this->entityRepo->renderPage($page);
 | 
			
		||||
            $page->html = (new PageContent($page))->render();
 | 
			
		||||
        });
 | 
			
		||||
        $html = view('chapters/export', [
 | 
			
		||||
            'chapter' => $chapter,
 | 
			
		||||
| 
						 | 
				
			
			@ -57,13 +54,11 @@ class ExportService
 | 
			
		|||
 | 
			
		||||
    /**
 | 
			
		||||
     * Convert a book to a self-contained HTML file.
 | 
			
		||||
     * @param Book $book
 | 
			
		||||
     * @return mixed|string
 | 
			
		||||
     * @throws \Throwable
 | 
			
		||||
     * @throws Throwable
 | 
			
		||||
     */
 | 
			
		||||
    public function bookToContainedHtml(Book $book)
 | 
			
		||||
    {
 | 
			
		||||
        $bookTree = $this->entityRepo->getBookChildren($book, true, true);
 | 
			
		||||
        $bookTree = (new BookContents($book))->getTree(false, true);
 | 
			
		||||
        $html = view('books/export', [
 | 
			
		||||
            'book' => $book,
 | 
			
		||||
            'bookChildren' => $bookTree
 | 
			
		||||
| 
						 | 
				
			
			@ -73,13 +68,11 @@ class ExportService
 | 
			
		|||
 | 
			
		||||
    /**
 | 
			
		||||
     * Convert a page to a PDF file.
 | 
			
		||||
     * @param Page $page
 | 
			
		||||
     * @return mixed|string
 | 
			
		||||
     * @throws \Throwable
 | 
			
		||||
     * @throws Throwable
 | 
			
		||||
     */
 | 
			
		||||
    public function pageToPdf(Page $page)
 | 
			
		||||
    {
 | 
			
		||||
        $this->entityRepo->renderPage($page);
 | 
			
		||||
        $page->html = (new PageContent($page))->render();
 | 
			
		||||
        $html = view('pages/pdf', [
 | 
			
		||||
            'page' => $page
 | 
			
		||||
        ])->render();
 | 
			
		||||
| 
						 | 
				
			
			@ -88,32 +81,30 @@ class ExportService
 | 
			
		|||
 | 
			
		||||
    /**
 | 
			
		||||
     * Convert a chapter to a PDF file.
 | 
			
		||||
     * @param \BookStack\Entities\Chapter $chapter
 | 
			
		||||
     * @return mixed|string
 | 
			
		||||
     * @throws \Throwable
 | 
			
		||||
     * @throws Throwable
 | 
			
		||||
     */
 | 
			
		||||
    public function chapterToPdf(Chapter $chapter)
 | 
			
		||||
    {
 | 
			
		||||
        $pages = $this->entityRepo->getChapterChildren($chapter);
 | 
			
		||||
        $pages = $chapter->getVisiblePages();
 | 
			
		||||
        $pages->each(function ($page) {
 | 
			
		||||
            $page->html = $this->entityRepo->renderPage($page);
 | 
			
		||||
            $page->html = (new PageContent($page))->render();
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        $html = view('chapters/export', [
 | 
			
		||||
            'chapter' => $chapter,
 | 
			
		||||
            'pages' => $pages
 | 
			
		||||
        ])->render();
 | 
			
		||||
 | 
			
		||||
        return $this->htmlToPdf($html);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Convert a book to a PDF file
 | 
			
		||||
     * @param \BookStack\Entities\Book $book
 | 
			
		||||
     * @return string
 | 
			
		||||
     * @throws \Throwable
 | 
			
		||||
     * Convert a book to a PDF file.
 | 
			
		||||
     * @throws Throwable
 | 
			
		||||
     */
 | 
			
		||||
    public function bookToPdf(Book $book)
 | 
			
		||||
    {
 | 
			
		||||
        $bookTree = $this->entityRepo->getBookChildren($book, true, true);
 | 
			
		||||
        $bookTree = (new BookContents($book))->getTree(false, true);
 | 
			
		||||
        $html = view('books/export', [
 | 
			
		||||
            'book' => $book,
 | 
			
		||||
            'bookChildren' => $bookTree
 | 
			
		||||
| 
						 | 
				
			
			@ -122,31 +113,27 @@ class ExportService
 | 
			
		|||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Convert normal webpage HTML to a PDF.
 | 
			
		||||
     * @param $html
 | 
			
		||||
     * @return string
 | 
			
		||||
     * @throws \Exception
 | 
			
		||||
     * Convert normal web-page HTML to a PDF.
 | 
			
		||||
     * @throws Exception
 | 
			
		||||
     */
 | 
			
		||||
    protected function htmlToPdf($html)
 | 
			
		||||
    protected function htmlToPdf(string $html): string
 | 
			
		||||
    {
 | 
			
		||||
        $containedHtml = $this->containHtml($html);
 | 
			
		||||
        $useWKHTML = config('snappy.pdf.binary') !== false;
 | 
			
		||||
        if ($useWKHTML) {
 | 
			
		||||
            $pdf = \SnappyPDF::loadHTML($containedHtml);
 | 
			
		||||
            $pdf = SnappyPDF::loadHTML($containedHtml);
 | 
			
		||||
            $pdf->setOption('print-media-type', true);
 | 
			
		||||
        } else {
 | 
			
		||||
            $pdf = \DomPDF::loadHTML($containedHtml);
 | 
			
		||||
            $pdf = DomPDF::loadHTML($containedHtml);
 | 
			
		||||
        }
 | 
			
		||||
        return $pdf->output();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Bundle of the contents of a html file to be self-contained.
 | 
			
		||||
     * @param $htmlContent
 | 
			
		||||
     * @return mixed|string
 | 
			
		||||
     * @throws \Exception
 | 
			
		||||
     * @throws Exception
 | 
			
		||||
     */
 | 
			
		||||
    protected function containHtml($htmlContent)
 | 
			
		||||
    protected function containHtml(string $htmlContent): string
 | 
			
		||||
    {
 | 
			
		||||
        $imageTagsOutput = [];
 | 
			
		||||
        preg_match_all("/\<img.*src\=(\'|\")(.*?)(\'|\").*?\>/i", $htmlContent, $imageTagsOutput);
 | 
			
		||||
| 
						 | 
				
			
			@ -188,12 +175,10 @@ class ExportService
 | 
			
		|||
    /**
 | 
			
		||||
     * Converts the page contents into simple plain text.
 | 
			
		||||
     * This method filters any bad looking content to provide a nice final output.
 | 
			
		||||
     * @param Page $page
 | 
			
		||||
     * @return mixed
 | 
			
		||||
     */
 | 
			
		||||
    public function pageToPlainText(Page $page)
 | 
			
		||||
    public function pageToPlainText(Page $page): string
 | 
			
		||||
    {
 | 
			
		||||
        $html = $this->entityRepo->renderPage($page);
 | 
			
		||||
        $html = (new PageContent($page))->render();
 | 
			
		||||
        $text = strip_tags($html);
 | 
			
		||||
        // Replace multiple spaces with single spaces
 | 
			
		||||
        $text = preg_replace('/\ {2,}/', ' ', $text);
 | 
			
		||||
| 
						 | 
				
			
			@ -207,10 +192,8 @@ class ExportService
 | 
			
		|||
 | 
			
		||||
    /**
 | 
			
		||||
     * Convert a chapter into a plain text string.
 | 
			
		||||
     * @param \BookStack\Entities\Chapter $chapter
 | 
			
		||||
     * @return string
 | 
			
		||||
     */
 | 
			
		||||
    public function chapterToPlainText(Chapter $chapter)
 | 
			
		||||
    public function chapterToPlainText(Chapter $chapter): string
 | 
			
		||||
    {
 | 
			
		||||
        $text = $chapter->name . "\n\n";
 | 
			
		||||
        $text .= $chapter->description . "\n\n";
 | 
			
		||||
| 
						 | 
				
			
			@ -222,12 +205,10 @@ class ExportService
 | 
			
		|||
 | 
			
		||||
    /**
 | 
			
		||||
     * Convert a book into a plain text string.
 | 
			
		||||
     * @param Book $book
 | 
			
		||||
     * @return string
 | 
			
		||||
     */
 | 
			
		||||
    public function bookToPlainText(Book $book)
 | 
			
		||||
    public function bookToPlainText(Book $book): string
 | 
			
		||||
    {
 | 
			
		||||
        $bookTree = $this->entityRepo->getBookChildren($book, true, true);
 | 
			
		||||
        $bookTree = (new BookContents($book))->getTree(false, true);
 | 
			
		||||
        $text = $book->name . "\n\n";
 | 
			
		||||
        foreach ($bookTree as $bookChild) {
 | 
			
		||||
            if ($bookChild->isA('chapter')) {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,20 @@
 | 
			
		|||
<?php
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
namespace BookStack\Entities;
 | 
			
		||||
 | 
			
		||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
 | 
			
		||||
 | 
			
		||||
interface HasCoverImage
 | 
			
		||||
{
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the cover image for this item.
 | 
			
		||||
     */
 | 
			
		||||
    public function cover(): BelongsTo;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the type of the image model that is used when storing a cover image.
 | 
			
		||||
     */
 | 
			
		||||
    public function coverImageTypeKey(): string;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,204 @@
 | 
			
		|||
<?php namespace BookStack\Entities\Managers;
 | 
			
		||||
 | 
			
		||||
use BookStack\Entities\Book;
 | 
			
		||||
use BookStack\Entities\BookChild;
 | 
			
		||||
use BookStack\Entities\Chapter;
 | 
			
		||||
use BookStack\Entities\Entity;
 | 
			
		||||
use BookStack\Entities\Page;
 | 
			
		||||
use BookStack\Exceptions\SortOperationException;
 | 
			
		||||
use Illuminate\Support\Collection;
 | 
			
		||||
 | 
			
		||||
class BookContents
 | 
			
		||||
{
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @var Book
 | 
			
		||||
     */
 | 
			
		||||
    protected $book;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * BookContents constructor.
 | 
			
		||||
     * @param $book
 | 
			
		||||
     */
 | 
			
		||||
    public function __construct(Book $book)
 | 
			
		||||
    {
 | 
			
		||||
        $this->book = $book;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the current priority of the last item
 | 
			
		||||
     * at the top-level of the book.
 | 
			
		||||
     */
 | 
			
		||||
    public function getLastPriority(): int
 | 
			
		||||
    {
 | 
			
		||||
        $maxPage = Page::visible()->where('book_id', '=', $this->book->id)
 | 
			
		||||
            ->where('draft', '=', false)
 | 
			
		||||
            ->where('chapter_id', '=', 0)->max('priority');
 | 
			
		||||
        $maxChapter = Chapter::visible()->where('book_id', '=', $this->book->id)
 | 
			
		||||
            ->max('priority');
 | 
			
		||||
        return max($maxChapter, $maxPage, 1);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the contents as a sorted collection tree.
 | 
			
		||||
     * TODO - Support $renderPages option
 | 
			
		||||
     */
 | 
			
		||||
    public function getTree(bool $showDrafts = false, bool $renderPages = false): Collection
 | 
			
		||||
    {
 | 
			
		||||
        $pages = $this->getPages($showDrafts);
 | 
			
		||||
        $chapters = Chapter::visible()->where('book_id', '=', $this->book->id)->get();
 | 
			
		||||
        $all = collect()->concat($pages)->concat($chapters);
 | 
			
		||||
        $chapterMap = $chapters->keyBy('id');
 | 
			
		||||
        $lonePages = collect();
 | 
			
		||||
 | 
			
		||||
        $pages->groupBy('chapter_id')->each(function ($pages, $chapter_id) use ($chapterMap, &$lonePages) {
 | 
			
		||||
            $chapter = $chapterMap->get($chapter_id);
 | 
			
		||||
            if ($chapter) {
 | 
			
		||||
                $chapter->setAttribute('pages', collect($pages)->sortBy($this->bookChildSortFunc()));
 | 
			
		||||
            } else {
 | 
			
		||||
                $lonePages = $lonePages->concat($pages);
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        $all->each(function (Entity $entity) {
 | 
			
		||||
            $entity->setRelation('book', $this->book);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        return collect($chapters)->concat($lonePages)->sortBy($this->bookChildSortFunc());
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Function for providing a sorting score for an entity in relation to the
 | 
			
		||||
     * other items within the book.
 | 
			
		||||
     */
 | 
			
		||||
    protected function bookChildSortFunc(): callable
 | 
			
		||||
    {
 | 
			
		||||
        return function (Entity $entity) {
 | 
			
		||||
            if (isset($entity['draft']) && $entity['draft']) {
 | 
			
		||||
                return -100;
 | 
			
		||||
            }
 | 
			
		||||
            return $entity['priority'] ?? 0;
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the visible pages within this book.
 | 
			
		||||
     */
 | 
			
		||||
    protected function getPages(bool $showDrafts = false): Collection
 | 
			
		||||
    {
 | 
			
		||||
        $query = Page::visible()->where('book_id', '=', $this->book->id);
 | 
			
		||||
 | 
			
		||||
        if (!$showDrafts) {
 | 
			
		||||
            $query->where('draft', '=', false);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return $query->get();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Sort the books content using the given map.
 | 
			
		||||
     * The map is a single-dimension collection of objects in the following format:
 | 
			
		||||
     *   {
 | 
			
		||||
     *     +"id": "294" (ID of item)
 | 
			
		||||
     *     +"sort": 1 (Sort order index)
 | 
			
		||||
     *     +"parentChapter": false (ID of parent chapter, as string, or false)
 | 
			
		||||
     *     +"type": "page" (Entity type of item)
 | 
			
		||||
     *     +"book": "1" (Id of book to place item in)
 | 
			
		||||
     *   }
 | 
			
		||||
     *
 | 
			
		||||
     * Returns a list of books that were involved in the operation.
 | 
			
		||||
     * @throws SortOperationException
 | 
			
		||||
     */
 | 
			
		||||
    public function sortUsingMap(Collection $sortMap): Collection
 | 
			
		||||
    {
 | 
			
		||||
        // Load models into map
 | 
			
		||||
        $this->loadModelsIntoSortMap($sortMap);
 | 
			
		||||
        $booksInvolved = $this->getBooksInvolvedInSort($sortMap);
 | 
			
		||||
 | 
			
		||||
        // Perform the sort
 | 
			
		||||
        $sortMap->each(function ($mapItem) {
 | 
			
		||||
            $this->applySortUpdates($mapItem);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        // Update permissions and activity.
 | 
			
		||||
        $booksInvolved->each(function (Book $book) {
 | 
			
		||||
            $book->rebuildPermissions();
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        return $booksInvolved;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Using the given sort map item, detect changes for the related model
 | 
			
		||||
     * and update it if required.
 | 
			
		||||
     */
 | 
			
		||||
    protected function applySortUpdates(\stdClass $sortMapItem)
 | 
			
		||||
    {
 | 
			
		||||
        /** @var BookChild $model */
 | 
			
		||||
        $model = $sortMapItem->model;
 | 
			
		||||
 | 
			
		||||
        $priorityChanged = intval($model->priority) !== intval($sortMapItem->sort);
 | 
			
		||||
        $bookChanged = intval($model->book_id) !== intval($sortMapItem->book);
 | 
			
		||||
        $chapterChanged = ($sortMapItem->type === 'page') && intval($model->chapter_id) !== $sortMapItem->parentChapter;
 | 
			
		||||
 | 
			
		||||
        if ($bookChanged) {
 | 
			
		||||
            $model->changeBook($sortMapItem->book);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if ($chapterChanged) {
 | 
			
		||||
            $model->chapter_id = intval($sortMapItem->parentChapter);
 | 
			
		||||
            $model->save();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if ($priorityChanged) {
 | 
			
		||||
            $model->priority = intval($sortMapItem->sort);
 | 
			
		||||
            $model->save();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Load models from the database into the given sort map.
 | 
			
		||||
     */
 | 
			
		||||
    protected function loadModelsIntoSortMap(Collection $sortMap): void
 | 
			
		||||
    {
 | 
			
		||||
        $keyMap = $sortMap->keyBy(function (\stdClass $sortMapItem) {
 | 
			
		||||
            return  $sortMapItem->type . ':' . $sortMapItem->id;
 | 
			
		||||
        });
 | 
			
		||||
        $pageIds = $sortMap->where('type', '=', 'page')->pluck('id');
 | 
			
		||||
        $chapterIds = $sortMap->where('type', '=', 'chapter')->pluck('id');
 | 
			
		||||
 | 
			
		||||
        $pages = Page::visible()->whereIn('id', $pageIds)->get();
 | 
			
		||||
        $chapters = Chapter::visible()->whereIn('id', $chapterIds)->get();
 | 
			
		||||
 | 
			
		||||
        foreach ($pages as $page) {
 | 
			
		||||
            $sortItem = $keyMap->get('page:' . $page->id);
 | 
			
		||||
            $sortItem->model = $page;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        foreach ($chapters as $chapter) {
 | 
			
		||||
            $sortItem = $keyMap->get('chapter:' . $chapter->id);
 | 
			
		||||
            $sortItem->model = $chapter;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the books involved in a sort.
 | 
			
		||||
     * The given sort map should have its models loaded first.
 | 
			
		||||
     * @throws SortOperationException
 | 
			
		||||
     */
 | 
			
		||||
    protected function getBooksInvolvedInSort(Collection $sortMap): Collection
 | 
			
		||||
    {
 | 
			
		||||
        $bookIdsInvolved = collect([$this->book->id]);
 | 
			
		||||
        $bookIdsInvolved = $bookIdsInvolved->concat($sortMap->pluck('book'));
 | 
			
		||||
        $bookIdsInvolved = $bookIdsInvolved->concat($sortMap->pluck('model.book_id'));
 | 
			
		||||
        $bookIdsInvolved = $bookIdsInvolved->unique()->toArray();
 | 
			
		||||
 | 
			
		||||
        $books = Book::hasPermission('update')->whereIn('id', $bookIdsInvolved)->get();
 | 
			
		||||
 | 
			
		||||
        if (count($books) !== count($bookIdsInvolved)) {
 | 
			
		||||
            throw new SortOperationException("Could not find all books requested in sort operation");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return $books;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,44 +1,38 @@
 | 
			
		|||
<?php namespace BookStack\Entities;
 | 
			
		||||
<?php namespace BookStack\Entities\Managers;
 | 
			
		||||
 | 
			
		||||
use BookStack\Entities\Repos\EntityRepo;
 | 
			
		||||
use BookStack\Entities\Book;
 | 
			
		||||
use BookStack\Entities\Bookshelf;
 | 
			
		||||
use Illuminate\Session\Store;
 | 
			
		||||
 | 
			
		||||
class EntityContextManager
 | 
			
		||||
class EntityContext
 | 
			
		||||
{
 | 
			
		||||
    protected $session;
 | 
			
		||||
    protected $entityRepo;
 | 
			
		||||
 | 
			
		||||
    protected $KEY_SHELF_CONTEXT_ID = 'context_bookshelf_id';
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * EntityContextManager constructor.
 | 
			
		||||
     * @param Store $session
 | 
			
		||||
     * @param EntityRepo $entityRepo
 | 
			
		||||
     */
 | 
			
		||||
    public function __construct(Store $session, EntityRepo $entityRepo)
 | 
			
		||||
    public function __construct(Store $session)
 | 
			
		||||
    {
 | 
			
		||||
        $this->session = $session;
 | 
			
		||||
        $this->entityRepo = $entityRepo;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the current bookshelf context for the given book.
 | 
			
		||||
     * @param Book $book
 | 
			
		||||
     * @return Bookshelf|null
 | 
			
		||||
     */
 | 
			
		||||
    public function getContextualShelfForBook(Book $book)
 | 
			
		||||
    public function getContextualShelfForBook(Book $book): ?Bookshelf
 | 
			
		||||
    {
 | 
			
		||||
        $contextBookshelfId = $this->session->get($this->KEY_SHELF_CONTEXT_ID, null);
 | 
			
		||||
        if (is_int($contextBookshelfId)) {
 | 
			
		||||
 | 
			
		||||
            /** @var Bookshelf $shelf */
 | 
			
		||||
            $shelf = $this->entityRepo->getById('bookshelf', $contextBookshelfId);
 | 
			
		||||
 | 
			
		||||
            if ($shelf && $shelf->contains($book)) {
 | 
			
		||||
                return $shelf;
 | 
			
		||||
            }
 | 
			
		||||
        if (!is_int($contextBookshelfId)) {
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
        return null;
 | 
			
		||||
 | 
			
		||||
        $shelf = Bookshelf::visible()->find($contextBookshelfId);
 | 
			
		||||
        $shelfContainsBook = $shelf && $shelf->contains($book);
 | 
			
		||||
 | 
			
		||||
        return $shelfContainsBook ? $shelf : null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,304 @@
 | 
			
		|||
<?php namespace BookStack\Entities\Managers;
 | 
			
		||||
 | 
			
		||||
use BookStack\Entities\Page;
 | 
			
		||||
use DOMDocument;
 | 
			
		||||
use DOMElement;
 | 
			
		||||
use DOMNodeList;
 | 
			
		||||
use DOMXPath;
 | 
			
		||||
 | 
			
		||||
class PageContent
 | 
			
		||||
{
 | 
			
		||||
 | 
			
		||||
    protected $page;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * PageContent constructor.
 | 
			
		||||
     */
 | 
			
		||||
    public function __construct(Page $page)
 | 
			
		||||
    {
 | 
			
		||||
        $this->page = $page;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Update the content of the page with new provided HTML.
 | 
			
		||||
     */
 | 
			
		||||
    public function setNewHTML(string $html)
 | 
			
		||||
    {
 | 
			
		||||
        $this->page->html = $this->formatHtml($html);
 | 
			
		||||
        $this->page->text = $this->toPlainText();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Formats a page's html to be tagged correctly within the system.
 | 
			
		||||
     */
 | 
			
		||||
    protected function formatHtml(string $htmlText): string
 | 
			
		||||
    {
 | 
			
		||||
        if ($htmlText == '') {
 | 
			
		||||
            return $htmlText;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        libxml_use_internal_errors(true);
 | 
			
		||||
        $doc = new DOMDocument();
 | 
			
		||||
        $doc->loadHTML(mb_convert_encoding($htmlText, 'HTML-ENTITIES', 'UTF-8'));
 | 
			
		||||
 | 
			
		||||
        $container = $doc->documentElement;
 | 
			
		||||
        $body = $container->childNodes->item(0);
 | 
			
		||||
        $childNodes = $body->childNodes;
 | 
			
		||||
 | 
			
		||||
        // Set ids on top-level nodes
 | 
			
		||||
        $idMap = [];
 | 
			
		||||
        foreach ($childNodes as $index => $childNode) {
 | 
			
		||||
            $this->setUniqueId($childNode, $idMap);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Ensure no duplicate ids within child items
 | 
			
		||||
        $xPath = new DOMXPath($doc);
 | 
			
		||||
        $idElems = $xPath->query('//body//*//*[@id]');
 | 
			
		||||
        foreach ($idElems as $domElem) {
 | 
			
		||||
            $this->setUniqueId($domElem, $idMap);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Generate inner html as a string
 | 
			
		||||
        $html = '';
 | 
			
		||||
        foreach ($childNodes as $childNode) {
 | 
			
		||||
            $html .= $doc->saveHTML($childNode);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return $html;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Set a unique id on the given DOMElement.
 | 
			
		||||
     * A map for existing ID's should be passed in to check for current existence.
 | 
			
		||||
     * @param DOMElement $element
 | 
			
		||||
     * @param array $idMap
 | 
			
		||||
     */
 | 
			
		||||
    protected function setUniqueId($element, array &$idMap)
 | 
			
		||||
    {
 | 
			
		||||
        if (get_class($element) !== 'DOMElement') {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Overwrite id if not a BookStack custom id
 | 
			
		||||
        $existingId = $element->getAttribute('id');
 | 
			
		||||
        if (strpos($existingId, 'bkmrk') === 0 && !isset($idMap[$existingId])) {
 | 
			
		||||
            $idMap[$existingId] = true;
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Create an unique id for the element
 | 
			
		||||
        // Uses the content as a basis to ensure output is the same every time
 | 
			
		||||
        // the same content is passed through.
 | 
			
		||||
        $contentId = 'bkmrk-' . mb_substr(strtolower(preg_replace('/\s+/', '-', trim($element->nodeValue))), 0, 20);
 | 
			
		||||
        $newId = urlencode($contentId);
 | 
			
		||||
        $loopIndex = 0;
 | 
			
		||||
 | 
			
		||||
        while (isset($idMap[$newId])) {
 | 
			
		||||
            $newId = urlencode($contentId . '-' . $loopIndex);
 | 
			
		||||
            $loopIndex++;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $element->setAttribute('id', $newId);
 | 
			
		||||
        $idMap[$newId] = true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get a plain-text visualisation of this page.
 | 
			
		||||
     */
 | 
			
		||||
    protected function toPlainText(): string
 | 
			
		||||
    {
 | 
			
		||||
        $html = $this->render(true);
 | 
			
		||||
        return strip_tags($html);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Render the page for viewing
 | 
			
		||||
     */
 | 
			
		||||
    public function render(bool $blankIncludes = false) : string
 | 
			
		||||
    {
 | 
			
		||||
        $content = $this->page->html;
 | 
			
		||||
 | 
			
		||||
        if (!config('app.allow_content_scripts')) {
 | 
			
		||||
            $content = $this->escapeScripts($content);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if ($blankIncludes) {
 | 
			
		||||
            $content = $this->blankPageIncludes($content);
 | 
			
		||||
        } else {
 | 
			
		||||
            $content = $this->parsePageIncludes($content);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return $content;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Parse the headers on the page to get a navigation menu
 | 
			
		||||
     */
 | 
			
		||||
    public function getNavigation(string $htmlContent): array
 | 
			
		||||
    {
 | 
			
		||||
        if (empty($htmlContent)) {
 | 
			
		||||
            return [];
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        libxml_use_internal_errors(true);
 | 
			
		||||
        $doc = new DOMDocument();
 | 
			
		||||
        $doc->loadHTML(mb_convert_encoding($htmlContent, 'HTML-ENTITIES', 'UTF-8'));
 | 
			
		||||
        $xPath = new DOMXPath($doc);
 | 
			
		||||
        $headers = $xPath->query("//h1|//h2|//h3|//h4|//h5|//h6");
 | 
			
		||||
 | 
			
		||||
        return $headers ? $this->headerNodesToLevelList($headers) : [];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Convert a DOMNodeList into an array of readable header attributes
 | 
			
		||||
     * with levels normalised to the lower header level.
 | 
			
		||||
     */
 | 
			
		||||
    protected function headerNodesToLevelList(DOMNodeList $nodeList): array
 | 
			
		||||
    {
 | 
			
		||||
        $tree = collect($nodeList)->map(function ($header) {
 | 
			
		||||
            $text = trim(str_replace("\xc2\xa0", '', $header->nodeValue));
 | 
			
		||||
            $text = mb_substr($text, 0, 100);
 | 
			
		||||
 | 
			
		||||
            return [
 | 
			
		||||
                'nodeName' => strtolower($header->nodeName),
 | 
			
		||||
                'level' => intval(str_replace('h', '', $header->nodeName)),
 | 
			
		||||
                'link' => '#' . $header->getAttribute('id'),
 | 
			
		||||
                'text' => $text,
 | 
			
		||||
            ];
 | 
			
		||||
        })->filter(function ($header) {
 | 
			
		||||
            return mb_strlen($header['text']) > 0;
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        // Shift headers if only smaller headers have been used
 | 
			
		||||
        $levelChange = ($tree->pluck('level')->min() - 1);
 | 
			
		||||
        $tree = $tree->map(function ($header) use ($levelChange) {
 | 
			
		||||
            $header['level'] -= ($levelChange);
 | 
			
		||||
            return $header;
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        return $tree->toArray();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Remove any page include tags within the given HTML.
 | 
			
		||||
     */
 | 
			
		||||
    protected function blankPageIncludes(string $html) : string
 | 
			
		||||
    {
 | 
			
		||||
        return preg_replace("/{{@\s?([0-9].*?)}}/", '', $html);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Parse any include tags "{{@<page_id>#section}}" to be part of the page.
 | 
			
		||||
     */
 | 
			
		||||
    protected function parsePageIncludes(string $html) : string
 | 
			
		||||
    {
 | 
			
		||||
        $matches = [];
 | 
			
		||||
        preg_match_all("/{{@\s?([0-9].*?)}}/", $html, $matches);
 | 
			
		||||
 | 
			
		||||
        foreach ($matches[1] as $index => $includeId) {
 | 
			
		||||
            $fullMatch = $matches[0][$index];
 | 
			
		||||
            $splitInclude = explode('#', $includeId, 2);
 | 
			
		||||
 | 
			
		||||
            // Get page id from reference
 | 
			
		||||
            $pageId = intval($splitInclude[0]);
 | 
			
		||||
            if (is_nan($pageId)) {
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Find page and skip this if page not found
 | 
			
		||||
            $matchedPage = Page::visible()->find($pageId);
 | 
			
		||||
            if ($matchedPage === null) {
 | 
			
		||||
                $html = str_replace($fullMatch, '', $html);
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // If we only have page id, just insert all page html and continue.
 | 
			
		||||
            if (count($splitInclude) === 1) {
 | 
			
		||||
                $html = str_replace($fullMatch, $matchedPage->html, $html);
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Create and load HTML into a document
 | 
			
		||||
            $innerContent = $this->fetchSectionOfPage($matchedPage, $splitInclude[1]);
 | 
			
		||||
            $html = str_replace($fullMatch, trim($innerContent), $html);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return $html;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Fetch the content from a specific section of the given page.
 | 
			
		||||
     */
 | 
			
		||||
    protected function fetchSectionOfPage(Page $page, string $sectionId): string
 | 
			
		||||
    {
 | 
			
		||||
        $topLevelTags = ['table', 'ul', 'ol'];
 | 
			
		||||
        $doc = new DOMDocument();
 | 
			
		||||
        libxml_use_internal_errors(true);
 | 
			
		||||
        $doc->loadHTML(mb_convert_encoding('<body>'.$page->html.'</body>', 'HTML-ENTITIES', 'UTF-8'));
 | 
			
		||||
 | 
			
		||||
        // Search included content for the id given and blank out if not exists.
 | 
			
		||||
        $matchingElem = $doc->getElementById($sectionId);
 | 
			
		||||
        if ($matchingElem === null) {
 | 
			
		||||
            return '';
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Otherwise replace the content with the found content
 | 
			
		||||
        // Checks if the top-level wrapper should be included by matching on tag types
 | 
			
		||||
        $innerContent = '';
 | 
			
		||||
        $isTopLevel = in_array(strtolower($matchingElem->nodeName), $topLevelTags);
 | 
			
		||||
        if ($isTopLevel) {
 | 
			
		||||
            $innerContent .= $doc->saveHTML($matchingElem);
 | 
			
		||||
        } else {
 | 
			
		||||
            foreach ($matchingElem->childNodes as $childNode) {
 | 
			
		||||
                $innerContent .= $doc->saveHTML($childNode);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        libxml_clear_errors();
 | 
			
		||||
 | 
			
		||||
        return $innerContent;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Escape script tags within HTML content.
 | 
			
		||||
     */
 | 
			
		||||
    protected function escapeScripts(string $html) : string
 | 
			
		||||
    {
 | 
			
		||||
        if (empty($html)) {
 | 
			
		||||
            return $html;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        libxml_use_internal_errors(true);
 | 
			
		||||
        $doc = new DOMDocument();
 | 
			
		||||
        $doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
 | 
			
		||||
        $xPath = new DOMXPath($doc);
 | 
			
		||||
 | 
			
		||||
        // Remove standard script tags
 | 
			
		||||
        $scriptElems = $xPath->query('//script');
 | 
			
		||||
        foreach ($scriptElems as $scriptElem) {
 | 
			
		||||
            $scriptElem->parentNode->removeChild($scriptElem);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Remove data or JavaScript iFrames
 | 
			
		||||
        $badIframes = $xPath->query('//*[contains(@src, \'data:\')] | //*[contains(@src, \'javascript:\')] | //*[@srcdoc]');
 | 
			
		||||
        foreach ($badIframes as $badIframe) {
 | 
			
		||||
            $badIframe->parentNode->removeChild($badIframe);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Remove 'on*' attributes
 | 
			
		||||
        $onAttributes = $xPath->query('//@*[starts-with(name(), \'on\')]');
 | 
			
		||||
        foreach ($onAttributes as $attr) {
 | 
			
		||||
            /** @var \DOMAttr $attr*/
 | 
			
		||||
            $attrName = $attr->nodeName;
 | 
			
		||||
            $attr->parentNode->removeAttribute($attrName);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $html = '';
 | 
			
		||||
        $topElems = $doc->documentElement->childNodes->item(0)->childNodes;
 | 
			
		||||
        foreach ($topElems as $child) {
 | 
			
		||||
            $html .= $doc->saveHTML($child);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return $html;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,74 @@
 | 
			
		|||
<?php namespace BookStack\Entities\Managers;
 | 
			
		||||
 | 
			
		||||
use BookStack\Entities\Page;
 | 
			
		||||
use BookStack\Entities\PageRevision;
 | 
			
		||||
use Carbon\Carbon;
 | 
			
		||||
use Illuminate\Database\Eloquent\Builder;
 | 
			
		||||
 | 
			
		||||
class PageEditActivity
 | 
			
		||||
{
 | 
			
		||||
 | 
			
		||||
    protected $page;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * PageEditActivity constructor.
 | 
			
		||||
     */
 | 
			
		||||
    public function __construct(Page $page)
 | 
			
		||||
    {
 | 
			
		||||
        $this->page = $page;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if there's active editing being performed on this page.
 | 
			
		||||
     * @return bool
 | 
			
		||||
     */
 | 
			
		||||
    public function hasActiveEditing(): bool
 | 
			
		||||
    {
 | 
			
		||||
        return $this->activePageEditingQuery(60)->count() > 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get a notification message concerning the editing activity on the page.
 | 
			
		||||
     */
 | 
			
		||||
    public function activeEditingMessage(): string
 | 
			
		||||
    {
 | 
			
		||||
        $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]);
 | 
			
		||||
        $timeMessage = trans('entities.pages_draft_edit_active.time_b', ['minCount'=> 60]);
 | 
			
		||||
        return trans('entities.pages_draft_edit_active.message', ['start' => $userMessage, 'time' => $timeMessage]);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the message to show when the user will be editing one of their drafts.
 | 
			
		||||
     * @param PageRevision $draft
 | 
			
		||||
     * @return string
 | 
			
		||||
     */
 | 
			
		||||
    public function getEditingActiveDraftMessage(PageRevision $draft): string
 | 
			
		||||
    {
 | 
			
		||||
        $message = trans('entities.pages_editing_draft_notification', ['timeDiff' => $draft->updated_at->diffForHumans()]);
 | 
			
		||||
        if ($draft->page->updated_at->timestamp <= $draft->updated_at->timestamp) {
 | 
			
		||||
            return $message;
 | 
			
		||||
        }
 | 
			
		||||
        return $message . "\n" . trans('entities.pages_draft_edited_notification');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * A query to check for active update drafts on a particular page
 | 
			
		||||
     * within the last given many minutes.
 | 
			
		||||
     */
 | 
			
		||||
    protected function activePageEditingQuery(int $withinMinutes): Builder
 | 
			
		||||
    {
 | 
			
		||||
        $checkTime = Carbon::now()->subMinutes($withinMinutes);
 | 
			
		||||
        $query = PageRevision::query()
 | 
			
		||||
            ->where('type', '=', 'update_draft')
 | 
			
		||||
            ->where('page_id', '=', $this->page->id)
 | 
			
		||||
            ->where('updated_at', '>', $this->page->updated_at)
 | 
			
		||||
            ->where('created_by', '!=', user()->id)
 | 
			
		||||
            ->where('updated_at', '>=', $checkTime)
 | 
			
		||||
            ->with('createdBy');
 | 
			
		||||
 | 
			
		||||
        return $query;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,109 @@
 | 
			
		|||
<?php namespace BookStack\Entities\Managers;
 | 
			
		||||
 | 
			
		||||
use BookStack\Entities\Book;
 | 
			
		||||
use BookStack\Entities\Bookshelf;
 | 
			
		||||
use BookStack\Entities\Chapter;
 | 
			
		||||
use BookStack\Entities\Entity;
 | 
			
		||||
use BookStack\Entities\HasCoverImage;
 | 
			
		||||
use BookStack\Entities\Page;
 | 
			
		||||
use BookStack\Exceptions\NotifyException;
 | 
			
		||||
use BookStack\Facades\Activity;
 | 
			
		||||
use BookStack\Uploads\AttachmentService;
 | 
			
		||||
use BookStack\Uploads\ImageService;
 | 
			
		||||
use Exception;
 | 
			
		||||
use Illuminate\Contracts\Container\BindingResolutionException;
 | 
			
		||||
 | 
			
		||||
class TrashCan
 | 
			
		||||
{
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Remove a bookshelf from the system.
 | 
			
		||||
     * @throws Exception
 | 
			
		||||
     */
 | 
			
		||||
    public function destroyShelf(Bookshelf $shelf)
 | 
			
		||||
    {
 | 
			
		||||
        $this->destroyCommonRelations($shelf);
 | 
			
		||||
        $shelf->delete();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Remove a book from the system.
 | 
			
		||||
     * @throws NotifyException
 | 
			
		||||
     * @throws BindingResolutionException
 | 
			
		||||
     */
 | 
			
		||||
    public function destroyBook(Book $book)
 | 
			
		||||
    {
 | 
			
		||||
        foreach ($book->pages as $page) {
 | 
			
		||||
            $this->destroyPage($page);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        foreach ($book->chapters as $chapter) {
 | 
			
		||||
            $this->destroyChapter($chapter);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $this->destroyCommonRelations($book);
 | 
			
		||||
        $book->delete();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Remove a page from the system.
 | 
			
		||||
     * @throws NotifyException
 | 
			
		||||
     */
 | 
			
		||||
    public function destroyPage(Page $page)
 | 
			
		||||
    {
 | 
			
		||||
        // Check if set as custom homepage & remove setting if not used or throw error if active
 | 
			
		||||
        $customHome = setting('app-homepage', '0:');
 | 
			
		||||
        if (intval($page->id) === intval(explode(':', $customHome)[0])) {
 | 
			
		||||
            if (setting('app-homepage-type') === 'page') {
 | 
			
		||||
                throw new NotifyException(trans('errors.page_custom_home_deletion'), $page->getUrl());
 | 
			
		||||
            }
 | 
			
		||||
            setting()->remove('app-homepage');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $this->destroyCommonRelations($page);
 | 
			
		||||
 | 
			
		||||
        // Delete Attached Files
 | 
			
		||||
        $attachmentService = app(AttachmentService::class);
 | 
			
		||||
        foreach ($page->attachments as $attachment) {
 | 
			
		||||
            $attachmentService->deleteFile($attachment);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $page->delete();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Remove a chapter from the system.
 | 
			
		||||
     * @throws Exception
 | 
			
		||||
     */
 | 
			
		||||
    public function destroyChapter(Chapter $chapter)
 | 
			
		||||
    {
 | 
			
		||||
        if (count($chapter->pages) > 0) {
 | 
			
		||||
            foreach ($chapter->pages as $page) {
 | 
			
		||||
                $page->chapter_id = 0;
 | 
			
		||||
                $page->save();
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $this->destroyCommonRelations($chapter);
 | 
			
		||||
        $chapter->delete();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Update entity relations to remove or update outstanding connections.
 | 
			
		||||
     */
 | 
			
		||||
    protected function destroyCommonRelations(Entity $entity)
 | 
			
		||||
    {
 | 
			
		||||
        Activity::removeEntity($entity);
 | 
			
		||||
        $entity->views()->delete();
 | 
			
		||||
        $entity->permissions()->delete();
 | 
			
		||||
        $entity->tags()->delete();
 | 
			
		||||
        $entity->comments()->delete();
 | 
			
		||||
        $entity->jointPermissions()->delete();
 | 
			
		||||
        $entity->searchTerms()->delete();
 | 
			
		||||
 | 
			
		||||
        if ($entity instanceof HasCoverImage && $entity->cover) {
 | 
			
		||||
            $imageService = app()->make(ImageService::class);
 | 
			
		||||
            $imageService->destroy($entity->cover);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,8 +1,25 @@
 | 
			
		|||
<?php namespace BookStack\Entities;
 | 
			
		||||
 | 
			
		||||
use BookStack\Uploads\Attachment;
 | 
			
		||||
use Illuminate\Database\Eloquent\Builder;
 | 
			
		||||
use Illuminate\Database\Eloquent\Collection;
 | 
			
		||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
 | 
			
		||||
use Illuminate\Database\Eloquent\Relations\HasMany;
 | 
			
		||||
use Permissions;
 | 
			
		||||
 | 
			
		||||
class Page extends Entity
 | 
			
		||||
/**
 | 
			
		||||
 * Class Page
 | 
			
		||||
 * @property int $chapter_id
 | 
			
		||||
 * @property string $html
 | 
			
		||||
 * @property string $markdown
 | 
			
		||||
 * @property string $text
 | 
			
		||||
 * @property bool $template
 | 
			
		||||
 * @property bool $draft
 | 
			
		||||
 * @property int $revision_count
 | 
			
		||||
 * @property Chapter $chapter
 | 
			
		||||
 * @property Collection $attachments
 | 
			
		||||
 */
 | 
			
		||||
class Page extends BookChild
 | 
			
		||||
{
 | 
			
		||||
    protected $fillable = ['name', 'html', 'priority', 'markdown'];
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -11,12 +28,12 @@ class Page extends Entity
 | 
			
		|||
    public $textField = 'text';
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the morph class for this model.
 | 
			
		||||
     * @return string
 | 
			
		||||
     * Get the entities that are visible to the current user.
 | 
			
		||||
     */
 | 
			
		||||
    public function getMorphClass()
 | 
			
		||||
    public function scopeVisible(Builder $query)
 | 
			
		||||
    {
 | 
			
		||||
        return 'BookStack\\Page';
 | 
			
		||||
        $query = Permissions::enforceDraftVisiblityOnQuery($query);
 | 
			
		||||
        return parent::scopeVisible($query);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
| 
						 | 
				
			
			@ -30,27 +47,17 @@ class Page extends Entity
 | 
			
		|||
        return $array;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the book this page sits in.
 | 
			
		||||
     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
 | 
			
		||||
     */
 | 
			
		||||
    public function book()
 | 
			
		||||
    {
 | 
			
		||||
        return $this->belongsTo(Book::class);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the parent item
 | 
			
		||||
     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
 | 
			
		||||
     */
 | 
			
		||||
    public function parent()
 | 
			
		||||
    public function parent(): Entity
 | 
			
		||||
    {
 | 
			
		||||
        return $this->chapter_id ? $this->chapter() : $this->book();
 | 
			
		||||
        return $this->chapter_id ? $this->chapter : $this->book;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the chapter that this page is in, If applicable.
 | 
			
		||||
     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
 | 
			
		||||
     * @return BelongsTo
 | 
			
		||||
     */
 | 
			
		||||
    public function chapter()
 | 
			
		||||
    {
 | 
			
		||||
| 
						 | 
				
			
			@ -72,12 +79,12 @@ class Page extends Entity
 | 
			
		|||
     */
 | 
			
		||||
    public function revisions()
 | 
			
		||||
    {
 | 
			
		||||
        return $this->hasMany(PageRevision::class)->where('type', '=', 'version')->orderBy('created_at', 'desc');
 | 
			
		||||
        return $this->hasMany(PageRevision::class)->where('type', '=', 'version')->orderBy('created_at', 'desc')->orderBy('id', 'desc');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the attachments assigned to this page.
 | 
			
		||||
     * @return \Illuminate\Database\Eloquent\Relations\HasMany
 | 
			
		||||
     * @return HasMany
 | 
			
		||||
     */
 | 
			
		||||
    public function attachments()
 | 
			
		||||
    {
 | 
			
		||||
| 
						 | 
				
			
			@ -95,27 +102,17 @@ class Page extends Entity
 | 
			
		|||
        $midText = $this->draft ? '/draft/' : '/page/';
 | 
			
		||||
        $idComponent = $this->draft ? $this->id : urlencode($this->slug);
 | 
			
		||||
 | 
			
		||||
        $url = '/books/' . urlencode($bookSlug) . $midText . $idComponent;
 | 
			
		||||
        if ($path !== false) {
 | 
			
		||||
            return url('/books/' . urlencode($bookSlug) . $midText . $idComponent . '/' . trim($path, '/'));
 | 
			
		||||
            $url .= '/' . trim($path, '/');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return url('/books/' . urlencode($bookSlug) . $midText . $idComponent);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Return a generalised, common raw query that can be 'unioned' across entities.
 | 
			
		||||
     * @param bool $withContent
 | 
			
		||||
     * @return string
 | 
			
		||||
     */
 | 
			
		||||
    public function entityRawQuery($withContent = false)
 | 
			
		||||
    {
 | 
			
		||||
        $htmlQuery = $withContent ? 'html' : "'' as html";
 | 
			
		||||
        return "'BookStack\\\\Page' as entity_type, id, id as entity_id, slug, name, {$this->textField} as text, {$htmlQuery}, book_id, priority, chapter_id, draft, created_by, updated_by, updated_at, created_at";
 | 
			
		||||
        return url($url);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the current revision for the page if existing
 | 
			
		||||
     * @return \BookStack\Entities\PageRevision|null
 | 
			
		||||
     * @return PageRevision|null
 | 
			
		||||
     */
 | 
			
		||||
    public function getCurrentRevision()
 | 
			
		||||
    {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,7 +2,21 @@
 | 
			
		|||
 | 
			
		||||
use BookStack\Auth\User;
 | 
			
		||||
use BookStack\Model;
 | 
			
		||||
use Carbon\Carbon;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Class PageRevision
 | 
			
		||||
 * @property int $page_id
 | 
			
		||||
 * @property string $slug
 | 
			
		||||
 * @property string $book_slug
 | 
			
		||||
 * @property int $created_by
 | 
			
		||||
 * @property Carbon $created_at
 | 
			
		||||
 * @property string $type
 | 
			
		||||
 * @property string $summary
 | 
			
		||||
 * @property string $markdown
 | 
			
		||||
 * @property string $html
 | 
			
		||||
 * @property int $revision_number
 | 
			
		||||
 */
 | 
			
		||||
class PageRevision extends Model
 | 
			
		||||
{
 | 
			
		||||
    protected $fillable = ['name', 'html', 'text', 'markdown', 'summary'];
 | 
			
		||||
| 
						 | 
				
			
			@ -41,13 +55,18 @@ class PageRevision extends Model
 | 
			
		|||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the previous revision for the same page if existing
 | 
			
		||||
     * @return \BookStack\PageRevision|null
 | 
			
		||||
     * @return \BookStack\Entities\PageRevision|null
 | 
			
		||||
     */
 | 
			
		||||
    public function getPrevious()
 | 
			
		||||
    {
 | 
			
		||||
        if ($id = static::where('page_id', '=', $this->page_id)->where('id', '<', $this->id)->max('id')) {
 | 
			
		||||
            return static::find($id);
 | 
			
		||||
        $id = static::newQuery()->where('page_id', '=', $this->page_id)
 | 
			
		||||
            ->where('id', '<', $this->id)
 | 
			
		||||
            ->max('id');
 | 
			
		||||
 | 
			
		||||
        if ($id) {
 | 
			
		||||
            return static::query()->find($id);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,119 @@
 | 
			
		|||
<?php
 | 
			
		||||
 | 
			
		||||
namespace BookStack\Entities\Repos;
 | 
			
		||||
 | 
			
		||||
use BookStack\Actions\TagRepo;
 | 
			
		||||
use BookStack\Entities\Book;
 | 
			
		||||
use BookStack\Entities\Entity;
 | 
			
		||||
use BookStack\Entities\HasCoverImage;
 | 
			
		||||
use BookStack\Exceptions\ImageUploadException;
 | 
			
		||||
use BookStack\Uploads\ImageRepo;
 | 
			
		||||
use Illuminate\Http\UploadedFile;
 | 
			
		||||
use Illuminate\Support\Collection;
 | 
			
		||||
 | 
			
		||||
class BaseRepo
 | 
			
		||||
{
 | 
			
		||||
 | 
			
		||||
    protected $tagRepo;
 | 
			
		||||
    protected $imageRepo;
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * BaseRepo constructor.
 | 
			
		||||
     * @param $tagRepo
 | 
			
		||||
     */
 | 
			
		||||
    public function __construct(TagRepo $tagRepo, ImageRepo $imageRepo)
 | 
			
		||||
    {
 | 
			
		||||
        $this->tagRepo = $tagRepo;
 | 
			
		||||
        $this->imageRepo = $imageRepo;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Create a new entity in the system
 | 
			
		||||
     */
 | 
			
		||||
    public function create(Entity $entity, array $input)
 | 
			
		||||
    {
 | 
			
		||||
        $entity->fill($input);
 | 
			
		||||
        $entity->forceFill([
 | 
			
		||||
            'created_by' => user()->id,
 | 
			
		||||
            'updated_by' => user()->id,
 | 
			
		||||
        ]);
 | 
			
		||||
        $entity->refreshSlug();
 | 
			
		||||
        $entity->save();
 | 
			
		||||
 | 
			
		||||
        if (isset($input['tags'])) {
 | 
			
		||||
            $this->tagRepo->saveTagsToEntity($entity, $input['tags']);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $entity->rebuildPermissions();
 | 
			
		||||
        $entity->indexForSearch();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Update the given entity.
 | 
			
		||||
     */
 | 
			
		||||
    public function update(Entity $entity, array $input)
 | 
			
		||||
    {
 | 
			
		||||
        $entity->fill($input);
 | 
			
		||||
        $entity->updated_by = user()->id;
 | 
			
		||||
 | 
			
		||||
        if ($entity->isDirty('name')) {
 | 
			
		||||
            $entity->refreshSlug();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $entity->save();
 | 
			
		||||
 | 
			
		||||
        if (isset($input['tags'])) {
 | 
			
		||||
            $this->tagRepo->saveTagsToEntity($entity, $input['tags']);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $entity->rebuildPermissions();
 | 
			
		||||
        $entity->indexForSearch();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Update the given items' cover image, or clear it.
 | 
			
		||||
     * @throws ImageUploadException
 | 
			
		||||
     * @throws \Exception
 | 
			
		||||
     */
 | 
			
		||||
    public function updateCoverImage(HasCoverImage $entity, UploadedFile $coverImage = null, bool $removeImage = false)
 | 
			
		||||
    {
 | 
			
		||||
        if ($coverImage) {
 | 
			
		||||
            $this->imageRepo->destroyImage($entity->cover);
 | 
			
		||||
            $image = $this->imageRepo->saveNew($coverImage, 'cover_book', $entity->id, 512, 512, true);
 | 
			
		||||
            $entity->cover()->associate($image);
 | 
			
		||||
            $entity->save();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if ($removeImage) {
 | 
			
		||||
            $this->imageRepo->destroyImage($entity->cover);
 | 
			
		||||
            $entity->image_id = 0;
 | 
			
		||||
            $entity->save();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Update the permissions of an entity.
 | 
			
		||||
     */
 | 
			
		||||
    public function updatePermissions(Entity $entity, bool $restricted, Collection $permissions = null)
 | 
			
		||||
    {
 | 
			
		||||
        $entity->restricted = $restricted;
 | 
			
		||||
        $entity->permissions()->delete();
 | 
			
		||||
 | 
			
		||||
        if (!is_null($permissions)) {
 | 
			
		||||
            $entityPermissionData = $permissions->flatMap(function ($restrictions, $roleId) {
 | 
			
		||||
                return collect($restrictions)->keys()->map(function ($action) use ($roleId) {
 | 
			
		||||
                    return [
 | 
			
		||||
                        'role_id' => $roleId,
 | 
			
		||||
                        'action' => strtolower($action),
 | 
			
		||||
                    ] ;
 | 
			
		||||
                });
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            $entity->permissions()->createMany($entityPermissionData);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $entity->save();
 | 
			
		||||
        $entity->rebuildPermissions();
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,134 @@
 | 
			
		|||
<?php namespace BookStack\Entities\Repos;
 | 
			
		||||
 | 
			
		||||
use BookStack\Actions\TagRepo;
 | 
			
		||||
use BookStack\Entities\Book;
 | 
			
		||||
use BookStack\Entities\Managers\TrashCan;
 | 
			
		||||
use BookStack\Exceptions\ImageUploadException;
 | 
			
		||||
use BookStack\Exceptions\NotFoundException;
 | 
			
		||||
use BookStack\Exceptions\NotifyException;
 | 
			
		||||
use BookStack\Uploads\ImageRepo;
 | 
			
		||||
use Exception;
 | 
			
		||||
use Illuminate\Contracts\Container\BindingResolutionException;
 | 
			
		||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
 | 
			
		||||
use Illuminate\Http\UploadedFile;
 | 
			
		||||
use Illuminate\Support\Collection;
 | 
			
		||||
 | 
			
		||||
class BookRepo
 | 
			
		||||
{
 | 
			
		||||
 | 
			
		||||
    protected $baseRepo;
 | 
			
		||||
    protected $tagRepo;
 | 
			
		||||
    protected $imageRepo;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * BookRepo constructor.
 | 
			
		||||
     * @param $tagRepo
 | 
			
		||||
     */
 | 
			
		||||
    public function __construct(BaseRepo $baseRepo, TagRepo $tagRepo, ImageRepo $imageRepo)
 | 
			
		||||
    {
 | 
			
		||||
        $this->baseRepo = $baseRepo;
 | 
			
		||||
        $this->tagRepo = $tagRepo;
 | 
			
		||||
        $this->imageRepo = $imageRepo;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get all books in a paginated format.
 | 
			
		||||
     */
 | 
			
		||||
    public function getAllPaginated(int $count = 20, string $sort = 'name', string $order = 'asc'): LengthAwarePaginator
 | 
			
		||||
    {
 | 
			
		||||
        return Book::visible()->orderBy($sort, $order)->paginate($count);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the books that were most recently viewed by this user.
 | 
			
		||||
     */
 | 
			
		||||
    public function getRecentlyViewed(int $count = 20): Collection
 | 
			
		||||
    {
 | 
			
		||||
        return Book::visible()->withLastView()
 | 
			
		||||
            ->having('last_viewed_at', '>', 0)
 | 
			
		||||
            ->orderBy('last_viewed_at', 'desc')
 | 
			
		||||
            ->take($count)->get();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the most popular books in the system.
 | 
			
		||||
     */
 | 
			
		||||
    public function getPopular(int $count = 20): Collection
 | 
			
		||||
    {
 | 
			
		||||
        return Book::visible()->withViewCount()
 | 
			
		||||
            ->having('view_count', '>', 0)
 | 
			
		||||
            ->orderBy('view_count', 'desc')
 | 
			
		||||
            ->take($count)->get();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the most recently created books from the system.
 | 
			
		||||
     */
 | 
			
		||||
    public function getRecentlyCreated(int $count = 20): Collection
 | 
			
		||||
    {
 | 
			
		||||
        return Book::visible()->orderBy('created_at', 'desc')
 | 
			
		||||
            ->take($count)->get();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get a book by its slug.
 | 
			
		||||
     */
 | 
			
		||||
    public function getBySlug(string $slug): Book
 | 
			
		||||
    {
 | 
			
		||||
        $book = Book::visible()->where('slug', '=', $slug)->first();
 | 
			
		||||
 | 
			
		||||
        if ($book === null) {
 | 
			
		||||
            throw new NotFoundException(trans('errors.book_not_found'));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return $book;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Create a new book in the system
 | 
			
		||||
     */
 | 
			
		||||
    public function create(array $input): Book
 | 
			
		||||
    {
 | 
			
		||||
        $book = new Book();
 | 
			
		||||
        $this->baseRepo->create($book, $input);
 | 
			
		||||
        return $book;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Update the given book.
 | 
			
		||||
     */
 | 
			
		||||
    public function update(Book $book, array $input): Book
 | 
			
		||||
    {
 | 
			
		||||
        $this->baseRepo->update($book, $input);
 | 
			
		||||
        return $book;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Update the given book's cover image, or clear it.
 | 
			
		||||
     * @throws ImageUploadException
 | 
			
		||||
     * @throws Exception
 | 
			
		||||
     */
 | 
			
		||||
    public function updateCoverImage(Book $book, UploadedFile $coverImage = null, bool $removeImage = false)
 | 
			
		||||
    {
 | 
			
		||||
        $this->baseRepo->updateCoverImage($book, $coverImage, $removeImage);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Update the permissions of a book.
 | 
			
		||||
     */
 | 
			
		||||
    public function updatePermissions(Book $book, bool $restricted, Collection $permissions = null)
 | 
			
		||||
    {
 | 
			
		||||
        $this->baseRepo->updatePermissions($book, $restricted, $permissions);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Remove a book from the system.
 | 
			
		||||
     * @throws NotifyException
 | 
			
		||||
     * @throws BindingResolutionException
 | 
			
		||||
     */
 | 
			
		||||
    public function destroy(Book $book)
 | 
			
		||||
    {
 | 
			
		||||
        $trashCan = new TrashCan();
 | 
			
		||||
        $trashCan->destroyBook($book);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,173 @@
 | 
			
		|||
<?php namespace BookStack\Entities\Repos;
 | 
			
		||||
 | 
			
		||||
use BookStack\Entities\Book;
 | 
			
		||||
use BookStack\Entities\Bookshelf;
 | 
			
		||||
use BookStack\Entities\Managers\TrashCan;
 | 
			
		||||
use BookStack\Exceptions\ImageUploadException;
 | 
			
		||||
use BookStack\Exceptions\NotFoundException;
 | 
			
		||||
use Exception;
 | 
			
		||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
 | 
			
		||||
use Illuminate\Http\UploadedFile;
 | 
			
		||||
use Illuminate\Support\Collection;
 | 
			
		||||
 | 
			
		||||
class BookshelfRepo
 | 
			
		||||
{
 | 
			
		||||
    protected $baseRepo;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * BookshelfRepo constructor.
 | 
			
		||||
     * @param $baseRepo
 | 
			
		||||
     */
 | 
			
		||||
    public function __construct(BaseRepo $baseRepo)
 | 
			
		||||
    {
 | 
			
		||||
        $this->baseRepo = $baseRepo;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get all bookshelves in a paginated format.
 | 
			
		||||
     */
 | 
			
		||||
    public function getAllPaginated(int $count = 20, string $sort = 'name', string $order = 'asc'): LengthAwarePaginator
 | 
			
		||||
    {
 | 
			
		||||
        return Bookshelf::visible()->with('visibleBooks')
 | 
			
		||||
            ->orderBy($sort, $order)->paginate($count);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the bookshelves that were most recently viewed by this user.
 | 
			
		||||
     */
 | 
			
		||||
    public function getRecentlyViewed(int $count = 20): Collection
 | 
			
		||||
    {
 | 
			
		||||
        return Bookshelf::visible()->withLastView()
 | 
			
		||||
            ->having('last_viewed_at', '>', 0)
 | 
			
		||||
            ->orderBy('last_viewed_at', 'desc')
 | 
			
		||||
            ->take($count)->get();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the most popular bookshelves in the system.
 | 
			
		||||
     */
 | 
			
		||||
    public function getPopular(int $count = 20): Collection
 | 
			
		||||
    {
 | 
			
		||||
        return Bookshelf::visible()->withViewCount()
 | 
			
		||||
            ->having('view_count', '>', 0)
 | 
			
		||||
            ->orderBy('view_count', 'desc')
 | 
			
		||||
            ->take($count)->get();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the most recently created bookshelves from the system.
 | 
			
		||||
     */
 | 
			
		||||
    public function getRecentlyCreated(int $count = 20): Collection
 | 
			
		||||
    {
 | 
			
		||||
        return Bookshelf::visible()->orderBy('created_at', 'desc')
 | 
			
		||||
            ->take($count)->get();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get a shelf by its slug.
 | 
			
		||||
     */
 | 
			
		||||
    public function getBySlug(string $slug): Bookshelf
 | 
			
		||||
    {
 | 
			
		||||
        $shelf = Bookshelf::visible()->where('slug', '=', $slug)->first();
 | 
			
		||||
 | 
			
		||||
        if ($shelf === null) {
 | 
			
		||||
            throw new NotFoundException(trans('errors.bookshelf_not_found'));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return $shelf;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Create a new shelf in the system.
 | 
			
		||||
     */
 | 
			
		||||
    public function create(array $input, array $bookIds): Bookshelf
 | 
			
		||||
    {
 | 
			
		||||
        $shelf = new Bookshelf();
 | 
			
		||||
        $this->baseRepo->create($shelf, $input);
 | 
			
		||||
        $this->updateBooks($shelf, $bookIds);
 | 
			
		||||
        return $shelf;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Create a new shelf in the system.
 | 
			
		||||
     */
 | 
			
		||||
    public function update(Bookshelf $shelf, array $input, array $bookIds): Bookshelf
 | 
			
		||||
    {
 | 
			
		||||
        $this->baseRepo->update($shelf, $input);
 | 
			
		||||
        $this->updateBooks($shelf, $bookIds);
 | 
			
		||||
        return $shelf;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Update which books are assigned to this shelf by
 | 
			
		||||
     * syncing the given book ids.
 | 
			
		||||
     * Function ensures the books are visible to the current user and existing.
 | 
			
		||||
     */
 | 
			
		||||
    protected function updateBooks(Bookshelf $shelf, array $bookIds)
 | 
			
		||||
    {
 | 
			
		||||
        $numericIDs = collect($bookIds)->map(function ($id) {
 | 
			
		||||
            return intval($id);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        $syncData = Book::visible()
 | 
			
		||||
            ->whereIn('id', $bookIds)
 | 
			
		||||
            ->get(['id'])->pluck('id')->mapWithKeys(function ($bookId) use ($numericIDs) {
 | 
			
		||||
                return [$bookId => ['order' => $numericIDs->search($bookId)]];
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
        $shelf->books()->sync($syncData);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Update the given shelf cover image, or clear it.
 | 
			
		||||
     * @throws ImageUploadException
 | 
			
		||||
     * @throws Exception
 | 
			
		||||
     */
 | 
			
		||||
    public function updateCoverImage(Bookshelf $shelf, UploadedFile $coverImage = null, bool $removeImage = false)
 | 
			
		||||
    {
 | 
			
		||||
        $this->baseRepo->updateCoverImage($shelf, $coverImage, $removeImage);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Update the permissions of a bookshelf.
 | 
			
		||||
     */
 | 
			
		||||
    public function updatePermissions(Bookshelf $shelf, bool $restricted, Collection $permissions = null)
 | 
			
		||||
    {
 | 
			
		||||
        $this->baseRepo->updatePermissions($shelf, $restricted, $permissions);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Copy down the permissions of the given shelf to all child books.
 | 
			
		||||
     */
 | 
			
		||||
    public function copyDownPermissions(Bookshelf $shelf, $checkUserPermissions = true): int
 | 
			
		||||
    {
 | 
			
		||||
        $shelfPermissions = $shelf->permissions()->get(['role_id', 'action'])->toArray();
 | 
			
		||||
        $shelfBooks = $shelf->books()->get(['id', 'restricted']);
 | 
			
		||||
        $updatedBookCount = 0;
 | 
			
		||||
 | 
			
		||||
        /** @var Book $book */
 | 
			
		||||
        foreach ($shelfBooks as $book) {
 | 
			
		||||
            if ($checkUserPermissions && !userCan('restrictions-manage', $book)) {
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
            $book->permissions()->delete();
 | 
			
		||||
            $book->restricted = $shelf->restricted;
 | 
			
		||||
            $book->permissions()->createMany($shelfPermissions);
 | 
			
		||||
            $book->save();
 | 
			
		||||
            $book->rebuildPermissions();
 | 
			
		||||
            $updatedBookCount++;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return $updatedBookCount;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Remove a bookshelf from the system.
 | 
			
		||||
     * @throws Exception
 | 
			
		||||
     */
 | 
			
		||||
    public function destroy(Bookshelf $shelf)
 | 
			
		||||
    {
 | 
			
		||||
        $trashCan = new TrashCan();
 | 
			
		||||
        $trashCan->destroyShelf($shelf);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,108 @@
 | 
			
		|||
<?php namespace BookStack\Entities\Repos;
 | 
			
		||||
 | 
			
		||||
use BookStack\Entities\Book;
 | 
			
		||||
use BookStack\Entities\Chapter;
 | 
			
		||||
use BookStack\Entities\Managers\BookContents;
 | 
			
		||||
use BookStack\Entities\Managers\TrashCan;
 | 
			
		||||
use BookStack\Exceptions\MoveOperationException;
 | 
			
		||||
use BookStack\Exceptions\NotFoundException;
 | 
			
		||||
use BookStack\Exceptions\NotifyException;
 | 
			
		||||
use Exception;
 | 
			
		||||
use Illuminate\Contracts\Container\BindingResolutionException;
 | 
			
		||||
use Illuminate\Database\Eloquent\Builder;
 | 
			
		||||
use Illuminate\Support\Collection;
 | 
			
		||||
 | 
			
		||||
class ChapterRepo
 | 
			
		||||
{
 | 
			
		||||
 | 
			
		||||
    protected $baseRepo;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * ChapterRepo constructor.
 | 
			
		||||
     * @param $baseRepo
 | 
			
		||||
     */
 | 
			
		||||
    public function __construct(BaseRepo $baseRepo)
 | 
			
		||||
    {
 | 
			
		||||
        $this->baseRepo = $baseRepo;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get a chapter via the slug.
 | 
			
		||||
     * @throws NotFoundException
 | 
			
		||||
     */
 | 
			
		||||
    public function getBySlug(string $bookSlug, string $chapterSlug): Chapter
 | 
			
		||||
    {
 | 
			
		||||
        $chapter = Chapter::visible()->whereSlugs($bookSlug, $chapterSlug)->first();
 | 
			
		||||
 | 
			
		||||
        if ($chapter === null) {
 | 
			
		||||
            throw new NotFoundException(trans('errors.chapter_not_found'));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return $chapter;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Create a new chapter in the system.
 | 
			
		||||
     */
 | 
			
		||||
    public function create(array $input, Book $parentBook): Chapter
 | 
			
		||||
    {
 | 
			
		||||
        $chapter = new Chapter();
 | 
			
		||||
        $chapter->book_id = $parentBook->id;
 | 
			
		||||
        $chapter->priority = (new BookContents($parentBook))->getLastPriority() + 1;
 | 
			
		||||
        $this->baseRepo->create($chapter, $input);
 | 
			
		||||
        return $chapter;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Update the given chapter.
 | 
			
		||||
     */
 | 
			
		||||
    public function update(Chapter $chapter, array $input): Chapter
 | 
			
		||||
    {
 | 
			
		||||
        $this->baseRepo->update($chapter, $input);
 | 
			
		||||
        return $chapter;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Update the permissions of a chapter.
 | 
			
		||||
     */
 | 
			
		||||
    public function updatePermissions(Chapter $chapter, bool $restricted, Collection $permissions = null)
 | 
			
		||||
    {
 | 
			
		||||
        $this->baseRepo->updatePermissions($chapter, $restricted, $permissions);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Remove a chapter from the system.
 | 
			
		||||
     * @throws Exception
 | 
			
		||||
     */
 | 
			
		||||
    public function destroy(Chapter $chapter)
 | 
			
		||||
    {
 | 
			
		||||
        $trashCan = new TrashCan();
 | 
			
		||||
        $trashCan->destroyChapter($chapter);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Move the given chapter into a new parent book.
 | 
			
		||||
     * The $parentIdentifier must be a string of the following format:
 | 
			
		||||
     * 'book:<id>' (book:5)
 | 
			
		||||
     * @throws MoveOperationException
 | 
			
		||||
     */
 | 
			
		||||
    public function move(Chapter $chapter, string $parentIdentifier): Book
 | 
			
		||||
    {
 | 
			
		||||
        $stringExploded = explode(':', $parentIdentifier);
 | 
			
		||||
        $entityType = $stringExploded[0];
 | 
			
		||||
        $entityId = intval($stringExploded[1]);
 | 
			
		||||
 | 
			
		||||
        if ($entityType !== 'book') {
 | 
			
		||||
            throw new MoveOperationException('Chapters can only be moved into books');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $parent = Book::visible()->where('id', '=', $entityId)->first();
 | 
			
		||||
        if ($parent === null) {
 | 
			
		||||
            throw new MoveOperationException('Book to move chapter into not found');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $chapter->changeBook($parent->id);
 | 
			
		||||
        $chapter->rebuildPermissions();
 | 
			
		||||
        return $parent;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,924 +0,0 @@
 | 
			
		|||
<?php namespace BookStack\Entities\Repos;
 | 
			
		||||
 | 
			
		||||
use Activity;
 | 
			
		||||
use BookStack\Actions\TagRepo;
 | 
			
		||||
use BookStack\Actions\ViewService;
 | 
			
		||||
use BookStack\Auth\Permissions\PermissionService;
 | 
			
		||||
use BookStack\Auth\User;
 | 
			
		||||
use BookStack\Entities\Book;
 | 
			
		||||
use BookStack\Entities\Bookshelf;
 | 
			
		||||
use BookStack\Entities\Chapter;
 | 
			
		||||
use BookStack\Entities\Entity;
 | 
			
		||||
use BookStack\Entities\EntityProvider;
 | 
			
		||||
use BookStack\Entities\Page;
 | 
			
		||||
use BookStack\Entities\SearchService;
 | 
			
		||||
use BookStack\Exceptions\NotFoundException;
 | 
			
		||||
use BookStack\Exceptions\NotifyException;
 | 
			
		||||
use BookStack\Uploads\AttachmentService;
 | 
			
		||||
use DOMDocument;
 | 
			
		||||
use DOMNode;
 | 
			
		||||
use DOMXPath;
 | 
			
		||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
 | 
			
		||||
use Illuminate\Database\Eloquent\Builder;
 | 
			
		||||
use Illuminate\Http\Request;
 | 
			
		||||
use Illuminate\Support\Collection;
 | 
			
		||||
use Throwable;
 | 
			
		||||
 | 
			
		||||
class EntityRepo
 | 
			
		||||
{
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @var EntityProvider
 | 
			
		||||
     */
 | 
			
		||||
    protected $entityProvider;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @var PermissionService
 | 
			
		||||
     */
 | 
			
		||||
    protected $permissionService;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @var ViewService
 | 
			
		||||
     */
 | 
			
		||||
    protected $viewService;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @var TagRepo
 | 
			
		||||
     */
 | 
			
		||||
    protected $tagRepo;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @var SearchService
 | 
			
		||||
     */
 | 
			
		||||
    protected $searchService;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * EntityRepo constructor.
 | 
			
		||||
     * @param EntityProvider $entityProvider
 | 
			
		||||
     * @param ViewService $viewService
 | 
			
		||||
     * @param PermissionService $permissionService
 | 
			
		||||
     * @param TagRepo $tagRepo
 | 
			
		||||
     * @param SearchService $searchService
 | 
			
		||||
     */
 | 
			
		||||
    public function __construct(
 | 
			
		||||
        EntityProvider $entityProvider,
 | 
			
		||||
        ViewService $viewService,
 | 
			
		||||
        PermissionService $permissionService,
 | 
			
		||||
        TagRepo $tagRepo,
 | 
			
		||||
        SearchService $searchService
 | 
			
		||||
    ) {
 | 
			
		||||
        $this->entityProvider = $entityProvider;
 | 
			
		||||
        $this->viewService = $viewService;
 | 
			
		||||
        $this->permissionService = $permissionService;
 | 
			
		||||
        $this->tagRepo = $tagRepo;
 | 
			
		||||
        $this->searchService = $searchService;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Base query for searching entities via permission system
 | 
			
		||||
     * @param string $type
 | 
			
		||||
     * @param bool $allowDrafts
 | 
			
		||||
     * @param string $permission
 | 
			
		||||
     * @return \Illuminate\Database\Query\Builder
 | 
			
		||||
     */
 | 
			
		||||
    protected function entityQuery($type, $allowDrafts = false, $permission = 'view')
 | 
			
		||||
    {
 | 
			
		||||
        $q = $this->permissionService->enforceEntityRestrictions($type, $this->entityProvider->get($type), $permission);
 | 
			
		||||
        if (strtolower($type) === 'page' && !$allowDrafts) {
 | 
			
		||||
            $q = $q->where('draft', '=', false);
 | 
			
		||||
        }
 | 
			
		||||
        return $q;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if an entity with the given id exists.
 | 
			
		||||
     * @param $type
 | 
			
		||||
     * @param $id
 | 
			
		||||
     * @return bool
 | 
			
		||||
     */
 | 
			
		||||
    public function exists($type, $id)
 | 
			
		||||
    {
 | 
			
		||||
        return $this->entityQuery($type)->where('id', '=', $id)->exists();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get an entity by ID
 | 
			
		||||
     * @param string $type
 | 
			
		||||
     * @param integer $id
 | 
			
		||||
     * @param bool $allowDrafts
 | 
			
		||||
     * @param bool $ignorePermissions
 | 
			
		||||
     * @return Entity
 | 
			
		||||
     */
 | 
			
		||||
    public function getById($type, $id, $allowDrafts = false, $ignorePermissions = false)
 | 
			
		||||
    {
 | 
			
		||||
        $query = $this->entityQuery($type, $allowDrafts);
 | 
			
		||||
 | 
			
		||||
        if ($ignorePermissions) {
 | 
			
		||||
            $query = $this->entityProvider->get($type)->newQuery();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return $query->find($id);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @param string $type
 | 
			
		||||
     * @param []int $ids
 | 
			
		||||
     * @param bool $allowDrafts
 | 
			
		||||
     * @param bool $ignorePermissions
 | 
			
		||||
     * @return Builder[]|\Illuminate\Database\Eloquent\Collection|Collection
 | 
			
		||||
     */
 | 
			
		||||
    public function getManyById($type, $ids, $allowDrafts = false, $ignorePermissions = false)
 | 
			
		||||
    {
 | 
			
		||||
        $query = $this->entityQuery($type, $allowDrafts);
 | 
			
		||||
 | 
			
		||||
        if ($ignorePermissions) {
 | 
			
		||||
            $query = $this->entityProvider->get($type)->newQuery();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return $query->whereIn('id', $ids)->get();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get an entity by its url slug.
 | 
			
		||||
     * @param string $type
 | 
			
		||||
     * @param string $slug
 | 
			
		||||
     * @param string|bool $bookSlug
 | 
			
		||||
     * @return Entity
 | 
			
		||||
     * @throws NotFoundException
 | 
			
		||||
     */
 | 
			
		||||
    public function getBySlug($type, $slug, $bookSlug = false)
 | 
			
		||||
    {
 | 
			
		||||
        $q = $this->entityQuery($type)->where('slug', '=', $slug);
 | 
			
		||||
 | 
			
		||||
        if (strtolower($type) === 'chapter' || strtolower($type) === 'page') {
 | 
			
		||||
            $q = $q->where('book_id', '=', function ($query) use ($bookSlug) {
 | 
			
		||||
                $query->select('id')
 | 
			
		||||
                    ->from($this->entityProvider->book->getTable())
 | 
			
		||||
                    ->where('slug', '=', $bookSlug)->limit(1);
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
        $entity = $q->first();
 | 
			
		||||
        if ($entity === null) {
 | 
			
		||||
            throw new NotFoundException(trans('errors.' . strtolower($type) . '_not_found'));
 | 
			
		||||
        }
 | 
			
		||||
        return $entity;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get all entities of a type with the given permission, limited by count unless count is false.
 | 
			
		||||
     * @param string $type
 | 
			
		||||
     * @param integer|bool $count
 | 
			
		||||
     * @param string $permission
 | 
			
		||||
     * @return Collection
 | 
			
		||||
     */
 | 
			
		||||
    public function getAll($type, $count = 20, $permission = 'view')
 | 
			
		||||
    {
 | 
			
		||||
        $q = $this->entityQuery($type, false, $permission)->orderBy('name', 'asc');
 | 
			
		||||
        if ($count !== false) {
 | 
			
		||||
            $q = $q->take($count);
 | 
			
		||||
        }
 | 
			
		||||
        return $q->get();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get all entities in a paginated format
 | 
			
		||||
     * @param $type
 | 
			
		||||
     * @param int $count
 | 
			
		||||
     * @param string $sort
 | 
			
		||||
     * @param string $order
 | 
			
		||||
     * @param null|callable $queryAddition
 | 
			
		||||
     * @return LengthAwarePaginator
 | 
			
		||||
     */
 | 
			
		||||
    public function getAllPaginated($type, int $count = 10, string $sort = 'name', string $order = 'asc', $queryAddition = null)
 | 
			
		||||
    {
 | 
			
		||||
        $query = $this->entityQuery($type);
 | 
			
		||||
        $query = $this->addSortToQuery($query, $sort, $order);
 | 
			
		||||
        if ($queryAddition) {
 | 
			
		||||
            $queryAddition($query);
 | 
			
		||||
        }
 | 
			
		||||
        return $query->paginate($count);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Add sorting operations to an entity query.
 | 
			
		||||
     * @param Builder $query
 | 
			
		||||
     * @param string $sort
 | 
			
		||||
     * @param string $order
 | 
			
		||||
     * @return Builder
 | 
			
		||||
     */
 | 
			
		||||
    protected function addSortToQuery(Builder $query, string $sort = 'name', string $order = 'asc')
 | 
			
		||||
    {
 | 
			
		||||
        $order = ($order === 'asc') ? 'asc' : 'desc';
 | 
			
		||||
        $propertySorts = ['name', 'created_at', 'updated_at'];
 | 
			
		||||
 | 
			
		||||
        if (in_array($sort, $propertySorts)) {
 | 
			
		||||
            return $query->orderBy($sort, $order);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return $query;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the most recently created entities of the given type.
 | 
			
		||||
     * @param string $type
 | 
			
		||||
     * @param int $count
 | 
			
		||||
     * @param int $page
 | 
			
		||||
     * @param bool|callable $additionalQuery
 | 
			
		||||
     * @return Collection
 | 
			
		||||
     */
 | 
			
		||||
    public function getRecentlyCreated($type, $count = 20, $page = 0, $additionalQuery = false)
 | 
			
		||||
    {
 | 
			
		||||
        $query = $this->permissionService->enforceEntityRestrictions($type, $this->entityProvider->get($type))
 | 
			
		||||
            ->orderBy('created_at', 'desc');
 | 
			
		||||
        if (strtolower($type) === 'page') {
 | 
			
		||||
            $query = $query->where('draft', '=', false);
 | 
			
		||||
        }
 | 
			
		||||
        if ($additionalQuery !== false && is_callable($additionalQuery)) {
 | 
			
		||||
            $additionalQuery($query);
 | 
			
		||||
        }
 | 
			
		||||
        return $query->skip($page * $count)->take($count)->get();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the most recently updated entities of the given type.
 | 
			
		||||
     * @param string $type
 | 
			
		||||
     * @param int $count
 | 
			
		||||
     * @param int $page
 | 
			
		||||
     * @param bool|callable $additionalQuery
 | 
			
		||||
     * @return Collection
 | 
			
		||||
     */
 | 
			
		||||
    public function getRecentlyUpdated($type, $count = 20, $page = 0, $additionalQuery = false)
 | 
			
		||||
    {
 | 
			
		||||
        $query = $this->permissionService->enforceEntityRestrictions($type, $this->entityProvider->get($type))
 | 
			
		||||
            ->orderBy('updated_at', 'desc');
 | 
			
		||||
        if (strtolower($type) === 'page') {
 | 
			
		||||
            $query = $query->where('draft', '=', false);
 | 
			
		||||
        }
 | 
			
		||||
        if ($additionalQuery !== false && is_callable($additionalQuery)) {
 | 
			
		||||
            $additionalQuery($query);
 | 
			
		||||
        }
 | 
			
		||||
        return $query->skip($page * $count)->take($count)->get();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the most recently viewed entities.
 | 
			
		||||
     * @param string|bool $type
 | 
			
		||||
     * @param int $count
 | 
			
		||||
     * @param int $page
 | 
			
		||||
     * @return mixed
 | 
			
		||||
     */
 | 
			
		||||
    public function getRecentlyViewed($type, $count = 10, $page = 0)
 | 
			
		||||
    {
 | 
			
		||||
        $filter = is_bool($type) ? false : $this->entityProvider->get($type);
 | 
			
		||||
        return $this->viewService->getUserRecentlyViewed($count, $page, $filter);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the latest pages added to the system with pagination.
 | 
			
		||||
     * @param string $type
 | 
			
		||||
     * @param int $count
 | 
			
		||||
     * @return mixed
 | 
			
		||||
     */
 | 
			
		||||
    public function getRecentlyCreatedPaginated($type, $count = 20)
 | 
			
		||||
    {
 | 
			
		||||
        return $this->entityQuery($type)->orderBy('created_at', 'desc')->paginate($count);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the latest pages added to the system with pagination.
 | 
			
		||||
     * @param string $type
 | 
			
		||||
     * @param int $count
 | 
			
		||||
     * @return mixed
 | 
			
		||||
     */
 | 
			
		||||
    public function getRecentlyUpdatedPaginated($type, $count = 20)
 | 
			
		||||
    {
 | 
			
		||||
        return $this->entityQuery($type)->orderBy('updated_at', 'desc')->paginate($count);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the most popular entities base on all views.
 | 
			
		||||
     * @param string $type
 | 
			
		||||
     * @param int $count
 | 
			
		||||
     * @param int $page
 | 
			
		||||
     * @return mixed
 | 
			
		||||
     */
 | 
			
		||||
    public function getPopular(string $type, int $count = 10, int $page = 0)
 | 
			
		||||
    {
 | 
			
		||||
        return $this->viewService->getPopular($count, $page, $type);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get draft pages owned by the current user.
 | 
			
		||||
     * @param int $count
 | 
			
		||||
     * @param int $page
 | 
			
		||||
     * @return Collection
 | 
			
		||||
     */
 | 
			
		||||
    public function getUserDraftPages($count = 20, $page = 0)
 | 
			
		||||
    {
 | 
			
		||||
        return $this->entityProvider->page->where('draft', '=', true)
 | 
			
		||||
            ->where('created_by', '=', user()->id)
 | 
			
		||||
            ->orderBy('updated_at', 'desc')
 | 
			
		||||
            ->skip($count * $page)->take($count)->get();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the number of entities the given user has created.
 | 
			
		||||
     * @param string $type
 | 
			
		||||
     * @param User $user
 | 
			
		||||
     * @return int
 | 
			
		||||
     */
 | 
			
		||||
    public function getUserTotalCreated(string $type, User $user)
 | 
			
		||||
    {
 | 
			
		||||
        return $this->entityProvider->get($type)
 | 
			
		||||
            ->where('created_by', '=', $user->id)->count();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the child items for a chapter sorted by priority but
 | 
			
		||||
     * with draft items floated to the top.
 | 
			
		||||
     * @param Bookshelf $bookshelf
 | 
			
		||||
     * @return \Illuminate\Database\Eloquent\Collection|static[]
 | 
			
		||||
     */
 | 
			
		||||
    public function getBookshelfChildren(Bookshelf $bookshelf)
 | 
			
		||||
    {
 | 
			
		||||
        return $this->permissionService->enforceEntityRestrictions('book', $bookshelf->books())->get();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the direct children of a book.
 | 
			
		||||
     * @param Book $book
 | 
			
		||||
     * @return \Illuminate\Database\Eloquent\Collection
 | 
			
		||||
     */
 | 
			
		||||
    public function getBookDirectChildren(Book $book)
 | 
			
		||||
    {
 | 
			
		||||
        $pages = $this->permissionService->enforceEntityRestrictions('page', $book->directPages())->get();
 | 
			
		||||
        $chapters = $this->permissionService->enforceEntityRestrictions('chapters', $book->chapters())->get();
 | 
			
		||||
        return collect()->concat($pages)->concat($chapters)->sortBy('priority')->sortByDesc('draft');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get all child objects of a book.
 | 
			
		||||
     * Returns a sorted collection of Pages and Chapters.
 | 
			
		||||
     * Loads the book slug onto child elements to prevent access database access for getting the slug.
 | 
			
		||||
     * @param Book $book
 | 
			
		||||
     * @param bool $filterDrafts
 | 
			
		||||
     * @param bool $renderPages
 | 
			
		||||
     * @return mixed
 | 
			
		||||
     */
 | 
			
		||||
    public function getBookChildren(Book $book, $filterDrafts = false, $renderPages = false)
 | 
			
		||||
    {
 | 
			
		||||
        $q = $this->permissionService->bookChildrenQuery($book->id, $filterDrafts, $renderPages)->get();
 | 
			
		||||
        $entities = [];
 | 
			
		||||
        $parents = [];
 | 
			
		||||
        $tree = [];
 | 
			
		||||
 | 
			
		||||
        foreach ($q as $index => $rawEntity) {
 | 
			
		||||
            if ($rawEntity->entity_type ===  $this->entityProvider->page->getMorphClass()) {
 | 
			
		||||
                $entities[$index] = $this->entityProvider->page->newFromBuilder($rawEntity);
 | 
			
		||||
                if ($renderPages) {
 | 
			
		||||
                    $entities[$index]->html = $rawEntity->html;
 | 
			
		||||
                    $entities[$index]->html = $this->renderPage($entities[$index]);
 | 
			
		||||
                };
 | 
			
		||||
            } else if ($rawEntity->entity_type === $this->entityProvider->chapter->getMorphClass()) {
 | 
			
		||||
                $entities[$index] = $this->entityProvider->chapter->newFromBuilder($rawEntity);
 | 
			
		||||
                $key = $entities[$index]->entity_type . ':' . $entities[$index]->id;
 | 
			
		||||
                $parents[$key] = $entities[$index];
 | 
			
		||||
                $parents[$key]->setAttribute('pages', collect());
 | 
			
		||||
            }
 | 
			
		||||
            if ($entities[$index]->chapter_id === 0 || $entities[$index]->chapter_id === '0') {
 | 
			
		||||
                $tree[] = $entities[$index];
 | 
			
		||||
            }
 | 
			
		||||
            $entities[$index]->book = $book;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        foreach ($entities as $entity) {
 | 
			
		||||
            if ($entity->chapter_id === 0 || $entity->chapter_id === '0') {
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
            $parentKey = $this->entityProvider->chapter->getMorphClass() . ':' . $entity->chapter_id;
 | 
			
		||||
            if (!isset($parents[$parentKey])) {
 | 
			
		||||
                $tree[] = $entity;
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
            $chapter = $parents[$parentKey];
 | 
			
		||||
            $chapter->pages->push($entity);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return collect($tree);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the child items for a chapter sorted by priority but
 | 
			
		||||
     * with draft items floated to the top.
 | 
			
		||||
     * @param Chapter $chapter
 | 
			
		||||
     * @return \Illuminate\Database\Eloquent\Collection|static[]
 | 
			
		||||
     */
 | 
			
		||||
    public function getChapterChildren(Chapter $chapter)
 | 
			
		||||
    {
 | 
			
		||||
        return $this->permissionService->enforceEntityRestrictions('page', $chapter->pages())
 | 
			
		||||
            ->orderBy('draft', 'DESC')->orderBy('priority', 'ASC')->get();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the next sequential priority for a new child element in the given book.
 | 
			
		||||
     * @param Book $book
 | 
			
		||||
     * @return int
 | 
			
		||||
     */
 | 
			
		||||
    public function getNewBookPriority(Book $book)
 | 
			
		||||
    {
 | 
			
		||||
        $lastElem = $this->getBookChildren($book)->pop();
 | 
			
		||||
        return $lastElem ? $lastElem->priority + 1 : 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get a new priority for a new page to be added to the given chapter.
 | 
			
		||||
     * @param Chapter $chapter
 | 
			
		||||
     * @return int
 | 
			
		||||
     */
 | 
			
		||||
    public function getNewChapterPriority(Chapter $chapter)
 | 
			
		||||
    {
 | 
			
		||||
        $lastPage = $chapter->pages('DESC')->first();
 | 
			
		||||
        return $lastPage !== null ? $lastPage->priority + 1 : 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Find a suitable slug for an entity.
 | 
			
		||||
     * @param string $type
 | 
			
		||||
     * @param string $name
 | 
			
		||||
     * @param bool|integer $currentId
 | 
			
		||||
     * @param bool|integer $bookId Only pass if type is not a book
 | 
			
		||||
     * @return string
 | 
			
		||||
     */
 | 
			
		||||
    public function findSuitableSlug($type, $name, $currentId = false, $bookId = false)
 | 
			
		||||
    {
 | 
			
		||||
        $slug = $this->nameToSlug($name);
 | 
			
		||||
        while ($this->slugExists($type, $slug, $currentId, $bookId)) {
 | 
			
		||||
            $slug .= '-' . substr(md5(rand(1, 500)), 0, 3);
 | 
			
		||||
        }
 | 
			
		||||
        return $slug;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if a slug already exists in the database.
 | 
			
		||||
     * @param string $type
 | 
			
		||||
     * @param string $slug
 | 
			
		||||
     * @param bool|integer $currentId
 | 
			
		||||
     * @param bool|integer $bookId
 | 
			
		||||
     * @return bool
 | 
			
		||||
     */
 | 
			
		||||
    protected function slugExists($type, $slug, $currentId = false, $bookId = false)
 | 
			
		||||
    {
 | 
			
		||||
        $query = $this->entityProvider->get($type)->where('slug', '=', $slug);
 | 
			
		||||
        if (strtolower($type) === 'page' || strtolower($type) === 'chapter') {
 | 
			
		||||
            $query = $query->where('book_id', '=', $bookId);
 | 
			
		||||
        }
 | 
			
		||||
        if ($currentId) {
 | 
			
		||||
            $query = $query->where('id', '!=', $currentId);
 | 
			
		||||
        }
 | 
			
		||||
        return $query->count() > 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Updates entity restrictions from a request
 | 
			
		||||
     * @param Request $request
 | 
			
		||||
     * @param Entity $entity
 | 
			
		||||
     * @throws Throwable
 | 
			
		||||
     */
 | 
			
		||||
    public function updateEntityPermissionsFromRequest(Request $request, Entity $entity)
 | 
			
		||||
    {
 | 
			
		||||
        $entity->restricted = $request->get('restricted', '') === 'true';
 | 
			
		||||
        $entity->permissions()->delete();
 | 
			
		||||
 | 
			
		||||
        if ($request->filled('restrictions')) {
 | 
			
		||||
            foreach ($request->get('restrictions') as $roleId => $restrictions) {
 | 
			
		||||
                foreach ($restrictions as $action => $value) {
 | 
			
		||||
                    $entity->permissions()->create([
 | 
			
		||||
                        'role_id' => $roleId,
 | 
			
		||||
                        'action'  => strtolower($action)
 | 
			
		||||
                    ]);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $entity->save();
 | 
			
		||||
        $this->permissionService->buildJointPermissionsForEntity($entity);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Create a new entity from request input.
 | 
			
		||||
     * Used for books and chapters.
 | 
			
		||||
     * @param string $type
 | 
			
		||||
     * @param array $input
 | 
			
		||||
     * @param bool|Book $book
 | 
			
		||||
     * @return Entity
 | 
			
		||||
     */
 | 
			
		||||
    public function createFromInput($type, $input = [], $book = false)
 | 
			
		||||
    {
 | 
			
		||||
        $isChapter = strtolower($type) === 'chapter';
 | 
			
		||||
        $entityModel = $this->entityProvider->get($type)->newInstance($input);
 | 
			
		||||
        $entityModel->slug = $this->findSuitableSlug($type, $entityModel->name, false, $isChapter ? $book->id : false);
 | 
			
		||||
        $entityModel->created_by = user()->id;
 | 
			
		||||
        $entityModel->updated_by = user()->id;
 | 
			
		||||
        $isChapter ? $book->chapters()->save($entityModel) : $entityModel->save();
 | 
			
		||||
 | 
			
		||||
        if (isset($input['tags'])) {
 | 
			
		||||
            $this->tagRepo->saveTagsToEntity($entityModel, $input['tags']);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $this->permissionService->buildJointPermissionsForEntity($entityModel);
 | 
			
		||||
        $this->searchService->indexEntity($entityModel);
 | 
			
		||||
        return $entityModel;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Update entity details from request input.
 | 
			
		||||
     * Used for books and chapters
 | 
			
		||||
     * @param string $type
 | 
			
		||||
     * @param Entity $entityModel
 | 
			
		||||
     * @param array $input
 | 
			
		||||
     * @return Entity
 | 
			
		||||
     */
 | 
			
		||||
    public function updateFromInput($type, Entity $entityModel, $input = [])
 | 
			
		||||
    {
 | 
			
		||||
        if ($entityModel->name !== $input['name']) {
 | 
			
		||||
            $entityModel->slug = $this->findSuitableSlug($type, $input['name'], $entityModel->id);
 | 
			
		||||
        }
 | 
			
		||||
        $entityModel->fill($input);
 | 
			
		||||
        $entityModel->updated_by = user()->id;
 | 
			
		||||
        $entityModel->save();
 | 
			
		||||
 | 
			
		||||
        if (isset($input['tags'])) {
 | 
			
		||||
            $this->tagRepo->saveTagsToEntity($entityModel, $input['tags']);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $this->permissionService->buildJointPermissionsForEntity($entityModel);
 | 
			
		||||
        $this->searchService->indexEntity($entityModel);
 | 
			
		||||
        return $entityModel;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Sync the books assigned to a shelf from a comma-separated list
 | 
			
		||||
     * of book IDs.
 | 
			
		||||
     * @param Bookshelf $shelf
 | 
			
		||||
     * @param string $books
 | 
			
		||||
     */
 | 
			
		||||
    public function updateShelfBooks(Bookshelf $shelf, string $books)
 | 
			
		||||
    {
 | 
			
		||||
        $ids = explode(',', $books);
 | 
			
		||||
 | 
			
		||||
        // Check books exist and match ordering
 | 
			
		||||
        $bookIds = $this->entityQuery('book')->whereIn('id', $ids)->get(['id'])->pluck('id');
 | 
			
		||||
        $syncData = [];
 | 
			
		||||
        foreach ($ids as $index => $id) {
 | 
			
		||||
            if ($bookIds->contains($id)) {
 | 
			
		||||
                $syncData[$id] = ['order' => $index];
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $shelf->books()->sync($syncData);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Append a Book to a BookShelf.
 | 
			
		||||
     * @param Bookshelf $shelf
 | 
			
		||||
     * @param Book $book
 | 
			
		||||
     */
 | 
			
		||||
    public function appendBookToShelf(Bookshelf $shelf, Book $book)
 | 
			
		||||
    {
 | 
			
		||||
        if ($shelf->contains($book)) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $maxOrder = $shelf->books()->max('order');
 | 
			
		||||
        $shelf->books()->attach($book->id, ['order' => $maxOrder + 1]);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Change the book that an entity belongs to.
 | 
			
		||||
     * @param string $type
 | 
			
		||||
     * @param integer $newBookId
 | 
			
		||||
     * @param Entity $entity
 | 
			
		||||
     * @param bool $rebuildPermissions
 | 
			
		||||
     * @return Entity
 | 
			
		||||
     */
 | 
			
		||||
    public function changeBook($type, $newBookId, Entity $entity, $rebuildPermissions = false)
 | 
			
		||||
    {
 | 
			
		||||
        $entity->book_id = $newBookId;
 | 
			
		||||
        // Update related activity
 | 
			
		||||
        foreach ($entity->activity as $activity) {
 | 
			
		||||
            $activity->book_id = $newBookId;
 | 
			
		||||
            $activity->save();
 | 
			
		||||
        }
 | 
			
		||||
        $entity->slug = $this->findSuitableSlug($type, $entity->name, $entity->id, $newBookId);
 | 
			
		||||
        $entity->save();
 | 
			
		||||
 | 
			
		||||
        // Update all child pages if a chapter
 | 
			
		||||
        if (strtolower($type) === 'chapter') {
 | 
			
		||||
            foreach ($entity->pages as $page) {
 | 
			
		||||
                $this->changeBook('page', $newBookId, $page, false);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Update permissions if applicable
 | 
			
		||||
        if ($rebuildPermissions) {
 | 
			
		||||
            $entity->load('book');
 | 
			
		||||
            $this->permissionService->buildJointPermissionsForEntity($entity->book);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return $entity;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Alias method to update the book jointPermissions in the PermissionService.
 | 
			
		||||
     * @param Book $book
 | 
			
		||||
     */
 | 
			
		||||
    public function buildJointPermissionsForBook(Book $book)
 | 
			
		||||
    {
 | 
			
		||||
        $this->permissionService->buildJointPermissionsForEntity($book);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Format a name as a url slug.
 | 
			
		||||
     * @param $name
 | 
			
		||||
     * @return string
 | 
			
		||||
     */
 | 
			
		||||
    protected function nameToSlug($name)
 | 
			
		||||
    {
 | 
			
		||||
        $slug = preg_replace('/[\+\/\\\?\@\}\{\.\,\=\[\]\#\&\!\*\'\;\:\$\%]/', '', mb_strtolower($name));
 | 
			
		||||
        $slug = preg_replace('/\s{2,}/', ' ', $slug);
 | 
			
		||||
        $slug = str_replace(' ', '-', $slug);
 | 
			
		||||
        if ($slug === "") {
 | 
			
		||||
            $slug = substr(md5(rand(1, 500)), 0, 5);
 | 
			
		||||
        }
 | 
			
		||||
        return $slug;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Render the page for viewing
 | 
			
		||||
     * @param Page $page
 | 
			
		||||
     * @param bool $blankIncludes
 | 
			
		||||
     * @return string
 | 
			
		||||
     */
 | 
			
		||||
    public function renderPage(Page $page, bool $blankIncludes = false) : string
 | 
			
		||||
    {
 | 
			
		||||
        $content = $page->html;
 | 
			
		||||
 | 
			
		||||
        if (!config('app.allow_content_scripts')) {
 | 
			
		||||
            $content = $this->escapeScripts($content);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if ($blankIncludes) {
 | 
			
		||||
            $content = $this->blankPageIncludes($content);
 | 
			
		||||
        } else {
 | 
			
		||||
            $content = $this->parsePageIncludes($content);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return $content;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Remove any page include tags within the given HTML.
 | 
			
		||||
     * @param string $html
 | 
			
		||||
     * @return string
 | 
			
		||||
     */
 | 
			
		||||
    protected function blankPageIncludes(string $html) : string
 | 
			
		||||
    {
 | 
			
		||||
        return preg_replace("/{{@\s?([0-9].*?)}}/", '', $html);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Parse any include tags "{{@<page_id>#section}}" to be part of the page.
 | 
			
		||||
     * @param string $html
 | 
			
		||||
     * @return mixed|string
 | 
			
		||||
     */
 | 
			
		||||
    protected function parsePageIncludes(string $html) : string
 | 
			
		||||
    {
 | 
			
		||||
        $matches = [];
 | 
			
		||||
        preg_match_all("/{{@\s?([0-9].*?)}}/", $html, $matches);
 | 
			
		||||
 | 
			
		||||
        $topLevelTags = ['table', 'ul', 'ol'];
 | 
			
		||||
        foreach ($matches[1] as $index => $includeId) {
 | 
			
		||||
            $splitInclude = explode('#', $includeId, 2);
 | 
			
		||||
            $pageId = intval($splitInclude[0]);
 | 
			
		||||
            if (is_nan($pageId)) {
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            $matchedPage = $this->getById('page', $pageId);
 | 
			
		||||
            if ($matchedPage === null) {
 | 
			
		||||
                $html = str_replace($matches[0][$index], '', $html);
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (count($splitInclude) === 1) {
 | 
			
		||||
                $html = str_replace($matches[0][$index], $matchedPage->html, $html);
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            $doc = new DOMDocument();
 | 
			
		||||
            libxml_use_internal_errors(true);
 | 
			
		||||
            $doc->loadHTML(mb_convert_encoding('<body>'.$matchedPage->html.'</body>', 'HTML-ENTITIES', 'UTF-8'));
 | 
			
		||||
            $matchingElem = $doc->getElementById($splitInclude[1]);
 | 
			
		||||
            if ($matchingElem === null) {
 | 
			
		||||
                $html = str_replace($matches[0][$index], '', $html);
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
            $innerContent = '';
 | 
			
		||||
            $isTopLevel = in_array(strtolower($matchingElem->nodeName), $topLevelTags);
 | 
			
		||||
            if ($isTopLevel) {
 | 
			
		||||
                $innerContent .= $doc->saveHTML($matchingElem);
 | 
			
		||||
            } else {
 | 
			
		||||
                foreach ($matchingElem->childNodes as $childNode) {
 | 
			
		||||
                    $innerContent .= $doc->saveHTML($childNode);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            libxml_clear_errors();
 | 
			
		||||
            $html = str_replace($matches[0][$index], trim($innerContent), $html);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return $html;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Escape script tags within HTML content.
 | 
			
		||||
     * @param string $html
 | 
			
		||||
     * @return string
 | 
			
		||||
     */
 | 
			
		||||
    protected function escapeScripts(string $html) : string
 | 
			
		||||
    {
 | 
			
		||||
        if ($html == '') {
 | 
			
		||||
            return $html;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        libxml_use_internal_errors(true);
 | 
			
		||||
        $doc = new DOMDocument();
 | 
			
		||||
        $doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
 | 
			
		||||
        $xPath = new DOMXPath($doc);
 | 
			
		||||
 | 
			
		||||
        // Remove standard script tags
 | 
			
		||||
        $scriptElems = $xPath->query('//script');
 | 
			
		||||
        foreach ($scriptElems as $scriptElem) {
 | 
			
		||||
            $scriptElem->parentNode->removeChild($scriptElem);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Remove data or JavaScript iFrames
 | 
			
		||||
        $badIframes = $xPath->query('//*[contains(@src, \'data:\')] | //*[contains(@src, \'javascript:\')] | //*[@srcdoc]');
 | 
			
		||||
        foreach ($badIframes as $badIframe) {
 | 
			
		||||
            $badIframe->parentNode->removeChild($badIframe);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Remove 'on*' attributes
 | 
			
		||||
        $onAttributes = $xPath->query('//@*[starts-with(name(), \'on\')]');
 | 
			
		||||
        foreach ($onAttributes as $attr) {
 | 
			
		||||
            /** @var \DOMAttr $attr*/
 | 
			
		||||
            $attrName = $attr->nodeName;
 | 
			
		||||
            $attr->parentNode->removeAttribute($attrName);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $html = '';
 | 
			
		||||
        $topElems = $doc->documentElement->childNodes->item(0)->childNodes;
 | 
			
		||||
        foreach ($topElems as $child) {
 | 
			
		||||
            $html .= $doc->saveHTML($child);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return $html;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Search for image usage within page content.
 | 
			
		||||
     * @param $imageString
 | 
			
		||||
     * @return mixed
 | 
			
		||||
     */
 | 
			
		||||
    public function searchForImage($imageString)
 | 
			
		||||
    {
 | 
			
		||||
        $pages = $this->entityQuery('page')->where('html', 'like', '%' . $imageString . '%')->get(['id', 'name', 'slug', 'book_id']);
 | 
			
		||||
        foreach ($pages as $page) {
 | 
			
		||||
            $page->url = $page->getUrl();
 | 
			
		||||
            $page->html = '';
 | 
			
		||||
            $page->text = '';
 | 
			
		||||
        }
 | 
			
		||||
        return count($pages) > 0 ? $pages : false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Destroy a bookshelf instance
 | 
			
		||||
     * @param Bookshelf $shelf
 | 
			
		||||
     * @throws Throwable
 | 
			
		||||
     */
 | 
			
		||||
    public function destroyBookshelf(Bookshelf $shelf)
 | 
			
		||||
    {
 | 
			
		||||
        $this->destroyEntityCommonRelations($shelf);
 | 
			
		||||
        $shelf->delete();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Destroy the provided book and all its child entities.
 | 
			
		||||
     * @param Book $book
 | 
			
		||||
     * @throws NotifyException
 | 
			
		||||
     * @throws Throwable
 | 
			
		||||
     */
 | 
			
		||||
    public function destroyBook(Book $book)
 | 
			
		||||
    {
 | 
			
		||||
        foreach ($book->pages as $page) {
 | 
			
		||||
            $this->destroyPage($page);
 | 
			
		||||
        }
 | 
			
		||||
        foreach ($book->chapters as $chapter) {
 | 
			
		||||
            $this->destroyChapter($chapter);
 | 
			
		||||
        }
 | 
			
		||||
        $this->destroyEntityCommonRelations($book);
 | 
			
		||||
        $book->delete();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Destroy a chapter and its relations.
 | 
			
		||||
     * @param Chapter $chapter
 | 
			
		||||
     * @throws Throwable
 | 
			
		||||
     */
 | 
			
		||||
    public function destroyChapter(Chapter $chapter)
 | 
			
		||||
    {
 | 
			
		||||
        if (count($chapter->pages) > 0) {
 | 
			
		||||
            foreach ($chapter->pages as $page) {
 | 
			
		||||
                $page->chapter_id = 0;
 | 
			
		||||
                $page->save();
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        $this->destroyEntityCommonRelations($chapter);
 | 
			
		||||
        $chapter->delete();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Destroy a given page along with its dependencies.
 | 
			
		||||
     * @param Page $page
 | 
			
		||||
     * @throws NotifyException
 | 
			
		||||
     * @throws Throwable
 | 
			
		||||
     */
 | 
			
		||||
    public function destroyPage(Page $page)
 | 
			
		||||
    {
 | 
			
		||||
        // Check if set as custom homepage & remove setting if not used or throw error if active
 | 
			
		||||
        $customHome = setting('app-homepage', '0:');
 | 
			
		||||
        if (intval($page->id) === intval(explode(':', $customHome)[0])) {
 | 
			
		||||
            if (setting('app-homepage-type') === 'page') {
 | 
			
		||||
                throw new NotifyException(trans('errors.page_custom_home_deletion'), $page->getUrl());
 | 
			
		||||
            }
 | 
			
		||||
            setting()->remove('app-homepage');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $this->destroyEntityCommonRelations($page);
 | 
			
		||||
 | 
			
		||||
        // Delete Attached Files
 | 
			
		||||
        $attachmentService = app(AttachmentService::class);
 | 
			
		||||
        foreach ($page->attachments as $attachment) {
 | 
			
		||||
            $attachmentService->deleteFile($attachment);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $page->delete();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Destroy or handle the common relations connected to an entity.
 | 
			
		||||
     * @param Entity $entity
 | 
			
		||||
     * @throws Throwable
 | 
			
		||||
     */
 | 
			
		||||
    protected function destroyEntityCommonRelations(Entity $entity)
 | 
			
		||||
    {
 | 
			
		||||
        Activity::removeEntity($entity);
 | 
			
		||||
        $entity->views()->delete();
 | 
			
		||||
        $entity->permissions()->delete();
 | 
			
		||||
        $entity->tags()->delete();
 | 
			
		||||
        $entity->comments()->delete();
 | 
			
		||||
        $this->permissionService->deleteJointPermissionsForEntity($entity);
 | 
			
		||||
        $this->searchService->deleteEntityTerms($entity);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Copy the permissions of a bookshelf to all child books.
 | 
			
		||||
     * Returns the number of books that had permissions updated.
 | 
			
		||||
     * @param Bookshelf $bookshelf
 | 
			
		||||
     * @return int
 | 
			
		||||
     * @throws Throwable
 | 
			
		||||
     */
 | 
			
		||||
    public function copyBookshelfPermissions(Bookshelf $bookshelf)
 | 
			
		||||
    {
 | 
			
		||||
        $shelfPermissions = $bookshelf->permissions()->get(['role_id', 'action'])->toArray();
 | 
			
		||||
        $shelfBooks = $bookshelf->books()->get();
 | 
			
		||||
        $updatedBookCount = 0;
 | 
			
		||||
 | 
			
		||||
        foreach ($shelfBooks as $book) {
 | 
			
		||||
            if (!userCan('restrictions-manage', $book)) {
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
            $book->permissions()->delete();
 | 
			
		||||
            $book->restricted = $bookshelf->restricted;
 | 
			
		||||
            $book->permissions()->createMany($shelfPermissions);
 | 
			
		||||
            $book->save();
 | 
			
		||||
            $this->permissionService->buildJointPermissionsForEntity($book);
 | 
			
		||||
            $updatedBookCount++;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return $updatedBookCount;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -3,91 +3,199 @@
 | 
			
		|||
use BookStack\Entities\Book;
 | 
			
		||||
use BookStack\Entities\Chapter;
 | 
			
		||||
use BookStack\Entities\Entity;
 | 
			
		||||
use BookStack\Entities\Managers\BookContents;
 | 
			
		||||
use BookStack\Entities\Managers\PageContent;
 | 
			
		||||
use BookStack\Entities\Managers\TrashCan;
 | 
			
		||||
use BookStack\Entities\Page;
 | 
			
		||||
use BookStack\Entities\PageRevision;
 | 
			
		||||
use Carbon\Carbon;
 | 
			
		||||
use DOMDocument;
 | 
			
		||||
use DOMElement;
 | 
			
		||||
use DOMXPath;
 | 
			
		||||
use BookStack\Exceptions\MoveOperationException;
 | 
			
		||||
use BookStack\Exceptions\NotFoundException;
 | 
			
		||||
use BookStack\Exceptions\NotifyException;
 | 
			
		||||
use BookStack\Exceptions\PermissionsException;
 | 
			
		||||
use Illuminate\Database\Eloquent\Builder;
 | 
			
		||||
use Illuminate\Pagination\LengthAwarePaginator;
 | 
			
		||||
use Illuminate\Support\Collection;
 | 
			
		||||
 | 
			
		||||
class PageRepo extends EntityRepo
 | 
			
		||||
class PageRepo
 | 
			
		||||
{
 | 
			
		||||
 | 
			
		||||
    protected $baseRepo;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get page by slug.
 | 
			
		||||
     * @param string $pageSlug
 | 
			
		||||
     * @param string $bookSlug
 | 
			
		||||
     * @return Page
 | 
			
		||||
     * @throws \BookStack\Exceptions\NotFoundException
 | 
			
		||||
     * PageRepo constructor.
 | 
			
		||||
     */
 | 
			
		||||
    public function getPageBySlug(string $pageSlug, string $bookSlug)
 | 
			
		||||
    public function __construct(BaseRepo $baseRepo)
 | 
			
		||||
    {
 | 
			
		||||
        return $this->getBySlug('page', $pageSlug, $bookSlug);
 | 
			
		||||
        $this->baseRepo = $baseRepo;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Search through page revisions and retrieve the last page in the
 | 
			
		||||
     * current book that has a slug equal to the one given.
 | 
			
		||||
     * @param string $pageSlug
 | 
			
		||||
     * @param string $bookSlug
 | 
			
		||||
     * @return null|Page
 | 
			
		||||
     * Get a page by ID.
 | 
			
		||||
     * @throws NotFoundException
 | 
			
		||||
     */
 | 
			
		||||
    public function getPageByOldSlug(string $pageSlug, string $bookSlug)
 | 
			
		||||
    public function getById(int $id): Page
 | 
			
		||||
    {
 | 
			
		||||
        $revision = $this->entityProvider->pageRevision->where('slug', '=', $pageSlug)
 | 
			
		||||
            ->whereHas('page', function ($query) {
 | 
			
		||||
                $this->permissionService->enforceEntityRestrictions('page', $query);
 | 
			
		||||
        $page = Page::visible()->with(['book'])->find($id);
 | 
			
		||||
 | 
			
		||||
        if (!$page) {
 | 
			
		||||
            throw new NotFoundException(trans('errors.page_not_found'));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return $page;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get a page its book and own slug.
 | 
			
		||||
     * @throws NotFoundException
 | 
			
		||||
     */
 | 
			
		||||
    public function getBySlug(string $bookSlug, string $pageSlug): Page
 | 
			
		||||
    {
 | 
			
		||||
        $page = Page::visible()->whereSlugs($bookSlug, $pageSlug)->first();
 | 
			
		||||
 | 
			
		||||
        if (!$page) {
 | 
			
		||||
            throw new NotFoundException(trans('errors.page_not_found'));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return $page;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get a page by its old slug but checking the revisions table
 | 
			
		||||
     * for the last revision that matched the given page and book slug.
 | 
			
		||||
     */
 | 
			
		||||
    public function getByOldSlug(string $bookSlug, string $pageSlug): ?Page
 | 
			
		||||
    {
 | 
			
		||||
        $revision = PageRevision::query()
 | 
			
		||||
            ->whereHas('page', function (Builder $query) {
 | 
			
		||||
                $query->visible();
 | 
			
		||||
            })
 | 
			
		||||
            ->where('slug', '=', $pageSlug)
 | 
			
		||||
            ->where('type', '=', 'version')
 | 
			
		||||
            ->where('book_slug', '=', $bookSlug)
 | 
			
		||||
            ->orderBy('created_at', 'desc')
 | 
			
		||||
            ->with('page')->first();
 | 
			
		||||
        return $revision !== null ? $revision->page : null;
 | 
			
		||||
            ->with('page')
 | 
			
		||||
            ->first();
 | 
			
		||||
        return $revision ? $revision->page : null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Updates a page with any fillable data and saves it into the database.
 | 
			
		||||
     * @param Page $page
 | 
			
		||||
     * @param int $book_id
 | 
			
		||||
     * @param array $input
 | 
			
		||||
     * @return Page
 | 
			
		||||
     * @throws \Exception
 | 
			
		||||
     * Get pages that have been marked as a template.
 | 
			
		||||
     */
 | 
			
		||||
    public function updatePage(Page $page, int $book_id, array $input)
 | 
			
		||||
    public function getTemplates(int $count = 10, int $page = 1, string $search = ''): LengthAwarePaginator
 | 
			
		||||
    {
 | 
			
		||||
        $query = Page::visible()
 | 
			
		||||
            ->where('template', '=', true)
 | 
			
		||||
            ->orderBy('name', 'asc')
 | 
			
		||||
            ->skip(($page - 1) * $count)
 | 
			
		||||
            ->take($count);
 | 
			
		||||
 | 
			
		||||
        if ($search) {
 | 
			
		||||
            $query->where('name', 'like', '%' . $search . '%');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $paginator = $query->paginate($count, ['*'], 'page', $page);
 | 
			
		||||
        $paginator->withPath('/templates');
 | 
			
		||||
 | 
			
		||||
        return $paginator;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get a parent item via slugs.
 | 
			
		||||
     */
 | 
			
		||||
    public function getParentFromSlugs(string $bookSlug, string $chapterSlug = null): Entity
 | 
			
		||||
    {
 | 
			
		||||
        if ($chapterSlug !== null) {
 | 
			
		||||
            return $chapter = Chapter::visible()->whereSlugs($bookSlug, $chapterSlug)->firstOrFail();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return Book::visible()->where('slug', '=', $bookSlug)->firstOrFail();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the draft copy of the given page for the current user.
 | 
			
		||||
     */
 | 
			
		||||
    public function getUserDraft(Page $page): ?PageRevision
 | 
			
		||||
    {
 | 
			
		||||
        $revision = $this->getUserDraftQuery($page)->first();
 | 
			
		||||
        return $revision;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get a new draft page belonging to the given parent entity.
 | 
			
		||||
     */
 | 
			
		||||
    public function getNewDraftPage(Entity $parent)
 | 
			
		||||
    {
 | 
			
		||||
        $page = (new Page())->forceFill([
 | 
			
		||||
            'name' => trans('entities.pages_initial_name'),
 | 
			
		||||
            'created_by' => user()->id,
 | 
			
		||||
            'updated_by' => user()->id,
 | 
			
		||||
            'draft' => true,
 | 
			
		||||
        ]);
 | 
			
		||||
 | 
			
		||||
        if ($parent instanceof Chapter) {
 | 
			
		||||
            $page->chapter_id = $parent->id;
 | 
			
		||||
            $page->book_id = $parent->book_id;
 | 
			
		||||
        } else {
 | 
			
		||||
            $page->book_id = $parent->id;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $page->save();
 | 
			
		||||
        $page->refresh()->rebuildPermissions();
 | 
			
		||||
        return $page;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Publish a draft page to make it a live, non-draft page.
 | 
			
		||||
     */
 | 
			
		||||
    public function publishDraft(Page $draft, array $input): Page
 | 
			
		||||
    {
 | 
			
		||||
        $this->baseRepo->update($draft, $input);
 | 
			
		||||
        if (isset($input['template']) && userCan('templates-manage')) {
 | 
			
		||||
            $draft->template = ($input['template'] === 'true');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $pageContent = new PageContent($draft);
 | 
			
		||||
        $pageContent->setNewHTML($input['html']);
 | 
			
		||||
        $draft->draft = false;
 | 
			
		||||
        $draft->revision_count = 1;
 | 
			
		||||
        $draft->priority = $this->getNewPriority($draft);
 | 
			
		||||
        $draft->refreshSlug();
 | 
			
		||||
        $draft->save();
 | 
			
		||||
 | 
			
		||||
        $this->savePageRevision($draft, trans('entities.pages_initial_revision'));
 | 
			
		||||
        $draft->indexForSearch();
 | 
			
		||||
        return $draft->refresh();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Update a page in the system.
 | 
			
		||||
     */
 | 
			
		||||
    public function update(Page $page, array $input): Page
 | 
			
		||||
    {
 | 
			
		||||
        // Hold the old details to compare later
 | 
			
		||||
        $oldHtml = $page->html;
 | 
			
		||||
        $oldName = $page->name;
 | 
			
		||||
 | 
			
		||||
        // Prevent slug being updated if no name change
 | 
			
		||||
        if ($page->name !== $input['name']) {
 | 
			
		||||
            $page->slug = $this->findSuitableSlug('page', $input['name'], $page->id, $book_id);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Save page tags if present
 | 
			
		||||
        if (isset($input['tags'])) {
 | 
			
		||||
            $this->tagRepo->saveTagsToEntity($page, $input['tags']);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (isset($input['template']) && userCan('templates-manage')) {
 | 
			
		||||
            $page->template = ($input['template'] === 'true');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $this->baseRepo->update($page, $input);
 | 
			
		||||
 | 
			
		||||
        // Update with new details
 | 
			
		||||
        $userId = user()->id;
 | 
			
		||||
        $page->fill($input);
 | 
			
		||||
        $page->html = $this->formatHtml($input['html']);
 | 
			
		||||
        $page->text = $this->pageToPlainText($page);
 | 
			
		||||
        $pageContent = new PageContent($page);
 | 
			
		||||
        $pageContent->setNewHTML($input['html']);
 | 
			
		||||
        $page->revision_count++;
 | 
			
		||||
 | 
			
		||||
        if (setting('app-editor') !== 'markdown') {
 | 
			
		||||
            $page->markdown = '';
 | 
			
		||||
        }
 | 
			
		||||
        $page->updated_by = $userId;
 | 
			
		||||
        $page->revision_count++;
 | 
			
		||||
 | 
			
		||||
        $page->save();
 | 
			
		||||
 | 
			
		||||
        // Remove all update drafts for this user & page.
 | 
			
		||||
        $this->userUpdatePageDraftsQuery($page, $userId)->delete();
 | 
			
		||||
        $this->getUserDraftQuery($page)->delete();
 | 
			
		||||
 | 
			
		||||
        // Save a revision after updating
 | 
			
		||||
        $summary = $input['summary'] ?? null;
 | 
			
		||||
| 
						 | 
				
			
			@ -95,24 +203,20 @@ class PageRepo extends EntityRepo
 | 
			
		|||
            $this->savePageRevision($page, $summary);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $this->searchService->indexEntity($page);
 | 
			
		||||
 | 
			
		||||
        return $page;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Saves a page revision into the system.
 | 
			
		||||
     * @param Page $page
 | 
			
		||||
     * @param null|string $summary
 | 
			
		||||
     * @return PageRevision
 | 
			
		||||
     * @throws \Exception
 | 
			
		||||
     */
 | 
			
		||||
    public function savePageRevision(Page $page, string $summary = null)
 | 
			
		||||
    protected function savePageRevision(Page $page, string $summary = null)
 | 
			
		||||
    {
 | 
			
		||||
        $revision = $this->entityProvider->pageRevision->newInstance($page->toArray());
 | 
			
		||||
        $revision = new PageRevision($page->toArray());
 | 
			
		||||
 | 
			
		||||
        if (setting('app-editor') !== 'markdown') {
 | 
			
		||||
            $revision->markdown = '';
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $revision->page_id = $page->id;
 | 
			
		||||
        $revision->slug = $page->slug;
 | 
			
		||||
        $revision->book_slug = $page->book->slug;
 | 
			
		||||
| 
						 | 
				
			
			@ -123,164 +227,29 @@ class PageRepo extends EntityRepo
 | 
			
		|||
        $revision->revision_number = $page->revision_count;
 | 
			
		||||
        $revision->save();
 | 
			
		||||
 | 
			
		||||
        $revisionLimit = config('app.revision_limit');
 | 
			
		||||
        if ($revisionLimit !== false) {
 | 
			
		||||
            $revisionsToDelete = $this->entityProvider->pageRevision->where('page_id', '=', $page->id)
 | 
			
		||||
                ->orderBy('created_at', 'desc')->skip(intval($revisionLimit))->take(10)->get(['id']);
 | 
			
		||||
            if ($revisionsToDelete->count() > 0) {
 | 
			
		||||
                $this->entityProvider->pageRevision->whereIn('id', $revisionsToDelete->pluck('id'))->delete();
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $this->deleteOldRevisions($page);
 | 
			
		||||
        return $revision;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Formats a page's html to be tagged correctly within the system.
 | 
			
		||||
     * @param string $htmlText
 | 
			
		||||
     * @return string
 | 
			
		||||
     */
 | 
			
		||||
    protected function formatHtml(string $htmlText)
 | 
			
		||||
    {
 | 
			
		||||
        if ($htmlText == '') {
 | 
			
		||||
            return $htmlText;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        libxml_use_internal_errors(true);
 | 
			
		||||
        $doc = new DOMDocument();
 | 
			
		||||
        $doc->loadHTML(mb_convert_encoding($htmlText, 'HTML-ENTITIES', 'UTF-8'));
 | 
			
		||||
 | 
			
		||||
        $container = $doc->documentElement;
 | 
			
		||||
        $body = $container->childNodes->item(0);
 | 
			
		||||
        $childNodes = $body->childNodes;
 | 
			
		||||
 | 
			
		||||
        // Set ids on top-level nodes
 | 
			
		||||
        $idMap = [];
 | 
			
		||||
        foreach ($childNodes as $index => $childNode) {
 | 
			
		||||
            $this->setUniqueId($childNode, $idMap);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Ensure no duplicate ids within child items
 | 
			
		||||
        $xPath = new DOMXPath($doc);
 | 
			
		||||
        $idElems = $xPath->query('//body//*//*[@id]');
 | 
			
		||||
        foreach ($idElems as $domElem) {
 | 
			
		||||
            $this->setUniqueId($domElem, $idMap);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Generate inner html as a string
 | 
			
		||||
        $html = '';
 | 
			
		||||
        foreach ($childNodes as $childNode) {
 | 
			
		||||
            $html .= $doc->saveHTML($childNode);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return $html;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Set a unique id on the given DOMElement.
 | 
			
		||||
     * A map for existing ID's should be passed in to check for current existence.
 | 
			
		||||
     * @param DOMElement $element
 | 
			
		||||
     * @param array $idMap
 | 
			
		||||
     */
 | 
			
		||||
    protected function setUniqueId($element, array &$idMap)
 | 
			
		||||
    {
 | 
			
		||||
        if (get_class($element) !== 'DOMElement') {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Overwrite id if not a BookStack custom id
 | 
			
		||||
        $existingId = $element->getAttribute('id');
 | 
			
		||||
        if (strpos($existingId, 'bkmrk') === 0 && !isset($idMap[$existingId])) {
 | 
			
		||||
            $idMap[$existingId] = true;
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Create an unique id for the element
 | 
			
		||||
        // Uses the content as a basis to ensure output is the same every time
 | 
			
		||||
        // the same content is passed through.
 | 
			
		||||
        $contentId = 'bkmrk-' . mb_substr(strtolower(preg_replace('/\s+/', '-', trim($element->nodeValue))), 0, 20);
 | 
			
		||||
        $newId = urlencode($contentId);
 | 
			
		||||
        $loopIndex = 0;
 | 
			
		||||
 | 
			
		||||
        while (isset($idMap[$newId])) {
 | 
			
		||||
            $newId = urlencode($contentId . '-' . $loopIndex);
 | 
			
		||||
            $loopIndex++;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $element->setAttribute('id', $newId);
 | 
			
		||||
        $idMap[$newId] = true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the plain text version of a page's content.
 | 
			
		||||
     * @param \BookStack\Entities\Page $page
 | 
			
		||||
     * @return string
 | 
			
		||||
     */
 | 
			
		||||
    protected function pageToPlainText(Page $page) : string
 | 
			
		||||
    {
 | 
			
		||||
        $html = $this->renderPage($page, true);
 | 
			
		||||
        return strip_tags($html);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get a new draft page instance.
 | 
			
		||||
     * @param Book $book
 | 
			
		||||
     * @param Chapter|null $chapter
 | 
			
		||||
     * @return \BookStack\Entities\Page
 | 
			
		||||
     * @throws \Throwable
 | 
			
		||||
     */
 | 
			
		||||
    public function getDraftPage(Book $book, Chapter $chapter = null)
 | 
			
		||||
    {
 | 
			
		||||
        $page = $this->entityProvider->page->newInstance();
 | 
			
		||||
        $page->name = trans('entities.pages_initial_name');
 | 
			
		||||
        $page->created_by = user()->id;
 | 
			
		||||
        $page->updated_by = user()->id;
 | 
			
		||||
        $page->draft = true;
 | 
			
		||||
 | 
			
		||||
        if ($chapter) {
 | 
			
		||||
            $page->chapter_id = $chapter->id;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $book->pages()->save($page);
 | 
			
		||||
        $page = $this->entityProvider->page->find($page->id);
 | 
			
		||||
        $this->permissionService->buildJointPermissionsForEntity($page);
 | 
			
		||||
        return $page;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Save a page update draft.
 | 
			
		||||
     * @param Page $page
 | 
			
		||||
     * @param array $data
 | 
			
		||||
     * @return PageRevision|Page
 | 
			
		||||
     */
 | 
			
		||||
    public function updatePageDraft(Page $page, array $data = [])
 | 
			
		||||
    public function updatePageDraft(Page $page, array $input)
 | 
			
		||||
    {
 | 
			
		||||
        // If the page itself is a draft simply update that
 | 
			
		||||
        if ($page->draft) {
 | 
			
		||||
            $page->fill($data);
 | 
			
		||||
            if (isset($data['html'])) {
 | 
			
		||||
                $page->text = $this->pageToPlainText($page);
 | 
			
		||||
            $page->fill($input);
 | 
			
		||||
            if (isset($input['html'])) {
 | 
			
		||||
                $content = new PageContent($page);
 | 
			
		||||
                $content->setNewHTML($input['html']);
 | 
			
		||||
            }
 | 
			
		||||
            $page->save();
 | 
			
		||||
            return $page;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Otherwise save the data to a revision
 | 
			
		||||
        $userId = user()->id;
 | 
			
		||||
        $drafts = $this->userUpdatePageDraftsQuery($page, $userId)->get();
 | 
			
		||||
 | 
			
		||||
        if ($drafts->count() > 0) {
 | 
			
		||||
            $draft = $drafts->first();
 | 
			
		||||
        } else {
 | 
			
		||||
            $draft = $this->entityProvider->pageRevision->newInstance();
 | 
			
		||||
            $draft->page_id = $page->id;
 | 
			
		||||
            $draft->slug = $page->slug;
 | 
			
		||||
            $draft->book_slug = $page->book->slug;
 | 
			
		||||
            $draft->created_by = $userId;
 | 
			
		||||
            $draft->type = 'update_draft';
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $draft->fill($data);
 | 
			
		||||
        $draft = $this->getPageRevisionToUpdate($page);
 | 
			
		||||
        $draft->fill($input);
 | 
			
		||||
        if (setting('app-editor') !== 'markdown') {
 | 
			
		||||
            $draft->markdown = '';
 | 
			
		||||
        }
 | 
			
		||||
| 
						 | 
				
			
			@ -290,225 +259,78 @@ class PageRepo extends EntityRepo
 | 
			
		|||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Publish a draft page to make it a normal page.
 | 
			
		||||
     * Sets the slug and updates the content.
 | 
			
		||||
     * @param Page $draftPage
 | 
			
		||||
     * @param array $input
 | 
			
		||||
     * @return Page
 | 
			
		||||
     * @throws \Exception
 | 
			
		||||
     * Destroy a page from the system.
 | 
			
		||||
     * @throws NotifyException
 | 
			
		||||
     */
 | 
			
		||||
    public function publishPageDraft(Page $draftPage, array $input)
 | 
			
		||||
    public function destroy(Page $page)
 | 
			
		||||
    {
 | 
			
		||||
        $draftPage->fill($input);
 | 
			
		||||
 | 
			
		||||
        // Save page tags if present
 | 
			
		||||
        if (isset($input['tags'])) {
 | 
			
		||||
            $this->tagRepo->saveTagsToEntity($draftPage, $input['tags']);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (isset($input['template']) && userCan('templates-manage')) {
 | 
			
		||||
            $draftPage->template = ($input['template'] === 'true');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $draftPage->slug = $this->findSuitableSlug('page', $draftPage->name, false, $draftPage->book->id);
 | 
			
		||||
        $draftPage->html = $this->formatHtml($input['html']);
 | 
			
		||||
        $draftPage->text = $this->pageToPlainText($draftPage);
 | 
			
		||||
        $draftPage->draft = false;
 | 
			
		||||
        $draftPage->revision_count = 1;
 | 
			
		||||
 | 
			
		||||
        $draftPage->save();
 | 
			
		||||
        $this->savePageRevision($draftPage, trans('entities.pages_initial_revision'));
 | 
			
		||||
        $this->searchService->indexEntity($draftPage);
 | 
			
		||||
        return $draftPage;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The base query for getting user update drafts.
 | 
			
		||||
     * @param Page $page
 | 
			
		||||
     * @param $userId
 | 
			
		||||
     * @return mixed
 | 
			
		||||
     */
 | 
			
		||||
    protected function userUpdatePageDraftsQuery(Page $page, int $userId)
 | 
			
		||||
    {
 | 
			
		||||
        return $this->entityProvider->pageRevision->where('created_by', '=', $userId)
 | 
			
		||||
            ->where('type', 'update_draft')
 | 
			
		||||
            ->where('page_id', '=', $page->id)
 | 
			
		||||
            ->orderBy('created_at', 'desc');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the latest updated draft revision for a particular page and user.
 | 
			
		||||
     * @param Page $page
 | 
			
		||||
     * @param $userId
 | 
			
		||||
     * @return PageRevision|null
 | 
			
		||||
     */
 | 
			
		||||
    public function getUserPageDraft(Page $page, int $userId)
 | 
			
		||||
    {
 | 
			
		||||
        return $this->userUpdatePageDraftsQuery($page, $userId)->first();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the notification message that informs the user that they are editing a draft page.
 | 
			
		||||
     * @param PageRevision $draft
 | 
			
		||||
     * @return string
 | 
			
		||||
     */
 | 
			
		||||
    public function getUserPageDraftMessage(PageRevision $draft)
 | 
			
		||||
    {
 | 
			
		||||
        $message = trans('entities.pages_editing_draft_notification', ['timeDiff' => $draft->updated_at->diffForHumans()]);
 | 
			
		||||
        if ($draft->page->updated_at->timestamp <= $draft->updated_at->timestamp) {
 | 
			
		||||
            return $message;
 | 
			
		||||
        }
 | 
			
		||||
        return $message . "\n" . trans('entities.pages_draft_edited_notification');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * A query to check for active update drafts on a particular page.
 | 
			
		||||
     * @param Page $page
 | 
			
		||||
     * @param int $minRange
 | 
			
		||||
     * @return mixed
 | 
			
		||||
     */
 | 
			
		||||
    protected function activePageEditingQuery(Page $page, int $minRange = null)
 | 
			
		||||
    {
 | 
			
		||||
        $query = $this->entityProvider->pageRevision->where('type', '=', 'update_draft')
 | 
			
		||||
            ->where('page_id', '=', $page->id)
 | 
			
		||||
            ->where('updated_at', '>', $page->updated_at)
 | 
			
		||||
            ->where('created_by', '!=', user()->id)
 | 
			
		||||
            ->with('createdBy');
 | 
			
		||||
 | 
			
		||||
        if ($minRange !== null) {
 | 
			
		||||
            $query = $query->where('updated_at', '>=', Carbon::now()->subMinutes($minRange));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return $query;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if a page is being actively editing.
 | 
			
		||||
     * Checks for edits since last page updated.
 | 
			
		||||
     * Passing in a minuted range will check for edits
 | 
			
		||||
     * within the last x minutes.
 | 
			
		||||
     * @param Page $page
 | 
			
		||||
     * @param int $minRange
 | 
			
		||||
     * @return bool
 | 
			
		||||
     */
 | 
			
		||||
    public function isPageEditingActive(Page $page, int $minRange = null)
 | 
			
		||||
    {
 | 
			
		||||
        $draftSearch = $this->activePageEditingQuery($page, $minRange);
 | 
			
		||||
        return $draftSearch->count() > 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get a notification message concerning the editing activity on a particular page.
 | 
			
		||||
     * @param Page $page
 | 
			
		||||
     * @param int $minRange
 | 
			
		||||
     * @return string
 | 
			
		||||
     */
 | 
			
		||||
    public function getPageEditingActiveMessage(Page $page, int $minRange = null)
 | 
			
		||||
    {
 | 
			
		||||
        $pageDraftEdits = $this->activePageEditingQuery($page, $minRange)->get();
 | 
			
		||||
 | 
			
		||||
        $userMessage = $pageDraftEdits->count() > 1 ? trans('entities.pages_draft_edit_active.start_a', ['count' => $pageDraftEdits->count()]): trans('entities.pages_draft_edit_active.start_b', ['userName' => $pageDraftEdits->first()->createdBy->name]);
 | 
			
		||||
        $timeMessage = $minRange === null ? trans('entities.pages_draft_edit_active.time_a') : trans('entities.pages_draft_edit_active.time_b', ['minCount'=>$minRange]);
 | 
			
		||||
        return trans('entities.pages_draft_edit_active.message', ['start' => $userMessage, 'time' => $timeMessage]);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Parse the headers on the page to get a navigation menu
 | 
			
		||||
     * @param string $pageContent
 | 
			
		||||
     * @return array
 | 
			
		||||
     */
 | 
			
		||||
    public function getPageNav(string $pageContent)
 | 
			
		||||
    {
 | 
			
		||||
        if ($pageContent == '') {
 | 
			
		||||
            return [];
 | 
			
		||||
        }
 | 
			
		||||
        libxml_use_internal_errors(true);
 | 
			
		||||
        $doc = new DOMDocument();
 | 
			
		||||
        $doc->loadHTML(mb_convert_encoding($pageContent, 'HTML-ENTITIES', 'UTF-8'));
 | 
			
		||||
        $xPath = new DOMXPath($doc);
 | 
			
		||||
        $headers = $xPath->query("//h1|//h2|//h3|//h4|//h5|//h6");
 | 
			
		||||
 | 
			
		||||
        if (is_null($headers)) {
 | 
			
		||||
            return [];
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $tree = collect($headers)->map(function($header) {
 | 
			
		||||
            $text = trim(str_replace("\xc2\xa0", '', $header->nodeValue));
 | 
			
		||||
            $text = mb_substr($text, 0, 100);
 | 
			
		||||
 | 
			
		||||
            return [
 | 
			
		||||
                'nodeName' => strtolower($header->nodeName),
 | 
			
		||||
                'level' => intval(str_replace('h', '', $header->nodeName)),
 | 
			
		||||
                'link' => '#' . $header->getAttribute('id'),
 | 
			
		||||
                'text' => $text,
 | 
			
		||||
            ];
 | 
			
		||||
        })->filter(function($header) {
 | 
			
		||||
            return mb_strlen($header['text']) > 0;
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        // Shift headers if only smaller headers have been used
 | 
			
		||||
        $levelChange = ($tree->pluck('level')->min() - 1);
 | 
			
		||||
        $tree = $tree->map(function ($header) use ($levelChange) {
 | 
			
		||||
            $header['level'] -= ($levelChange);
 | 
			
		||||
            return $header;
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        return $tree->toArray();
 | 
			
		||||
        $trashCan = new TrashCan();
 | 
			
		||||
        $trashCan->destroyPage($page);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Restores a revision's content back into a page.
 | 
			
		||||
     * @param Page $page
 | 
			
		||||
     * @param Book $book
 | 
			
		||||
     * @param  int $revisionId
 | 
			
		||||
     * @return Page
 | 
			
		||||
     * @throws \Exception
 | 
			
		||||
     */
 | 
			
		||||
    public function restorePageRevision(Page $page, Book $book, int $revisionId)
 | 
			
		||||
    public function restoreRevision(Page $page, int $revisionId): Page
 | 
			
		||||
    {
 | 
			
		||||
        $page->revision_count++;
 | 
			
		||||
        $this->savePageRevision($page);
 | 
			
		||||
 | 
			
		||||
        $revision = $page->revisions()->where('id', '=', $revisionId)->first();
 | 
			
		||||
        $page->fill($revision->toArray());
 | 
			
		||||
        $page->slug = $this->findSuitableSlug('page', $page->name, $page->id, $book->id);
 | 
			
		||||
        $page->text = $this->pageToPlainText($page);
 | 
			
		||||
        $content = new PageContent($page);
 | 
			
		||||
        $content->setNewHTML($page->html);
 | 
			
		||||
        $page->updated_by = user()->id;
 | 
			
		||||
        $page->refreshSlug();
 | 
			
		||||
        $page->save();
 | 
			
		||||
        $this->searchService->indexEntity($page);
 | 
			
		||||
 | 
			
		||||
        $page->indexForSearch();
 | 
			
		||||
        return $page;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Change the page's parent to the given entity.
 | 
			
		||||
     * @param Page $page
 | 
			
		||||
     * @param Entity $parent
 | 
			
		||||
     * @throws \Throwable
 | 
			
		||||
     * Move the given page into a new parent book or chapter.
 | 
			
		||||
     * The $parentIdentifier must be a string of the following format:
 | 
			
		||||
     * 'book:<id>' (book:5)
 | 
			
		||||
     * @throws MoveOperationException
 | 
			
		||||
     * @throws PermissionsException
 | 
			
		||||
     */
 | 
			
		||||
    public function changePageParent(Page $page, Entity $parent)
 | 
			
		||||
    public function move(Page $page, string $parentIdentifier): Book
 | 
			
		||||
    {
 | 
			
		||||
        $book = $parent->isA('book') ? $parent : $parent->book;
 | 
			
		||||
        $page->chapter_id = $parent->isA('chapter') ? $parent->id : 0;
 | 
			
		||||
        $page->save();
 | 
			
		||||
        if ($page->book->id !== $book->id) {
 | 
			
		||||
            $page = $this->changeBook('page', $book->id, $page);
 | 
			
		||||
        $parent = $this->findParentByIdentifier($parentIdentifier);
 | 
			
		||||
        if ($parent === null) {
 | 
			
		||||
            throw new MoveOperationException('Book or chapter to move page into not found');
 | 
			
		||||
        }
 | 
			
		||||
        $page->load('book');
 | 
			
		||||
        $this->permissionService->buildJointPermissionsForEntity($book);
 | 
			
		||||
 | 
			
		||||
        if (!userCan('page-create', $parent)) {
 | 
			
		||||
            throw new PermissionsException('User does not have permission to create a page within the new parent');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $page->chapter_id = ($parent instanceof Chapter) ? $parent->id : null;
 | 
			
		||||
        $page->changeBook($parent instanceof Book ? $parent->id : $parent->book->id);
 | 
			
		||||
        $page->rebuildPermissions();
 | 
			
		||||
 | 
			
		||||
        return ($parent instanceof Book ? $parent : $parent->book);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Create a copy of a page in a new location with a new name.
 | 
			
		||||
     * @param \BookStack\Entities\Page $page
 | 
			
		||||
     * @param \BookStack\Entities\Entity $newParent
 | 
			
		||||
     * @param string $newName
 | 
			
		||||
     * @return \BookStack\Entities\Page
 | 
			
		||||
     * @throws \Throwable
 | 
			
		||||
     * Copy an existing page in the system.
 | 
			
		||||
     * Optionally providing a new parent via string identifier and a new name.
 | 
			
		||||
     * @throws MoveOperationException
 | 
			
		||||
     * @throws PermissionsException
 | 
			
		||||
     */
 | 
			
		||||
    public function copyPage(Page $page, Entity $newParent, string $newName = '')
 | 
			
		||||
    public function copy(Page $page, string $parentIdentifier = null, string $newName = null): Page
 | 
			
		||||
    {
 | 
			
		||||
        $newBook = $newParent->isA('book') ? $newParent : $newParent->book;
 | 
			
		||||
        $newChapter = $newParent->isA('chapter') ? $newParent : null;
 | 
			
		||||
        $copyPage = $this->getDraftPage($newBook, $newChapter);
 | 
			
		||||
        $parent = $parentIdentifier ? $this->findParentByIdentifier($parentIdentifier) : $page->parent();
 | 
			
		||||
        if ($parent === null) {
 | 
			
		||||
            throw new MoveOperationException('Book or chapter to move page into not found');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!userCan('page-create', $parent)) {
 | 
			
		||||
            throw new PermissionsException('User does not have permission to create a page within the new parent');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $copyPage = $this->getNewDraftPage($parent);
 | 
			
		||||
        $pageData = $page->getAttributes();
 | 
			
		||||
 | 
			
		||||
        // Update name
 | 
			
		||||
| 
						 | 
				
			
			@ -524,38 +346,116 @@ class PageRepo extends EntityRepo
 | 
			
		|||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Set priority
 | 
			
		||||
        if ($newParent->isA('chapter')) {
 | 
			
		||||
            $pageData['priority'] = $this->getNewChapterPriority($newParent);
 | 
			
		||||
        } else {
 | 
			
		||||
            $pageData['priority'] = $this->getNewBookPriority($newParent);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return $this->publishPageDraft($copyPage, $pageData);
 | 
			
		||||
        return $this->publishDraft($copyPage, $pageData);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get pages that have been marked as templates.
 | 
			
		||||
     * @param int $count
 | 
			
		||||
     * @param int $page
 | 
			
		||||
     * @param string $search
 | 
			
		||||
     * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator
 | 
			
		||||
     * Find a page parent entity via a identifier string in the format:
 | 
			
		||||
     * {type}:{id}
 | 
			
		||||
     * Example: (book:5)
 | 
			
		||||
     * @throws MoveOperationException
 | 
			
		||||
     */
 | 
			
		||||
    public function getPageTemplates(int $count = 10, int $page = 1,  string $search = '')
 | 
			
		||||
    protected function findParentByIdentifier(string $identifier): ?Entity
 | 
			
		||||
    {
 | 
			
		||||
        $query = $this->entityQuery('page')
 | 
			
		||||
            ->where('template', '=', true)
 | 
			
		||||
            ->orderBy('name', 'asc')
 | 
			
		||||
            ->skip( ($page - 1) * $count)
 | 
			
		||||
            ->take($count);
 | 
			
		||||
        $stringExploded = explode(':', $identifier);
 | 
			
		||||
        $entityType = $stringExploded[0];
 | 
			
		||||
        $entityId = intval($stringExploded[1]);
 | 
			
		||||
 | 
			
		||||
        if ($search) {
 | 
			
		||||
            $query->where('name', 'like', '%' . $search . '%');
 | 
			
		||||
        if ($entityType !== 'book' && $entityType !== 'chapter') {
 | 
			
		||||
            throw new MoveOperationException('Pages can only be in books or chapters');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $paginator = $query->paginate($count, ['*'], 'page', $page);
 | 
			
		||||
        $paginator->withPath('/templates');
 | 
			
		||||
        $parentClass = $entityType === 'book' ? Book::class : Chapter::class;
 | 
			
		||||
        return $parentClass::visible()->where('id', '=', $entityId)->first();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
        return $paginator;
 | 
			
		||||
    /**
 | 
			
		||||
     * Update the permissions of a page.
 | 
			
		||||
     */
 | 
			
		||||
    public function updatePermissions(Page $page, bool $restricted, Collection $permissions = null)
 | 
			
		||||
    {
 | 
			
		||||
        $this->baseRepo->updatePermissions($page, $restricted, $permissions);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Change the page's parent to the given entity.
 | 
			
		||||
     */
 | 
			
		||||
    protected function changeParent(Page $page, Entity $parent)
 | 
			
		||||
    {
 | 
			
		||||
        $book = ($parent instanceof Book) ? $parent : $parent->book;
 | 
			
		||||
        $page->chapter_id = ($parent instanceof Chapter) ? $parent->id : 0;
 | 
			
		||||
        $page->save();
 | 
			
		||||
 | 
			
		||||
        if ($page->book->id !== $book->id) {
 | 
			
		||||
            $page->changeBook($book->id);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $page->load('book');
 | 
			
		||||
        $book->rebuildPermissions();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get a page revision to update for the given page.
 | 
			
		||||
     * Checks for an existing revisions before providing a fresh one.
 | 
			
		||||
     */
 | 
			
		||||
    protected function getPageRevisionToUpdate(Page $page): PageRevision
 | 
			
		||||
    {
 | 
			
		||||
        $drafts = $this->getUserDraftQuery($page)->get();
 | 
			
		||||
        if ($drafts->count() > 0) {
 | 
			
		||||
            return $drafts->first();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $draft = new PageRevision();
 | 
			
		||||
        $draft->page_id = $page->id;
 | 
			
		||||
        $draft->slug = $page->slug;
 | 
			
		||||
        $draft->book_slug = $page->book->slug;
 | 
			
		||||
        $draft->created_by = user()->id;
 | 
			
		||||
        $draft->type = 'update_draft';
 | 
			
		||||
        return $draft;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Delete old revisions, for the given page, from the system.
 | 
			
		||||
     */
 | 
			
		||||
    protected function deleteOldRevisions(Page $page)
 | 
			
		||||
    {
 | 
			
		||||
        $revisionLimit = config('app.revision_limit');
 | 
			
		||||
        if ($revisionLimit === false) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $revisionsToDelete = PageRevision::query()
 | 
			
		||||
            ->where('page_id', '=', $page->id)
 | 
			
		||||
            ->orderBy('created_at', 'desc')
 | 
			
		||||
            ->skip(intval($revisionLimit))
 | 
			
		||||
            ->take(10)
 | 
			
		||||
            ->get(['id']);
 | 
			
		||||
        if ($revisionsToDelete->count() > 0) {
 | 
			
		||||
            PageRevision::query()->whereIn('id', $revisionsToDelete->pluck('id'))->delete();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get a new priority for a page
 | 
			
		||||
     */
 | 
			
		||||
    protected function getNewPriority(Page $page): int
 | 
			
		||||
    {
 | 
			
		||||
        if ($page->parent() instanceof Chapter) {
 | 
			
		||||
            $lastPage = $page->parent()->pages('desc')->first();
 | 
			
		||||
            return $lastPage ? $lastPage->priority + 1 : 0;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return (new BookContents($page->book))->getLastPriority() + 1;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the query to find the user's draft copies of the given page.
 | 
			
		||||
     */
 | 
			
		||||
    protected function getUserDraftQuery(Page $page)
 | 
			
		||||
    {
 | 
			
		||||
        return PageRevision::query()->where('created_by', '=', user()->id)
 | 
			
		||||
            ->where('type', 'update_draft')
 | 
			
		||||
            ->where('page_id', '=', $page->id)
 | 
			
		||||
            ->orderBy('created_at', 'desc');
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
 | 
			
		|||
use Illuminate\Database\Query\Builder;
 | 
			
		||||
use Illuminate\Database\Query\JoinClause;
 | 
			
		||||
use Illuminate\Support\Collection;
 | 
			
		||||
use Illuminate\Support\Str;
 | 
			
		||||
 | 
			
		||||
class SearchService
 | 
			
		||||
{
 | 
			
		||||
| 
						 | 
				
			
			@ -210,7 +211,7 @@ class SearchService
 | 
			
		|||
 | 
			
		||||
        // Handle filters
 | 
			
		||||
        foreach ($terms['filters'] as $filterTerm => $filterValue) {
 | 
			
		||||
            $functionName = camel_case('filter_' . $filterTerm);
 | 
			
		||||
            $functionName = Str::camel('filter_' . $filterTerm);
 | 
			
		||||
            if (method_exists($this, $functionName)) {
 | 
			
		||||
                $this->$functionName($entitySelect, $entity, $filterValue);
 | 
			
		||||
            }
 | 
			
		||||
| 
						 | 
				
			
			@ -514,7 +515,7 @@ class SearchService
 | 
			
		|||
 | 
			
		||||
    protected function filterSortBy(EloquentBuilder $query, Entity $model, $input)
 | 
			
		||||
    {
 | 
			
		||||
        $functionName = camel_case('sort_by_' . $input);
 | 
			
		||||
        $functionName = Str::camel('sort_by_' . $input);
 | 
			
		||||
        if (method_exists($this, $functionName)) {
 | 
			
		||||
            $this->$functionName($query, $model);
 | 
			
		||||
        }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,62 @@
 | 
			
		|||
<?php namespace BookStack\Entities;
 | 
			
		||||
 | 
			
		||||
class SlugGenerator
 | 
			
		||||
{
 | 
			
		||||
 | 
			
		||||
    protected $entity;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * SlugGenerator constructor.
 | 
			
		||||
     * @param $entity
 | 
			
		||||
     */
 | 
			
		||||
    public function __construct(Entity $entity)
 | 
			
		||||
    {
 | 
			
		||||
        $this->entity = $entity;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Generate a fresh slug for the given entity.
 | 
			
		||||
     * The slug will generated so it does not conflict within the same parent item.
 | 
			
		||||
     */
 | 
			
		||||
    public function generate(): string
 | 
			
		||||
    {
 | 
			
		||||
        $slug = $this->formatNameAsSlug($this->entity->name);
 | 
			
		||||
        while ($this->slugInUse($slug)) {
 | 
			
		||||
            $slug .= '-' . substr(md5(rand(1, 500)), 0, 3);
 | 
			
		||||
        }
 | 
			
		||||
        return $slug;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Format a name as a url slug.
 | 
			
		||||
     */
 | 
			
		||||
    protected function formatNameAsSlug(string $name): string
 | 
			
		||||
    {
 | 
			
		||||
        $slug = preg_replace('/[\+\/\\\?\@\}\{\.\,\=\[\]\#\&\!\*\'\;\:\$\%]/', '', mb_strtolower($name));
 | 
			
		||||
        $slug = preg_replace('/\s{2,}/', ' ', $slug);
 | 
			
		||||
        $slug = str_replace(' ', '-', $slug);
 | 
			
		||||
        if ($slug === "") {
 | 
			
		||||
            $slug = substr(md5(rand(1, 500)), 0, 5);
 | 
			
		||||
        }
 | 
			
		||||
        return $slug;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if a slug is already in-use for this
 | 
			
		||||
     * type of model within the same parent.
 | 
			
		||||
     */
 | 
			
		||||
    protected function slugInUse(string $slug): bool
 | 
			
		||||
    {
 | 
			
		||||
        $query = $this->entity->newQuery()->where('slug', '=', $slug);
 | 
			
		||||
 | 
			
		||||
        if ($this->entity instanceof BookChild) {
 | 
			
		||||
            $query->where('book_id', '=', $this->entity->book_id);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if ($this->entity->id) {
 | 
			
		||||
            $query->where('id', '!=', $this->entity->id);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return $query->count() > 0;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,7 @@
 | 
			
		|||
<?php
 | 
			
		||||
 | 
			
		||||
namespace BookStack\Exceptions;
 | 
			
		||||
 | 
			
		||||
class ApiAuthException extends UnauthorizedException {
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -7,6 +7,9 @@ use Illuminate\Auth\Access\AuthorizationException;
 | 
			
		|||
use Illuminate\Auth\AuthenticationException;
 | 
			
		||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
 | 
			
		||||
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
 | 
			
		||||
use Illuminate\Http\JsonResponse;
 | 
			
		||||
use Illuminate\Http\Request;
 | 
			
		||||
use Illuminate\Http\Response;
 | 
			
		||||
use Illuminate\Validation\ValidationException;
 | 
			
		||||
use Symfony\Component\HttpKernel\Exception\HttpException;
 | 
			
		||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
 | 
			
		||||
| 
						 | 
				
			
			@ -47,10 +50,17 @@ class Handler extends ExceptionHandler
 | 
			
		|||
     */
 | 
			
		||||
    public function render($request, Exception $e)
 | 
			
		||||
    {
 | 
			
		||||
        if ($this->isApiRequest($request)) {
 | 
			
		||||
            return $this->renderApiException($e);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Handle notify exceptions which will redirect to the
 | 
			
		||||
        // specified location then show a notification message.
 | 
			
		||||
        if ($this->isExceptionType($e, NotifyException::class)) {
 | 
			
		||||
            session()->flash('error', $this->getOriginalMessage($e));
 | 
			
		||||
            $message = $this->getOriginalMessage($e);
 | 
			
		||||
            if (!empty($message)) {
 | 
			
		||||
                session()->flash('error', $message);
 | 
			
		||||
            }
 | 
			
		||||
            return redirect($e->redirectLocation);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -70,6 +80,41 @@ class Handler extends ExceptionHandler
 | 
			
		|||
        return parent::render($request, $e);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check if the given request is an API request.
 | 
			
		||||
     */
 | 
			
		||||
    protected function isApiRequest(Request $request): bool
 | 
			
		||||
    {
 | 
			
		||||
        return strpos($request->path(), 'api/') === 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Render an exception when the API is in use.
 | 
			
		||||
     */
 | 
			
		||||
    protected function renderApiException(Exception $e): JsonResponse
 | 
			
		||||
    {
 | 
			
		||||
        $code = $e->getCode() === 0 ? 500 : $e->getCode();
 | 
			
		||||
        $headers = [];
 | 
			
		||||
        if ($e instanceof HttpException) {
 | 
			
		||||
            $code = $e->getStatusCode();
 | 
			
		||||
            $headers = $e->getHeaders();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $responseData = [
 | 
			
		||||
            'error' => [
 | 
			
		||||
                'message' => $e->getMessage(),
 | 
			
		||||
            ]
 | 
			
		||||
        ];
 | 
			
		||||
 | 
			
		||||
        if ($e instanceof ValidationException) {
 | 
			
		||||
            $responseData['error']['validation'] = $e->errors();
 | 
			
		||||
            $code = $e->status;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $responseData['error']['code'] = $code;
 | 
			
		||||
        return new JsonResponse($responseData, $code, $headers);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check the exception chain to compare against the original exception type.
 | 
			
		||||
     * @param Exception $e
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,25 @@
 | 
			
		|||
<?php namespace BookStack\Exceptions;
 | 
			
		||||
 | 
			
		||||
use Exception;
 | 
			
		||||
 | 
			
		||||
class JsonDebugException extends Exception
 | 
			
		||||
{
 | 
			
		||||
 | 
			
		||||
    protected $data;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * JsonDebugException constructor.
 | 
			
		||||
     */
 | 
			
		||||
    public function __construct($data)
 | 
			
		||||
    {
 | 
			
		||||
        $this->data = $data;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Covert this exception into a response.
 | 
			
		||||
     */
 | 
			
		||||
    public function render()
 | 
			
		||||
    {
 | 
			
		||||
        return response()->json($this->data);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,6 @@
 | 
			
		|||
<?php namespace BookStack\Exceptions;
 | 
			
		||||
 | 
			
		||||
class LoginAttemptEmailNeededException extends LoginAttemptException
 | 
			
		||||
{
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,6 @@
 | 
			
		|||
<?php namespace BookStack\Exceptions;
 | 
			
		||||
 | 
			
		||||
class LoginAttemptException extends \Exception
 | 
			
		||||
{
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,8 @@
 | 
			
		|||
<?php namespace BookStack\Exceptions;
 | 
			
		||||
 | 
			
		||||
use Exception;
 | 
			
		||||
 | 
			
		||||
class MoveOperationException extends Exception
 | 
			
		||||
{
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,6 +1,6 @@
 | 
			
		|||
<?php namespace BookStack\Exceptions;
 | 
			
		||||
 | 
			
		||||
class AuthException extends PrettyException
 | 
			
		||||
class SamlException extends NotifyException
 | 
			
		||||
{
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,8 @@
 | 
			
		|||
<?php namespace BookStack\Exceptions;
 | 
			
		||||
 | 
			
		||||
use Exception;
 | 
			
		||||
 | 
			
		||||
class SortOperationException extends Exception
 | 
			
		||||
{
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,17 @@
 | 
			
		|||
<?php
 | 
			
		||||
 | 
			
		||||
namespace BookStack\Exceptions;
 | 
			
		||||
 | 
			
		||||
use Exception;
 | 
			
		||||
 | 
			
		||||
class UnauthorizedException extends Exception
 | 
			
		||||
{
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * ApiAuthException constructor.
 | 
			
		||||
     */
 | 
			
		||||
    public function __construct($message, $code = 401)
 | 
			
		||||
    {
 | 
			
		||||
        parent::__construct($message, $code);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,6 +1,7 @@
 | 
			
		|||
<?php namespace BookStack\Exceptions;
 | 
			
		||||
 | 
			
		||||
class UserTokenExpiredException extends \Exception {
 | 
			
		||||
class UserTokenExpiredException extends \Exception
 | 
			
		||||
{
 | 
			
		||||
 | 
			
		||||
    public $userId;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -14,6 +15,4 @@ class UserTokenExpiredException extends \Exception {
 | 
			
		|||
        $this->userId = $userId;
 | 
			
		||||
        parent::__construct($message);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,3 +1,6 @@
 | 
			
		|||
<?php namespace BookStack\Exceptions;
 | 
			
		||||
 | 
			
		||||
class UserTokenNotFoundException extends \Exception {}
 | 
			
		||||
class UserTokenNotFoundException extends \Exception
 | 
			
		||||
{
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,16 @@
 | 
			
		|||
<?php namespace BookStack\Facades;
 | 
			
		||||
 | 
			
		||||
use Illuminate\Support\Facades\Facade;
 | 
			
		||||
 | 
			
		||||
class Permissions extends Facade
 | 
			
		||||
{
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the registered name of the component.
 | 
			
		||||
     *
 | 
			
		||||
     * @return string
 | 
			
		||||
     */
 | 
			
		||||
    protected static function getFacadeAccessor()
 | 
			
		||||
    {
 | 
			
		||||
        return 'permissions';
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,30 @@
 | 
			
		|||
<?php namespace BookStack\Http\Controllers\Api;
 | 
			
		||||
 | 
			
		||||
use BookStack\Api\ListingResponseBuilder;
 | 
			
		||||
use BookStack\Http\Controllers\Controller;
 | 
			
		||||
use Illuminate\Database\Eloquent\Builder;
 | 
			
		||||
use Illuminate\Http\JsonResponse;
 | 
			
		||||
 | 
			
		||||
class ApiController extends Controller
 | 
			
		||||
{
 | 
			
		||||
 | 
			
		||||
    protected $rules = [];
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Provide a paginated listing JSON response in a standard format
 | 
			
		||||
     * taking into account any pagination parameters passed by the user.
 | 
			
		||||
     */
 | 
			
		||||
    protected function apiListingResponse(Builder $query, array $fields): JsonResponse
 | 
			
		||||
    {
 | 
			
		||||
        $listing = new ListingResponseBuilder($query, request(), $fields);
 | 
			
		||||
        return $listing->toResponse();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the validation rules for this controller.
 | 
			
		||||
     */
 | 
			
		||||
    public function getValdationRules(): array
 | 
			
		||||
    {
 | 
			
		||||
        return $this->rules;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,47 @@
 | 
			
		|||
<?php namespace BookStack\Http\Controllers\Api;
 | 
			
		||||
 | 
			
		||||
use BookStack\Api\ApiDocsGenerator;
 | 
			
		||||
use Cache;
 | 
			
		||||
use Illuminate\Support\Collection;
 | 
			
		||||
 | 
			
		||||
class ApiDocsController extends ApiController
 | 
			
		||||
{
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Load the docs page for the API.
 | 
			
		||||
     */
 | 
			
		||||
    public function display()
 | 
			
		||||
    {
 | 
			
		||||
        $docs = $this->getDocs();
 | 
			
		||||
        return view('api-docs.index', [
 | 
			
		||||
            'docs' => $docs,
 | 
			
		||||
        ]);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Show a JSON view of the API docs data.
 | 
			
		||||
     */
 | 
			
		||||
    public function json() {
 | 
			
		||||
        $docs = $this->getDocs();
 | 
			
		||||
        return response()->json($docs);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the base docs data.
 | 
			
		||||
     * Checks and uses the system cache for quick re-fetching.
 | 
			
		||||
     */
 | 
			
		||||
    protected function getDocs(): Collection
 | 
			
		||||
    {
 | 
			
		||||
        $appVersion = trim(file_get_contents(base_path('version')));
 | 
			
		||||
        $cacheKey = 'api-docs::' . $appVersion;
 | 
			
		||||
        if (Cache::has($cacheKey) && config('app.env') === 'production') {
 | 
			
		||||
            $docs = Cache::get($cacheKey);
 | 
			
		||||
        } else {
 | 
			
		||||
            $docs = (new ApiDocsGenerator())->generate();
 | 
			
		||||
            Cache::put($cacheKey, $docs, 60*24);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return $docs;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,101 @@
 | 
			
		|||
<?php namespace BookStack\Http\Controllers\Api;
 | 
			
		||||
 | 
			
		||||
use BookStack\Entities\Book;
 | 
			
		||||
use BookStack\Entities\Repos\BookRepo;
 | 
			
		||||
use BookStack\Exceptions\NotifyException;
 | 
			
		||||
use BookStack\Facades\Activity;
 | 
			
		||||
use Illuminate\Contracts\Container\BindingResolutionException;
 | 
			
		||||
use Illuminate\Http\Request;
 | 
			
		||||
use Illuminate\Validation\ValidationException;
 | 
			
		||||
 | 
			
		||||
class BooksApiController extends ApiController
 | 
			
		||||
{
 | 
			
		||||
 | 
			
		||||
    protected $bookRepo;
 | 
			
		||||
 | 
			
		||||
    protected $rules = [
 | 
			
		||||
        'create' => [
 | 
			
		||||
            'name' => 'required|string|max:255',
 | 
			
		||||
            'description' => 'string|max:1000',
 | 
			
		||||
        ],
 | 
			
		||||
        'update' => [
 | 
			
		||||
            'name' => 'string|min:1|max:255',
 | 
			
		||||
            'description' => 'string|max:1000',
 | 
			
		||||
        ],
 | 
			
		||||
    ];
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * BooksApiController constructor.
 | 
			
		||||
     */
 | 
			
		||||
    public function __construct(BookRepo $bookRepo)
 | 
			
		||||
    {
 | 
			
		||||
        $this->bookRepo = $bookRepo;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get a listing of books visible to the user.
 | 
			
		||||
     */
 | 
			
		||||
    public function list()
 | 
			
		||||
    {
 | 
			
		||||
        $books = Book::visible();
 | 
			
		||||
        return $this->apiListingResponse($books, [
 | 
			
		||||
            'id', 'name', 'slug', 'description', 'created_at', 'updated_at', 'created_by', 'updated_by', 'image_id',
 | 
			
		||||
        ]);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Create a new book in the system.
 | 
			
		||||
     * @throws ValidationException
 | 
			
		||||
     */
 | 
			
		||||
    public function create(Request $request)
 | 
			
		||||
    {
 | 
			
		||||
        $this->checkPermission('book-create-all');
 | 
			
		||||
        $requestData = $this->validate($request, $this->rules['create']);
 | 
			
		||||
 | 
			
		||||
        $book = $this->bookRepo->create($requestData);
 | 
			
		||||
        Activity::add($book, 'book_create', $book->id);
 | 
			
		||||
 | 
			
		||||
        return response()->json($book);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * View the details of a single book.
 | 
			
		||||
     */
 | 
			
		||||
    public function read(string $id)
 | 
			
		||||
    {
 | 
			
		||||
        $book = Book::visible()->with(['tags', 'cover', 'createdBy', 'updatedBy'])->findOrFail($id);
 | 
			
		||||
        return response()->json($book);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Update the details of a single book.
 | 
			
		||||
     * @throws ValidationException
 | 
			
		||||
     */
 | 
			
		||||
    public function update(Request $request, string $id)
 | 
			
		||||
    {
 | 
			
		||||
        $book = Book::visible()->findOrFail($id);
 | 
			
		||||
        $this->checkOwnablePermission('book-update', $book);
 | 
			
		||||
 | 
			
		||||
        $requestData = $this->validate($request, $this->rules['update']);
 | 
			
		||||
        $book = $this->bookRepo->update($book, $requestData);
 | 
			
		||||
        Activity::add($book, 'book_update', $book->id);
 | 
			
		||||
 | 
			
		||||
        return response()->json($book);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Delete a single book from the system.
 | 
			
		||||
     * @throws NotifyException
 | 
			
		||||
     * @throws BindingResolutionException
 | 
			
		||||
     */
 | 
			
		||||
    public function delete(string $id)
 | 
			
		||||
    {
 | 
			
		||||
        $book = Book::visible()->findOrFail($id);
 | 
			
		||||
        $this->checkOwnablePermission('book-delete', $book);
 | 
			
		||||
 | 
			
		||||
        $this->bookRepo->destroy($book);
 | 
			
		||||
        Activity::addMessage('book_delete', $book->name);
 | 
			
		||||
 | 
			
		||||
        return response('', 204);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,37 +1,37 @@
 | 
			
		|||
<?php namespace BookStack\Http\Controllers;
 | 
			
		||||
 | 
			
		||||
use BookStack\Entities\Repos\EntityRepo;
 | 
			
		||||
use BookStack\Entities\Repos\PageRepo;
 | 
			
		||||
use BookStack\Exceptions\FileUploadException;
 | 
			
		||||
use BookStack\Exceptions\NotFoundException;
 | 
			
		||||
use BookStack\Uploads\Attachment;
 | 
			
		||||
use BookStack\Uploads\AttachmentService;
 | 
			
		||||
use Exception;
 | 
			
		||||
use Illuminate\Contracts\Filesystem\FileNotFoundException;
 | 
			
		||||
use Illuminate\Http\Request;
 | 
			
		||||
use Illuminate\Validation\ValidationException;
 | 
			
		||||
 | 
			
		||||
class AttachmentController extends Controller
 | 
			
		||||
{
 | 
			
		||||
    protected $attachmentService;
 | 
			
		||||
    protected $attachment;
 | 
			
		||||
    protected $entityRepo;
 | 
			
		||||
    protected $pageRepo;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * AttachmentController constructor.
 | 
			
		||||
     * @param \BookStack\Uploads\AttachmentService $attachmentService
 | 
			
		||||
     * @param Attachment $attachment
 | 
			
		||||
     * @param EntityRepo $entityRepo
 | 
			
		||||
     */
 | 
			
		||||
    public function __construct(AttachmentService $attachmentService, Attachment $attachment, EntityRepo $entityRepo)
 | 
			
		||||
    public function __construct(AttachmentService $attachmentService, Attachment $attachment, PageRepo $pageRepo)
 | 
			
		||||
    {
 | 
			
		||||
        $this->attachmentService = $attachmentService;
 | 
			
		||||
        $this->attachment = $attachment;
 | 
			
		||||
        $this->entityRepo = $entityRepo;
 | 
			
		||||
        $this->pageRepo = $pageRepo;
 | 
			
		||||
        parent::__construct();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Endpoint at which attachments are uploaded to.
 | 
			
		||||
     * @param Request $request
 | 
			
		||||
     * @return \Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Http\JsonResponse|\Symfony\Component\HttpFoundation\Response
 | 
			
		||||
     * @throws ValidationException
 | 
			
		||||
     * @throws NotFoundException
 | 
			
		||||
     */
 | 
			
		||||
    public function upload(Request $request)
 | 
			
		||||
    {
 | 
			
		||||
| 
						 | 
				
			
			@ -41,7 +41,7 @@ class AttachmentController extends Controller
 | 
			
		|||
        ]);
 | 
			
		||||
 | 
			
		||||
        $pageId = $request->get('uploaded_to');
 | 
			
		||||
        $page = $this->entityRepo->getById('page', $pageId, true);
 | 
			
		||||
        $page = $this->pageRepo->getById($pageId);
 | 
			
		||||
 | 
			
		||||
        $this->checkPermission('attachment-create-all');
 | 
			
		||||
        $this->checkOwnablePermission('page-update', $page);
 | 
			
		||||
| 
						 | 
				
			
			@ -59,11 +59,10 @@ class AttachmentController extends Controller
 | 
			
		|||
 | 
			
		||||
    /**
 | 
			
		||||
     * Update an uploaded attachment.
 | 
			
		||||
     * @param int $attachmentId
 | 
			
		||||
     * @param Request $request
 | 
			
		||||
     * @return mixed
 | 
			
		||||
     * @throws ValidationException
 | 
			
		||||
     * @throws NotFoundException
 | 
			
		||||
     */
 | 
			
		||||
    public function uploadUpdate($attachmentId, Request $request)
 | 
			
		||||
    public function uploadUpdate(Request $request, $attachmentId)
 | 
			
		||||
    {
 | 
			
		||||
        $this->validate($request, [
 | 
			
		||||
            'uploaded_to' => 'required|integer|exists:pages,id',
 | 
			
		||||
| 
						 | 
				
			
			@ -71,7 +70,7 @@ class AttachmentController extends Controller
 | 
			
		|||
        ]);
 | 
			
		||||
 | 
			
		||||
        $pageId = $request->get('uploaded_to');
 | 
			
		||||
        $page = $this->entityRepo->getById('page', $pageId, true);
 | 
			
		||||
        $page = $this->pageRepo->getById($pageId);
 | 
			
		||||
        $attachment = $this->attachment->findOrFail($attachmentId);
 | 
			
		||||
 | 
			
		||||
        $this->checkOwnablePermission('page-update', $page);
 | 
			
		||||
| 
						 | 
				
			
			@ -94,11 +93,10 @@ class AttachmentController extends Controller
 | 
			
		|||
 | 
			
		||||
    /**
 | 
			
		||||
     * Update the details of an existing file.
 | 
			
		||||
     * @param $attachmentId
 | 
			
		||||
     * @param Request $request
 | 
			
		||||
     * @return Attachment|mixed
 | 
			
		||||
     * @throws ValidationException
 | 
			
		||||
     * @throws NotFoundException
 | 
			
		||||
     */
 | 
			
		||||
    public function update($attachmentId, Request $request)
 | 
			
		||||
    public function update(Request $request, $attachmentId)
 | 
			
		||||
    {
 | 
			
		||||
        $this->validate($request, [
 | 
			
		||||
            'uploaded_to' => 'required|integer|exists:pages,id',
 | 
			
		||||
| 
						 | 
				
			
			@ -107,7 +105,7 @@ class AttachmentController extends Controller
 | 
			
		|||
        ]);
 | 
			
		||||
 | 
			
		||||
        $pageId = $request->get('uploaded_to');
 | 
			
		||||
        $page = $this->entityRepo->getById('page', $pageId, true);
 | 
			
		||||
        $page = $this->pageRepo->getById($pageId);
 | 
			
		||||
        $attachment = $this->attachment->findOrFail($attachmentId);
 | 
			
		||||
 | 
			
		||||
        $this->checkOwnablePermission('page-update', $page);
 | 
			
		||||
| 
						 | 
				
			
			@ -123,8 +121,8 @@ class AttachmentController extends Controller
 | 
			
		|||
 | 
			
		||||
    /**
 | 
			
		||||
     * Attach a link to a page.
 | 
			
		||||
     * @param Request $request
 | 
			
		||||
     * @return mixed
 | 
			
		||||
     * @throws ValidationException
 | 
			
		||||
     * @throws NotFoundException
 | 
			
		||||
     */
 | 
			
		||||
    public function attachLink(Request $request)
 | 
			
		||||
    {
 | 
			
		||||
| 
						 | 
				
			
			@ -135,7 +133,7 @@ class AttachmentController extends Controller
 | 
			
		|||
        ]);
 | 
			
		||||
 | 
			
		||||
        $pageId = $request->get('uploaded_to');
 | 
			
		||||
        $page = $this->entityRepo->getById('page', $pageId, true);
 | 
			
		||||
        $page = $this->pageRepo->getById($pageId);
 | 
			
		||||
 | 
			
		||||
        $this->checkPermission('attachment-create-all');
 | 
			
		||||
        $this->checkOwnablePermission('page-update', $page);
 | 
			
		||||
| 
						 | 
				
			
			@ -149,29 +147,26 @@ class AttachmentController extends Controller
 | 
			
		|||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the attachments for a specific page.
 | 
			
		||||
     * @param $pageId
 | 
			
		||||
     * @return mixed
 | 
			
		||||
     */
 | 
			
		||||
    public function listForPage($pageId)
 | 
			
		||||
    public function listForPage(int $pageId)
 | 
			
		||||
    {
 | 
			
		||||
        $page = $this->entityRepo->getById('page', $pageId, true);
 | 
			
		||||
        $page = $this->pageRepo->getById($pageId);
 | 
			
		||||
        $this->checkOwnablePermission('page-view', $page);
 | 
			
		||||
        return response()->json($page->attachments);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Update the attachment sorting.
 | 
			
		||||
     * @param $pageId
 | 
			
		||||
     * @param Request $request
 | 
			
		||||
     * @return mixed
 | 
			
		||||
     * @throws ValidationException
 | 
			
		||||
     * @throws NotFoundException
 | 
			
		||||
     */
 | 
			
		||||
    public function sortForPage($pageId, Request $request)
 | 
			
		||||
    public function sortForPage(Request $request, int $pageId)
 | 
			
		||||
    {
 | 
			
		||||
        $this->validate($request, [
 | 
			
		||||
            'files' => 'required|array',
 | 
			
		||||
            'files.*.id' => 'required|integer',
 | 
			
		||||
        ]);
 | 
			
		||||
        $page = $this->entityRepo->getById('page', $pageId);
 | 
			
		||||
        $page = $this->pageRepo->getById($pageId);
 | 
			
		||||
        $this->checkOwnablePermission('page-update', $page);
 | 
			
		||||
 | 
			
		||||
        $attachments = $request->get('files');
 | 
			
		||||
| 
						 | 
				
			
			@ -181,16 +176,15 @@ class AttachmentController extends Controller
 | 
			
		|||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get an attachment from storage.
 | 
			
		||||
     * @param $attachmentId
 | 
			
		||||
     * @return \Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector|\Symfony\Component\HttpFoundation\Response
 | 
			
		||||
     * @throws \Illuminate\Contracts\Filesystem\FileNotFoundException
 | 
			
		||||
     * @throws FileNotFoundException
 | 
			
		||||
     * @throws NotFoundException
 | 
			
		||||
     */
 | 
			
		||||
    public function get($attachmentId)
 | 
			
		||||
    public function get(int $attachmentId)
 | 
			
		||||
    {
 | 
			
		||||
        $attachment = $this->attachment->findOrFail($attachmentId);
 | 
			
		||||
        $page = $this->entityRepo->getById('page', $attachment->uploaded_to);
 | 
			
		||||
        if ($page === null) {
 | 
			
		||||
        try {
 | 
			
		||||
            $page = $this->pageRepo->getById($attachment->uploaded_to);
 | 
			
		||||
        } catch (NotFoundException $exception) {
 | 
			
		||||
            throw new NotFoundException(trans('errors.attachment_not_found'));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -208,9 +202,9 @@ class AttachmentController extends Controller
 | 
			
		|||
     * Delete a specific attachment in the system.
 | 
			
		||||
     * @param $attachmentId
 | 
			
		||||
     * @return mixed
 | 
			
		||||
     * @throws \Exception
 | 
			
		||||
     * @throws Exception
 | 
			
		||||
     */
 | 
			
		||||
    public function delete($attachmentId)
 | 
			
		||||
    public function delete(int $attachmentId)
 | 
			
		||||
    {
 | 
			
		||||
        $attachment = $this->attachment->findOrFail($attachmentId);
 | 
			
		||||
        $this->checkOwnablePermission('attachment-delete', $attachment);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -64,16 +64,15 @@ class ConfirmEmailController extends Controller
 | 
			
		|||
        try {
 | 
			
		||||
            $userId = $this->emailConfirmationService->checkTokenAndGetUserId($token);
 | 
			
		||||
        } catch (Exception $exception) {
 | 
			
		||||
 | 
			
		||||
            if ($exception instanceof UserTokenNotFoundException) {
 | 
			
		||||
                session()->flash('error', trans('errors.email_confirmation_invalid'));
 | 
			
		||||
                $this->showErrorNotification(trans('errors.email_confirmation_invalid'));
 | 
			
		||||
                return redirect('/register');
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if ($exception instanceof UserTokenExpiredException) {
 | 
			
		||||
                $user = $this->userRepo->getById($exception->userId);
 | 
			
		||||
                $this->emailConfirmationService->sendConfirmation($user);
 | 
			
		||||
                session()->flash('error', trans('errors.email_confirmation_expired'));
 | 
			
		||||
                $this->showErrorNotification(trans('errors.email_confirmation_expired'));
 | 
			
		||||
                return redirect('/register/confirm');
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -85,7 +84,7 @@ class ConfirmEmailController extends Controller
 | 
			
		|||
        $user->save();
 | 
			
		||||
 | 
			
		||||
        auth()->login($user);
 | 
			
		||||
        session()->flash('success', trans('auth.email_confirm_success'));
 | 
			
		||||
        $this->showSuccessNotification(trans('auth.email_confirm_success'));
 | 
			
		||||
        $this->emailConfirmationService->deleteByUser($user);
 | 
			
		||||
 | 
			
		||||
        return redirect('/');
 | 
			
		||||
| 
						 | 
				
			
			@ -107,12 +106,11 @@ class ConfirmEmailController extends Controller
 | 
			
		|||
        try {
 | 
			
		||||
            $this->emailConfirmationService->sendConfirmation($user);
 | 
			
		||||
        } catch (Exception $e) {
 | 
			
		||||
            session()->flash('error', trans('auth.email_confirm_send_error'));
 | 
			
		||||
            $this->showErrorNotification(trans('auth.email_confirm_send_error'));
 | 
			
		||||
            return redirect('/register/confirm');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        session()->flash('success', trans('auth.email_confirm_resent'));
 | 
			
		||||
        $this->showSuccessNotification(trans('auth.email_confirm_resent'));
 | 
			
		||||
        return redirect('/register/confirm');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -30,6 +30,7 @@ class ForgotPasswordController extends Controller
 | 
			
		|||
    public function __construct()
 | 
			
		||||
    {
 | 
			
		||||
        $this->middleware('guest');
 | 
			
		||||
        $this->middleware('guard:standard');
 | 
			
		||||
        parent::__construct();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -53,7 +54,7 @@ class ForgotPasswordController extends Controller
 | 
			
		|||
 | 
			
		||||
        if ($response === Password::RESET_LINK_SENT) {
 | 
			
		||||
            $message = trans('auth.reset_password_sent_success', ['email' => $request->get('email')]);
 | 
			
		||||
            session()->flash('success', $message);
 | 
			
		||||
            $this->showSuccessNotification($message);
 | 
			
		||||
            return back()->with('status', trans($response));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,12 +2,11 @@
 | 
			
		|||
 | 
			
		||||
namespace BookStack\Http\Controllers\Auth;
 | 
			
		||||
 | 
			
		||||
use BookStack\Auth\Access\LdapService;
 | 
			
		||||
use BookStack\Auth\Access\SocialAuthService;
 | 
			
		||||
use BookStack\Auth\UserRepo;
 | 
			
		||||
use BookStack\Exceptions\AuthException;
 | 
			
		||||
use BookStack\Exceptions\LoginAttemptEmailNeededException;
 | 
			
		||||
use BookStack\Exceptions\LoginAttemptException;
 | 
			
		||||
use BookStack\Exceptions\UserRegistrationException;
 | 
			
		||||
use BookStack\Http\Controllers\Controller;
 | 
			
		||||
use Illuminate\Contracts\Auth\Authenticatable;
 | 
			
		||||
use Illuminate\Foundation\Auth\AuthenticatesUsers;
 | 
			
		||||
use Illuminate\Http\Request;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -27,32 +26,23 @@ class LoginController extends Controller
 | 
			
		|||
    use AuthenticatesUsers;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Where to redirect users after login.
 | 
			
		||||
     *
 | 
			
		||||
     * @var string
 | 
			
		||||
     * Redirection paths
 | 
			
		||||
     */
 | 
			
		||||
    protected $redirectTo = '/';
 | 
			
		||||
 | 
			
		||||
    protected $redirectPath = '/';
 | 
			
		||||
    protected $redirectAfterLogout = '/login';
 | 
			
		||||
 | 
			
		||||
    protected $socialAuthService;
 | 
			
		||||
    protected $ldapService;
 | 
			
		||||
    protected $userRepo;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Create a new controller instance.
 | 
			
		||||
     *
 | 
			
		||||
     * @param \BookStack\Auth\\BookStack\Auth\Access\SocialAuthService $socialAuthService
 | 
			
		||||
     * @param LdapService $ldapService
 | 
			
		||||
     * @param \BookStack\Auth\UserRepo $userRepo
 | 
			
		||||
     */
 | 
			
		||||
    public function __construct(SocialAuthService $socialAuthService, LdapService $ldapService, UserRepo $userRepo)
 | 
			
		||||
    public function __construct(SocialAuthService $socialAuthService)
 | 
			
		||||
    {
 | 
			
		||||
        $this->middleware('guest', ['only' => ['getLogin', 'postLogin']]);
 | 
			
		||||
        $this->middleware('guest', ['only' => ['getLogin', 'login']]);
 | 
			
		||||
        $this->middleware('guard:standard,ldap', ['only' => ['login', 'logout']]);
 | 
			
		||||
 | 
			
		||||
        $this->socialAuthService = $socialAuthService;
 | 
			
		||||
        $this->ldapService = $ldapService;
 | 
			
		||||
        $this->userRepo = $userRepo;
 | 
			
		||||
        $this->redirectPath = url('/');
 | 
			
		||||
        $this->redirectAfterLogout = url('/login');
 | 
			
		||||
        parent::__construct();
 | 
			
		||||
| 
						 | 
				
			
			@ -64,55 +54,15 @@ class LoginController extends Controller
 | 
			
		|||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Overrides the action when a user is authenticated.
 | 
			
		||||
     * If the user authenticated but does not exist in the user table we create them.
 | 
			
		||||
     * @param Request $request
 | 
			
		||||
     * @param Authenticatable $user
 | 
			
		||||
     * @return \Illuminate\Http\RedirectResponse
 | 
			
		||||
     * @throws AuthException
 | 
			
		||||
     * @throws \BookStack\Exceptions\LdapException
 | 
			
		||||
     * Get the needed authorization credentials from the request.
 | 
			
		||||
     */
 | 
			
		||||
    protected function authenticated(Request $request, Authenticatable $user)
 | 
			
		||||
    protected function credentials(Request $request)
 | 
			
		||||
    {
 | 
			
		||||
        // Explicitly log them out for now if they do no exist.
 | 
			
		||||
        if (!$user->exists) {
 | 
			
		||||
            auth()->logout($user);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!$user->exists && $user->email === null && !$request->filled('email')) {
 | 
			
		||||
            $request->flash();
 | 
			
		||||
            session()->flash('request-email', true);
 | 
			
		||||
            return redirect('/login');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!$user->exists && $user->email === null && $request->filled('email')) {
 | 
			
		||||
            $user->email = $request->get('email');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!$user->exists) {
 | 
			
		||||
            // Check for users with same email already
 | 
			
		||||
            $alreadyUser = $user->newQuery()->where('email', '=', $user->email)->count() > 0;
 | 
			
		||||
            if ($alreadyUser) {
 | 
			
		||||
                throw new AuthException(trans('errors.error_user_exists_different_creds', ['email' => $user->email]));
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            $user->save();
 | 
			
		||||
            $this->userRepo->attachDefaultRole($user);
 | 
			
		||||
            auth()->login($user);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Sync LDAP groups if required
 | 
			
		||||
        if ($this->ldapService->shouldSyncGroups()) {
 | 
			
		||||
            $this->ldapService->syncGroups($user, $request->get($this->username()));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return redirect()->intended('/');
 | 
			
		||||
        return $request->only('username', 'email', 'password');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Show the application login form.
 | 
			
		||||
     * @param Request $request
 | 
			
		||||
     * @return \Illuminate\Http\Response
 | 
			
		||||
     */
 | 
			
		||||
    public function getLogin(Request $request)
 | 
			
		||||
    {
 | 
			
		||||
| 
						 | 
				
			
			@ -126,18 +76,90 @@ class LoginController extends Controller
 | 
			
		|||
            ]);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return view('auth.login', ['socialDrivers' => $socialDrivers, 'authMethod' => $authMethod]);
 | 
			
		||||
        return view('auth.login', [
 | 
			
		||||
          'socialDrivers' => $socialDrivers,
 | 
			
		||||
          'authMethod' => $authMethod,
 | 
			
		||||
        ]);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Redirect to the relevant social site.
 | 
			
		||||
     * @param $socialDriver
 | 
			
		||||
     * @return \Symfony\Component\HttpFoundation\RedirectResponse
 | 
			
		||||
     * @throws \BookStack\Exceptions\SocialDriverNotConfigured
 | 
			
		||||
     * Handle a login request to the application.
 | 
			
		||||
     *
 | 
			
		||||
     * @param  \Illuminate\Http\Request  $request
 | 
			
		||||
     * @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\Response|\Illuminate\Http\JsonResponse
 | 
			
		||||
     *
 | 
			
		||||
     * @throws \Illuminate\Validation\ValidationException
 | 
			
		||||
     */
 | 
			
		||||
    public function getSocialLogin($socialDriver)
 | 
			
		||||
    public function login(Request $request)
 | 
			
		||||
    {
 | 
			
		||||
        session()->put('social-callback', 'login');
 | 
			
		||||
        return $this->socialAuthService->startLogIn($socialDriver);
 | 
			
		||||
        $this->validateLogin($request);
 | 
			
		||||
 | 
			
		||||
        // If the class is using the ThrottlesLogins trait, we can automatically throttle
 | 
			
		||||
        // the login attempts for this application. We'll key this by the username and
 | 
			
		||||
        // the IP address of the client making these requests into this application.
 | 
			
		||||
        if (method_exists($this, 'hasTooManyLoginAttempts') &&
 | 
			
		||||
            $this->hasTooManyLoginAttempts($request)) {
 | 
			
		||||
            $this->fireLockoutEvent($request);
 | 
			
		||||
 | 
			
		||||
            return $this->sendLockoutResponse($request);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
            if ($this->attemptLogin($request)) {
 | 
			
		||||
                return $this->sendLoginResponse($request);
 | 
			
		||||
            }
 | 
			
		||||
        } catch (LoginAttemptException $exception) {
 | 
			
		||||
            return $this->sendLoginAttemptExceptionResponse($exception, $request);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // If the login attempt was unsuccessful we will increment the number of attempts
 | 
			
		||||
        // to login and redirect the user back to the login form. Of course, when this
 | 
			
		||||
        // user surpasses their maximum number of attempts they will get locked out.
 | 
			
		||||
        $this->incrementLoginAttempts($request);
 | 
			
		||||
 | 
			
		||||
        return $this->sendFailedLoginResponse($request);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Validate the user login request.
 | 
			
		||||
     *
 | 
			
		||||
     * @param  \Illuminate\Http\Request  $request
 | 
			
		||||
     * @return void
 | 
			
		||||
     *
 | 
			
		||||
     * @throws \Illuminate\Validation\ValidationException
 | 
			
		||||
     */
 | 
			
		||||
    protected function validateLogin(Request $request)
 | 
			
		||||
    {
 | 
			
		||||
        $rules = ['password' => 'required|string'];
 | 
			
		||||
        $authMethod = config('auth.method');
 | 
			
		||||
 | 
			
		||||
        if ($authMethod === 'standard') {
 | 
			
		||||
            $rules['email'] = 'required|email';
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if ($authMethod === 'ldap') {
 | 
			
		||||
            $rules['username'] = 'required|string';
 | 
			
		||||
            $rules['email'] = 'email';
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $request->validate($rules);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Send a response when a login attempt exception occurs.
 | 
			
		||||
     */
 | 
			
		||||
    protected function sendLoginAttemptExceptionResponse(LoginAttemptException $exception, Request $request)
 | 
			
		||||
    {
 | 
			
		||||
        if ($exception instanceof LoginAttemptEmailNeededException) {
 | 
			
		||||
            $request->flash();
 | 
			
		||||
            session()->flash('request-email', true);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if ($message = $exception->getMessage()) {
 | 
			
		||||
            $this->showWarningNotification($message);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return redirect('/login');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,23 +2,14 @@
 | 
			
		|||
 | 
			
		||||
namespace BookStack\Http\Controllers\Auth;
 | 
			
		||||
 | 
			
		||||
use BookStack\Auth\Access\EmailConfirmationService;
 | 
			
		||||
use BookStack\Auth\Access\RegistrationService;
 | 
			
		||||
use BookStack\Auth\Access\SocialAuthService;
 | 
			
		||||
use BookStack\Auth\SocialAccount;
 | 
			
		||||
use BookStack\Auth\User;
 | 
			
		||||
use BookStack\Auth\UserRepo;
 | 
			
		||||
use BookStack\Exceptions\SocialDriverNotConfigured;
 | 
			
		||||
use BookStack\Exceptions\SocialSignInAccountNotUsed;
 | 
			
		||||
use BookStack\Exceptions\SocialSignInException;
 | 
			
		||||
use BookStack\Exceptions\UserRegistrationException;
 | 
			
		||||
use BookStack\Http\Controllers\Controller;
 | 
			
		||||
use Exception;
 | 
			
		||||
use Illuminate\Foundation\Auth\RegistersUsers;
 | 
			
		||||
use Illuminate\Http\RedirectResponse;
 | 
			
		||||
use Illuminate\Http\Request;
 | 
			
		||||
use Illuminate\Http\Response;
 | 
			
		||||
use Illuminate\Routing\Redirector;
 | 
			
		||||
use Laravel\Socialite\Contracts\User as SocialUser;
 | 
			
		||||
use Illuminate\Support\Facades\Hash;
 | 
			
		||||
use Validator;
 | 
			
		||||
 | 
			
		||||
class RegisterController extends Controller
 | 
			
		||||
| 
						 | 
				
			
			@ -37,8 +28,7 @@ class RegisterController extends Controller
 | 
			
		|||
    use RegistersUsers;
 | 
			
		||||
 | 
			
		||||
    protected $socialAuthService;
 | 
			
		||||
    protected $emailConfirmationService;
 | 
			
		||||
    protected $userRepo;
 | 
			
		||||
    protected $registrationService;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Where to redirect users after login / registration.
 | 
			
		||||
| 
						 | 
				
			
			@ -50,17 +40,15 @@ class RegisterController extends Controller
 | 
			
		|||
 | 
			
		||||
    /**
 | 
			
		||||
     * Create a new controller instance.
 | 
			
		||||
     *
 | 
			
		||||
     * @param SocialAuthService $socialAuthService
 | 
			
		||||
     * @param EmailConfirmationService $emailConfirmationService
 | 
			
		||||
     * @param UserRepo $userRepo
 | 
			
		||||
     */
 | 
			
		||||
    public function __construct(SocialAuthService $socialAuthService, EmailConfirmationService $emailConfirmationService, UserRepo $userRepo)
 | 
			
		||||
    public function __construct(SocialAuthService $socialAuthService, RegistrationService $registrationService)
 | 
			
		||||
    {
 | 
			
		||||
        $this->middleware('guest')->only(['getRegister', 'postRegister', 'socialRegister']);
 | 
			
		||||
        $this->middleware('guest');
 | 
			
		||||
        $this->middleware('guard:standard');
 | 
			
		||||
 | 
			
		||||
        $this->socialAuthService = $socialAuthService;
 | 
			
		||||
        $this->emailConfirmationService = $emailConfirmationService;
 | 
			
		||||
        $this->userRepo = $userRepo;
 | 
			
		||||
        $this->registrationService = $registrationService;
 | 
			
		||||
 | 
			
		||||
        $this->redirectTo = url('/');
 | 
			
		||||
        $this->redirectPath = url('/');
 | 
			
		||||
        parent::__construct();
 | 
			
		||||
| 
						 | 
				
			
			@ -69,7 +57,6 @@ class RegisterController extends Controller
 | 
			
		|||
    /**
 | 
			
		||||
     * Get a validator for an incoming registration request.
 | 
			
		||||
     *
 | 
			
		||||
     * @param  array $data
 | 
			
		||||
     * @return \Illuminate\Contracts\Validation\Validator
 | 
			
		||||
     */
 | 
			
		||||
    protected function validator(array $data)
 | 
			
		||||
| 
						 | 
				
			
			@ -77,46 +64,45 @@ class RegisterController extends Controller
 | 
			
		|||
        return Validator::make($data, [
 | 
			
		||||
            'name' => 'required|min:2|max:255',
 | 
			
		||||
            'email' => 'required|email|max:255|unique:users',
 | 
			
		||||
            'password' => 'required|min:6',
 | 
			
		||||
            'password' => 'required|min:8',
 | 
			
		||||
        ]);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check whether or not registrations are allowed in the app settings.
 | 
			
		||||
     * @throws UserRegistrationException
 | 
			
		||||
     */
 | 
			
		||||
    protected function checkRegistrationAllowed()
 | 
			
		||||
    {
 | 
			
		||||
        if (!setting('registration-enabled')) {
 | 
			
		||||
            throw new UserRegistrationException(trans('auth.registrations_disabled'), '/login');
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Show the application registration form.
 | 
			
		||||
     * @return Response
 | 
			
		||||
     * @throws UserRegistrationException
 | 
			
		||||
     */
 | 
			
		||||
    public function getRegister()
 | 
			
		||||
    {
 | 
			
		||||
        $this->checkRegistrationAllowed();
 | 
			
		||||
        $this->registrationService->ensureRegistrationAllowed();
 | 
			
		||||
        $socialDrivers = $this->socialAuthService->getActiveDrivers();
 | 
			
		||||
        return view('auth.register', ['socialDrivers' => $socialDrivers]);
 | 
			
		||||
        return view('auth.register', [
 | 
			
		||||
            'socialDrivers' => $socialDrivers,
 | 
			
		||||
        ]);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Handle a registration request for the application.
 | 
			
		||||
     * @param Request|Request $request
 | 
			
		||||
     * @return RedirectResponse|Redirector
 | 
			
		||||
     * @throws UserRegistrationException
 | 
			
		||||
     */
 | 
			
		||||
    public function postRegister(Request $request)
 | 
			
		||||
    {
 | 
			
		||||
        $this->checkRegistrationAllowed();
 | 
			
		||||
        $this->registrationService->ensureRegistrationAllowed();
 | 
			
		||||
        $this->validator($request->all())->validate();
 | 
			
		||||
 | 
			
		||||
        $userData = $request->all();
 | 
			
		||||
        return $this->registerUser($userData);
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
            $user = $this->registrationService->registerUser($userData);
 | 
			
		||||
            auth()->login($user);
 | 
			
		||||
        } catch (UserRegistrationException $exception) {
 | 
			
		||||
            if ($exception->getMessage()) {
 | 
			
		||||
                $this->showErrorNotification($exception->getMessage());
 | 
			
		||||
            }
 | 
			
		||||
            return redirect($exception->redirectLocation);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $this->showSuccessNotification(trans('auth.register_success'));
 | 
			
		||||
        return redirect($this->redirectPath());
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
| 
						 | 
				
			
			@ -129,140 +115,8 @@ class RegisterController extends Controller
 | 
			
		|||
        return User::create([
 | 
			
		||||
            'name' => $data['name'],
 | 
			
		||||
            'email' => $data['email'],
 | 
			
		||||
            'password' => bcrypt($data['password']),
 | 
			
		||||
            'password' => Hash::make($data['password']),
 | 
			
		||||
        ]);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The registrations flow for all users.
 | 
			
		||||
     * @param array $userData
 | 
			
		||||
     * @param bool|false|SocialAccount $socialAccount
 | 
			
		||||
     * @param bool $emailVerified
 | 
			
		||||
     * @return RedirectResponse|Redirector
 | 
			
		||||
     * @throws UserRegistrationException
 | 
			
		||||
     */
 | 
			
		||||
    protected function registerUser(array $userData, $socialAccount = false, $emailVerified = false)
 | 
			
		||||
    {
 | 
			
		||||
        $registrationRestrict = setting('registration-restrict');
 | 
			
		||||
 | 
			
		||||
        if ($registrationRestrict) {
 | 
			
		||||
            $restrictedEmailDomains = explode(',', str_replace(' ', '', $registrationRestrict));
 | 
			
		||||
            $userEmailDomain = $domain = mb_substr(mb_strrchr($userData['email'], "@"), 1);
 | 
			
		||||
            if (!in_array($userEmailDomain, $restrictedEmailDomains)) {
 | 
			
		||||
                throw new UserRegistrationException(trans('auth.registration_email_domain_invalid'), '/register');
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $newUser = $this->userRepo->registerNew($userData, $emailVerified);
 | 
			
		||||
        if ($socialAccount) {
 | 
			
		||||
            $newUser->socialAccounts()->save($socialAccount);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if ($this->emailConfirmationService->confirmationRequired() && !$emailVerified) {
 | 
			
		||||
            $newUser->save();
 | 
			
		||||
 | 
			
		||||
            try {
 | 
			
		||||
                $this->emailConfirmationService->sendConfirmation($newUser);
 | 
			
		||||
            } catch (Exception $e) {
 | 
			
		||||
                session()->flash('error', trans('auth.email_confirm_send_error'));
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            return redirect('/register/confirm');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        auth()->login($newUser);
 | 
			
		||||
        session()->flash('success', trans('auth.register_success'));
 | 
			
		||||
        return redirect($this->redirectPath());
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Redirect to the social site for authentication intended to register.
 | 
			
		||||
     * @param $socialDriver
 | 
			
		||||
     * @return mixed
 | 
			
		||||
     * @throws UserRegistrationException
 | 
			
		||||
     * @throws SocialDriverNotConfigured
 | 
			
		||||
     */
 | 
			
		||||
    public function socialRegister($socialDriver)
 | 
			
		||||
    {
 | 
			
		||||
        $this->checkRegistrationAllowed();
 | 
			
		||||
        session()->put('social-callback', 'register');
 | 
			
		||||
        return $this->socialAuthService->startRegister($socialDriver);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The callback for social login services.
 | 
			
		||||
     * @param $socialDriver
 | 
			
		||||
     * @param Request $request
 | 
			
		||||
     * @return RedirectResponse|Redirector
 | 
			
		||||
     * @throws SocialSignInException
 | 
			
		||||
     * @throws UserRegistrationException
 | 
			
		||||
     * @throws SocialDriverNotConfigured
 | 
			
		||||
     */
 | 
			
		||||
    public function socialCallback($socialDriver, Request $request)
 | 
			
		||||
    {
 | 
			
		||||
        if (!session()->has('social-callback')) {
 | 
			
		||||
            throw new SocialSignInException(trans('errors.social_no_action_defined'), '/login');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Check request for error information
 | 
			
		||||
        if ($request->has('error') && $request->has('error_description')) {
 | 
			
		||||
            throw new SocialSignInException(trans('errors.social_login_bad_response', [
 | 
			
		||||
                'socialAccount' => $socialDriver,
 | 
			
		||||
                'error' => $request->get('error_description'),
 | 
			
		||||
            ]), '/login');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $action = session()->pull('social-callback');
 | 
			
		||||
 | 
			
		||||
        // Attempt login or fall-back to register if allowed.
 | 
			
		||||
        $socialUser = $this->socialAuthService->getSocialUser($socialDriver);
 | 
			
		||||
        if ($action == 'login') {
 | 
			
		||||
            try {
 | 
			
		||||
                return $this->socialAuthService->handleLoginCallback($socialDriver, $socialUser);
 | 
			
		||||
            } catch (SocialSignInAccountNotUsed $exception) {
 | 
			
		||||
                if ($this->socialAuthService->driverAutoRegisterEnabled($socialDriver)) {
 | 
			
		||||
                    return $this->socialRegisterCallback($socialDriver, $socialUser);
 | 
			
		||||
                }
 | 
			
		||||
                throw $exception;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if ($action == 'register') {
 | 
			
		||||
            return $this->socialRegisterCallback($socialDriver, $socialUser);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return redirect()->back();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Detach a social account from a user.
 | 
			
		||||
     * @param $socialDriver
 | 
			
		||||
     * @return RedirectResponse|Redirector
 | 
			
		||||
     */
 | 
			
		||||
    public function detachSocialAccount($socialDriver)
 | 
			
		||||
    {
 | 
			
		||||
        return $this->socialAuthService->detachSocialAccount($socialDriver);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Register a new user after a registration callback.
 | 
			
		||||
     * @param string $socialDriver
 | 
			
		||||
     * @param SocialUser $socialUser
 | 
			
		||||
     * @return RedirectResponse|Redirector
 | 
			
		||||
     * @throws UserRegistrationException
 | 
			
		||||
     */
 | 
			
		||||
    protected function socialRegisterCallback(string $socialDriver, SocialUser $socialUser)
 | 
			
		||||
    {
 | 
			
		||||
        $socialUser = $this->socialAuthService->handleRegistrationCallback($socialDriver, $socialUser);
 | 
			
		||||
        $socialAccount = $this->socialAuthService->fillSocialAccount($socialDriver, $socialUser);
 | 
			
		||||
        $emailVerified = $this->socialAuthService->driverAutoConfirmEmailEnabled($socialDriver);
 | 
			
		||||
 | 
			
		||||
        // Create an array of the user data to create a new user instance
 | 
			
		||||
        $userData = [
 | 
			
		||||
            'name' => $socialUser->getName(),
 | 
			
		||||
            'email' => $socialUser->getEmail(),
 | 
			
		||||
            'password' => str_random(30)
 | 
			
		||||
        ];
 | 
			
		||||
        return $this->registerUser($userData, $socialAccount, $emailVerified);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -4,6 +4,7 @@ namespace BookStack\Http\Controllers\Auth;
 | 
			
		|||
 | 
			
		||||
use BookStack\Http\Controllers\Controller;
 | 
			
		||||
use Illuminate\Foundation\Auth\ResetsPasswords;
 | 
			
		||||
use Illuminate\Http\Request;
 | 
			
		||||
 | 
			
		||||
class ResetPasswordController extends Controller
 | 
			
		||||
{
 | 
			
		||||
| 
						 | 
				
			
			@ -30,19 +31,21 @@ class ResetPasswordController extends Controller
 | 
			
		|||
    public function __construct()
 | 
			
		||||
    {
 | 
			
		||||
        $this->middleware('guest');
 | 
			
		||||
        $this->middleware('guard:standard');
 | 
			
		||||
        parent::__construct();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the response for a successful password reset.
 | 
			
		||||
     *
 | 
			
		||||
     * @param  string  $response
 | 
			
		||||
     * @param Request $request
 | 
			
		||||
     * @param string $response
 | 
			
		||||
     * @return \Illuminate\Http\Response
 | 
			
		||||
     */
 | 
			
		||||
    protected function sendResetResponse($response)
 | 
			
		||||
    protected function sendResetResponse(Request $request, $response)
 | 
			
		||||
    {
 | 
			
		||||
        $message = trans('auth.reset_password_success');
 | 
			
		||||
        session()->flash('success', $message);
 | 
			
		||||
        $this->showSuccessNotification($message);
 | 
			
		||||
        return redirect($this->redirectPath())
 | 
			
		||||
            ->with('status', trans($response));
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,87 @@
 | 
			
		|||
<?php
 | 
			
		||||
 | 
			
		||||
namespace BookStack\Http\Controllers\Auth;
 | 
			
		||||
 | 
			
		||||
use BookStack\Auth\Access\Saml2Service;
 | 
			
		||||
use BookStack\Http\Controllers\Controller;
 | 
			
		||||
 | 
			
		||||
class Saml2Controller extends Controller
 | 
			
		||||
{
 | 
			
		||||
 | 
			
		||||
    protected $samlService;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Saml2Controller constructor.
 | 
			
		||||
     */
 | 
			
		||||
    public function __construct(Saml2Service $samlService)
 | 
			
		||||
    {
 | 
			
		||||
        parent::__construct();
 | 
			
		||||
        $this->samlService = $samlService;
 | 
			
		||||
        $this->middleware('guard:saml2');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Start the login flow via SAML2.
 | 
			
		||||
     */
 | 
			
		||||
    public function login()
 | 
			
		||||
    {
 | 
			
		||||
        $loginDetails = $this->samlService->login();
 | 
			
		||||
        session()->flash('saml2_request_id', $loginDetails['id']);
 | 
			
		||||
 | 
			
		||||
        return redirect($loginDetails['url']);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Start the logout flow via SAML2.
 | 
			
		||||
     */
 | 
			
		||||
    public function logout()
 | 
			
		||||
    {
 | 
			
		||||
        $logoutDetails = $this->samlService->logout();
 | 
			
		||||
 | 
			
		||||
        if ($logoutDetails['id']) {
 | 
			
		||||
            session()->flash('saml2_logout_request_id', $logoutDetails['id']);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return redirect($logoutDetails['url']);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /*
 | 
			
		||||
     * Get the metadata for this SAML2 service provider.
 | 
			
		||||
     */
 | 
			
		||||
    public function metadata()
 | 
			
		||||
    {
 | 
			
		||||
        $metaData = $this->samlService->metadata();
 | 
			
		||||
        return response()->make($metaData, 200, [
 | 
			
		||||
            'Content-Type' => 'text/xml'
 | 
			
		||||
        ]);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Single logout service.
 | 
			
		||||
     * Handle logout requests and responses.
 | 
			
		||||
     */
 | 
			
		||||
    public function sls()
 | 
			
		||||
    {
 | 
			
		||||
        $requestId = session()->pull('saml2_logout_request_id', null);
 | 
			
		||||
        $redirect = $this->samlService->processSlsResponse($requestId) ?? '/';
 | 
			
		||||
        return redirect($redirect);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Assertion Consumer Service.
 | 
			
		||||
     * Processes the SAML response from the IDP.
 | 
			
		||||
     */
 | 
			
		||||
    public function acs()
 | 
			
		||||
    {
 | 
			
		||||
        $requestId = session()->pull('saml2_request_id', null);
 | 
			
		||||
 | 
			
		||||
        $user = $this->samlService->processAcsResponse($requestId);
 | 
			
		||||
        if ($user === null) {
 | 
			
		||||
            $this->showErrorNotification(trans('errors.saml_fail_authed', ['system' => config('saml2.name')]));
 | 
			
		||||
            return redirect('/login');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return redirect()->intended();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,132 @@
 | 
			
		|||
<?php
 | 
			
		||||
 | 
			
		||||
namespace BookStack\Http\Controllers\Auth;
 | 
			
		||||
 | 
			
		||||
use BookStack\Auth\Access\RegistrationService;
 | 
			
		||||
use BookStack\Auth\Access\SocialAuthService;
 | 
			
		||||
use BookStack\Exceptions\SocialDriverNotConfigured;
 | 
			
		||||
use BookStack\Exceptions\SocialSignInAccountNotUsed;
 | 
			
		||||
use BookStack\Exceptions\SocialSignInException;
 | 
			
		||||
use BookStack\Exceptions\UserRegistrationException;
 | 
			
		||||
use BookStack\Http\Controllers\Controller;
 | 
			
		||||
use Illuminate\Http\RedirectResponse;
 | 
			
		||||
use Illuminate\Http\Request;
 | 
			
		||||
use Illuminate\Routing\Redirector;
 | 
			
		||||
use Illuminate\Support\Str;
 | 
			
		||||
use Laravel\Socialite\Contracts\User as SocialUser;
 | 
			
		||||
 | 
			
		||||
class SocialController extends Controller
 | 
			
		||||
{
 | 
			
		||||
 | 
			
		||||
    protected $socialAuthService;
 | 
			
		||||
    protected $registrationService;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * SocialController constructor.
 | 
			
		||||
     */
 | 
			
		||||
    public function __construct(SocialAuthService $socialAuthService, RegistrationService $registrationService)
 | 
			
		||||
    {
 | 
			
		||||
        $this->middleware('guest')->only(['getRegister', 'postRegister']);
 | 
			
		||||
        $this->socialAuthService = $socialAuthService;
 | 
			
		||||
        $this->registrationService = $registrationService;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Redirect to the relevant social site.
 | 
			
		||||
     * @throws \BookStack\Exceptions\SocialDriverNotConfigured
 | 
			
		||||
     */
 | 
			
		||||
    public function getSocialLogin(string $socialDriver)
 | 
			
		||||
    {
 | 
			
		||||
        session()->put('social-callback', 'login');
 | 
			
		||||
        return $this->socialAuthService->startLogIn($socialDriver);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Redirect to the social site for authentication intended to register.
 | 
			
		||||
     * @throws SocialDriverNotConfigured
 | 
			
		||||
     * @throws UserRegistrationException
 | 
			
		||||
     */
 | 
			
		||||
    public function socialRegister(string $socialDriver)
 | 
			
		||||
    {
 | 
			
		||||
        $this->registrationService->ensureRegistrationAllowed();
 | 
			
		||||
        session()->put('social-callback', 'register');
 | 
			
		||||
        return $this->socialAuthService->startRegister($socialDriver);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The callback for social login services.
 | 
			
		||||
     * @throws SocialSignInException
 | 
			
		||||
     * @throws SocialDriverNotConfigured
 | 
			
		||||
     * @throws UserRegistrationException
 | 
			
		||||
     */
 | 
			
		||||
    public function socialCallback(Request $request, string $socialDriver)
 | 
			
		||||
    {
 | 
			
		||||
        if (!session()->has('social-callback')) {
 | 
			
		||||
            throw new SocialSignInException(trans('errors.social_no_action_defined'), '/login');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Check request for error information
 | 
			
		||||
        if ($request->has('error') && $request->has('error_description')) {
 | 
			
		||||
            throw new SocialSignInException(trans('errors.social_login_bad_response', [
 | 
			
		||||
                'socialAccount' => $socialDriver,
 | 
			
		||||
                'error' => $request->get('error_description'),
 | 
			
		||||
            ]), '/login');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $action = session()->pull('social-callback');
 | 
			
		||||
 | 
			
		||||
        // Attempt login or fall-back to register if allowed.
 | 
			
		||||
        $socialUser = $this->socialAuthService->getSocialUser($socialDriver);
 | 
			
		||||
        if ($action === 'login') {
 | 
			
		||||
            try {
 | 
			
		||||
                return $this->socialAuthService->handleLoginCallback($socialDriver, $socialUser);
 | 
			
		||||
            } catch (SocialSignInAccountNotUsed $exception) {
 | 
			
		||||
                if ($this->socialAuthService->driverAutoRegisterEnabled($socialDriver)) {
 | 
			
		||||
                    return $this->socialRegisterCallback($socialDriver, $socialUser);
 | 
			
		||||
                }
 | 
			
		||||
                throw $exception;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if ($action === 'register') {
 | 
			
		||||
            return $this->socialRegisterCallback($socialDriver, $socialUser);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return redirect()->back();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Detach a social account from a user.
 | 
			
		||||
     */
 | 
			
		||||
    public function detachSocialAccount(string $socialDriver)
 | 
			
		||||
    {
 | 
			
		||||
        $this->socialAuthService->detachSocialAccount($socialDriver);
 | 
			
		||||
        session()->flash('success', trans('settings.users_social_disconnected', ['socialAccount' => Str::title($socialDriver)]));
 | 
			
		||||
        return redirect(user()->getEditUrl());
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Register a new user after a registration callback.
 | 
			
		||||
     * @throws UserRegistrationException
 | 
			
		||||
     */
 | 
			
		||||
    protected function socialRegisterCallback(string $socialDriver, SocialUser $socialUser)
 | 
			
		||||
    {
 | 
			
		||||
        $socialUser = $this->socialAuthService->handleRegistrationCallback($socialDriver, $socialUser);
 | 
			
		||||
        $socialAccount = $this->socialAuthService->fillSocialAccount($socialDriver, $socialUser);
 | 
			
		||||
        $emailVerified = $this->socialAuthService->driverAutoConfirmEmailEnabled($socialDriver);
 | 
			
		||||
 | 
			
		||||
        // Create an array of the user data to create a new user instance
 | 
			
		||||
        $userData = [
 | 
			
		||||
            'name' => $socialUser->getName(),
 | 
			
		||||
            'email' => $socialUser->getEmail(),
 | 
			
		||||
            'password' => Str::random(32)
 | 
			
		||||
        ];
 | 
			
		||||
 | 
			
		||||
        $user = $this->registrationService->registerUser($userData, $socialAccount, $emailVerified);
 | 
			
		||||
        auth()->login($user);
 | 
			
		||||
 | 
			
		||||
        $this->showSuccessNotification(trans('auth.register_success'));
 | 
			
		||||
        return redirect('/');
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -8,11 +8,9 @@ use BookStack\Exceptions\UserTokenExpiredException;
 | 
			
		|||
use BookStack\Exceptions\UserTokenNotFoundException;
 | 
			
		||||
use BookStack\Http\Controllers\Controller;
 | 
			
		||||
use Exception;
 | 
			
		||||
use Illuminate\Contracts\View\Factory;
 | 
			
		||||
use Illuminate\Http\RedirectResponse;
 | 
			
		||||
use Illuminate\Http\Request;
 | 
			
		||||
use Illuminate\Routing\Redirector;
 | 
			
		||||
use Illuminate\View\View;
 | 
			
		||||
 | 
			
		||||
class UserInviteController extends Controller
 | 
			
		||||
{
 | 
			
		||||
| 
						 | 
				
			
			@ -21,22 +19,20 @@ class UserInviteController extends Controller
 | 
			
		|||
 | 
			
		||||
    /**
 | 
			
		||||
     * Create a new controller instance.
 | 
			
		||||
     *
 | 
			
		||||
     * @param UserInviteService $inviteService
 | 
			
		||||
     * @param UserRepo $userRepo
 | 
			
		||||
     */
 | 
			
		||||
    public function __construct(UserInviteService $inviteService, UserRepo $userRepo)
 | 
			
		||||
    {
 | 
			
		||||
        $this->middleware('guest');
 | 
			
		||||
        $this->middleware('guard:standard');
 | 
			
		||||
 | 
			
		||||
        $this->inviteService = $inviteService;
 | 
			
		||||
        $this->userRepo = $userRepo;
 | 
			
		||||
        $this->middleware('guest');
 | 
			
		||||
 | 
			
		||||
        parent::__construct();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Show the page for the user to set the password for their account.
 | 
			
		||||
     * @param string $token
 | 
			
		||||
     * @return Factory|View|RedirectResponse
 | 
			
		||||
     * @throws Exception
 | 
			
		||||
     */
 | 
			
		||||
    public function showSetPassword(string $token)
 | 
			
		||||
| 
						 | 
				
			
			@ -54,15 +50,12 @@ class UserInviteController extends Controller
 | 
			
		|||
 | 
			
		||||
    /**
 | 
			
		||||
     * Sets the password for an invited user and then grants them access.
 | 
			
		||||
     * @param string $token
 | 
			
		||||
     * @param Request $request
 | 
			
		||||
     * @return RedirectResponse|Redirector
 | 
			
		||||
     * @throws Exception
 | 
			
		||||
     */
 | 
			
		||||
    public function setPassword(string $token, Request $request)
 | 
			
		||||
    public function setPassword(Request $request, string $token)
 | 
			
		||||
    {
 | 
			
		||||
        $this->validate($request, [
 | 
			
		||||
            'password' => 'required|min:6'
 | 
			
		||||
            'password' => 'required|min:8'
 | 
			
		||||
        ]);
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
| 
						 | 
				
			
			@ -77,7 +70,7 @@ class UserInviteController extends Controller
 | 
			
		|||
        $user->save();
 | 
			
		||||
 | 
			
		||||
        auth()->login($user);
 | 
			
		||||
        session()->flash('success', trans('auth.user_invite_success', ['appName' => setting('app-name')]));
 | 
			
		||||
        $this->showSuccessNotification(trans('auth.user_invite_success', ['appName' => setting('app-name')]));
 | 
			
		||||
        $this->inviteService->deleteByUser($user);
 | 
			
		||||
 | 
			
		||||
        return redirect('/');
 | 
			
		||||
| 
						 | 
				
			
			@ -85,7 +78,6 @@ class UserInviteController extends Controller
 | 
			
		|||
 | 
			
		||||
    /**
 | 
			
		||||
     * Check and validate the exception thrown when checking an invite token.
 | 
			
		||||
     * @param Exception $exception
 | 
			
		||||
     * @return RedirectResponse|Redirector
 | 
			
		||||
     * @throws Exception
 | 
			
		||||
     */
 | 
			
		||||
| 
						 | 
				
			
			@ -96,11 +88,10 @@ class UserInviteController extends Controller
 | 
			
		|||
        }
 | 
			
		||||
 | 
			
		||||
        if ($exception instanceof UserTokenExpiredException) {
 | 
			
		||||
            session()->flash('error', trans('errors.invite_token_expired'));
 | 
			
		||||
            $this->showErrorNotification(trans('errors.invite_token_expired'));
 | 
			
		||||
            return redirect('/password/email');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        throw $exception;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,67 +1,46 @@
 | 
			
		|||
<?php namespace BookStack\Http\Controllers;
 | 
			
		||||
 | 
			
		||||
use Activity;
 | 
			
		||||
use BookStack\Auth\UserRepo;
 | 
			
		||||
use BookStack\Entities\Book;
 | 
			
		||||
use BookStack\Entities\EntityContextManager;
 | 
			
		||||
use BookStack\Entities\Repos\EntityRepo;
 | 
			
		||||
use BookStack\Entities\ExportService;
 | 
			
		||||
use BookStack\Uploads\ImageRepo;
 | 
			
		||||
use BookStack\Entities\Managers\BookContents;
 | 
			
		||||
use BookStack\Entities\Bookshelf;
 | 
			
		||||
use BookStack\Entities\Managers\EntityContext;
 | 
			
		||||
use BookStack\Entities\Repos\BookRepo;
 | 
			
		||||
use BookStack\Exceptions\ImageUploadException;
 | 
			
		||||
use BookStack\Exceptions\NotifyException;
 | 
			
		||||
use Illuminate\Http\Request;
 | 
			
		||||
use Illuminate\Http\Response;
 | 
			
		||||
use Illuminate\Validation\ValidationException;
 | 
			
		||||
use Throwable;
 | 
			
		||||
use Views;
 | 
			
		||||
 | 
			
		||||
class BookController extends Controller
 | 
			
		||||
{
 | 
			
		||||
 | 
			
		||||
    protected $entityRepo;
 | 
			
		||||
    protected $userRepo;
 | 
			
		||||
    protected $exportService;
 | 
			
		||||
    protected $bookRepo;
 | 
			
		||||
    protected $entityContextManager;
 | 
			
		||||
    protected $imageRepo;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * BookController constructor.
 | 
			
		||||
     * @param EntityRepo $entityRepo
 | 
			
		||||
     * @param UserRepo $userRepo
 | 
			
		||||
     * @param ExportService $exportService
 | 
			
		||||
     * @param EntityContextManager $entityContextManager
 | 
			
		||||
     * @param ImageRepo $imageRepo
 | 
			
		||||
     */
 | 
			
		||||
    public function __construct(
 | 
			
		||||
        EntityRepo $entityRepo,
 | 
			
		||||
        UserRepo $userRepo,
 | 
			
		||||
        ExportService $exportService,
 | 
			
		||||
        EntityContextManager $entityContextManager,
 | 
			
		||||
        ImageRepo $imageRepo
 | 
			
		||||
    ) {
 | 
			
		||||
        $this->entityRepo = $entityRepo;
 | 
			
		||||
        $this->userRepo = $userRepo;
 | 
			
		||||
        $this->exportService = $exportService;
 | 
			
		||||
    public function __construct(EntityContext $entityContextManager, BookRepo $bookRepo)
 | 
			
		||||
    {
 | 
			
		||||
        $this->bookRepo = $bookRepo;
 | 
			
		||||
        $this->entityContextManager = $entityContextManager;
 | 
			
		||||
        $this->imageRepo = $imageRepo;
 | 
			
		||||
        parent::__construct();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Display a listing of the book.
 | 
			
		||||
     * @return Response
 | 
			
		||||
     */
 | 
			
		||||
    public function index()
 | 
			
		||||
    {
 | 
			
		||||
        $view = setting()->getUser($this->currentUser, 'books_view_type', config('app.views.books'));
 | 
			
		||||
        $sort = setting()->getUser($this->currentUser, 'books_sort', 'name');
 | 
			
		||||
        $order = setting()->getUser($this->currentUser, 'books_sort_order', 'asc');
 | 
			
		||||
        $sortOptions = [
 | 
			
		||||
            'name' => trans('common.sort_name'),
 | 
			
		||||
            'created_at' => trans('common.sort_created_at'),
 | 
			
		||||
            'updated_at' => trans('common.sort_updated_at'),
 | 
			
		||||
        ];
 | 
			
		||||
        $view = setting()->getForCurrentUser('books_view_type', config('app.views.books'));
 | 
			
		||||
        $sort = setting()->getForCurrentUser('books_sort', 'name');
 | 
			
		||||
        $order = setting()->getForCurrentUser('books_sort_order', 'asc');
 | 
			
		||||
 | 
			
		||||
        $books = $this->entityRepo->getAllPaginated('book', 18, $sort, $order);
 | 
			
		||||
        $recents = $this->signedIn ? $this->entityRepo->getRecentlyViewed('book', 4, 0) : false;
 | 
			
		||||
        $popular = $this->entityRepo->getPopular('book', 4, 0);
 | 
			
		||||
        $new = $this->entityRepo->getRecentlyCreated('book', 4, 0);
 | 
			
		||||
        $books = $this->bookRepo->getAllPaginated(18, $sort, $order);
 | 
			
		||||
        $recents = $this->isSignedIn() ? $this->bookRepo->getRecentlyViewed(4) : false;
 | 
			
		||||
        $popular = $this->bookRepo->getPopular(4);
 | 
			
		||||
        $new = $this->bookRepo->getRecentlyCreated(4);
 | 
			
		||||
 | 
			
		||||
        $this->entityContextManager->clearShelfContext();
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -74,25 +53,22 @@ class BookController extends Controller
 | 
			
		|||
            'view' => $view,
 | 
			
		||||
            'sort' => $sort,
 | 
			
		||||
            'order' => $order,
 | 
			
		||||
            'sortOptions' => $sortOptions,
 | 
			
		||||
        ]);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Show the form for creating a new book.
 | 
			
		||||
     * @param string $shelfSlug
 | 
			
		||||
     * @return Response
 | 
			
		||||
     * @throws \BookStack\Exceptions\NotFoundException
 | 
			
		||||
     */
 | 
			
		||||
    public function create(string $shelfSlug = null)
 | 
			
		||||
    {
 | 
			
		||||
        $this->checkPermission('book-create-all');
 | 
			
		||||
 | 
			
		||||
        $bookshelf = null;
 | 
			
		||||
        if ($shelfSlug !== null) {
 | 
			
		||||
            $bookshelf = $this->entityRepo->getBySlug('bookshelf', $shelfSlug);
 | 
			
		||||
            $bookshelf = Bookshelf::visible()->where('slug', '=', $shelfSlug)->firstOrFail();
 | 
			
		||||
            $this->checkOwnablePermission('bookshelf-update', $bookshelf);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $this->checkPermission('book-create-all');
 | 
			
		||||
        $this->setPageTitle(trans('entities.books_create'));
 | 
			
		||||
        return view('books.create', [
 | 
			
		||||
            'bookshelf' => $bookshelf
 | 
			
		||||
| 
						 | 
				
			
			@ -101,12 +77,8 @@ class BookController extends Controller
 | 
			
		|||
 | 
			
		||||
    /**
 | 
			
		||||
     * Store a newly created book in storage.
 | 
			
		||||
     *
 | 
			
		||||
     * @param Request $request
 | 
			
		||||
     * @param string $shelfSlug
 | 
			
		||||
     * @return Response
 | 
			
		||||
     * @throws \BookStack\Exceptions\NotFoundException
 | 
			
		||||
     * @throws \BookStack\Exceptions\ImageUploadException
 | 
			
		||||
     * @throws ImageUploadException
 | 
			
		||||
     * @throws ValidationException
 | 
			
		||||
     */
 | 
			
		||||
    public function store(Request $request, string $shelfSlug = null)
 | 
			
		||||
    {
 | 
			
		||||
| 
						 | 
				
			
			@ -114,21 +86,21 @@ class BookController extends Controller
 | 
			
		|||
        $this->validate($request, [
 | 
			
		||||
            'name' => 'required|string|max:255',
 | 
			
		||||
            'description' => 'string|max:1000',
 | 
			
		||||
            'image' => $this->imageRepo->getImageValidationRules(),
 | 
			
		||||
            'image' => $this->getImageValidationRules(),
 | 
			
		||||
        ]);
 | 
			
		||||
 | 
			
		||||
        $bookshelf = null;
 | 
			
		||||
        if ($shelfSlug !== null) {
 | 
			
		||||
            $bookshelf = $this->entityRepo->getBySlug('bookshelf', $shelfSlug);
 | 
			
		||||
            $bookshelf = Bookshelf::visible()->where('slug', '=', $shelfSlug)->firstOrFail();
 | 
			
		||||
            $this->checkOwnablePermission('bookshelf-update', $bookshelf);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $book = $this->entityRepo->createFromInput('book', $request->all());
 | 
			
		||||
        $this->bookUpdateActions($book, $request);
 | 
			
		||||
        $book = $this->bookRepo->create($request->all());
 | 
			
		||||
        $this->bookRepo->updateCoverImage($book, $request->file('image', null));
 | 
			
		||||
        Activity::add($book, 'book_create', $book->id);
 | 
			
		||||
 | 
			
		||||
        if ($bookshelf) {
 | 
			
		||||
            $this->entityRepo->appendBookToShelf($bookshelf, $book);
 | 
			
		||||
            $bookshelf->appendBook($book);
 | 
			
		||||
            Activity::add($bookshelf, 'bookshelf_update');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -137,17 +109,11 @@ class BookController extends Controller
 | 
			
		|||
 | 
			
		||||
    /**
 | 
			
		||||
     * Display the specified book.
 | 
			
		||||
     * @param $slug
 | 
			
		||||
     * @param Request $request
 | 
			
		||||
     * @return Response
 | 
			
		||||
     * @throws \BookStack\Exceptions\NotFoundException
 | 
			
		||||
     */
 | 
			
		||||
    public function show($slug, Request $request)
 | 
			
		||||
    public function show(Request $request, string $slug)
 | 
			
		||||
    {
 | 
			
		||||
        $book = $this->entityRepo->getBySlug('book', $slug);
 | 
			
		||||
        $this->checkOwnablePermission('book-view', $book);
 | 
			
		||||
 | 
			
		||||
        $bookChildren = $this->entityRepo->getBookChildren($book);
 | 
			
		||||
        $book = $this->bookRepo->getBySlug($slug);
 | 
			
		||||
        $bookChildren = (new BookContents($book))->getTree(true);
 | 
			
		||||
 | 
			
		||||
        Views::add($book);
 | 
			
		||||
        if ($request->has('shelf')) {
 | 
			
		||||
| 
						 | 
				
			
			@ -165,12 +131,10 @@ class BookController extends Controller
 | 
			
		|||
 | 
			
		||||
    /**
 | 
			
		||||
     * Show the form for editing the specified book.
 | 
			
		||||
     * @param $slug
 | 
			
		||||
     * @return Response
 | 
			
		||||
     */
 | 
			
		||||
    public function edit($slug)
 | 
			
		||||
    public function edit(string $slug)
 | 
			
		||||
    {
 | 
			
		||||
        $book = $this->entityRepo->getBySlug('book', $slug);
 | 
			
		||||
        $book = $this->bookRepo->getBySlug($slug);
 | 
			
		||||
        $this->checkOwnablePermission('book-update', $book);
 | 
			
		||||
        $this->setPageTitle(trans('entities.books_edit_named', ['bookName'=>$book->getShortName()]));
 | 
			
		||||
        return view('books.edit', ['book' => $book, 'current' => $book]);
 | 
			
		||||
| 
						 | 
				
			
			@ -178,254 +142,83 @@ class BookController extends Controller
 | 
			
		|||
 | 
			
		||||
    /**
 | 
			
		||||
     * Update the specified book in storage.
 | 
			
		||||
     * @param Request $request
 | 
			
		||||
     * @param          $slug
 | 
			
		||||
     * @return Response
 | 
			
		||||
     * @throws \BookStack\Exceptions\ImageUploadException
 | 
			
		||||
     * @throws \BookStack\Exceptions\NotFoundException
 | 
			
		||||
     * @throws ImageUploadException
 | 
			
		||||
     * @throws ValidationException
 | 
			
		||||
     * @throws Throwable
 | 
			
		||||
     */
 | 
			
		||||
    public function update(Request $request, string $slug)
 | 
			
		||||
    {
 | 
			
		||||
        $book = $this->entityRepo->getBySlug('book', $slug);
 | 
			
		||||
        $book = $this->bookRepo->getBySlug($slug);
 | 
			
		||||
        $this->checkOwnablePermission('book-update', $book);
 | 
			
		||||
        $this->validate($request, [
 | 
			
		||||
            'name' => 'required|string|max:255',
 | 
			
		||||
            'description' => 'string|max:1000',
 | 
			
		||||
            'image' => $this->imageRepo->getImageValidationRules(),
 | 
			
		||||
            'image' => $this->getImageValidationRules(),
 | 
			
		||||
        ]);
 | 
			
		||||
 | 
			
		||||
         $book = $this->entityRepo->updateFromInput('book', $book, $request->all());
 | 
			
		||||
         $this->bookUpdateActions($book, $request);
 | 
			
		||||
        $book = $this->bookRepo->update($book, $request->all());
 | 
			
		||||
        $resetCover = $request->has('image_reset');
 | 
			
		||||
        $this->bookRepo->updateCoverImage($book, $request->file('image', null), $resetCover);
 | 
			
		||||
 | 
			
		||||
         Activity::add($book, 'book_update', $book->id);
 | 
			
		||||
 | 
			
		||||
         return redirect($book->getUrl());
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Shows the page to confirm deletion
 | 
			
		||||
     * @param $bookSlug
 | 
			
		||||
     * @return \Illuminate\View\View
 | 
			
		||||
     */
 | 
			
		||||
    public function showDelete($bookSlug)
 | 
			
		||||
    {
 | 
			
		||||
        $book = $this->entityRepo->getBySlug('book', $bookSlug);
 | 
			
		||||
        $this->checkOwnablePermission('book-delete', $book);
 | 
			
		||||
        $this->setPageTitle(trans('entities.books_delete_named', ['bookName'=>$book->getShortName()]));
 | 
			
		||||
        return view('books.delete', ['book' => $book, 'current' => $book]);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Shows the view which allows pages to be re-ordered and sorted.
 | 
			
		||||
     * @param string $bookSlug
 | 
			
		||||
     * @return \Illuminate\View\View
 | 
			
		||||
     * @throws \BookStack\Exceptions\NotFoundException
 | 
			
		||||
     */
 | 
			
		||||
    public function sort($bookSlug)
 | 
			
		||||
    {
 | 
			
		||||
        $book = $this->entityRepo->getBySlug('book', $bookSlug);
 | 
			
		||||
        $this->checkOwnablePermission('book-update', $book);
 | 
			
		||||
 | 
			
		||||
        $bookChildren = $this->entityRepo->getBookChildren($book, true);
 | 
			
		||||
 | 
			
		||||
        $this->setPageTitle(trans('entities.books_sort_named', ['bookName'=>$book->getShortName()]));
 | 
			
		||||
        return view('books.sort', ['book' => $book, 'current' => $book, 'bookChildren' => $bookChildren]);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Shows the sort box for a single book.
 | 
			
		||||
     * Used via AJAX when loading in extra books to a sort.
 | 
			
		||||
     * @param $bookSlug
 | 
			
		||||
     * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
 | 
			
		||||
     */
 | 
			
		||||
    public function getSortItem($bookSlug)
 | 
			
		||||
    {
 | 
			
		||||
        $book = $this->entityRepo->getBySlug('book', $bookSlug);
 | 
			
		||||
        $bookChildren = $this->entityRepo->getBookChildren($book);
 | 
			
		||||
        return view('books.sort-box', ['book' => $book, 'bookChildren' => $bookChildren]);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Saves an array of sort mapping to pages and chapters.
 | 
			
		||||
     * @param  string $bookSlug
 | 
			
		||||
     * @param Request $request
 | 
			
		||||
     * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
 | 
			
		||||
     */
 | 
			
		||||
    public function saveSort($bookSlug, Request $request)
 | 
			
		||||
    {
 | 
			
		||||
        $book = $this->entityRepo->getBySlug('book', $bookSlug);
 | 
			
		||||
        $this->checkOwnablePermission('book-update', $book);
 | 
			
		||||
 | 
			
		||||
        // Return if no map sent
 | 
			
		||||
        if (!$request->filled('sort-tree')) {
 | 
			
		||||
            return redirect($book->getUrl());
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Sort pages and chapters
 | 
			
		||||
        $sortMap = collect(json_decode($request->get('sort-tree')));
 | 
			
		||||
        $bookIdsInvolved = collect([$book->id]);
 | 
			
		||||
 | 
			
		||||
        // Load models into map
 | 
			
		||||
        $sortMap->each(function ($mapItem) use ($bookIdsInvolved) {
 | 
			
		||||
            $mapItem->type = ($mapItem->type === 'page' ? 'page' : 'chapter');
 | 
			
		||||
            $mapItem->model = $this->entityRepo->getById($mapItem->type, $mapItem->id);
 | 
			
		||||
            // Store source and target books
 | 
			
		||||
            $bookIdsInvolved->push(intval($mapItem->model->book_id));
 | 
			
		||||
            $bookIdsInvolved->push(intval($mapItem->book));
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        // Get the books involved in the sort
 | 
			
		||||
        $bookIdsInvolved = $bookIdsInvolved->unique()->toArray();
 | 
			
		||||
        $booksInvolved = $this->entityRepo->getManyById('book', $bookIdsInvolved, false, true);
 | 
			
		||||
        // Throw permission error if invalid ids or inaccessible books given.
 | 
			
		||||
        if (count($bookIdsInvolved) !== count($booksInvolved)) {
 | 
			
		||||
            $this->showPermissionError();
 | 
			
		||||
        }
 | 
			
		||||
        // Check permissions of involved books
 | 
			
		||||
        $booksInvolved->each(function (Book $book) {
 | 
			
		||||
             $this->checkOwnablePermission('book-update', $book);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        // Perform the sort
 | 
			
		||||
        $sortMap->each(function ($mapItem) {
 | 
			
		||||
            $model = $mapItem->model;
 | 
			
		||||
 | 
			
		||||
            $priorityChanged = intval($model->priority) !== intval($mapItem->sort);
 | 
			
		||||
            $bookChanged = intval($model->book_id) !== intval($mapItem->book);
 | 
			
		||||
            $chapterChanged = ($mapItem->type === 'page') && intval($model->chapter_id) !== $mapItem->parentChapter;
 | 
			
		||||
 | 
			
		||||
            if ($bookChanged) {
 | 
			
		||||
                $this->entityRepo->changeBook($mapItem->type, $mapItem->book, $model);
 | 
			
		||||
            }
 | 
			
		||||
            if ($chapterChanged) {
 | 
			
		||||
                $model->chapter_id = intval($mapItem->parentChapter);
 | 
			
		||||
                $model->save();
 | 
			
		||||
            }
 | 
			
		||||
            if ($priorityChanged) {
 | 
			
		||||
                $model->priority = intval($mapItem->sort);
 | 
			
		||||
                $model->save();
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        // Rebuild permissions and add activity for involved books.
 | 
			
		||||
        $booksInvolved->each(function (Book $book) {
 | 
			
		||||
            $this->entityRepo->buildJointPermissionsForBook($book);
 | 
			
		||||
            Activity::add($book, 'book_sort', $book->id);
 | 
			
		||||
        });
 | 
			
		||||
        Activity::add($book, 'book_update', $book->id);
 | 
			
		||||
 | 
			
		||||
        return redirect($book->getUrl());
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Remove the specified book from storage.
 | 
			
		||||
     * @param $bookSlug
 | 
			
		||||
     * @return Response
 | 
			
		||||
     * Shows the page to confirm deletion.
 | 
			
		||||
     */
 | 
			
		||||
    public function destroy($bookSlug)
 | 
			
		||||
    public function showDelete(string $bookSlug)
 | 
			
		||||
    {
 | 
			
		||||
        $book = $this->entityRepo->getBySlug('book', $bookSlug);
 | 
			
		||||
        $book = $this->bookRepo->getBySlug($bookSlug);
 | 
			
		||||
        $this->checkOwnablePermission('book-delete', $book);
 | 
			
		||||
        Activity::addMessage('book_delete', 0, $book->name);
 | 
			
		||||
        $this->setPageTitle(trans('entities.books_delete_named', ['bookName' => $book->getShortName()]));
 | 
			
		||||
        return view('books.delete', ['book' => $book, 'current' => $book]);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
        if ($book->cover) {
 | 
			
		||||
            $this->imageRepo->destroyImage($book->cover);
 | 
			
		||||
        }
 | 
			
		||||
        $this->entityRepo->destroyBook($book);
 | 
			
		||||
    /**
 | 
			
		||||
     * Remove the specified book from the system.
 | 
			
		||||
     * @throws Throwable
 | 
			
		||||
     * @throws NotifyException
 | 
			
		||||
     */
 | 
			
		||||
    public function destroy(string $bookSlug)
 | 
			
		||||
    {
 | 
			
		||||
        $book = $this->bookRepo->getBySlug($bookSlug);
 | 
			
		||||
        $this->checkOwnablePermission('book-delete', $book);
 | 
			
		||||
 | 
			
		||||
        Activity::addMessage('book_delete', $book->name);
 | 
			
		||||
        $this->bookRepo->destroy($book);
 | 
			
		||||
 | 
			
		||||
        return redirect('/books');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Show the Restrictions view.
 | 
			
		||||
     * @param $bookSlug
 | 
			
		||||
     * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
 | 
			
		||||
     * Show the permissions view.
 | 
			
		||||
     */
 | 
			
		||||
    public function showPermissions($bookSlug)
 | 
			
		||||
    public function showPermissions(string $bookSlug)
 | 
			
		||||
    {
 | 
			
		||||
        $book = $this->entityRepo->getBySlug('book', $bookSlug);
 | 
			
		||||
        $book = $this->bookRepo->getBySlug($bookSlug);
 | 
			
		||||
        $this->checkOwnablePermission('restrictions-manage', $book);
 | 
			
		||||
        $roles = $this->userRepo->getRestrictableRoles();
 | 
			
		||||
 | 
			
		||||
        return view('books.permissions', [
 | 
			
		||||
            'book' => $book,
 | 
			
		||||
            'roles' => $roles
 | 
			
		||||
        ]);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Set the restrictions for this book.
 | 
			
		||||
     * @param $bookSlug
 | 
			
		||||
     * @param Request $request
 | 
			
		||||
     * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
 | 
			
		||||
     * @throws \BookStack\Exceptions\NotFoundException
 | 
			
		||||
     * @throws \Throwable
 | 
			
		||||
     * @throws Throwable
 | 
			
		||||
     */
 | 
			
		||||
    public function permissions($bookSlug, Request $request)
 | 
			
		||||
    public function permissions(Request $request, string $bookSlug)
 | 
			
		||||
    {
 | 
			
		||||
        $book = $this->entityRepo->getBySlug('book', $bookSlug);
 | 
			
		||||
        $book = $this->bookRepo->getBySlug($bookSlug);
 | 
			
		||||
        $this->checkOwnablePermission('restrictions-manage', $book);
 | 
			
		||||
        $this->entityRepo->updateEntityPermissionsFromRequest($request, $book);
 | 
			
		||||
        session()->flash('success', trans('entities.books_permissions_updated'));
 | 
			
		||||
 | 
			
		||||
        $restricted = $request->get('restricted') === 'true';
 | 
			
		||||
        $permissions = $request->filled('restrictions') ? collect($request->get('restrictions')) : null;
 | 
			
		||||
        $this->bookRepo->updatePermissions($book, $restricted, $permissions);
 | 
			
		||||
 | 
			
		||||
        $this->showSuccessNotification(trans('entities.books_permissions_updated'));
 | 
			
		||||
        return redirect($book->getUrl());
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Export a book as a PDF file.
 | 
			
		||||
     * @param string $bookSlug
 | 
			
		||||
     * @return mixed
 | 
			
		||||
     */
 | 
			
		||||
    public function exportPdf($bookSlug)
 | 
			
		||||
    {
 | 
			
		||||
        $book = $this->entityRepo->getBySlug('book', $bookSlug);
 | 
			
		||||
        $pdfContent = $this->exportService->bookToPdf($book);
 | 
			
		||||
        return $this->downloadResponse($pdfContent, $bookSlug . '.pdf');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Export a book as a contained HTML file.
 | 
			
		||||
     * @param string $bookSlug
 | 
			
		||||
     * @return mixed
 | 
			
		||||
     */
 | 
			
		||||
    public function exportHtml($bookSlug)
 | 
			
		||||
    {
 | 
			
		||||
        $book = $this->entityRepo->getBySlug('book', $bookSlug);
 | 
			
		||||
        $htmlContent = $this->exportService->bookToContainedHtml($book);
 | 
			
		||||
        return $this->downloadResponse($htmlContent, $bookSlug . '.html');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Export a book as a plain text file.
 | 
			
		||||
     * @param $bookSlug
 | 
			
		||||
     * @return mixed
 | 
			
		||||
     */
 | 
			
		||||
    public function exportPlainText($bookSlug)
 | 
			
		||||
    {
 | 
			
		||||
        $book = $this->entityRepo->getBySlug('book', $bookSlug);
 | 
			
		||||
        $textContent = $this->exportService->bookToPlainText($book);
 | 
			
		||||
        return $this->downloadResponse($textContent, $bookSlug . '.txt');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Common actions to run on book update.
 | 
			
		||||
     * Handles updating the cover image.
 | 
			
		||||
     * @param Book $book
 | 
			
		||||
     * @param Request $request
 | 
			
		||||
     * @throws \BookStack\Exceptions\ImageUploadException
 | 
			
		||||
     */
 | 
			
		||||
    protected function bookUpdateActions(Book $book, Request $request)
 | 
			
		||||
    {
 | 
			
		||||
        // Update the cover image if in request
 | 
			
		||||
        if ($request->has('image')) {
 | 
			
		||||
            $this->imageRepo->destroyImage($book->cover);
 | 
			
		||||
            $newImage = $request->file('image');
 | 
			
		||||
            $image = $this->imageRepo->saveNew($newImage, 'cover_book', $book->id, 512, 512, true);
 | 
			
		||||
            $book->image_id = $image->id;
 | 
			
		||||
            $book->save();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if ($request->has('image_reset')) {
 | 
			
		||||
            $this->imageRepo->destroyImage($book->cover);
 | 
			
		||||
            $book->image_id = 0;
 | 
			
		||||
            $book->save();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,56 @@
 | 
			
		|||
<?php
 | 
			
		||||
 | 
			
		||||
namespace BookStack\Http\Controllers;
 | 
			
		||||
 | 
			
		||||
use BookStack\Entities\ExportService;
 | 
			
		||||
use BookStack\Entities\Repos\BookRepo;
 | 
			
		||||
use Throwable;
 | 
			
		||||
 | 
			
		||||
class BookExportController extends Controller
 | 
			
		||||
{
 | 
			
		||||
 | 
			
		||||
    protected $bookRepo;
 | 
			
		||||
    protected $exportService;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * BookExportController constructor.
 | 
			
		||||
     */
 | 
			
		||||
    public function __construct(BookRepo $bookRepo, ExportService $exportService)
 | 
			
		||||
    {
 | 
			
		||||
        $this->bookRepo = $bookRepo;
 | 
			
		||||
        $this->exportService = $exportService;
 | 
			
		||||
        parent::__construct();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Export a book as a PDF file.
 | 
			
		||||
     * @throws Throwable
 | 
			
		||||
     */
 | 
			
		||||
    public function pdf(string $bookSlug)
 | 
			
		||||
    {
 | 
			
		||||
        $book = $this->bookRepo->getBySlug($bookSlug);
 | 
			
		||||
        $pdfContent = $this->exportService->bookToPdf($book);
 | 
			
		||||
        return $this->downloadResponse($pdfContent, $bookSlug . '.pdf');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Export a book as a contained HTML file.
 | 
			
		||||
     * @throws Throwable
 | 
			
		||||
     */
 | 
			
		||||
    public function html(string $bookSlug)
 | 
			
		||||
    {
 | 
			
		||||
        $book = $this->bookRepo->getBySlug($bookSlug);
 | 
			
		||||
        $htmlContent = $this->exportService->bookToContainedHtml($book);
 | 
			
		||||
        return $this->downloadResponse($htmlContent, $bookSlug . '.html');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Export a book as a plain text file.
 | 
			
		||||
     */
 | 
			
		||||
    public function plainText(string $bookSlug)
 | 
			
		||||
    {
 | 
			
		||||
        $book = $this->bookRepo->getBySlug($bookSlug);
 | 
			
		||||
        $textContent = $this->exportService->bookToPlainText($book);
 | 
			
		||||
        return $this->downloadResponse($textContent, $bookSlug . '.txt');
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
Some files were not shown because too many files have changed in this diff Show More
		Loading…
	
		Reference in New Issue