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
|
# Valid timezone values can be found here: https://www.php.net/manual/en/timezones.php
|
||||||
APP_TIMEZONE=UTC
|
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
|
# Database details
|
||||||
# Host can contain a port (localhost:3306) or a separate DB_PORT option can be used.
|
# Host can contain a port (localhost:3306) or a separate DB_PORT option can be used.
|
||||||
DB_HOST=localhost
|
DB_HOST=localhost
|
||||||
|
@ -89,7 +94,7 @@ REDIS_SERVERS=127.0.0.1:6379:0
|
||||||
# Queue driver to use
|
# Queue driver to use
|
||||||
# Queue not really currently used but may be configurable in the future.
|
# Queue not really currently used but may be configurable in the future.
|
||||||
# Would advise not to change this for now.
|
# Would advise not to change this for now.
|
||||||
QUEUE_DRIVER=sync
|
QUEUE_CONNECTION=sync
|
||||||
|
|
||||||
# Storage system to use
|
# Storage system to use
|
||||||
# Can be 'local', 'local_secure' or 's3'
|
# 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
|
STORAGE_URL=false
|
||||||
|
|
||||||
# Authentication method to use
|
# Authentication method to use
|
||||||
# Can be 'standard' or 'ldap'
|
# Can be 'standard', 'ldap' or 'saml2'
|
||||||
AUTH_METHOD=standard
|
AUTH_METHOD=standard
|
||||||
|
|
||||||
# Social authentication configuration
|
# Social authentication configuration
|
||||||
|
@ -191,6 +196,7 @@ LDAP_PASS=false
|
||||||
LDAP_USER_FILTER=false
|
LDAP_USER_FILTER=false
|
||||||
LDAP_VERSION=false
|
LDAP_VERSION=false
|
||||||
LDAP_TLS_INSECURE=false
|
LDAP_TLS_INSECURE=false
|
||||||
|
LDAP_ID_ATTRIBUTE=uid
|
||||||
LDAP_EMAIL_ATTRIBUTE=mail
|
LDAP_EMAIL_ATTRIBUTE=mail
|
||||||
LDAP_DISPLAY_NAME_ATTRIBUTE=cn
|
LDAP_DISPLAY_NAME_ATTRIBUTE=cn
|
||||||
LDAP_FOLLOW_REFERRALS=true
|
LDAP_FOLLOW_REFERRALS=true
|
||||||
|
@ -201,6 +207,26 @@ LDAP_USER_TO_GROUPS=false
|
||||||
LDAP_GROUP_ATTRIBUTE="memberOf"
|
LDAP_GROUP_ATTRIBUTE="memberOf"
|
||||||
LDAP_REMOVE_FROM_GROUPS=false
|
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
|
# Disable default third-party services such as Gravatar and Draw.IO
|
||||||
# Service-specific options will override this option
|
# Service-specific options will override this option
|
||||||
DISABLE_EXTERNAL_SERVICES=false
|
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.
|
# Contents of the robots.txt file can be overridden, making this option obsolete.
|
||||||
ALLOW_ROBOTS=null
|
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
|
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
|
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
|
.buildpath
|
||||||
.project
|
.project
|
||||||
.settings/
|
.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;
|
namespace BookStack\Actions;
|
||||||
|
|
||||||
use BookStack\Auth\User;
|
use BookStack\Auth\User;
|
||||||
|
use BookStack\Entities\Entity;
|
||||||
use BookStack\Model;
|
use BookStack\Model;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @property string key
|
* @property string $key
|
||||||
* @property \User user
|
* @property User $user
|
||||||
* @property \Entity entity
|
* @property Entity $entity
|
||||||
* @property string extra
|
* @property string $extra
|
||||||
|
* @property string $entity_type
|
||||||
|
* @property int $entity_id
|
||||||
|
* @property int $user_id
|
||||||
|
* @property int $book_id
|
||||||
*/
|
*/
|
||||||
class Activity extends Model
|
class Activity extends Model
|
||||||
{
|
{
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
<?php namespace BookStack\Actions;
|
<?php namespace BookStack\Actions;
|
||||||
|
|
||||||
use BookStack\Auth\Permissions\PermissionService;
|
use BookStack\Auth\Permissions\PermissionService;
|
||||||
|
use BookStack\Entities\Book;
|
||||||
use BookStack\Entities\Entity;
|
use BookStack\Entities\Entity;
|
||||||
use Session;
|
|
||||||
|
|
||||||
class ActivityService
|
class ActivityService
|
||||||
{
|
{
|
||||||
|
@ -12,7 +12,7 @@ class ActivityService
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ActivityService constructor.
|
* ActivityService constructor.
|
||||||
* @param \BookStack\Actions\Activity $activity
|
* @param Activity $activity
|
||||||
* @param PermissionService $permissionService
|
* @param PermissionService $permissionService
|
||||||
*/
|
*/
|
||||||
public function __construct(Activity $activity, PermissionService $permissionService)
|
public function __construct(Activity $activity, PermissionService $permissionService)
|
||||||
|
@ -24,52 +24,57 @@ class ActivityService
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add activity data to database.
|
* Add activity data to database.
|
||||||
* @param Entity $entity
|
* @param \BookStack\Entities\Entity $entity
|
||||||
* @param $activityKey
|
* @param string $activityKey
|
||||||
* @param int $bookId
|
* @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 = $this->newActivityForUser($activityKey, $bookId);
|
||||||
$activity->user_id = $this->user->id;
|
|
||||||
$activity->book_id = $bookId;
|
|
||||||
$activity->key = strtolower($activityKey);
|
|
||||||
if ($extra !== false) {
|
|
||||||
$activity->extra = $extra;
|
|
||||||
}
|
|
||||||
$entity->activity()->save($activity);
|
$entity->activity()->save($activity);
|
||||||
$this->setNotification($activityKey);
|
$this->setNotification($activityKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds a activity history with a message & without binding to a entity.
|
* Adds a activity history with a message, without binding to a entity.
|
||||||
* @param $activityKey
|
* @param string $activityKey
|
||||||
|
* @param string $message
|
||||||
* @param int $bookId
|
* @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->newActivityForUser($activityKey, $bookId)->forceFill([
|
||||||
$this->activity->book_id = $bookId;
|
'extra' => $message
|
||||||
$this->activity->key = strtolower($activityKey);
|
])->save();
|
||||||
if ($extra !== false) {
|
|
||||||
$this->activity->extra = $extra;
|
|
||||||
}
|
|
||||||
$this->activity->save();
|
|
||||||
$this->setNotification($activityKey);
|
$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
|
* Removes the entity attachment from each of its activities
|
||||||
* and instead uses the 'extra' field with the entities name.
|
* and instead uses the 'extra' field with the entities name.
|
||||||
* Used when an entity is deleted.
|
* Used when an entity is deleted.
|
||||||
* @param Entity $entity
|
* @param \BookStack\Entities\Entity $entity
|
||||||
* @return mixed
|
* @return mixed
|
||||||
*/
|
*/
|
||||||
public function removeEntity(Entity $entity)
|
public function removeEntity(Entity $entity)
|
||||||
{
|
{
|
||||||
|
// TODO - Rewrite to db query.
|
||||||
$activities = $entity->activity;
|
$activities = $entity->activity;
|
||||||
foreach ($activities as $activity) {
|
foreach ($activities as $activity) {
|
||||||
$activity->extra = $entity->name;
|
$activity->extra = $entity->name;
|
||||||
|
@ -90,7 +95,11 @@ class ActivityService
|
||||||
{
|
{
|
||||||
$activityList = $this->permissionService
|
$activityList = $this->permissionService
|
||||||
->filterRestrictedEntityRelations($this->activity, 'activities', 'entity_id', 'entity_type')
|
->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);
|
return $this->filterSimilar($activityList);
|
||||||
}
|
}
|
||||||
|
@ -98,7 +107,7 @@ class ActivityService
|
||||||
/**
|
/**
|
||||||
* Gets the latest activity for an entity, Filtering out similar
|
* Gets the latest activity for an entity, Filtering out similar
|
||||||
* items to prevent a message activity list.
|
* items to prevent a message activity list.
|
||||||
* @param Entity $entity
|
* @param \BookStack\Entities\Entity $entity
|
||||||
* @param int $count
|
* @param int $count
|
||||||
* @param int $page
|
* @param int $page
|
||||||
* @return array
|
* @return array
|
||||||
|
@ -171,7 +180,7 @@ class ActivityService
|
||||||
$notificationTextKey = 'activities.' . $activityKey . '_notification';
|
$notificationTextKey = 'activities.' . $activityKey . '_notification';
|
||||||
if (trans()->has($notificationTextKey)) {
|
if (trans()->has($notificationTextKey)) {
|
||||||
$message = trans($notificationTextKey);
|
$message = trans($notificationTextKey);
|
||||||
Session::flash('success', $message);
|
session()->flash('success', $message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
<?php namespace BookStack\Actions;
|
<?php namespace BookStack\Actions;
|
||||||
|
|
||||||
use BookStack\Auth\Permissions\PermissionService;
|
use BookStack\Auth\Permissions\PermissionService;
|
||||||
|
use BookStack\Entities\Book;
|
||||||
use BookStack\Entities\Entity;
|
use BookStack\Entities\Entity;
|
||||||
use BookStack\Entities\EntityProvider;
|
use BookStack\Entities\EntityProvider;
|
||||||
|
use DB;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
class ViewService
|
class ViewService
|
||||||
|
@ -13,8 +15,8 @@ class ViewService
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ViewService constructor.
|
* ViewService constructor.
|
||||||
* @param \BookStack\Actions\View $view
|
* @param View $view
|
||||||
* @param \BookStack\Auth\Permissions\PermissionService $permissionService
|
* @param PermissionService $permissionService
|
||||||
* @param EntityProvider $entityProvider
|
* @param EntityProvider $entityProvider
|
||||||
*/
|
*/
|
||||||
public function __construct(View $view, PermissionService $permissionService, EntityProvider $entityProvider)
|
public function __construct(View $view, PermissionService $permissionService, EntityProvider $entityProvider)
|
||||||
|
@ -26,7 +28,7 @@ class ViewService
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a view to the given entity.
|
* Add a view to the given entity.
|
||||||
* @param Entity $entity
|
* @param \BookStack\Entities\Entity $entity
|
||||||
* @return int
|
* @return int
|
||||||
*/
|
*/
|
||||||
public function add(Entity $entity)
|
public function add(Entity $entity)
|
||||||
|
@ -43,7 +45,7 @@ class ViewService
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise create new view count
|
// Otherwise create new view count
|
||||||
$entity->views()->save($this->view->create([
|
$entity->views()->save($this->view->newInstance([
|
||||||
'user_id' => $user->id,
|
'user_id' => $user->id,
|
||||||
'views' => 1
|
'views' => 1
|
||||||
]));
|
]));
|
||||||
|
@ -59,12 +61,12 @@ class ViewService
|
||||||
* @param string $action - used for permission checking
|
* @param string $action - used for permission checking
|
||||||
* @return Collection
|
* @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;
|
$skipCount = $count * $page;
|
||||||
$query = $this->permissionService
|
$query = $this->permissionService
|
||||||
->filterRestrictedEntityRelations($this->view, 'views', 'viewable_id', 'viewable_type', $action)
|
->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')
|
->groupBy('viewable_id', 'viewable_type')
|
||||||
->orderBy('view_count', 'desc');
|
->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 = '')
|
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')
|
return setting('registration-confirmation')
|
||||||
|| setting('registration-restrict');
|
|| 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
|
<?php
|
||||||
|
|
||||||
namespace BookStack\Providers;
|
namespace BookStack\Auth\Access;
|
||||||
|
|
||||||
use BookStack\Auth\Access\LdapService;
|
|
||||||
use Illuminate\Contracts\Auth\Authenticatable;
|
use Illuminate\Contracts\Auth\Authenticatable;
|
||||||
use Illuminate\Contracts\Auth\UserProvider;
|
use Illuminate\Contracts\Auth\UserProvider;
|
||||||
|
|
||||||
class LdapUserProvider implements UserProvider
|
class ExternalBaseUserProvider implements UserProvider
|
||||||
{
|
{
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -16,21 +15,13 @@ class LdapUserProvider implements UserProvider
|
||||||
*/
|
*/
|
||||||
protected $model;
|
protected $model;
|
||||||
|
|
||||||
/**
|
|
||||||
* @var \BookStack\Auth\LdapService
|
|
||||||
*/
|
|
||||||
protected $ldapService;
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* LdapUserProvider constructor.
|
* LdapUserProvider constructor.
|
||||||
* @param $model
|
* @param $model
|
||||||
* @param \BookStack\Auth\LdapService $ldapService
|
|
||||||
*/
|
*/
|
||||||
public function __construct($model, LdapService $ldapService)
|
public function __construct(string $model)
|
||||||
{
|
{
|
||||||
$this->model = $model;
|
$this->model = $model;
|
||||||
$this->ldapService = $ldapService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -44,7 +35,6 @@ class LdapUserProvider implements UserProvider
|
||||||
return new $class;
|
return new $class;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieve a user by their unique identifier.
|
* Retrieve a user by their unique identifier.
|
||||||
*
|
*
|
||||||
|
@ -65,12 +55,7 @@ class LdapUserProvider implements UserProvider
|
||||||
*/
|
*/
|
||||||
public function retrieveByToken($identifier, $token)
|
public function retrieveByToken($identifier, $token)
|
||||||
{
|
{
|
||||||
$model = $this->createModel();
|
return null;
|
||||||
|
|
||||||
return $model->newQuery()
|
|
||||||
->where($model->getAuthIdentifierName(), $identifier)
|
|
||||||
->where($model->getRememberTokenName(), $token)
|
|
||||||
->first();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -83,10 +68,7 @@ class LdapUserProvider implements UserProvider
|
||||||
*/
|
*/
|
||||||
public function updateRememberToken(Authenticatable $user, $token)
|
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)
|
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
|
// Search current user base by looking up a uid
|
||||||
$model = $this->createModel();
|
$model = $this->createModel();
|
||||||
$currentUser = $model->newQuery()
|
return $model->newQuery()
|
||||||
->where('external_auth_id', $userDetails['uid'])
|
->where('external_auth_id', $credentials['external_auth_id'])
|
||||||
->first();
|
->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)
|
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;
|
<?php namespace BookStack\Auth\Access;
|
||||||
|
|
||||||
use BookStack\Auth\Access;
|
|
||||||
use BookStack\Auth\Role;
|
|
||||||
use BookStack\Auth\User;
|
use BookStack\Auth\User;
|
||||||
use BookStack\Auth\UserRepo;
|
|
||||||
use BookStack\Exceptions\LdapException;
|
use BookStack\Exceptions\LdapException;
|
||||||
use Illuminate\Contracts\Auth\Authenticatable;
|
use ErrorException;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class LdapService
|
* Class LdapService
|
||||||
* Handles any app-specific LDAP tasks.
|
* Handles any app-specific LDAP tasks.
|
||||||
* @package BookStack\Services
|
|
||||||
*/
|
*/
|
||||||
class LdapService
|
class LdapService extends ExternalAuthService
|
||||||
{
|
{
|
||||||
|
|
||||||
protected $ldap;
|
protected $ldap;
|
||||||
protected $ldapConnection;
|
protected $ldapConnection;
|
||||||
protected $config;
|
protected $config;
|
||||||
protected $userRepo;
|
|
||||||
protected $enabled;
|
protected $enabled;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* LdapService constructor.
|
* 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->ldap = $ldap;
|
||||||
$this->config = config('services.ldap');
|
$this->config = config('services.ldap');
|
||||||
$this->userRepo = $userRepo;
|
|
||||||
$this->enabled = config('auth.method') === 'ldap';
|
$this->enabled = config('auth.method') === 'ldap';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -45,13 +36,10 @@ class LdapService
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Search for attributes for a specific user on the ldap
|
* Search for attributes for a specific user on the ldap.
|
||||||
* @param string $userName
|
|
||||||
* @param array $attributes
|
|
||||||
* @return null|array
|
|
||||||
* @throws LdapException
|
* @throws LdapException
|
||||||
*/
|
*/
|
||||||
private function getUserWithAttributes($userName, $attributes)
|
private function getUserWithAttributes(string $userName, array $attributes): ?array
|
||||||
{
|
{
|
||||||
$ldapConnection = $this->getConnection();
|
$ldapConnection = $this->getConnection();
|
||||||
$this->bindSystemUser($ldapConnection);
|
$this->bindSystemUser($ldapConnection);
|
||||||
|
@ -73,16 +61,15 @@ class LdapService
|
||||||
/**
|
/**
|
||||||
* Get the details of a user from LDAP using the given username.
|
* Get the details of a user from LDAP using the given username.
|
||||||
* User found via configurable user filter.
|
* User found via configurable user filter.
|
||||||
* @param $userName
|
|
||||||
* @return array|null
|
|
||||||
* @throws LdapException
|
* @throws LdapException
|
||||||
*/
|
*/
|
||||||
public function getUserDetails($userName)
|
public function getUserDetails(string $userName): ?array
|
||||||
{
|
{
|
||||||
|
$idAttr = $this->config['id_attribute'];
|
||||||
$emailAttr = $this->config['email_attribute'];
|
$emailAttr = $this->config['email_attribute'];
|
||||||
$displayNameAttr = $this->config['display_name_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) {
|
if ($user === null) {
|
||||||
return null;
|
return null;
|
||||||
|
@ -90,7 +77,7 @@ class LdapService
|
||||||
|
|
||||||
$userCn = $this->getUserResponseProperty($user, 'cn', null);
|
$userCn = $this->getUserResponseProperty($user, 'cn', null);
|
||||||
return [
|
return [
|
||||||
'uid' => $this->getUserResponseProperty($user, 'uid', $user['dn']),
|
'uid' => $this->getUserResponseProperty($user, $idAttr, $user['dn']),
|
||||||
'name' => $this->getUserResponseProperty($user, $displayNameAttr, $userCn),
|
'name' => $this->getUserResponseProperty($user, $displayNameAttr, $userCn),
|
||||||
'dn' => $user['dn'],
|
'dn' => $user['dn'],
|
||||||
'email' => $this->getUserResponseProperty($user, $emailAttr, null),
|
'email' => $this->getUserResponseProperty($user, $emailAttr, null),
|
||||||
|
@ -100,13 +87,10 @@ class LdapService
|
||||||
/**
|
/**
|
||||||
* Get a property from an LDAP user response fetch.
|
* Get a property from an LDAP user response fetch.
|
||||||
* Handles properties potentially being part of an array.
|
* 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)
|
protected function getUserResponseProperty(array $userDetails, string $propertyKey, $defaultValue)
|
||||||
{
|
{
|
||||||
|
$propertyKey = strtolower($propertyKey);
|
||||||
if (isset($userDetails[$propertyKey])) {
|
if (isset($userDetails[$propertyKey])) {
|
||||||
return (is_array($userDetails[$propertyKey]) ? $userDetails[$propertyKey][0] : $userDetails[$propertyKey]);
|
return (is_array($userDetails[$propertyKey]) ? $userDetails[$propertyKey][0] : $userDetails[$propertyKey]);
|
||||||
}
|
}
|
||||||
|
@ -115,27 +99,19 @@ class LdapService
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param Authenticatable $user
|
* Check if the given credentials are valid for the given user.
|
||||||
* @param string $username
|
|
||||||
* @param string $password
|
|
||||||
* @return bool
|
|
||||||
* @throws LdapException
|
* @throws LdapException
|
||||||
*/
|
*/
|
||||||
public function validateUserCredentials(Authenticatable $user, $username, $password)
|
public function validateUserCredentials(array $ldapUserDetails, string $username, string $password): bool
|
||||||
{
|
{
|
||||||
$ldapUser = $this->getUserDetails($username);
|
if ($ldapUserDetails === null) {
|
||||||
if ($ldapUser === null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($ldapUser['uid'] !== $user->external_auth_id) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$ldapConnection = $this->getConnection();
|
$ldapConnection = $this->getConnection();
|
||||||
try {
|
try {
|
||||||
$ldapBind = $this->ldap->bind($ldapConnection, $ldapUser['dn'], $password);
|
$ldapBind = $this->ldap->bind($ldapConnection, $ldapUserDetails['dn'], $password);
|
||||||
} catch (\ErrorException $e) {
|
} catch (ErrorException $e) {
|
||||||
$ldapBind = false;
|
$ldapBind = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -205,12 +181,10 @@ class LdapService
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a LDAP server string and return the host and port for
|
* Parse a LDAP server string and return the host and port for a connection.
|
||||||
* a connection. Is flexible to formats such as 'ldap.example.com:8069' or 'ldaps://ldap.example.com'
|
* Is flexible to formats such as 'ldap.example.com:8069' or 'ldaps://ldap.example.com'.
|
||||||
* @param $serverString
|
|
||||||
* @return array
|
|
||||||
*/
|
*/
|
||||||
protected function parseServerString($serverString)
|
protected function parseServerString(string $serverString): array
|
||||||
{
|
{
|
||||||
$serverNameParts = explode(':', $serverString);
|
$serverNameParts = explode(':', $serverString);
|
||||||
|
|
||||||
|
@ -227,11 +201,8 @@ class LdapService
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a filter string by injecting common variables.
|
* 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 = [];
|
$newAttrs = [];
|
||||||
foreach ($attrs as $key => $attrText) {
|
foreach ($attrs as $key => $attrText) {
|
||||||
|
@ -242,12 +213,10 @@ class LdapService
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the groups a user is a part of on ldap
|
* Get the groups a user is a part of on ldap.
|
||||||
* @param string $userName
|
|
||||||
* @return array
|
|
||||||
* @throws LdapException
|
* @throws LdapException
|
||||||
*/
|
*/
|
||||||
public function getUserGroups($userName)
|
public function getUserGroups(string $userName): array
|
||||||
{
|
{
|
||||||
$groupsAttr = $this->config['group_attribute'];
|
$groupsAttr = $this->config['group_attribute'];
|
||||||
$user = $this->getUserWithAttributes($userName, [$groupsAttr]);
|
$user = $this->getUserWithAttributes($userName, [$groupsAttr]);
|
||||||
|
@ -262,40 +231,36 @@ class LdapService
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the parent groups of an array of groups
|
* Get the parent groups of an array of groups.
|
||||||
* @param array $groupsArray
|
|
||||||
* @param array $checked
|
|
||||||
* @return array
|
|
||||||
* @throws LdapException
|
* @throws LdapException
|
||||||
*/
|
*/
|
||||||
private function getGroupsRecursive($groupsArray, $checked)
|
private function getGroupsRecursive(array $groupsArray, array $checked): array
|
||||||
{
|
{
|
||||||
$groups_to_add = [];
|
$groupsToAdd = [];
|
||||||
foreach ($groupsArray as $groupName) {
|
foreach ($groupsArray as $groupName) {
|
||||||
if (in_array($groupName, $checked)) {
|
if (in_array($groupName, $checked)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$groupsToAdd = $this->getGroupGroups($groupName);
|
$parentGroups = $this->getGroupGroups($groupName);
|
||||||
$groups_to_add = array_merge($groups_to_add, $groupsToAdd);
|
$groupsToAdd = array_merge($groupsToAdd, $parentGroups);
|
||||||
$checked[] = $groupName;
|
$checked[] = $groupName;
|
||||||
}
|
}
|
||||||
$groupsArray = array_unique(array_merge($groupsArray, $groups_to_add), SORT_REGULAR);
|
|
||||||
|
|
||||||
if (!empty($groups_to_add)) {
|
$groupsArray = array_unique(array_merge($groupsArray, $groupsToAdd), SORT_REGULAR);
|
||||||
return $this->getGroupsRecursive($groupsArray, $checked);
|
|
||||||
} else {
|
if (empty($groupsToAdd)) {
|
||||||
return $groupsArray;
|
return $groupsArray;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return $this->getGroupsRecursive($groupsArray, $checked);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the parent groups of a single group
|
* Get the parent groups of a single group.
|
||||||
* @param string $groupName
|
|
||||||
* @return array
|
|
||||||
* @throws LdapException
|
* @throws LdapException
|
||||||
*/
|
*/
|
||||||
private function getGroupGroups($groupName)
|
private function getGroupGroups(string $groupName): array
|
||||||
{
|
{
|
||||||
$ldapConnection = $this->getConnection();
|
$ldapConnection = $this->getConnection();
|
||||||
$this->bindSystemUser($ldapConnection);
|
$this->bindSystemUser($ldapConnection);
|
||||||
|
@ -312,17 +277,14 @@ class LdapService
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$groupGroups = $this->groupFilter($groups[0]);
|
return $this->groupFilter($groups[0]);
|
||||||
return $groupGroups;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filter out LDAP CN and DN language in a ldap search return
|
* Filter out LDAP CN and DN language in a ldap search return.
|
||||||
* Gets the base CN (common name) of the string
|
* Gets the base CN (common name) of the string.
|
||||||
* @param array $userGroupSearchResponse
|
|
||||||
* @return array
|
|
||||||
*/
|
*/
|
||||||
protected function groupFilter(array $userGroupSearchResponse)
|
protected function groupFilter(array $userGroupSearchResponse): array
|
||||||
{
|
{
|
||||||
$groupsAttr = strtolower($this->config['group_attribute']);
|
$groupsAttr = strtolower($this->config['group_attribute']);
|
||||||
$ldapGroups = [];
|
$ldapGroups = [];
|
||||||
|
@ -343,73 +305,12 @@ class LdapService
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sync the LDAP groups to the user roles for the current user
|
* Sync the LDAP groups to the user roles for the current user.
|
||||||
* @param \BookStack\Auth\User $user
|
|
||||||
* @param string $username
|
|
||||||
* @throws LdapException
|
* @throws LdapException
|
||||||
*/
|
*/
|
||||||
public function syncGroups(User $user, string $username)
|
public function syncGroups(User $user, string $username)
|
||||||
{
|
{
|
||||||
$userLdapGroups = $this->getUserGroups($username);
|
$userLdapGroups = $this->getUserGroups($username);
|
||||||
|
$this->syncWithGroups($user, $userLdapGroups);
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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\SocialDriverNotConfigured;
|
||||||
use BookStack\Exceptions\SocialSignInAccountNotUsed;
|
use BookStack\Exceptions\SocialSignInAccountNotUsed;
|
||||||
use BookStack\Exceptions\UserRegistrationException;
|
use BookStack\Exceptions\UserRegistrationException;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Laravel\Socialite\Contracts\Factory as Socialite;
|
use Laravel\Socialite\Contracts\Factory as Socialite;
|
||||||
|
use Laravel\Socialite\Contracts\Provider;
|
||||||
use Laravel\Socialite\Contracts\User as SocialUser;
|
use Laravel\Socialite\Contracts\User as SocialUser;
|
||||||
|
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||||
|
|
||||||
class SocialAuthService
|
class SocialAuthService
|
||||||
{
|
{
|
||||||
|
@ -19,9 +22,6 @@ class SocialAuthService
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SocialAuthService constructor.
|
* SocialAuthService constructor.
|
||||||
* @param \BookStack\Auth\UserRepo $userRepo
|
|
||||||
* @param Socialite $socialite
|
|
||||||
* @param SocialAccount $socialAccount
|
|
||||||
*/
|
*/
|
||||||
public function __construct(UserRepo $userRepo, Socialite $socialite, SocialAccount $socialAccount)
|
public function __construct(UserRepo $userRepo, Socialite $socialite, SocialAccount $socialAccount)
|
||||||
{
|
{
|
||||||
|
@ -33,11 +33,9 @@ class SocialAuthService
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start the social login path.
|
* Start the social login path.
|
||||||
* @param string $socialDriver
|
|
||||||
* @return \Symfony\Component\HttpFoundation\RedirectResponse
|
|
||||||
* @throws SocialDriverNotConfigured
|
* @throws SocialDriverNotConfigured
|
||||||
*/
|
*/
|
||||||
public function startLogIn($socialDriver)
|
public function startLogIn(string $socialDriver): RedirectResponse
|
||||||
{
|
{
|
||||||
$driver = $this->validateDriver($socialDriver);
|
$driver = $this->validateDriver($socialDriver);
|
||||||
return $this->getSocialDriver($driver)->redirect();
|
return $this->getSocialDriver($driver)->redirect();
|
||||||
|
@ -45,11 +43,9 @@ class SocialAuthService
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start the social registration process
|
* Start the social registration process
|
||||||
* @param string $socialDriver
|
|
||||||
* @return \Symfony\Component\HttpFoundation\RedirectResponse
|
|
||||||
* @throws SocialDriverNotConfigured
|
* @throws SocialDriverNotConfigured
|
||||||
*/
|
*/
|
||||||
public function startRegister($socialDriver)
|
public function startRegister(string $socialDriver): RedirectResponse
|
||||||
{
|
{
|
||||||
$driver = $this->validateDriver($socialDriver);
|
$driver = $this->validateDriver($socialDriver);
|
||||||
return $this->getSocialDriver($driver)->redirect();
|
return $this->getSocialDriver($driver)->redirect();
|
||||||
|
@ -57,12 +53,9 @@ class SocialAuthService
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle the social registration process on callback.
|
* Handle the social registration process on callback.
|
||||||
* @param string $socialDriver
|
|
||||||
* @param SocialUser $socialUser
|
|
||||||
* @return SocialUser
|
|
||||||
* @throws UserRegistrationException
|
* @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
|
// Check social account has not already been used
|
||||||
if ($this->socialAccount->where('driver_id', '=', $socialUser->getId())->exists()) {
|
if ($this->socialAccount->where('driver_id', '=', $socialUser->getId())->exists()) {
|
||||||
|
@ -71,7 +64,7 @@ class SocialAuthService
|
||||||
|
|
||||||
if ($this->userRepo->getByEmail($socialUser->getEmail())) {
|
if ($this->userRepo->getByEmail($socialUser->getEmail())) {
|
||||||
$email = $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;
|
return $socialUser;
|
||||||
|
@ -79,11 +72,9 @@ class SocialAuthService
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the social user details via the social driver.
|
* Get the social user details via the social driver.
|
||||||
* @param string $socialDriver
|
|
||||||
* @return SocialUser
|
|
||||||
* @throws SocialDriverNotConfigured
|
* @throws SocialDriverNotConfigured
|
||||||
*/
|
*/
|
||||||
public function getSocialUser(string $socialDriver)
|
public function getSocialUser(string $socialDriver): SocialUser
|
||||||
{
|
{
|
||||||
$driver = $this->validateDriver($socialDriver);
|
$driver = $this->validateDriver($socialDriver);
|
||||||
return $this->socialite->driver($driver)->user();
|
return $this->socialite->driver($driver)->user();
|
||||||
|
@ -91,12 +82,9 @@ class SocialAuthService
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle the login process on a oAuth callback.
|
* Handle the login process on a oAuth callback.
|
||||||
* @param $socialDriver
|
|
||||||
* @param SocialUser $socialUser
|
|
||||||
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
|
|
||||||
* @throws SocialSignInAccountNotUsed
|
* @throws SocialSignInAccountNotUsed
|
||||||
*/
|
*/
|
||||||
public function handleLoginCallback($socialDriver, SocialUser $socialUser)
|
public function handleLoginCallback(string $socialDriver, SocialUser $socialUser)
|
||||||
{
|
{
|
||||||
$socialId = $socialUser->getId();
|
$socialId = $socialUser->getId();
|
||||||
|
|
||||||
|
@ -104,6 +92,7 @@ class SocialAuthService
|
||||||
$socialAccount = $this->socialAccount->where('driver_id', '=', $socialId)->first();
|
$socialAccount = $this->socialAccount->where('driver_id', '=', $socialId)->first();
|
||||||
$isLoggedIn = auth()->check();
|
$isLoggedIn = auth()->check();
|
||||||
$currentUser = user();
|
$currentUser = user();
|
||||||
|
$titleCaseDriver = Str::title($socialDriver);
|
||||||
|
|
||||||
// When a user is not logged in and a matching SocialAccount exists,
|
// When a user is not logged in and a matching SocialAccount exists,
|
||||||
// Simply log the user into the application.
|
// Simply log the user into the application.
|
||||||
|
@ -117,26 +106,26 @@ class SocialAuthService
|
||||||
if ($isLoggedIn && $socialAccount === null) {
|
if ($isLoggedIn && $socialAccount === null) {
|
||||||
$this->fillSocialAccount($socialDriver, $socialUser);
|
$this->fillSocialAccount($socialDriver, $socialUser);
|
||||||
$currentUser->socialAccounts()->save($this->socialAccount);
|
$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());
|
return redirect($currentUser->getEditUrl());
|
||||||
}
|
}
|
||||||
|
|
||||||
// When a user is logged in and the social account exists and is already linked to the current user.
|
// 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) {
|
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());
|
return redirect($currentUser->getEditUrl());
|
||||||
}
|
}
|
||||||
|
|
||||||
// When a user is logged in, A social account exists but the users do not match.
|
// 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) {
|
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());
|
return redirect($currentUser->getEditUrl());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise let the user know this social account is not used by anyone.
|
// Otherwise let the user know this social account is not used by anyone.
|
||||||
$message = trans('errors.social_account_not_used', ['socialAccount' => title_case($socialDriver)]);
|
$message = trans('errors.social_account_not_used', ['socialAccount' => $titleCaseDriver]);
|
||||||
if (setting('registration-enabled')) {
|
if (setting('registration-enabled') && config('auth.method') !== 'ldap' && config('auth.method') !== 'saml2') {
|
||||||
$message .= trans('errors.social_account_register_instructions', ['socialAccount' => title_case($socialDriver)]);
|
$message .= trans('errors.social_account_register_instructions', ['socialAccount' => $titleCaseDriver]);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new SocialSignInAccountNotUsed($message, '/login');
|
throw new SocialSignInAccountNotUsed($message, '/login');
|
||||||
|
@ -144,20 +133,18 @@ class SocialAuthService
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure the social driver is correct and supported.
|
* Ensure the social driver is correct and supported.
|
||||||
*
|
|
||||||
* @param $socialDriver
|
|
||||||
* @return string
|
|
||||||
* @throws SocialDriverNotConfigured
|
* @throws SocialDriverNotConfigured
|
||||||
*/
|
*/
|
||||||
private function validateDriver($socialDriver)
|
protected function validateDriver(string $socialDriver): string
|
||||||
{
|
{
|
||||||
$driver = trim(strtolower($socialDriver));
|
$driver = trim(strtolower($socialDriver));
|
||||||
|
|
||||||
if (!in_array($driver, $this->validSocialDrivers)) {
|
if (!in_array($driver, $this->validSocialDrivers)) {
|
||||||
abort(404, trans('errors.social_driver_not_found'));
|
abort(404, trans('errors.social_driver_not_found'));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$this->checkDriverConfigured($driver)) {
|
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;
|
return $driver;
|
||||||
|
@ -165,10 +152,8 @@ class SocialAuthService
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check a social driver has been configured correctly.
|
* 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);
|
$lowerName = strtolower($driver);
|
||||||
$configPrefix = 'services.' . $lowerName . '.';
|
$configPrefix = 'services.' . $lowerName . '.';
|
||||||
|
@ -178,55 +163,48 @@ class SocialAuthService
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the names of the active social drivers.
|
* Gets the names of the active social drivers.
|
||||||
* @return array
|
|
||||||
*/
|
*/
|
||||||
public function getActiveDrivers()
|
public function getActiveDrivers(): array
|
||||||
{
|
{
|
||||||
$activeDrivers = [];
|
$activeDrivers = [];
|
||||||
|
|
||||||
foreach ($this->validSocialDrivers as $driverKey) {
|
foreach ($this->validSocialDrivers as $driverKey) {
|
||||||
if ($this->checkDriverConfigured($driverKey)) {
|
if ($this->checkDriverConfigured($driverKey)) {
|
||||||
$activeDrivers[$driverKey] = $this->getDriverName($driverKey);
|
$activeDrivers[$driverKey] = $this->getDriverName($driverKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $activeDrivers;
|
return $activeDrivers;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the presentational name for a driver.
|
* 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');
|
return config('services.' . strtolower($driver) . '.name');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if the current config for the given driver allows auto-registration.
|
* 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;
|
return config('services.' . strtolower($driver) . '.auto_register') === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if the current config for the given driver allow email address auto-confirmation.
|
* 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;
|
return config('services.' . strtolower($driver) . '.auto_confirm') === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param string $socialDriver
|
* Fill and return a SocialAccount from the given driver name and SocialUser.
|
||||||
* @param SocialUser $socialUser
|
|
||||||
* @return SocialAccount
|
|
||||||
*/
|
*/
|
||||||
public function fillSocialAccount($socialDriver, $socialUser)
|
public function fillSocialAccount(string $socialDriver, SocialUser $socialUser): SocialAccount
|
||||||
{
|
{
|
||||||
$this->socialAccount->fill([
|
$this->socialAccount->fill([
|
||||||
'driver' => $socialDriver,
|
'driver' => $socialDriver,
|
||||||
|
@ -238,22 +216,17 @@ class SocialAuthService
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detach a social account from a user.
|
* Detach a social account from a user.
|
||||||
* @param $socialDriver
|
|
||||||
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
|
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
|
||||||
*/
|
*/
|
||||||
public function detachSocialAccount($socialDriver)
|
public function detachSocialAccount(string $socialDriver)
|
||||||
{
|
{
|
||||||
user()->socialAccounts()->where('driver', '=', $socialDriver)->delete();
|
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
|
* 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);
|
$driver = $this->socialite->driver($driverName);
|
||||||
|
|
||||||
|
|
|
@ -19,5 +19,4 @@ class UserInviteService extends UserTokenService
|
||||||
$token = $this->createTokenForUser($user);
|
$token = $this->createTokenForUser($user);
|
||||||
$user->notify(new UserInvite($token));
|
$user->notify(new UserInvite($token));
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,6 +5,7 @@ use BookStack\Exceptions\UserTokenExpiredException;
|
||||||
use BookStack\Exceptions\UserTokenNotFoundException;
|
use BookStack\Exceptions\UserTokenNotFoundException;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Illuminate\Database\Connection as Database;
|
use Illuminate\Database\Connection as Database;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use stdClass;
|
use stdClass;
|
||||||
|
|
||||||
class UserTokenService
|
class UserTokenService
|
||||||
|
@ -73,9 +74,9 @@ class UserTokenService
|
||||||
*/
|
*/
|
||||||
protected function generateToken() : string
|
protected function generateToken() : string
|
||||||
{
|
{
|
||||||
$token = str_random(24);
|
$token = Str::random(24);
|
||||||
while ($this->tokenExists($token)) {
|
while ($this->tokenExists($token)) {
|
||||||
$token = str_random(25);
|
$token = Str::random(25);
|
||||||
}
|
}
|
||||||
return $token;
|
return $token;
|
||||||
}
|
}
|
||||||
|
@ -130,5 +131,4 @@ class UserTokenService
|
||||||
return Carbon::now()->subHours($this->expiryTime)
|
return Carbon::now()->subHours($this->expiryTime)
|
||||||
->gt(new Carbon($tokenEntry->created_at));
|
->gt(new Carbon($tokenEntry->created_at));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
|
||||||
|
|
|
@ -215,7 +215,6 @@ class PermissionService
|
||||||
* @param Collection $books
|
* @param Collection $books
|
||||||
* @param array $roles
|
* @param array $roles
|
||||||
* @param bool $deleteOld
|
* @param bool $deleteOld
|
||||||
* @throws \Throwable
|
|
||||||
*/
|
*/
|
||||||
protected function buildJointPermissionsForBooks($books, $roles, $deleteOld = false)
|
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.
|
* Limited the given entity query so that the query will only
|
||||||
* @param integer $book_id
|
* return items that the user has permission for the given ability.
|
||||||
* @param bool $filterDrafts
|
|
||||||
* @param bool $fetchPageContent
|
|
||||||
* @return QueryBuilder
|
|
||||||
*/
|
*/
|
||||||
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();
|
$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') {
|
if (strtolower($entityType) === 'page') {
|
||||||
// Prevent drafts being visible to others.
|
// Prevent drafts being visible to others.
|
||||||
$query = $query->where(function ($query) {
|
$query = $query->where(function ($query) {
|
||||||
$query->where('draft', '=', false);
|
$query->where('draft', '=', false)
|
||||||
if ($this->currentUser()) {
|
->orWhere(function ($query) {
|
||||||
$query->orWhere(function ($query) {
|
$query->where('draft', '=', true)
|
||||||
$query->where('draft', '=', true)->where('created_by', '=', $this->currentUser()->id);
|
->where('created_by', '=', $this->currentUser()->id);
|
||||||
});
|
});
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
use BookStack\Auth\Permissions;
|
use BookStack\Auth\Permissions;
|
||||||
use BookStack\Auth\Role;
|
use BookStack\Auth\Role;
|
||||||
use BookStack\Exceptions\PermissionsException;
|
use BookStack\Exceptions\PermissionsException;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class PermissionsRepo
|
class PermissionsRepo
|
||||||
{
|
{
|
||||||
|
@ -66,7 +67,7 @@ class PermissionsRepo
|
||||||
$role->name = str_replace(' ', '-', strtolower($roleData['display_name']));
|
$role->name = str_replace(' ', '-', strtolower($roleData['display_name']));
|
||||||
// Prevent duplicate names
|
// Prevent duplicate names
|
||||||
while ($this->role->where('name', '=', $role->name)->count() > 0) {
|
while ($this->role->where('name', '=', $role->name)->count() > 0) {
|
||||||
$role->name .= strtolower(str_random(2));
|
$role->name .= strtolower(Str::random(2));
|
||||||
}
|
}
|
||||||
$role->save();
|
$role->save();
|
||||||
|
|
||||||
|
@ -136,7 +137,7 @@ class PermissionsRepo
|
||||||
// Prevent deleting admin role or default registration role.
|
// Prevent deleting admin role or default registration role.
|
||||||
if ($role->system_name && in_array($role->system_name, $this->systemRoles)) {
|
if ($role->system_name && in_array($role->system_name, $this->systemRoles)) {
|
||||||
throw new PermissionsException(trans('errors.role_system_cannot_be_deleted'));
|
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'));
|
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\Auth\Permissions\RolePermission;
|
||||||
use BookStack\Model;
|
use BookStack\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class Role
|
||||||
|
* @property string $display_name
|
||||||
|
* @property string $description
|
||||||
|
* @property string $external_auth_id
|
||||||
|
* @package BookStack\Auth
|
||||||
|
*/
|
||||||
class Role extends Model
|
class Role extends Model
|
||||||
{
|
{
|
||||||
|
|
||||||
|
@ -65,7 +72,7 @@ class Role extends Model
|
||||||
*/
|
*/
|
||||||
public function detachPermission(RolePermission $permission)
|
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)
|
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)
|
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()
|
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;
|
<?php namespace BookStack\Auth;
|
||||||
|
|
||||||
|
use BookStack\Api\ApiToken;
|
||||||
use BookStack\Model;
|
use BookStack\Model;
|
||||||
use BookStack\Notifications\ResetPassword;
|
use BookStack\Notifications\ResetPassword;
|
||||||
use BookStack\Uploads\Image;
|
use BookStack\Uploads\Image;
|
||||||
|
@ -9,6 +10,7 @@ use Illuminate\Auth\Passwords\CanResetPassword;
|
||||||
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
|
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
|
||||||
use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;
|
use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -45,7 +47,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
|
||||||
* The attributes excluded from the model's JSON form.
|
* The attributes excluded from the model's JSON form.
|
||||||
* @var array
|
* @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.
|
* This holds the user's permissions when loaded.
|
||||||
|
@ -53,13 +55,24 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
|
||||||
*/
|
*/
|
||||||
protected $permissions;
|
protected $permissions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This holds the default user when loaded.
|
||||||
|
* @var null|User
|
||||||
|
*/
|
||||||
|
protected static $defaultUser = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the default public user.
|
* Returns the default public user.
|
||||||
* @return User
|
* @return User
|
||||||
*/
|
*/
|
||||||
public static function getDefault()
|
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);
|
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.
|
* Get all permissions belonging to a the current user.
|
||||||
* @param bool $cache
|
* @param bool $cache
|
||||||
|
@ -140,16 +164,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
|
||||||
*/
|
*/
|
||||||
public function attachRole(Role $role)
|
public function attachRole(Role $role)
|
||||||
{
|
{
|
||||||
$this->attachRoleId($role->id);
|
$this->roles()->attach($role->id);
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attach a role id to this user.
|
|
||||||
* @param $id
|
|
||||||
*/
|
|
||||||
public function attachRoleId($id)
|
|
||||||
{
|
|
||||||
$this->roles()->attach($id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -207,19 +222,26 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the url for editing this user.
|
* Get the API tokens assigned to this user.
|
||||||
* @return string
|
|
||||||
*/
|
*/
|
||||||
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.
|
* Get the url that links to this user's profile.
|
||||||
* @return mixed
|
|
||||||
*/
|
*/
|
||||||
public function getProfileUrl()
|
public function getProfileUrl(): string
|
||||||
{
|
{
|
||||||
return url('/user/' . $this->id);
|
return url('/user/' . $this->id);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,39 +1,37 @@
|
||||||
<?php namespace BookStack\Auth;
|
<?php namespace BookStack\Auth;
|
||||||
|
|
||||||
use Activity;
|
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\NotFoundException;
|
||||||
use BookStack\Exceptions\UserUpdateException;
|
use BookStack\Exceptions\UserUpdateException;
|
||||||
use BookStack\Uploads\Image;
|
use BookStack\Uploads\Image;
|
||||||
use Exception;
|
use Exception;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Images;
|
use Images;
|
||||||
|
use Log;
|
||||||
|
|
||||||
class UserRepo
|
class UserRepo
|
||||||
{
|
{
|
||||||
|
|
||||||
protected $user;
|
protected $user;
|
||||||
protected $role;
|
protected $role;
|
||||||
protected $entityRepo;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* UserRepo constructor.
|
* 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->user = $user;
|
||||||
$this->role = $role;
|
$this->role = $role;
|
||||||
$this->entityRepo = $entityRepo;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param string $email
|
* Get a user by their email address.
|
||||||
* @return User|null
|
|
||||||
*/
|
*/
|
||||||
public function getByEmail($email)
|
public function getByEmail(string $email): ?User
|
||||||
{
|
{
|
||||||
return $this->user->where('email', '=', $email)->first();
|
return $this->user->where('email', '=', $email)->first();
|
||||||
}
|
}
|
||||||
|
@ -79,31 +77,16 @@ class UserRepo
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new user and attaches a role to them.
|
* 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);
|
$user = $this->create($data, $emailConfirmed);
|
||||||
$this->attachDefaultRole($user);
|
$user->attachDefaultRole();
|
||||||
$this->downloadAndAssignUserAvatar($user);
|
$this->downloadAndAssignUserAvatar($user);
|
||||||
|
|
||||||
return $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.
|
* Assign a user to a system-level role.
|
||||||
* @param User $user
|
* @param User $user
|
||||||
|
@ -121,7 +104,7 @@ class UserRepo
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if the give user is the only admin.
|
* Checks if the give user is the only admin.
|
||||||
* @param \BookStack\Auth\User $user
|
* @param User $user
|
||||||
* @return bool
|
* @return bool
|
||||||
*/
|
*/
|
||||||
public function isOnlyAdmin(User $user)
|
public function isOnlyAdmin(User $user)
|
||||||
|
@ -173,28 +156,27 @@ class UserRepo
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new basic instance of user.
|
* 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([
|
return $this->user->forceCreate([
|
||||||
'name' => $data['name'],
|
'name' => $data['name'],
|
||||||
'email' => $data['email'],
|
'email' => $data['email'],
|
||||||
'password' => bcrypt($data['password']),
|
'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.
|
* Remove the given user from storage, Delete all related content.
|
||||||
* @param \BookStack\Auth\User $user
|
* @param User $user
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
public function destroy(User $user)
|
public function destroy(User $user)
|
||||||
{
|
{
|
||||||
$user->socialAccounts()->delete();
|
$user->socialAccounts()->delete();
|
||||||
|
$user->apiTokens()->delete();
|
||||||
$user->delete();
|
$user->delete();
|
||||||
|
|
||||||
// Delete user profile images
|
// Delete user profile images
|
||||||
|
@ -206,7 +188,7 @@ class UserRepo
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the latest activity for a user.
|
* Get the latest activity for a user.
|
||||||
* @param \BookStack\Auth\User $user
|
* @param User $user
|
||||||
* @param int $count
|
* @param int $count
|
||||||
* @param int $page
|
* @param int $page
|
||||||
* @return array
|
* @return array
|
||||||
|
@ -218,36 +200,35 @@ class UserRepo
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the recently created content for this given user.
|
* 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 = function (Builder $query) use ($user, $count) {
|
||||||
$query->where('created_by', '=', $user->id);
|
return $query->orderBy('created_at', 'desc')
|
||||||
|
->where('created_by', '=', $user->id)
|
||||||
|
->take($count)
|
||||||
|
->get();
|
||||||
};
|
};
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'pages' => $this->entityRepo->getRecentlyCreated('page', $count, 0, $createdByUserQuery),
|
'pages' => $query(Page::visible()->where('draft', '=', false)),
|
||||||
'chapters' => $this->entityRepo->getRecentlyCreated('chapter', $count, 0, $createdByUserQuery),
|
'chapters' => $query(Chapter::visible()),
|
||||||
'books' => $this->entityRepo->getRecentlyCreated('book', $count, 0, $createdByUserQuery),
|
'books' => $query(Book::visible()),
|
||||||
'shelves' => $this->entityRepo->getRecentlyCreated('bookshelf', $count, 0, $createdByUserQuery)
|
'shelves' => $query(Bookshelf::visible()),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get asset created counts for the give user.
|
* 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 [
|
return [
|
||||||
'pages' => $this->entityRepo->getUserTotalCreated('page', $user),
|
'pages' => Page::visible()->where($createdBy)->count(),
|
||||||
'chapters' => $this->entityRepo->getUserTotalCreated('chapter', $user),
|
'chapters' => Chapter::visible()->where($createdBy)->count(),
|
||||||
'books' => $this->entityRepo->getUserTotalCreated('book', $user),
|
'books' => Book::visible()->where($createdBy)->count(),
|
||||||
'shelves' => $this->entityRepo->getUserTotalCreated('bookshelf', $user),
|
'shelves' => Bookshelf::visible()->where($createdBy)->count(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -260,16 +241,6 @@ class UserRepo
|
||||||
return $this->role->newQuery()->orderBy('name', 'asc')->get();
|
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.
|
* Get an avatar image for a user and set it as their avatar.
|
||||||
* Returns early if avatars disabled or not set in config.
|
* Returns early if avatars disabled or not set in config.
|
||||||
|
@ -288,7 +259,7 @@ class UserRepo
|
||||||
$user->save();
|
$user->save();
|
||||||
return true;
|
return true;
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
\Log::error('Failed to save user avatar image');
|
Log::error('Failed to save user avatar image');
|
||||||
return false;
|
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'),
|
'locale' => env('APP_LANG', 'en'),
|
||||||
|
|
||||||
// Locales available
|
// 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
|
// Application Fallback Locale
|
||||||
'fallback_locale' => 'en',
|
'fallback_locale' => 'en',
|
||||||
|
|
||||||
|
// Faker Locale
|
||||||
|
'faker_locale' => 'en_GB',
|
||||||
|
|
||||||
// Enable right-to-left text control.
|
// Enable right-to-left text control.
|
||||||
'rtl' => false,
|
'rtl' => false,
|
||||||
|
|
||||||
|
@ -72,10 +75,6 @@ return [
|
||||||
// Encryption cipher
|
// Encryption cipher
|
||||||
'cipher' => 'AES-256-CBC',
|
'cipher' => 'AES-256-CBC',
|
||||||
|
|
||||||
// Logging configuration
|
|
||||||
// Options: single, daily, syslog, errorlog
|
|
||||||
'log' => env('APP_LOGGING', 'single'),
|
|
||||||
|
|
||||||
// Application Services Provides
|
// Application Services Provides
|
||||||
'providers' => [
|
'providers' => [
|
||||||
|
|
||||||
|
@ -107,7 +106,6 @@ return [
|
||||||
Barryvdh\DomPDF\ServiceProvider::class,
|
Barryvdh\DomPDF\ServiceProvider::class,
|
||||||
Barryvdh\Snappy\ServiceProvider::class,
|
Barryvdh\Snappy\ServiceProvider::class,
|
||||||
|
|
||||||
|
|
||||||
// BookStack replacement service providers (Extends Laravel)
|
// BookStack replacement service providers (Extends Laravel)
|
||||||
BookStack\Providers\PaginationServiceProvider::class,
|
BookStack\Providers\PaginationServiceProvider::class,
|
||||||
BookStack\Providers\TranslationServiceProvider::class,
|
BookStack\Providers\TranslationServiceProvider::class,
|
||||||
|
@ -137,6 +135,7 @@ return [
|
||||||
|
|
||||||
// Laravel
|
// Laravel
|
||||||
'App' => Illuminate\Support\Facades\App::class,
|
'App' => Illuminate\Support\Facades\App::class,
|
||||||
|
'Arr' => Illuminate\Support\Arr::class,
|
||||||
'Artisan' => Illuminate\Support\Facades\Artisan::class,
|
'Artisan' => Illuminate\Support\Facades\Artisan::class,
|
||||||
'Auth' => Illuminate\Support\Facades\Auth::class,
|
'Auth' => Illuminate\Support\Facades\Auth::class,
|
||||||
'Blade' => Illuminate\Support\Facades\Blade::class,
|
'Blade' => Illuminate\Support\Facades\Blade::class,
|
||||||
|
@ -166,6 +165,7 @@ return [
|
||||||
'Schema' => Illuminate\Support\Facades\Schema::class,
|
'Schema' => Illuminate\Support\Facades\Schema::class,
|
||||||
'Session' => Illuminate\Support\Facades\Session::class,
|
'Session' => Illuminate\Support\Facades\Session::class,
|
||||||
'Storage' => Illuminate\Support\Facades\Storage::class,
|
'Storage' => Illuminate\Support\Facades\Storage::class,
|
||||||
|
'Str' => Illuminate\Support\Str::class,
|
||||||
'URL' => Illuminate\Support\Facades\URL::class,
|
'URL' => Illuminate\Support\Facades\URL::class,
|
||||||
'Validator' => Illuminate\Support\Facades\Validator::class,
|
'Validator' => Illuminate\Support\Facades\Validator::class,
|
||||||
'View' => Illuminate\Support\Facades\View::class,
|
'View' => Illuminate\Support\Facades\View::class,
|
||||||
|
@ -181,6 +181,7 @@ return [
|
||||||
'Setting' => BookStack\Facades\Setting::class,
|
'Setting' => BookStack\Facades\Setting::class,
|
||||||
'Views' => BookStack\Facades\Views::class,
|
'Views' => BookStack\Facades\Views::class,
|
||||||
'Images' => BookStack\Facades\Images::class,
|
'Images' => BookStack\Facades\Images::class,
|
||||||
|
'Permissions' => BookStack\Facades\Permissions::class,
|
||||||
|
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|
|
@ -11,14 +11,14 @@
|
||||||
return [
|
return [
|
||||||
|
|
||||||
// Method of authentication to use
|
// Method of authentication to use
|
||||||
// Options: standard, ldap
|
// Options: standard, ldap, saml2
|
||||||
'method' => env('AUTH_METHOD', 'standard'),
|
'method' => env('AUTH_METHOD', 'standard'),
|
||||||
|
|
||||||
// Authentication Defaults
|
// Authentication Defaults
|
||||||
// This option controls the default authentication "guard" and password
|
// This option controls the default authentication "guard" and password
|
||||||
// reset options for your application.
|
// reset options for your application.
|
||||||
'defaults' => [
|
'defaults' => [
|
||||||
'guard' => 'web',
|
'guard' => env('AUTH_METHOD', 'standard'),
|
||||||
'passwords' => 'users',
|
'passwords' => 'users',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
@ -26,16 +26,22 @@ return [
|
||||||
// All authentication drivers have a user provider. This defines how the
|
// All authentication drivers have a user provider. This defines how the
|
||||||
// users are actually retrieved out of your database or other storage
|
// users are actually retrieved out of your database or other storage
|
||||||
// mechanisms used by this application to persist your user's data.
|
// mechanisms used by this application to persist your user's data.
|
||||||
// Supported: "session", "token"
|
// Supported drivers: "session", "api-token", "ldap-session"
|
||||||
'guards' => [
|
'guards' => [
|
||||||
'web' => [
|
'standard' => [
|
||||||
'driver' => 'session',
|
'driver' => 'session',
|
||||||
'provider' => 'users',
|
'provider' => 'users',
|
||||||
],
|
],
|
||||||
|
'ldap' => [
|
||||||
|
'driver' => 'ldap-session',
|
||||||
|
'provider' => 'external',
|
||||||
|
],
|
||||||
|
'saml2' => [
|
||||||
|
'driver' => 'saml2-session',
|
||||||
|
'provider' => 'external',
|
||||||
|
],
|
||||||
'api' => [
|
'api' => [
|
||||||
'driver' => 'token',
|
'driver' => 'api-token',
|
||||||
'provider' => 'users',
|
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
@ -43,17 +49,15 @@ return [
|
||||||
// All authentication drivers have a user provider. This defines how the
|
// All authentication drivers have a user provider. This defines how the
|
||||||
// users are actually retrieved out of your database or other storage
|
// users are actually retrieved out of your database or other storage
|
||||||
// mechanisms used by this application to persist your user's data.
|
// mechanisms used by this application to persist your user's data.
|
||||||
// Supported: database, eloquent, ldap
|
|
||||||
'providers' => [
|
'providers' => [
|
||||||
'users' => [
|
'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,
|
'model' => \BookStack\Auth\User::class,
|
||||||
],
|
],
|
||||||
|
|
||||||
// 'users' => [
|
|
||||||
// 'driver' => 'database',
|
|
||||||
// 'table' => 'users',
|
|
||||||
// ],
|
|
||||||
],
|
],
|
||||||
|
|
||||||
// Resetting Passwords
|
// Resetting Passwords
|
||||||
|
@ -69,4 +73,4 @@ return [
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
|
@ -24,9 +24,13 @@ return [
|
||||||
|
|
||||||
'pusher' => [
|
'pusher' => [
|
||||||
'driver' => 'pusher',
|
'driver' => 'pusher',
|
||||||
'key' => env('PUSHER_KEY'),
|
'key' => env('PUSHER_APP_KEY'),
|
||||||
'secret' => env('PUSHER_SECRET'),
|
'secret' => env('PUSHER_APP_SECRET'),
|
||||||
'app_id' => env('PUSHER_APP_ID'),
|
'app_id' => env('PUSHER_APP_ID'),
|
||||||
|
'options' => [
|
||||||
|
'cluster' => env('PUSHER_APP_CLUSTER'),
|
||||||
|
'useTLS' => true,
|
||||||
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
'redis' => [
|
'redis' => [
|
||||||
|
@ -38,6 +42,11 @@ return [
|
||||||
'driver' => 'log',
|
'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'), ','));
|
$memcachedServers = explode(',', trim(env('MEMCACHED_SERVERS', '127.0.0.1:11211:100'), ','));
|
||||||
foreach ($memcachedServers as $index => $memcachedServer) {
|
foreach ($memcachedServers as $index => $memcachedServer) {
|
||||||
$memcachedServerDetails = explode(':', $memcachedServer);
|
$memcachedServerDetails = explode(':', $memcachedServer);
|
||||||
if (count($memcachedServerDetails) < 2) $memcachedServerDetails[] = '11211';
|
if (count($memcachedServerDetails) < 2) {
|
||||||
if (count($memcachedServerDetails) < 3) $memcachedServerDetails[] = '100';
|
$memcachedServerDetails[] = '11211';
|
||||||
|
}
|
||||||
|
if (count($memcachedServerDetails) < 3) {
|
||||||
|
$memcachedServerDetails[] = '100';
|
||||||
|
}
|
||||||
$memcachedServers[$index] = array_combine($memcachedServerKeys, $memcachedServerDetails);
|
$memcachedServers[$index] = array_combine($memcachedServerKeys, $memcachedServerDetails);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -62,6 +66,6 @@ return [
|
||||||
|
|
||||||
// Cache key prefix
|
// Cache key prefix
|
||||||
// Used to prevent collisions in shared cache systems.
|
// Used to prevent collisions in shared cache systems.
|
||||||
'prefix' => env('CACHE_PREFIX', 'bookstack'),
|
'prefix' => env('CACHE_PREFIX', 'bookstack_cache'),
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
|
@ -11,10 +11,9 @@
|
||||||
// REDIS
|
// REDIS
|
||||||
// Split out configuration into an array
|
// Split out configuration into an array
|
||||||
if (env('REDIS_SERVERS', false)) {
|
if (env('REDIS_SERVERS', false)) {
|
||||||
|
|
||||||
$redisDefaults = ['host' => '127.0.0.1', 'port' => '6379', 'database' => '0', 'password' => null];
|
$redisDefaults = ['host' => '127.0.0.1', 'port' => '6379', 'database' => '0', 'password' => null];
|
||||||
$redisServers = explode(',', trim(env('REDIS_SERVERS', '127.0.0.1:6379:0'), ','));
|
$redisServers = explode(',', trim(env('REDIS_SERVERS', '127.0.0.1:6379:0'), ','));
|
||||||
$redisConfig = [];
|
$redisConfig = ['client' => 'predis'];
|
||||||
$cluster = count($redisServers) > 1;
|
$cluster = count($redisServers) > 1;
|
||||||
|
|
||||||
if ($cluster) {
|
if ($cluster) {
|
||||||
|
@ -59,14 +58,9 @@ return [
|
||||||
// Many of those shown here are unsupported by BookStack.
|
// Many of those shown here are unsupported by BookStack.
|
||||||
'connections' => [
|
'connections' => [
|
||||||
|
|
||||||
'sqlite' => [
|
|
||||||
'driver' => 'sqlite',
|
|
||||||
'database' => storage_path('database.sqlite'),
|
|
||||||
'prefix' => '',
|
|
||||||
],
|
|
||||||
|
|
||||||
'mysql' => [
|
'mysql' => [
|
||||||
'driver' => 'mysql',
|
'driver' => 'mysql',
|
||||||
|
'url' => env('DATABASE_URL'),
|
||||||
'host' => $mysql_host,
|
'host' => $mysql_host,
|
||||||
'database' => env('DB_DATABASE', 'forge'),
|
'database' => env('DB_DATABASE', 'forge'),
|
||||||
'username' => env('DB_USERNAME', 'forge'),
|
'username' => env('DB_USERNAME', 'forge'),
|
||||||
|
@ -76,43 +70,28 @@ return [
|
||||||
'charset' => 'utf8mb4',
|
'charset' => 'utf8mb4',
|
||||||
'collation' => 'utf8mb4_unicode_ci',
|
'collation' => 'utf8mb4_unicode_ci',
|
||||||
'prefix' => '',
|
'prefix' => '',
|
||||||
|
'prefix_indexes' => true,
|
||||||
'strict' => false,
|
'strict' => false,
|
||||||
'engine' => null,
|
'engine' => null,
|
||||||
|
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||||
|
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
|
||||||
|
]) : [],
|
||||||
],
|
],
|
||||||
|
|
||||||
'mysql_testing' => [
|
'mysql_testing' => [
|
||||||
'driver' => 'mysql',
|
'driver' => 'mysql',
|
||||||
|
'url' => env('TEST_DATABASE_URL'),
|
||||||
'host' => '127.0.0.1',
|
'host' => '127.0.0.1',
|
||||||
'database' => 'bookstack-test',
|
'database' => 'bookstack-test',
|
||||||
'username' => env('MYSQL_USER', 'bookstack-test'),
|
'username' => env('MYSQL_USER', 'bookstack-test'),
|
||||||
'password' => env('MYSQL_PASSWORD', 'bookstack-test'),
|
'password' => env('MYSQL_PASSWORD', 'bookstack-test'),
|
||||||
'charset' => 'utf8',
|
'charset' => 'utf8mb4',
|
||||||
'collation' => 'utf8_unicode_ci',
|
'collation' => 'utf8mb4_unicode_ci',
|
||||||
'prefix' => '',
|
'prefix' => '',
|
||||||
|
'prefix_indexes' => true,
|
||||||
'strict' => false,
|
'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
|
// Migration Repository Table
|
||||||
|
|
|
@ -79,6 +79,7 @@ return [
|
||||||
'files' => false, // Show the included files
|
'files' => false, // Show the included files
|
||||||
'config' => false, // Display config settings
|
'config' => false, // Display config settings
|
||||||
'cache' => false, // Display cache events
|
'cache' => false, // Display cache events
|
||||||
|
'models' => true, // Display models
|
||||||
],
|
],
|
||||||
|
|
||||||
// Configure some DataCollectors
|
// Configure some DataCollectors
|
||||||
|
|
|
@ -69,7 +69,7 @@ return [
|
||||||
* should be an absolute path.
|
* should be an absolute path.
|
||||||
* This is only checked on command line call by dompdf.php, but not by
|
* This is only checked on command line call by dompdf.php, but not by
|
||||||
* direct class use like:
|
* 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()),
|
"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
|
// Global "From" address & name
|
||||||
'from' => [
|
'from' => [
|
||||||
'address' => env('MAIL_FROM', 'mail@bookstackapp.com'),
|
'address' => env('MAIL_FROM', 'mail@bookstackapp.com'),
|
||||||
'name' => env('MAIL_FROM_NAME','BookStack')
|
'name' => env('MAIL_FROM_NAME', 'BookStack')
|
||||||
],
|
],
|
||||||
|
|
||||||
// Email encryption protocol
|
// 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
|
// Default driver to use for the queue
|
||||||
// Options: null, sync, redis
|
// Options: null, sync, redis
|
||||||
'default' => env('QUEUE_DRIVER', 'sync'),
|
'default' => env('QUEUE_CONNECTION', 'sync'),
|
||||||
|
|
||||||
// Queue connection configuration
|
// Queue connection configuration
|
||||||
'connections' => [
|
'connections' => [
|
||||||
|
|
||||||
|
|
||||||
'sync' => [
|
'sync' => [
|
||||||
'driver' => 'sync',
|
'driver' => 'sync',
|
||||||
],
|
],
|
||||||
|
@ -25,38 +26,15 @@ return [
|
||||||
'driver' => 'database',
|
'driver' => 'database',
|
||||||
'table' => 'jobs',
|
'table' => 'jobs',
|
||||||
'queue' => 'default',
|
'queue' => 'default',
|
||||||
'expire' => 60,
|
'retry_after' => 90,
|
||||||
],
|
|
||||||
|
|
||||||
'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,
|
|
||||||
],
|
],
|
||||||
|
|
||||||
'redis' => [
|
'redis' => [
|
||||||
'driver' => 'redis',
|
'driver' => 'redis',
|
||||||
'connection' => 'default',
|
'connection' => 'default',
|
||||||
'queue' => 'default',
|
'queue' => env('REDIS_QUEUE', 'default'),
|
||||||
'expire' => 60,
|
'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 for social authentication methods
|
||||||
'callback_url' => env('APP_URL', false),
|
'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' => [
|
'github' => [
|
||||||
'client_id' => env('GITHUB_APP_ID', false),
|
'client_id' => env('GITHUB_APP_ID', false),
|
||||||
'client_secret' => env('GITHUB_APP_SECRET', false),
|
'client_secret' => env('GITHUB_APP_SECRET', false),
|
||||||
|
@ -98,8 +81,8 @@ return [
|
||||||
'okta' => [
|
'okta' => [
|
||||||
'client_id' => env('OKTA_APP_ID'),
|
'client_id' => env('OKTA_APP_ID'),
|
||||||
'client_secret' => env('OKTA_APP_SECRET'),
|
'client_secret' => env('OKTA_APP_SECRET'),
|
||||||
'redirect' => env('APP_URL') . '/login/service/okta/callback',
|
'redirect' => env('APP_URL') . '/login/service/okta/callback',
|
||||||
'base_url' => env('OKTA_BASE_URL'),
|
'base_url' => env('OKTA_BASE_URL'),
|
||||||
'name' => 'Okta',
|
'name' => 'Okta',
|
||||||
'auto_register' => env('OKTA_AUTO_REGISTER', false),
|
'auto_register' => env('OKTA_AUTO_REGISTER', false),
|
||||||
'auto_confirm' => env('OKTA_AUTO_CONFIRM_EMAIL', false),
|
'auto_confirm' => env('OKTA_AUTO_CONFIRM_EMAIL', false),
|
||||||
|
@ -140,13 +123,14 @@ return [
|
||||||
'base_dn' => env('LDAP_BASE_DN', false),
|
'base_dn' => env('LDAP_BASE_DN', false),
|
||||||
'user_filter' => env('LDAP_USER_FILTER', '(&(uid=${user}))'),
|
'user_filter' => env('LDAP_USER_FILTER', '(&(uid=${user}))'),
|
||||||
'version' => env('LDAP_VERSION', false),
|
'version' => env('LDAP_VERSION', false),
|
||||||
|
'id_attribute' => env('LDAP_ID_ATTRIBUTE', 'uid'),
|
||||||
'email_attribute' => env('LDAP_EMAIL_ATTRIBUTE', 'mail'),
|
'email_attribute' => env('LDAP_EMAIL_ATTRIBUTE', 'mail'),
|
||||||
'display_name_attribute' => env('LDAP_DISPLAY_NAME_ATTRIBUTE', 'cn'),
|
'display_name_attribute' => env('LDAP_DISPLAY_NAME_ATTRIBUTE', 'cn'),
|
||||||
'follow_referrals' => env('LDAP_FOLLOW_REFERRALS', false),
|
'follow_referrals' => env('LDAP_FOLLOW_REFERRALS', false),
|
||||||
'user_to_groups' => env('LDAP_USER_TO_GROUPS',false),
|
'user_to_groups' => env('LDAP_USER_TO_GROUPS', false),
|
||||||
'group_attribute' => env('LDAP_GROUP_ATTRIBUTE', 'memberOf'),
|
'group_attribute' => env('LDAP_GROUP_ATTRIBUTE', 'memberOf'),
|
||||||
'remove_from_groups' => env('LDAP_REMOVE_FROM_GROUPS',false),
|
'remove_from_groups' => env('LDAP_REMOVE_FROM_GROUPS', false),
|
||||||
'tls_insecure' => env('LDAP_TLS_INSECURE', false),
|
'tls_insecure' => env('LDAP_TLS_INSECURE', false),
|
||||||
]
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
|
@ -35,13 +35,18 @@ return [
|
||||||
// Session database table, if database driver is in use
|
// Session database table, if database driver is in use
|
||||||
'table' => 'sessions',
|
'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
|
// Session Sweeping Lottery
|
||||||
// Some session drivers must manually sweep their storage location to get
|
// Some session drivers must manually sweep their storage location to get
|
||||||
// rid of old sessions from storage. Here are the chances that it will
|
// 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.
|
// happen on a given request. By default, the odds are 2 out of 100.
|
||||||
'lottery' => [2, 100],
|
'lottery' => [2, 100],
|
||||||
|
|
||||||
|
|
||||||
// Session Cookie Name
|
// Session Cookie Name
|
||||||
// Here you may change the name of the cookie used to identify a session
|
// 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
|
// instance by ID. The name specified here will get used every time a
|
||||||
|
|
|
@ -16,7 +16,12 @@ return [
|
||||||
'app-editor' => 'wysiwyg',
|
'app-editor' => 'wysiwyg',
|
||||||
'app-color' => '#206ea7',
|
'app-color' => '#206ea7',
|
||||||
'app-color-light' => 'rgba(32,110,167,0.15)',
|
'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,
|
'app-custom-head' => false,
|
||||||
'registration-enabled' => 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;
|
<?php namespace BookStack\Entities;
|
||||||
|
|
||||||
use BookStack\Uploads\Image;
|
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;
|
public $searchFactor = 2;
|
||||||
|
|
||||||
protected $fillable = ['name', 'description', 'image_id'];
|
protected $fillable = ['name', 'description'];
|
||||||
|
protected $hidden = ['restricted'];
|
||||||
/**
|
|
||||||
* Get the morph class for this model.
|
|
||||||
* @return string
|
|
||||||
*/
|
|
||||||
public function getMorphClass()
|
|
||||||
{
|
|
||||||
return 'BookStack\\Book';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the url for this book.
|
* Get the url for this book.
|
||||||
|
@ -45,7 +49,7 @@ class Book extends Entity
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$cover = $this->cover ? url($this->cover->getThumb($width, $height, false)) : $default;
|
$cover = $this->cover ? url($this->cover->getThumb($width, $height, false)) : $default;
|
||||||
} catch (\Exception $err) {
|
} catch (Exception $err) {
|
||||||
$cover = $default;
|
$cover = $default;
|
||||||
}
|
}
|
||||||
return $cover;
|
return $cover;
|
||||||
|
@ -53,16 +57,23 @@ class Book extends Entity
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the cover image of the book
|
* 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');
|
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.
|
* Get all pages within this book.
|
||||||
* @return \Illuminate\Database\Eloquent\Relations\HasMany
|
* @return HasMany
|
||||||
*/
|
*/
|
||||||
public function pages()
|
public function pages()
|
||||||
{
|
{
|
||||||
|
@ -71,7 +82,7 @@ class Book extends Entity
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the direct child pages of this book.
|
* Get the direct child pages of this book.
|
||||||
* @return \Illuminate\Database\Eloquent\Relations\HasMany
|
* @return HasMany
|
||||||
*/
|
*/
|
||||||
public function directPages()
|
public function directPages()
|
||||||
{
|
{
|
||||||
|
@ -80,7 +91,7 @@ class Book extends Entity
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all chapters within this book.
|
* Get all chapters within this book.
|
||||||
* @return \Illuminate\Database\Eloquent\Relations\HasMany
|
* @return HasMany
|
||||||
*/
|
*/
|
||||||
public function chapters()
|
public function chapters()
|
||||||
{
|
{
|
||||||
|
@ -89,13 +100,24 @@ class Book extends Entity
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the shelves this book is contained within.
|
* Get the shelves this book is contained within.
|
||||||
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
|
* @return BelongsToMany
|
||||||
*/
|
*/
|
||||||
public function shelves()
|
public function shelves()
|
||||||
{
|
{
|
||||||
return $this->belongsToMany(Bookshelf::class, 'bookshelves_books', 'book_id', 'bookshelf_id');
|
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.
|
* Get an excerpt of this book's description to the specified length or less.
|
||||||
* @param int $length
|
* @param int $length
|
||||||
|
@ -106,13 +128,4 @@ class Book extends Entity
|
||||||
$description = $this->description;
|
$description = $this->description;
|
||||||
return mb_strlen($description) > $length ? mb_substr($description, 0, $length-3) . '...' : $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;
|
<?php namespace BookStack\Entities;
|
||||||
|
|
||||||
use BookStack\Uploads\Image;
|
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';
|
protected $table = 'bookshelves';
|
||||||
|
|
||||||
|
@ -10,15 +12,6 @@ class Bookshelf extends Entity
|
||||||
|
|
||||||
protected $fillable = ['name', 'description', 'image_id'];
|
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.
|
* Get the books in this shelf.
|
||||||
* Should not be used directly since does not take into account permissions.
|
* Should not be used directly since does not take into account permissions.
|
||||||
|
@ -31,6 +24,14 @@ class Bookshelf extends Entity
|
||||||
->orderBy('order', 'asc');
|
->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.
|
* Get the url for this bookshelf.
|
||||||
* @param string|bool $path
|
* @param string|bool $path
|
||||||
|
@ -68,13 +69,20 @@ class Bookshelf extends Entity
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the cover image of the shelf
|
* 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');
|
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.
|
* Get an excerpt of this book's description to the specified length or less.
|
||||||
* @param int $length
|
* @param int $length
|
||||||
|
@ -86,22 +94,27 @@ class Bookshelf extends Entity
|
||||||
return mb_strlen($description) > $length ? mb_substr($description, 0, $length-3) . '...' : $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\\\\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.
|
* Check if this shelf contains the given book.
|
||||||
* @param Book $book
|
* @param Book $book
|
||||||
* @return bool
|
* @return bool
|
||||||
*/
|
*/
|
||||||
public function contains(Book $book)
|
public function contains(Book $book): bool
|
||||||
{
|
{
|
||||||
return $this->books()->where('id', '=', $book->id)->count() > 0;
|
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;
|
<?php namespace BookStack\Entities;
|
||||||
|
|
||||||
|
use BookStack\Entities\Managers\EntityContext;
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
|
|
||||||
class BreadcrumbsViewComposer
|
class BreadcrumbsViewComposer
|
||||||
|
@ -9,9 +10,9 @@ class BreadcrumbsViewComposer
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BreadcrumbsViewComposer constructor.
|
* BreadcrumbsViewComposer constructor.
|
||||||
* @param EntityContextManager $entityContextManager
|
* @param EntityContext $entityContextManager
|
||||||
*/
|
*/
|
||||||
public function __construct(EntityContextManager $entityContextManager)
|
public function __construct(EntityContext $entityContextManager)
|
||||||
{
|
{
|
||||||
$this->entityContextManager = $entityContextManager;
|
$this->entityContextManager = $entityContextManager;
|
||||||
}
|
}
|
||||||
|
@ -23,8 +24,9 @@ class BreadcrumbsViewComposer
|
||||||
public function compose(View $view)
|
public function compose(View $view)
|
||||||
{
|
{
|
||||||
$crumbs = $view->getData()['crumbs'];
|
$crumbs = $view->getData()['crumbs'];
|
||||||
if (array_first($crumbs) instanceof Book) {
|
$firstCrumb = $crumbs[0] ?? null;
|
||||||
$shelf = $this->entityContextManager->getContextualShelfForBook(array_first($crumbs));
|
if ($firstCrumb instanceof Book) {
|
||||||
|
$shelf = $this->entityContextManager->getContextualShelfForBook($firstCrumb);
|
||||||
if ($shelf) {
|
if ($shelf) {
|
||||||
array_unshift($crumbs, $shelf);
|
array_unshift($crumbs, $shelf);
|
||||||
$view->with('crumbs', $crumbs);
|
$view->with('crumbs', $crumbs);
|
||||||
|
|
|
@ -1,29 +1,18 @@
|
||||||
<?php namespace BookStack\Entities;
|
<?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;
|
public $searchFactor = 1.3;
|
||||||
|
|
||||||
protected $fillable = ['name', 'description', 'priority', 'book_id'];
|
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.
|
* Get the pages that this chapter contains.
|
||||||
* @param string $dir
|
* @param string $dir
|
||||||
|
@ -62,15 +51,6 @@ class Chapter extends Entity
|
||||||
return mb_strlen($description) > $length ? mb_substr($description, 0, $length-3) . '...' : $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\\\\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.
|
* Check if this chapter has any child pages.
|
||||||
* @return bool
|
* @return bool
|
||||||
|
@ -79,4 +59,15 @@ class Chapter extends Entity
|
||||||
{
|
{
|
||||||
return count($this->pages) > 0;
|
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\Actions\View;
|
||||||
use BookStack\Auth\Permissions\EntityPermission;
|
use BookStack\Auth\Permissions\EntityPermission;
|
||||||
use BookStack\Auth\Permissions\JointPermission;
|
use BookStack\Auth\Permissions\JointPermission;
|
||||||
|
use BookStack\Facades\Permissions;
|
||||||
use BookStack\Ownable;
|
use BookStack\Ownable;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
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.
|
* The base class for book-like items such as pages, chapters & books.
|
||||||
* This is not a database model in itself but extended.
|
* This is not a database model in itself but extended.
|
||||||
*
|
*
|
||||||
* @property integer $id
|
* @property int $id
|
||||||
* @property string $name
|
* @property string $name
|
||||||
* @property string $slug
|
* @property string $slug
|
||||||
* @property Carbon $created_at
|
* @property Carbon $created_at
|
||||||
|
@ -23,6 +26,11 @@ use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||||
* @property int $created_by
|
* @property int $created_by
|
||||||
* @property int $updated_by
|
* @property int $updated_by
|
||||||
* @property boolean $restricted
|
* @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
|
* @package BookStack\Entities
|
||||||
*/
|
*/
|
||||||
|
@ -40,14 +48,45 @@ class Entity extends Ownable
|
||||||
public $searchFactor = 1.0;
|
public $searchFactor = 1.0;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the morph class for this model.
|
* Get the entities that are visible to the current user.
|
||||||
* Set here since, due to folder changes, the namespace used
|
|
||||||
* in the database no longer matches the class namespace.
|
|
||||||
* @return string
|
|
||||||
*/
|
*/
|
||||||
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.
|
* Gets the activity objects for this entity.
|
||||||
* @return \Illuminate\Database\Eloquent\Relations\MorphMany
|
* @return MorphMany
|
||||||
*/
|
*/
|
||||||
public function activity()
|
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');
|
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.
|
* Get the Tag models that have been user assigned to this entity.
|
||||||
* @return \Illuminate\Database\Eloquent\Relations\MorphMany
|
* @return MorphMany
|
||||||
*/
|
*/
|
||||||
public function tags()
|
public function tags()
|
||||||
{
|
{
|
||||||
|
@ -129,7 +164,7 @@ class Entity extends Ownable
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the related search terms.
|
* Get the related search terms.
|
||||||
* @return \Illuminate\Database\Eloquent\Relations\MorphMany
|
* @return MorphMany
|
||||||
*/
|
*/
|
||||||
public function searchTerms()
|
public function searchTerms()
|
||||||
{
|
{
|
||||||
|
@ -158,7 +193,7 @@ class Entity extends Ownable
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the entity jointPermissions this is connected to.
|
* Get the entity jointPermissions this is connected to.
|
||||||
* @return \Illuminate\Database\Eloquent\Relations\MorphMany
|
* @return MorphMany
|
||||||
*/
|
*/
|
||||||
public function jointPermissions()
|
public function jointPermissions()
|
||||||
{
|
{
|
||||||
|
@ -237,15 +272,6 @@ class Entity extends Ownable
|
||||||
return trim($text);
|
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
|
* Get the url of this entity
|
||||||
* @param $path
|
* @param $path
|
||||||
|
@ -255,4 +281,32 @@ class Entity extends Ownable
|
||||||
{
|
{
|
||||||
return $path;
|
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.
|
* EntityProvider constructor.
|
||||||
* @param Bookshelf $bookshelf
|
|
||||||
* @param Book $book
|
|
||||||
* @param Chapter $chapter
|
|
||||||
* @param Page $page
|
|
||||||
* @param PageRevision $pageRevision
|
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
Bookshelf $bookshelf,
|
Bookshelf $bookshelf,
|
||||||
|
@ -62,9 +57,8 @@ class EntityProvider
|
||||||
/**
|
/**
|
||||||
* Fetch all core entity types as an associated array
|
* Fetch all core entity types as an associated array
|
||||||
* with their basic names as the keys.
|
* with their basic names as the keys.
|
||||||
* @return Entity[]
|
|
||||||
*/
|
*/
|
||||||
public function all()
|
public function all(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'bookshelf' => $this->bookshelf,
|
'bookshelf' => $this->bookshelf,
|
||||||
|
@ -76,10 +70,8 @@ class EntityProvider
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get an entity instance by it's basic name.
|
* 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);
|
$type = strtolower($type);
|
||||||
return $this->all()[$type];
|
return $this->all()[$type];
|
||||||
|
@ -87,15 +79,9 @@ class EntityProvider
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the morph classes, as an array, for a single or multiple types.
|
* 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 = [];
|
$morphClasses = [];
|
||||||
foreach ($types as $type) {
|
foreach ($types as $type) {
|
||||||
$model = $this->get($type);
|
$model = $this->get($type);
|
||||||
|
|
|
@ -1,35 +1,34 @@
|
||||||
<?php namespace BookStack\Entities;
|
<?php namespace BookStack\Entities;
|
||||||
|
|
||||||
use BookStack\Entities\Repos\EntityRepo;
|
use BookStack\Entities\Managers\BookContents;
|
||||||
|
use BookStack\Entities\Managers\PageContent;
|
||||||
use BookStack\Uploads\ImageService;
|
use BookStack\Uploads\ImageService;
|
||||||
|
use DomPDF;
|
||||||
|
use Exception;
|
||||||
|
use SnappyPDF;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
class ExportService
|
class ExportService
|
||||||
{
|
{
|
||||||
|
|
||||||
protected $entityRepo;
|
|
||||||
protected $imageService;
|
protected $imageService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ExportService constructor.
|
* 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;
|
$this->imageService = $imageService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a page to a self-contained HTML file.
|
* Convert a page to a self-contained HTML file.
|
||||||
* Includes required CSS & image content. Images are base64 encoded into the HTML.
|
* Includes required CSS & image content. Images are base64 encoded into the HTML.
|
||||||
* @param \BookStack\Entities\Page $page
|
* @throws Throwable
|
||||||
* @return mixed|string
|
|
||||||
* @throws \Throwable
|
|
||||||
*/
|
*/
|
||||||
public function pageToContainedHtml(Page $page)
|
public function pageToContainedHtml(Page $page)
|
||||||
{
|
{
|
||||||
$this->entityRepo->renderPage($page);
|
$page->html = (new PageContent($page))->render();
|
||||||
$pageHtml = view('pages/export', [
|
$pageHtml = view('pages/export', [
|
||||||
'page' => $page
|
'page' => $page
|
||||||
])->render();
|
])->render();
|
||||||
|
@ -38,15 +37,13 @@ class ExportService
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a chapter to a self-contained HTML file.
|
* Convert a chapter to a self-contained HTML file.
|
||||||
* @param \BookStack\Entities\Chapter $chapter
|
* @throws Throwable
|
||||||
* @return mixed|string
|
|
||||||
* @throws \Throwable
|
|
||||||
*/
|
*/
|
||||||
public function chapterToContainedHtml(Chapter $chapter)
|
public function chapterToContainedHtml(Chapter $chapter)
|
||||||
{
|
{
|
||||||
$pages = $this->entityRepo->getChapterChildren($chapter);
|
$pages = $chapter->getVisiblePages();
|
||||||
$pages->each(function ($page) {
|
$pages->each(function ($page) {
|
||||||
$page->html = $this->entityRepo->renderPage($page);
|
$page->html = (new PageContent($page))->render();
|
||||||
});
|
});
|
||||||
$html = view('chapters/export', [
|
$html = view('chapters/export', [
|
||||||
'chapter' => $chapter,
|
'chapter' => $chapter,
|
||||||
|
@ -57,13 +54,11 @@ class ExportService
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a book to a self-contained HTML file.
|
* Convert a book to a self-contained HTML file.
|
||||||
* @param Book $book
|
* @throws Throwable
|
||||||
* @return mixed|string
|
|
||||||
* @throws \Throwable
|
|
||||||
*/
|
*/
|
||||||
public function bookToContainedHtml(Book $book)
|
public function bookToContainedHtml(Book $book)
|
||||||
{
|
{
|
||||||
$bookTree = $this->entityRepo->getBookChildren($book, true, true);
|
$bookTree = (new BookContents($book))->getTree(false, true);
|
||||||
$html = view('books/export', [
|
$html = view('books/export', [
|
||||||
'book' => $book,
|
'book' => $book,
|
||||||
'bookChildren' => $bookTree
|
'bookChildren' => $bookTree
|
||||||
|
@ -73,13 +68,11 @@ class ExportService
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a page to a PDF file.
|
* Convert a page to a PDF file.
|
||||||
* @param Page $page
|
* @throws Throwable
|
||||||
* @return mixed|string
|
|
||||||
* @throws \Throwable
|
|
||||||
*/
|
*/
|
||||||
public function pageToPdf(Page $page)
|
public function pageToPdf(Page $page)
|
||||||
{
|
{
|
||||||
$this->entityRepo->renderPage($page);
|
$page->html = (new PageContent($page))->render();
|
||||||
$html = view('pages/pdf', [
|
$html = view('pages/pdf', [
|
||||||
'page' => $page
|
'page' => $page
|
||||||
])->render();
|
])->render();
|
||||||
|
@ -88,32 +81,30 @@ class ExportService
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a chapter to a PDF file.
|
* Convert a chapter to a PDF file.
|
||||||
* @param \BookStack\Entities\Chapter $chapter
|
* @throws Throwable
|
||||||
* @return mixed|string
|
|
||||||
* @throws \Throwable
|
|
||||||
*/
|
*/
|
||||||
public function chapterToPdf(Chapter $chapter)
|
public function chapterToPdf(Chapter $chapter)
|
||||||
{
|
{
|
||||||
$pages = $this->entityRepo->getChapterChildren($chapter);
|
$pages = $chapter->getVisiblePages();
|
||||||
$pages->each(function ($page) {
|
$pages->each(function ($page) {
|
||||||
$page->html = $this->entityRepo->renderPage($page);
|
$page->html = (new PageContent($page))->render();
|
||||||
});
|
});
|
||||||
|
|
||||||
$html = view('chapters/export', [
|
$html = view('chapters/export', [
|
||||||
'chapter' => $chapter,
|
'chapter' => $chapter,
|
||||||
'pages' => $pages
|
'pages' => $pages
|
||||||
])->render();
|
])->render();
|
||||||
|
|
||||||
return $this->htmlToPdf($html);
|
return $this->htmlToPdf($html);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a book to a PDF file
|
* Convert a book to a PDF file.
|
||||||
* @param \BookStack\Entities\Book $book
|
* @throws Throwable
|
||||||
* @return string
|
|
||||||
* @throws \Throwable
|
|
||||||
*/
|
*/
|
||||||
public function bookToPdf(Book $book)
|
public function bookToPdf(Book $book)
|
||||||
{
|
{
|
||||||
$bookTree = $this->entityRepo->getBookChildren($book, true, true);
|
$bookTree = (new BookContents($book))->getTree(false, true);
|
||||||
$html = view('books/export', [
|
$html = view('books/export', [
|
||||||
'book' => $book,
|
'book' => $book,
|
||||||
'bookChildren' => $bookTree
|
'bookChildren' => $bookTree
|
||||||
|
@ -122,31 +113,27 @@ class ExportService
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert normal webpage HTML to a PDF.
|
* Convert normal web-page HTML to a PDF.
|
||||||
* @param $html
|
* @throws Exception
|
||||||
* @return string
|
|
||||||
* @throws \Exception
|
|
||||||
*/
|
*/
|
||||||
protected function htmlToPdf($html)
|
protected function htmlToPdf(string $html): string
|
||||||
{
|
{
|
||||||
$containedHtml = $this->containHtml($html);
|
$containedHtml = $this->containHtml($html);
|
||||||
$useWKHTML = config('snappy.pdf.binary') !== false;
|
$useWKHTML = config('snappy.pdf.binary') !== false;
|
||||||
if ($useWKHTML) {
|
if ($useWKHTML) {
|
||||||
$pdf = \SnappyPDF::loadHTML($containedHtml);
|
$pdf = SnappyPDF::loadHTML($containedHtml);
|
||||||
$pdf->setOption('print-media-type', true);
|
$pdf->setOption('print-media-type', true);
|
||||||
} else {
|
} else {
|
||||||
$pdf = \DomPDF::loadHTML($containedHtml);
|
$pdf = DomPDF::loadHTML($containedHtml);
|
||||||
}
|
}
|
||||||
return $pdf->output();
|
return $pdf->output();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bundle of the contents of a html file to be self-contained.
|
* Bundle of the contents of a html file to be self-contained.
|
||||||
* @param $htmlContent
|
* @throws Exception
|
||||||
* @return mixed|string
|
|
||||||
* @throws \Exception
|
|
||||||
*/
|
*/
|
||||||
protected function containHtml($htmlContent)
|
protected function containHtml(string $htmlContent): string
|
||||||
{
|
{
|
||||||
$imageTagsOutput = [];
|
$imageTagsOutput = [];
|
||||||
preg_match_all("/\<img.*src\=(\'|\")(.*?)(\'|\").*?\>/i", $htmlContent, $imageTagsOutput);
|
preg_match_all("/\<img.*src\=(\'|\")(.*?)(\'|\").*?\>/i", $htmlContent, $imageTagsOutput);
|
||||||
|
@ -188,12 +175,10 @@ class ExportService
|
||||||
/**
|
/**
|
||||||
* Converts the page contents into simple plain text.
|
* Converts the page contents into simple plain text.
|
||||||
* This method filters any bad looking content to provide a nice final output.
|
* 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);
|
$text = strip_tags($html);
|
||||||
// Replace multiple spaces with single spaces
|
// Replace multiple spaces with single spaces
|
||||||
$text = preg_replace('/\ {2,}/', ' ', $text);
|
$text = preg_replace('/\ {2,}/', ' ', $text);
|
||||||
|
@ -207,10 +192,8 @@ class ExportService
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a chapter into a plain text string.
|
* 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->name . "\n\n";
|
||||||
$text .= $chapter->description . "\n\n";
|
$text .= $chapter->description . "\n\n";
|
||||||
|
@ -222,12 +205,10 @@ class ExportService
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a book into a plain text string.
|
* 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";
|
$text = $book->name . "\n\n";
|
||||||
foreach ($bookTree as $bookChild) {
|
foreach ($bookTree as $bookChild) {
|
||||||
if ($bookChild->isA('chapter')) {
|
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;
|
use Illuminate\Session\Store;
|
||||||
|
|
||||||
class EntityContextManager
|
class EntityContext
|
||||||
{
|
{
|
||||||
protected $session;
|
protected $session;
|
||||||
protected $entityRepo;
|
|
||||||
|
|
||||||
protected $KEY_SHELF_CONTEXT_ID = 'context_bookshelf_id';
|
protected $KEY_SHELF_CONTEXT_ID = 'context_bookshelf_id';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* EntityContextManager constructor.
|
* EntityContextManager constructor.
|
||||||
* @param Store $session
|
|
||||||
* @param EntityRepo $entityRepo
|
|
||||||
*/
|
*/
|
||||||
public function __construct(Store $session, EntityRepo $entityRepo)
|
public function __construct(Store $session)
|
||||||
{
|
{
|
||||||
$this->session = $session;
|
$this->session = $session;
|
||||||
$this->entityRepo = $entityRepo;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the current bookshelf context for the given book.
|
* 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);
|
$contextBookshelfId = $this->session->get($this->KEY_SHELF_CONTEXT_ID, null);
|
||||||
if (is_int($contextBookshelfId)) {
|
|
||||||
|
|
||||||
/** @var Bookshelf $shelf */
|
if (!is_int($contextBookshelfId)) {
|
||||||
$shelf = $this->entityRepo->getById('bookshelf', $contextBookshelfId);
|
return null;
|
||||||
|
|
||||||
if ($shelf && $shelf->contains($book)) {
|
|
||||||
return $shelf;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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;
|
<?php namespace BookStack\Entities;
|
||||||
|
|
||||||
use BookStack\Uploads\Attachment;
|
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'];
|
protected $fillable = ['name', 'html', 'priority', 'markdown'];
|
||||||
|
|
||||||
|
@ -11,12 +28,12 @@ class Page extends Entity
|
||||||
public $textField = 'text';
|
public $textField = 'text';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the morph class for this model.
|
* Get the entities that are visible to the current user.
|
||||||
* @return string
|
|
||||||
*/
|
*/
|
||||||
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;
|
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
|
* 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.
|
* Get the chapter that this page is in, If applicable.
|
||||||
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
|
* @return BelongsTo
|
||||||
*/
|
*/
|
||||||
public function chapter()
|
public function chapter()
|
||||||
{
|
{
|
||||||
|
@ -72,12 +79,12 @@ class Page extends Entity
|
||||||
*/
|
*/
|
||||||
public function revisions()
|
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.
|
* Get the attachments assigned to this page.
|
||||||
* @return \Illuminate\Database\Eloquent\Relations\HasMany
|
* @return HasMany
|
||||||
*/
|
*/
|
||||||
public function attachments()
|
public function attachments()
|
||||||
{
|
{
|
||||||
|
@ -95,27 +102,17 @@ class Page extends Entity
|
||||||
$midText = $this->draft ? '/draft/' : '/page/';
|
$midText = $this->draft ? '/draft/' : '/page/';
|
||||||
$idComponent = $this->draft ? $this->id : urlencode($this->slug);
|
$idComponent = $this->draft ? $this->id : urlencode($this->slug);
|
||||||
|
|
||||||
|
$url = '/books/' . urlencode($bookSlug) . $midText . $idComponent;
|
||||||
if ($path !== false) {
|
if ($path !== false) {
|
||||||
return url('/books/' . urlencode($bookSlug) . $midText . $idComponent . '/' . trim($path, '/'));
|
$url .= '/' . trim($path, '/');
|
||||||
}
|
}
|
||||||
|
|
||||||
return url('/books/' . urlencode($bookSlug) . $midText . $idComponent);
|
return url($url);
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the current revision for the page if existing
|
* Get the current revision for the page if existing
|
||||||
* @return \BookStack\Entities\PageRevision|null
|
* @return PageRevision|null
|
||||||
*/
|
*/
|
||||||
public function getCurrentRevision()
|
public function getCurrentRevision()
|
||||||
{
|
{
|
||||||
|
|
|
@ -2,7 +2,21 @@
|
||||||
|
|
||||||
use BookStack\Auth\User;
|
use BookStack\Auth\User;
|
||||||
use BookStack\Model;
|
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
|
class PageRevision extends Model
|
||||||
{
|
{
|
||||||
protected $fillable = ['name', 'html', 'text', 'markdown', 'summary'];
|
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
|
* Get the previous revision for the same page if existing
|
||||||
* @return \BookStack\PageRevision|null
|
* @return \BookStack\Entities\PageRevision|null
|
||||||
*/
|
*/
|
||||||
public function getPrevious()
|
public function getPrevious()
|
||||||
{
|
{
|
||||||
if ($id = static::where('page_id', '=', $this->page_id)->where('id', '<', $this->id)->max('id')) {
|
$id = static::newQuery()->where('page_id', '=', $this->page_id)
|
||||||
return static::find($id);
|
->where('id', '<', $this->id)
|
||||||
|
->max('id');
|
||||||
|
|
||||||
|
if ($id) {
|
||||||
|
return static::query()->find($id);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
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\Book;
|
||||||
use BookStack\Entities\Chapter;
|
use BookStack\Entities\Chapter;
|
||||||
use BookStack\Entities\Entity;
|
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\Page;
|
||||||
use BookStack\Entities\PageRevision;
|
use BookStack\Entities\PageRevision;
|
||||||
use Carbon\Carbon;
|
use BookStack\Exceptions\MoveOperationException;
|
||||||
use DOMDocument;
|
use BookStack\Exceptions\NotFoundException;
|
||||||
use DOMElement;
|
use BookStack\Exceptions\NotifyException;
|
||||||
use DOMXPath;
|
use BookStack\Exceptions\PermissionsException;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
class PageRepo extends EntityRepo
|
class PageRepo
|
||||||
{
|
{
|
||||||
|
|
||||||
|
protected $baseRepo;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get page by slug.
|
* PageRepo constructor.
|
||||||
* @param string $pageSlug
|
|
||||||
* @param string $bookSlug
|
|
||||||
* @return Page
|
|
||||||
* @throws \BookStack\Exceptions\NotFoundException
|
|
||||||
*/
|
*/
|
||||||
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
|
* Get a page by ID.
|
||||||
* current book that has a slug equal to the one given.
|
* @throws NotFoundException
|
||||||
* @param string $pageSlug
|
|
||||||
* @param string $bookSlug
|
|
||||||
* @return null|Page
|
|
||||||
*/
|
*/
|
||||||
public function getPageByOldSlug(string $pageSlug, string $bookSlug)
|
public function getById(int $id): Page
|
||||||
{
|
{
|
||||||
$revision = $this->entityProvider->pageRevision->where('slug', '=', $pageSlug)
|
$page = Page::visible()->with(['book'])->find($id);
|
||||||
->whereHas('page', function ($query) {
|
|
||||||
$this->permissionService->enforceEntityRestrictions('page', $query);
|
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('type', '=', 'version')
|
||||||
->where('book_slug', '=', $bookSlug)
|
->where('book_slug', '=', $bookSlug)
|
||||||
->orderBy('created_at', 'desc')
|
->orderBy('created_at', 'desc')
|
||||||
->with('page')->first();
|
->with('page')
|
||||||
return $revision !== null ? $revision->page : null;
|
->first();
|
||||||
|
return $revision ? $revision->page : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates a page with any fillable data and saves it into the database.
|
* Get pages that have been marked as a template.
|
||||||
* @param Page $page
|
|
||||||
* @param int $book_id
|
|
||||||
* @param array $input
|
|
||||||
* @return Page
|
|
||||||
* @throws \Exception
|
|
||||||
*/
|
*/
|
||||||
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
|
// Hold the old details to compare later
|
||||||
$oldHtml = $page->html;
|
$oldHtml = $page->html;
|
||||||
$oldName = $page->name;
|
$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')) {
|
if (isset($input['template']) && userCan('templates-manage')) {
|
||||||
$page->template = ($input['template'] === 'true');
|
$page->template = ($input['template'] === 'true');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->baseRepo->update($page, $input);
|
||||||
|
|
||||||
// Update with new details
|
// Update with new details
|
||||||
$userId = user()->id;
|
|
||||||
$page->fill($input);
|
$page->fill($input);
|
||||||
$page->html = $this->formatHtml($input['html']);
|
$pageContent = new PageContent($page);
|
||||||
$page->text = $this->pageToPlainText($page);
|
$pageContent->setNewHTML($input['html']);
|
||||||
|
$page->revision_count++;
|
||||||
|
|
||||||
if (setting('app-editor') !== 'markdown') {
|
if (setting('app-editor') !== 'markdown') {
|
||||||
$page->markdown = '';
|
$page->markdown = '';
|
||||||
}
|
}
|
||||||
$page->updated_by = $userId;
|
|
||||||
$page->revision_count++;
|
|
||||||
$page->save();
|
$page->save();
|
||||||
|
|
||||||
// Remove all update drafts for this user & page.
|
// Remove all update drafts for this user & page.
|
||||||
$this->userUpdatePageDraftsQuery($page, $userId)->delete();
|
$this->getUserDraftQuery($page)->delete();
|
||||||
|
|
||||||
// Save a revision after updating
|
// Save a revision after updating
|
||||||
$summary = $input['summary'] ?? null;
|
$summary = $input['summary'] ?? null;
|
||||||
|
@ -95,24 +203,20 @@ class PageRepo extends EntityRepo
|
||||||
$this->savePageRevision($page, $summary);
|
$this->savePageRevision($page, $summary);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->searchService->indexEntity($page);
|
|
||||||
|
|
||||||
return $page;
|
return $page;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Saves a page revision into the system.
|
* 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') {
|
if (setting('app-editor') !== 'markdown') {
|
||||||
$revision->markdown = '';
|
$revision->markdown = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
$revision->page_id = $page->id;
|
$revision->page_id = $page->id;
|
||||||
$revision->slug = $page->slug;
|
$revision->slug = $page->slug;
|
||||||
$revision->book_slug = $page->book->slug;
|
$revision->book_slug = $page->book->slug;
|
||||||
|
@ -123,164 +227,29 @@ class PageRepo extends EntityRepo
|
||||||
$revision->revision_number = $page->revision_count;
|
$revision->revision_number = $page->revision_count;
|
||||||
$revision->save();
|
$revision->save();
|
||||||
|
|
||||||
$revisionLimit = config('app.revision_limit');
|
$this->deleteOldRevisions($page);
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $revision;
|
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.
|
* 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 the page itself is a draft simply update that
|
||||||
if ($page->draft) {
|
if ($page->draft) {
|
||||||
$page->fill($data);
|
$page->fill($input);
|
||||||
if (isset($data['html'])) {
|
if (isset($input['html'])) {
|
||||||
$page->text = $this->pageToPlainText($page);
|
$content = new PageContent($page);
|
||||||
|
$content->setNewHTML($input['html']);
|
||||||
}
|
}
|
||||||
$page->save();
|
$page->save();
|
||||||
return $page;
|
return $page;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise save the data to a revision
|
// Otherwise save the data to a revision
|
||||||
$userId = user()->id;
|
$draft = $this->getPageRevisionToUpdate($page);
|
||||||
$drafts = $this->userUpdatePageDraftsQuery($page, $userId)->get();
|
$draft->fill($input);
|
||||||
|
|
||||||
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);
|
|
||||||
if (setting('app-editor') !== 'markdown') {
|
if (setting('app-editor') !== 'markdown') {
|
||||||
$draft->markdown = '';
|
$draft->markdown = '';
|
||||||
}
|
}
|
||||||
|
@ -290,225 +259,78 @@ class PageRepo extends EntityRepo
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Publish a draft page to make it a normal page.
|
* Destroy a page from the system.
|
||||||
* Sets the slug and updates the content.
|
* @throws NotifyException
|
||||||
* @param Page $draftPage
|
|
||||||
* @param array $input
|
|
||||||
* @return Page
|
|
||||||
* @throws \Exception
|
|
||||||
*/
|
*/
|
||||||
public function publishPageDraft(Page $draftPage, array $input)
|
public function destroy(Page $page)
|
||||||
{
|
{
|
||||||
$draftPage->fill($input);
|
$trashCan = new TrashCan();
|
||||||
|
$trashCan->destroyPage($page);
|
||||||
// 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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restores a revision's content back into a 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++;
|
$page->revision_count++;
|
||||||
$this->savePageRevision($page);
|
$this->savePageRevision($page);
|
||||||
|
|
||||||
$revision = $page->revisions()->where('id', '=', $revisionId)->first();
|
$revision = $page->revisions()->where('id', '=', $revisionId)->first();
|
||||||
$page->fill($revision->toArray());
|
$page->fill($revision->toArray());
|
||||||
$page->slug = $this->findSuitableSlug('page', $page->name, $page->id, $book->id);
|
$content = new PageContent($page);
|
||||||
$page->text = $this->pageToPlainText($page);
|
$content->setNewHTML($page->html);
|
||||||
$page->updated_by = user()->id;
|
$page->updated_by = user()->id;
|
||||||
|
$page->refreshSlug();
|
||||||
$page->save();
|
$page->save();
|
||||||
$this->searchService->indexEntity($page);
|
|
||||||
|
$page->indexForSearch();
|
||||||
return $page;
|
return $page;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Change the page's parent to the given entity.
|
* Move the given page into a new parent book or chapter.
|
||||||
* @param Page $page
|
* The $parentIdentifier must be a string of the following format:
|
||||||
* @param Entity $parent
|
* 'book:<id>' (book:5)
|
||||||
* @throws \Throwable
|
* @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;
|
$parent = $this->findParentByIdentifier($parentIdentifier);
|
||||||
$page->chapter_id = $parent->isA('chapter') ? $parent->id : 0;
|
if ($parent === null) {
|
||||||
$page->save();
|
throw new MoveOperationException('Book or chapter to move page into not found');
|
||||||
if ($page->book->id !== $book->id) {
|
|
||||||
$page = $this->changeBook('page', $book->id, $page);
|
|
||||||
}
|
}
|
||||||
$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.
|
* Copy an existing page in the system.
|
||||||
* @param \BookStack\Entities\Page $page
|
* Optionally providing a new parent via string identifier and a new name.
|
||||||
* @param \BookStack\Entities\Entity $newParent
|
* @throws MoveOperationException
|
||||||
* @param string $newName
|
* @throws PermissionsException
|
||||||
* @return \BookStack\Entities\Page
|
|
||||||
* @throws \Throwable
|
|
||||||
*/
|
*/
|
||||||
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;
|
$parent = $parentIdentifier ? $this->findParentByIdentifier($parentIdentifier) : $page->parent();
|
||||||
$newChapter = $newParent->isA('chapter') ? $newParent : null;
|
if ($parent === null) {
|
||||||
$copyPage = $this->getDraftPage($newBook, $newChapter);
|
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();
|
$pageData = $page->getAttributes();
|
||||||
|
|
||||||
// Update name
|
// Update name
|
||||||
|
@ -524,38 +346,116 @@ class PageRepo extends EntityRepo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set priority
|
return $this->publishDraft($copyPage, $pageData);
|
||||||
if ($newParent->isA('chapter')) {
|
|
||||||
$pageData['priority'] = $this->getNewChapterPriority($newParent);
|
|
||||||
} else {
|
|
||||||
$pageData['priority'] = $this->getNewBookPriority($newParent);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->publishPageDraft($copyPage, $pageData);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get pages that have been marked as templates.
|
* Find a page parent entity via a identifier string in the format:
|
||||||
* @param int $count
|
* {type}:{id}
|
||||||
* @param int $page
|
* Example: (book:5)
|
||||||
* @param string $search
|
* @throws MoveOperationException
|
||||||
* @return \Illuminate\Contracts\Pagination\LengthAwarePaginator
|
|
||||||
*/
|
*/
|
||||||
public function getPageTemplates(int $count = 10, int $page = 1, string $search = '')
|
protected function findParentByIdentifier(string $identifier): ?Entity
|
||||||
{
|
{
|
||||||
$query = $this->entityQuery('page')
|
$stringExploded = explode(':', $identifier);
|
||||||
->where('template', '=', true)
|
$entityType = $stringExploded[0];
|
||||||
->orderBy('name', 'asc')
|
$entityId = intval($stringExploded[1]);
|
||||||
->skip( ($page - 1) * $count)
|
|
||||||
->take($count);
|
|
||||||
|
|
||||||
if ($search) {
|
if ($entityType !== 'book' && $entityType !== 'chapter') {
|
||||||
$query->where('name', 'like', '%' . $search . '%');
|
throw new MoveOperationException('Pages can only be in books or chapters');
|
||||||
}
|
}
|
||||||
|
|
||||||
$paginator = $query->paginate($count, ['*'], 'page', $page);
|
$parentClass = $entityType === 'book' ? Book::class : Chapter::class;
|
||||||
$paginator->withPath('/templates');
|
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\Builder;
|
||||||
use Illuminate\Database\Query\JoinClause;
|
use Illuminate\Database\Query\JoinClause;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class SearchService
|
class SearchService
|
||||||
{
|
{
|
||||||
|
@ -210,7 +211,7 @@ class SearchService
|
||||||
|
|
||||||
// Handle filters
|
// Handle filters
|
||||||
foreach ($terms['filters'] as $filterTerm => $filterValue) {
|
foreach ($terms['filters'] as $filterTerm => $filterValue) {
|
||||||
$functionName = camel_case('filter_' . $filterTerm);
|
$functionName = Str::camel('filter_' . $filterTerm);
|
||||||
if (method_exists($this, $functionName)) {
|
if (method_exists($this, $functionName)) {
|
||||||
$this->$functionName($entitySelect, $entity, $filterValue);
|
$this->$functionName($entitySelect, $entity, $filterValue);
|
||||||
}
|
}
|
||||||
|
@ -514,7 +515,7 @@ class SearchService
|
||||||
|
|
||||||
protected function filterSortBy(EloquentBuilder $query, Entity $model, $input)
|
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)) {
|
if (method_exists($this, $functionName)) {
|
||||||
$this->$functionName($query, $model);
|
$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\Auth\AuthenticationException;
|
||||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||||
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
|
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
@ -47,10 +50,17 @@ class Handler extends ExceptionHandler
|
||||||
*/
|
*/
|
||||||
public function render($request, Exception $e)
|
public function render($request, Exception $e)
|
||||||
{
|
{
|
||||||
|
if ($this->isApiRequest($request)) {
|
||||||
|
return $this->renderApiException($e);
|
||||||
|
}
|
||||||
|
|
||||||
// Handle notify exceptions which will redirect to the
|
// Handle notify exceptions which will redirect to the
|
||||||
// specified location then show a notification message.
|
// specified location then show a notification message.
|
||||||
if ($this->isExceptionType($e, NotifyException::class)) {
|
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);
|
return redirect($e->redirectLocation);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -70,6 +80,41 @@ class Handler extends ExceptionHandler
|
||||||
return parent::render($request, $e);
|
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.
|
* Check the exception chain to compare against the original exception type.
|
||||||
* @param Exception $e
|
* @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;
|
<?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;
|
<?php namespace BookStack\Exceptions;
|
||||||
|
|
||||||
class UserTokenExpiredException extends \Exception {
|
class UserTokenExpiredException extends \Exception
|
||||||
|
{
|
||||||
|
|
||||||
public $userId;
|
public $userId;
|
||||||
|
|
||||||
|
@ -14,6 +15,4 @@ class UserTokenExpiredException extends \Exception {
|
||||||
$this->userId = $userId;
|
$this->userId = $userId;
|
||||||
parent::__construct($message);
|
parent::__construct($message);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,3 +1,6 @@
|
||||||
<?php namespace BookStack\Exceptions;
|
<?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;
|
<?php namespace BookStack\Http\Controllers;
|
||||||
|
|
||||||
use BookStack\Entities\Repos\EntityRepo;
|
use BookStack\Entities\Repos\PageRepo;
|
||||||
use BookStack\Exceptions\FileUploadException;
|
use BookStack\Exceptions\FileUploadException;
|
||||||
use BookStack\Exceptions\NotFoundException;
|
use BookStack\Exceptions\NotFoundException;
|
||||||
use BookStack\Uploads\Attachment;
|
use BookStack\Uploads\Attachment;
|
||||||
use BookStack\Uploads\AttachmentService;
|
use BookStack\Uploads\AttachmentService;
|
||||||
|
use Exception;
|
||||||
|
use Illuminate\Contracts\Filesystem\FileNotFoundException;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class AttachmentController extends Controller
|
class AttachmentController extends Controller
|
||||||
{
|
{
|
||||||
protected $attachmentService;
|
protected $attachmentService;
|
||||||
protected $attachment;
|
protected $attachment;
|
||||||
protected $entityRepo;
|
protected $pageRepo;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AttachmentController constructor.
|
* 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->attachmentService = $attachmentService;
|
||||||
$this->attachment = $attachment;
|
$this->attachment = $attachment;
|
||||||
$this->entityRepo = $entityRepo;
|
$this->pageRepo = $pageRepo;
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Endpoint at which attachments are uploaded to.
|
* Endpoint at which attachments are uploaded to.
|
||||||
* @param Request $request
|
* @throws ValidationException
|
||||||
* @return \Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Http\JsonResponse|\Symfony\Component\HttpFoundation\Response
|
* @throws NotFoundException
|
||||||
*/
|
*/
|
||||||
public function upload(Request $request)
|
public function upload(Request $request)
|
||||||
{
|
{
|
||||||
|
@ -41,7 +41,7 @@ class AttachmentController extends Controller
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$pageId = $request->get('uploaded_to');
|
$pageId = $request->get('uploaded_to');
|
||||||
$page = $this->entityRepo->getById('page', $pageId, true);
|
$page = $this->pageRepo->getById($pageId);
|
||||||
|
|
||||||
$this->checkPermission('attachment-create-all');
|
$this->checkPermission('attachment-create-all');
|
||||||
$this->checkOwnablePermission('page-update', $page);
|
$this->checkOwnablePermission('page-update', $page);
|
||||||
|
@ -59,11 +59,10 @@ class AttachmentController extends Controller
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update an uploaded attachment.
|
* Update an uploaded attachment.
|
||||||
* @param int $attachmentId
|
* @throws ValidationException
|
||||||
* @param Request $request
|
* @throws NotFoundException
|
||||||
* @return mixed
|
|
||||||
*/
|
*/
|
||||||
public function uploadUpdate($attachmentId, Request $request)
|
public function uploadUpdate(Request $request, $attachmentId)
|
||||||
{
|
{
|
||||||
$this->validate($request, [
|
$this->validate($request, [
|
||||||
'uploaded_to' => 'required|integer|exists:pages,id',
|
'uploaded_to' => 'required|integer|exists:pages,id',
|
||||||
|
@ -71,7 +70,7 @@ class AttachmentController extends Controller
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$pageId = $request->get('uploaded_to');
|
$pageId = $request->get('uploaded_to');
|
||||||
$page = $this->entityRepo->getById('page', $pageId, true);
|
$page = $this->pageRepo->getById($pageId);
|
||||||
$attachment = $this->attachment->findOrFail($attachmentId);
|
$attachment = $this->attachment->findOrFail($attachmentId);
|
||||||
|
|
||||||
$this->checkOwnablePermission('page-update', $page);
|
$this->checkOwnablePermission('page-update', $page);
|
||||||
|
@ -94,11 +93,10 @@ class AttachmentController extends Controller
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the details of an existing file.
|
* Update the details of an existing file.
|
||||||
* @param $attachmentId
|
* @throws ValidationException
|
||||||
* @param Request $request
|
* @throws NotFoundException
|
||||||
* @return Attachment|mixed
|
|
||||||
*/
|
*/
|
||||||
public function update($attachmentId, Request $request)
|
public function update(Request $request, $attachmentId)
|
||||||
{
|
{
|
||||||
$this->validate($request, [
|
$this->validate($request, [
|
||||||
'uploaded_to' => 'required|integer|exists:pages,id',
|
'uploaded_to' => 'required|integer|exists:pages,id',
|
||||||
|
@ -107,7 +105,7 @@ class AttachmentController extends Controller
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$pageId = $request->get('uploaded_to');
|
$pageId = $request->get('uploaded_to');
|
||||||
$page = $this->entityRepo->getById('page', $pageId, true);
|
$page = $this->pageRepo->getById($pageId);
|
||||||
$attachment = $this->attachment->findOrFail($attachmentId);
|
$attachment = $this->attachment->findOrFail($attachmentId);
|
||||||
|
|
||||||
$this->checkOwnablePermission('page-update', $page);
|
$this->checkOwnablePermission('page-update', $page);
|
||||||
|
@ -123,8 +121,8 @@ class AttachmentController extends Controller
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attach a link to a page.
|
* Attach a link to a page.
|
||||||
* @param Request $request
|
* @throws ValidationException
|
||||||
* @return mixed
|
* @throws NotFoundException
|
||||||
*/
|
*/
|
||||||
public function attachLink(Request $request)
|
public function attachLink(Request $request)
|
||||||
{
|
{
|
||||||
|
@ -135,7 +133,7 @@ class AttachmentController extends Controller
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$pageId = $request->get('uploaded_to');
|
$pageId = $request->get('uploaded_to');
|
||||||
$page = $this->entityRepo->getById('page', $pageId, true);
|
$page = $this->pageRepo->getById($pageId);
|
||||||
|
|
||||||
$this->checkPermission('attachment-create-all');
|
$this->checkPermission('attachment-create-all');
|
||||||
$this->checkOwnablePermission('page-update', $page);
|
$this->checkOwnablePermission('page-update', $page);
|
||||||
|
@ -149,29 +147,26 @@ class AttachmentController extends Controller
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the attachments for a specific page.
|
* 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);
|
$this->checkOwnablePermission('page-view', $page);
|
||||||
return response()->json($page->attachments);
|
return response()->json($page->attachments);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the attachment sorting.
|
* Update the attachment sorting.
|
||||||
* @param $pageId
|
* @throws ValidationException
|
||||||
* @param Request $request
|
* @throws NotFoundException
|
||||||
* @return mixed
|
|
||||||
*/
|
*/
|
||||||
public function sortForPage($pageId, Request $request)
|
public function sortForPage(Request $request, int $pageId)
|
||||||
{
|
{
|
||||||
$this->validate($request, [
|
$this->validate($request, [
|
||||||
'files' => 'required|array',
|
'files' => 'required|array',
|
||||||
'files.*.id' => 'required|integer',
|
'files.*.id' => 'required|integer',
|
||||||
]);
|
]);
|
||||||
$page = $this->entityRepo->getById('page', $pageId);
|
$page = $this->pageRepo->getById($pageId);
|
||||||
$this->checkOwnablePermission('page-update', $page);
|
$this->checkOwnablePermission('page-update', $page);
|
||||||
|
|
||||||
$attachments = $request->get('files');
|
$attachments = $request->get('files');
|
||||||
|
@ -181,16 +176,15 @@ class AttachmentController extends Controller
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get an attachment from storage.
|
* Get an attachment from storage.
|
||||||
* @param $attachmentId
|
* @throws FileNotFoundException
|
||||||
* @return \Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector|\Symfony\Component\HttpFoundation\Response
|
|
||||||
* @throws \Illuminate\Contracts\Filesystem\FileNotFoundException
|
|
||||||
* @throws NotFoundException
|
* @throws NotFoundException
|
||||||
*/
|
*/
|
||||||
public function get($attachmentId)
|
public function get(int $attachmentId)
|
||||||
{
|
{
|
||||||
$attachment = $this->attachment->findOrFail($attachmentId);
|
$attachment = $this->attachment->findOrFail($attachmentId);
|
||||||
$page = $this->entityRepo->getById('page', $attachment->uploaded_to);
|
try {
|
||||||
if ($page === null) {
|
$page = $this->pageRepo->getById($attachment->uploaded_to);
|
||||||
|
} catch (NotFoundException $exception) {
|
||||||
throw new NotFoundException(trans('errors.attachment_not_found'));
|
throw new NotFoundException(trans('errors.attachment_not_found'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -208,9 +202,9 @@ class AttachmentController extends Controller
|
||||||
* Delete a specific attachment in the system.
|
* Delete a specific attachment in the system.
|
||||||
* @param $attachmentId
|
* @param $attachmentId
|
||||||
* @return mixed
|
* @return mixed
|
||||||
* @throws \Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
public function delete($attachmentId)
|
public function delete(int $attachmentId)
|
||||||
{
|
{
|
||||||
$attachment = $this->attachment->findOrFail($attachmentId);
|
$attachment = $this->attachment->findOrFail($attachmentId);
|
||||||
$this->checkOwnablePermission('attachment-delete', $attachment);
|
$this->checkOwnablePermission('attachment-delete', $attachment);
|
||||||
|
|
|
@ -64,16 +64,15 @@ class ConfirmEmailController extends Controller
|
||||||
try {
|
try {
|
||||||
$userId = $this->emailConfirmationService->checkTokenAndGetUserId($token);
|
$userId = $this->emailConfirmationService->checkTokenAndGetUserId($token);
|
||||||
} catch (Exception $exception) {
|
} catch (Exception $exception) {
|
||||||
|
|
||||||
if ($exception instanceof UserTokenNotFoundException) {
|
if ($exception instanceof UserTokenNotFoundException) {
|
||||||
session()->flash('error', trans('errors.email_confirmation_invalid'));
|
$this->showErrorNotification(trans('errors.email_confirmation_invalid'));
|
||||||
return redirect('/register');
|
return redirect('/register');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($exception instanceof UserTokenExpiredException) {
|
if ($exception instanceof UserTokenExpiredException) {
|
||||||
$user = $this->userRepo->getById($exception->userId);
|
$user = $this->userRepo->getById($exception->userId);
|
||||||
$this->emailConfirmationService->sendConfirmation($user);
|
$this->emailConfirmationService->sendConfirmation($user);
|
||||||
session()->flash('error', trans('errors.email_confirmation_expired'));
|
$this->showErrorNotification(trans('errors.email_confirmation_expired'));
|
||||||
return redirect('/register/confirm');
|
return redirect('/register/confirm');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -85,7 +84,7 @@ class ConfirmEmailController extends Controller
|
||||||
$user->save();
|
$user->save();
|
||||||
|
|
||||||
auth()->login($user);
|
auth()->login($user);
|
||||||
session()->flash('success', trans('auth.email_confirm_success'));
|
$this->showSuccessNotification(trans('auth.email_confirm_success'));
|
||||||
$this->emailConfirmationService->deleteByUser($user);
|
$this->emailConfirmationService->deleteByUser($user);
|
||||||
|
|
||||||
return redirect('/');
|
return redirect('/');
|
||||||
|
@ -107,12 +106,11 @@ class ConfirmEmailController extends Controller
|
||||||
try {
|
try {
|
||||||
$this->emailConfirmationService->sendConfirmation($user);
|
$this->emailConfirmationService->sendConfirmation($user);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
session()->flash('error', trans('auth.email_confirm_send_error'));
|
$this->showErrorNotification(trans('auth.email_confirm_send_error'));
|
||||||
return redirect('/register/confirm');
|
return redirect('/register/confirm');
|
||||||
}
|
}
|
||||||
|
|
||||||
session()->flash('success', trans('auth.email_confirm_resent'));
|
$this->showSuccessNotification(trans('auth.email_confirm_resent'));
|
||||||
return redirect('/register/confirm');
|
return redirect('/register/confirm');
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,6 +30,7 @@ class ForgotPasswordController extends Controller
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->middleware('guest');
|
$this->middleware('guest');
|
||||||
|
$this->middleware('guard:standard');
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -53,7 +54,7 @@ class ForgotPasswordController extends Controller
|
||||||
|
|
||||||
if ($response === Password::RESET_LINK_SENT) {
|
if ($response === Password::RESET_LINK_SENT) {
|
||||||
$message = trans('auth.reset_password_sent_success', ['email' => $request->get('email')]);
|
$message = trans('auth.reset_password_sent_success', ['email' => $request->get('email')]);
|
||||||
session()->flash('success', $message);
|
$this->showSuccessNotification($message);
|
||||||
return back()->with('status', trans($response));
|
return back()->with('status', trans($response));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,12 +2,11 @@
|
||||||
|
|
||||||
namespace BookStack\Http\Controllers\Auth;
|
namespace BookStack\Http\Controllers\Auth;
|
||||||
|
|
||||||
use BookStack\Auth\Access\LdapService;
|
|
||||||
use BookStack\Auth\Access\SocialAuthService;
|
use BookStack\Auth\Access\SocialAuthService;
|
||||||
use BookStack\Auth\UserRepo;
|
use BookStack\Exceptions\LoginAttemptEmailNeededException;
|
||||||
use BookStack\Exceptions\AuthException;
|
use BookStack\Exceptions\LoginAttemptException;
|
||||||
|
use BookStack\Exceptions\UserRegistrationException;
|
||||||
use BookStack\Http\Controllers\Controller;
|
use BookStack\Http\Controllers\Controller;
|
||||||
use Illuminate\Contracts\Auth\Authenticatable;
|
|
||||||
use Illuminate\Foundation\Auth\AuthenticatesUsers;
|
use Illuminate\Foundation\Auth\AuthenticatesUsers;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
@ -27,32 +26,23 @@ class LoginController extends Controller
|
||||||
use AuthenticatesUsers;
|
use AuthenticatesUsers;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where to redirect users after login.
|
* Redirection paths
|
||||||
*
|
|
||||||
* @var string
|
|
||||||
*/
|
*/
|
||||||
protected $redirectTo = '/';
|
protected $redirectTo = '/';
|
||||||
|
|
||||||
protected $redirectPath = '/';
|
protected $redirectPath = '/';
|
||||||
protected $redirectAfterLogout = '/login';
|
protected $redirectAfterLogout = '/login';
|
||||||
|
|
||||||
protected $socialAuthService;
|
protected $socialAuthService;
|
||||||
protected $ldapService;
|
|
||||||
protected $userRepo;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new controller instance.
|
* 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->socialAuthService = $socialAuthService;
|
||||||
$this->ldapService = $ldapService;
|
|
||||||
$this->userRepo = $userRepo;
|
|
||||||
$this->redirectPath = url('/');
|
$this->redirectPath = url('/');
|
||||||
$this->redirectAfterLogout = url('/login');
|
$this->redirectAfterLogout = url('/login');
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
|
@ -64,55 +54,15 @@ class LoginController extends Controller
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Overrides the action when a user is authenticated.
|
* Get the needed authorization credentials from the request.
|
||||||
* 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
|
|
||||||
*/
|
*/
|
||||||
protected function authenticated(Request $request, Authenticatable $user)
|
protected function credentials(Request $request)
|
||||||
{
|
{
|
||||||
// Explicitly log them out for now if they do no exist.
|
return $request->only('username', 'email', 'password');
|
||||||
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('/');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show the application login form.
|
* Show the application login form.
|
||||||
* @param Request $request
|
|
||||||
* @return \Illuminate\Http\Response
|
|
||||||
*/
|
*/
|
||||||
public function getLogin(Request $request)
|
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.
|
* Handle a login request to the application.
|
||||||
* @param $socialDriver
|
*
|
||||||
* @return \Symfony\Component\HttpFoundation\RedirectResponse
|
* @param \Illuminate\Http\Request $request
|
||||||
* @throws \BookStack\Exceptions\SocialDriverNotConfigured
|
* @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');
|
$this->validateLogin($request);
|
||||||
return $this->socialAuthService->startLogIn($socialDriver);
|
|
||||||
|
// 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;
|
namespace BookStack\Http\Controllers\Auth;
|
||||||
|
|
||||||
use BookStack\Auth\Access\EmailConfirmationService;
|
use BookStack\Auth\Access\RegistrationService;
|
||||||
use BookStack\Auth\Access\SocialAuthService;
|
use BookStack\Auth\Access\SocialAuthService;
|
||||||
use BookStack\Auth\SocialAccount;
|
|
||||||
use BookStack\Auth\User;
|
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\Exceptions\UserRegistrationException;
|
||||||
use BookStack\Http\Controllers\Controller;
|
use BookStack\Http\Controllers\Controller;
|
||||||
use Exception;
|
|
||||||
use Illuminate\Foundation\Auth\RegistersUsers;
|
use Illuminate\Foundation\Auth\RegistersUsers;
|
||||||
use Illuminate\Http\RedirectResponse;
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Response;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Routing\Redirector;
|
|
||||||
use Laravel\Socialite\Contracts\User as SocialUser;
|
|
||||||
use Validator;
|
use Validator;
|
||||||
|
|
||||||
class RegisterController extends Controller
|
class RegisterController extends Controller
|
||||||
|
@ -37,8 +28,7 @@ class RegisterController extends Controller
|
||||||
use RegistersUsers;
|
use RegistersUsers;
|
||||||
|
|
||||||
protected $socialAuthService;
|
protected $socialAuthService;
|
||||||
protected $emailConfirmationService;
|
protected $registrationService;
|
||||||
protected $userRepo;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where to redirect users after login / registration.
|
* Where to redirect users after login / registration.
|
||||||
|
@ -50,17 +40,15 @@ class RegisterController extends Controller
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new controller instance.
|
* 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->socialAuthService = $socialAuthService;
|
||||||
$this->emailConfirmationService = $emailConfirmationService;
|
$this->registrationService = $registrationService;
|
||||||
$this->userRepo = $userRepo;
|
|
||||||
$this->redirectTo = url('/');
|
$this->redirectTo = url('/');
|
||||||
$this->redirectPath = url('/');
|
$this->redirectPath = url('/');
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
|
@ -69,7 +57,6 @@ class RegisterController extends Controller
|
||||||
/**
|
/**
|
||||||
* Get a validator for an incoming registration request.
|
* Get a validator for an incoming registration request.
|
||||||
*
|
*
|
||||||
* @param array $data
|
|
||||||
* @return \Illuminate\Contracts\Validation\Validator
|
* @return \Illuminate\Contracts\Validation\Validator
|
||||||
*/
|
*/
|
||||||
protected function validator(array $data)
|
protected function validator(array $data)
|
||||||
|
@ -77,46 +64,45 @@ class RegisterController extends Controller
|
||||||
return Validator::make($data, [
|
return Validator::make($data, [
|
||||||
'name' => 'required|min:2|max:255',
|
'name' => 'required|min:2|max:255',
|
||||||
'email' => 'required|email|max:255|unique:users',
|
'email' => 'required|email|max:255|unique:users',
|
||||||
'password' => 'required|min: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.
|
* Show the application registration form.
|
||||||
* @return Response
|
|
||||||
* @throws UserRegistrationException
|
* @throws UserRegistrationException
|
||||||
*/
|
*/
|
||||||
public function getRegister()
|
public function getRegister()
|
||||||
{
|
{
|
||||||
$this->checkRegistrationAllowed();
|
$this->registrationService->ensureRegistrationAllowed();
|
||||||
$socialDrivers = $this->socialAuthService->getActiveDrivers();
|
$socialDrivers = $this->socialAuthService->getActiveDrivers();
|
||||||
return view('auth.register', ['socialDrivers' => $socialDrivers]);
|
return view('auth.register', [
|
||||||
|
'socialDrivers' => $socialDrivers,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle a registration request for the application.
|
* Handle a registration request for the application.
|
||||||
* @param Request|Request $request
|
|
||||||
* @return RedirectResponse|Redirector
|
|
||||||
* @throws UserRegistrationException
|
* @throws UserRegistrationException
|
||||||
*/
|
*/
|
||||||
public function postRegister(Request $request)
|
public function postRegister(Request $request)
|
||||||
{
|
{
|
||||||
$this->checkRegistrationAllowed();
|
$this->registrationService->ensureRegistrationAllowed();
|
||||||
$this->validator($request->all())->validate();
|
$this->validator($request->all())->validate();
|
||||||
|
|
||||||
$userData = $request->all();
|
$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([
|
return User::create([
|
||||||
'name' => $data['name'],
|
'name' => $data['name'],
|
||||||
'email' => $data['email'],
|
'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 BookStack\Http\Controllers\Controller;
|
||||||
use Illuminate\Foundation\Auth\ResetsPasswords;
|
use Illuminate\Foundation\Auth\ResetsPasswords;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
class ResetPasswordController extends Controller
|
class ResetPasswordController extends Controller
|
||||||
{
|
{
|
||||||
|
@ -30,19 +31,21 @@ class ResetPasswordController extends Controller
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->middleware('guest');
|
$this->middleware('guest');
|
||||||
|
$this->middleware('guard:standard');
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the response for a successful password reset.
|
* Get the response for a successful password reset.
|
||||||
*
|
*
|
||||||
* @param string $response
|
* @param Request $request
|
||||||
|
* @param string $response
|
||||||
* @return \Illuminate\Http\Response
|
* @return \Illuminate\Http\Response
|
||||||
*/
|
*/
|
||||||
protected function sendResetResponse($response)
|
protected function sendResetResponse(Request $request, $response)
|
||||||
{
|
{
|
||||||
$message = trans('auth.reset_password_success');
|
$message = trans('auth.reset_password_success');
|
||||||
session()->flash('success', $message);
|
$this->showSuccessNotification($message);
|
||||||
return redirect($this->redirectPath())
|
return redirect($this->redirectPath())
|
||||||
->with('status', trans($response));
|
->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\Exceptions\UserTokenNotFoundException;
|
||||||
use BookStack\Http\Controllers\Controller;
|
use BookStack\Http\Controllers\Controller;
|
||||||
use Exception;
|
use Exception;
|
||||||
use Illuminate\Contracts\View\Factory;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Routing\Redirector;
|
use Illuminate\Routing\Redirector;
|
||||||
use Illuminate\View\View;
|
|
||||||
|
|
||||||
class UserInviteController extends Controller
|
class UserInviteController extends Controller
|
||||||
{
|
{
|
||||||
|
@ -21,22 +19,20 @@ class UserInviteController extends Controller
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new controller instance.
|
* Create a new controller instance.
|
||||||
*
|
|
||||||
* @param UserInviteService $inviteService
|
|
||||||
* @param UserRepo $userRepo
|
|
||||||
*/
|
*/
|
||||||
public function __construct(UserInviteService $inviteService, UserRepo $userRepo)
|
public function __construct(UserInviteService $inviteService, UserRepo $userRepo)
|
||||||
{
|
{
|
||||||
|
$this->middleware('guest');
|
||||||
|
$this->middleware('guard:standard');
|
||||||
|
|
||||||
$this->inviteService = $inviteService;
|
$this->inviteService = $inviteService;
|
||||||
$this->userRepo = $userRepo;
|
$this->userRepo = $userRepo;
|
||||||
$this->middleware('guest');
|
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show the page for the user to set the password for their account.
|
* Show the page for the user to set the password for their account.
|
||||||
* @param string $token
|
|
||||||
* @return Factory|View|RedirectResponse
|
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
public function showSetPassword(string $token)
|
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.
|
* Sets the password for an invited user and then grants them access.
|
||||||
* @param string $token
|
|
||||||
* @param Request $request
|
|
||||||
* @return RedirectResponse|Redirector
|
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
public function setPassword(string $token, Request $request)
|
public function setPassword(Request $request, string $token)
|
||||||
{
|
{
|
||||||
$this->validate($request, [
|
$this->validate($request, [
|
||||||
'password' => 'required|min:6'
|
'password' => 'required|min:8'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
@ -77,7 +70,7 @@ class UserInviteController extends Controller
|
||||||
$user->save();
|
$user->save();
|
||||||
|
|
||||||
auth()->login($user);
|
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);
|
$this->inviteService->deleteByUser($user);
|
||||||
|
|
||||||
return redirect('/');
|
return redirect('/');
|
||||||
|
@ -85,7 +78,6 @@ class UserInviteController extends Controller
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check and validate the exception thrown when checking an invite token.
|
* Check and validate the exception thrown when checking an invite token.
|
||||||
* @param Exception $exception
|
|
||||||
* @return RedirectResponse|Redirector
|
* @return RedirectResponse|Redirector
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
|
@ -96,11 +88,10 @@ class UserInviteController extends Controller
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($exception instanceof UserTokenExpiredException) {
|
if ($exception instanceof UserTokenExpiredException) {
|
||||||
session()->flash('error', trans('errors.invite_token_expired'));
|
$this->showErrorNotification(trans('errors.invite_token_expired'));
|
||||||
return redirect('/password/email');
|
return redirect('/password/email');
|
||||||
}
|
}
|
||||||
|
|
||||||
throw $exception;
|
throw $exception;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,67 +1,46 @@
|
||||||
<?php namespace BookStack\Http\Controllers;
|
<?php namespace BookStack\Http\Controllers;
|
||||||
|
|
||||||
use Activity;
|
use Activity;
|
||||||
use BookStack\Auth\UserRepo;
|
use BookStack\Entities\Managers\BookContents;
|
||||||
use BookStack\Entities\Book;
|
use BookStack\Entities\Bookshelf;
|
||||||
use BookStack\Entities\EntityContextManager;
|
use BookStack\Entities\Managers\EntityContext;
|
||||||
use BookStack\Entities\Repos\EntityRepo;
|
use BookStack\Entities\Repos\BookRepo;
|
||||||
use BookStack\Entities\ExportService;
|
use BookStack\Exceptions\ImageUploadException;
|
||||||
use BookStack\Uploads\ImageRepo;
|
use BookStack\Exceptions\NotifyException;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Response;
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Throwable;
|
||||||
use Views;
|
use Views;
|
||||||
|
|
||||||
class BookController extends Controller
|
class BookController extends Controller
|
||||||
{
|
{
|
||||||
|
|
||||||
protected $entityRepo;
|
protected $bookRepo;
|
||||||
protected $userRepo;
|
|
||||||
protected $exportService;
|
|
||||||
protected $entityContextManager;
|
protected $entityContextManager;
|
||||||
protected $imageRepo;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BookController constructor.
|
* BookController constructor.
|
||||||
* @param EntityRepo $entityRepo
|
|
||||||
* @param UserRepo $userRepo
|
|
||||||
* @param ExportService $exportService
|
|
||||||
* @param EntityContextManager $entityContextManager
|
|
||||||
* @param ImageRepo $imageRepo
|
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(EntityContext $entityContextManager, BookRepo $bookRepo)
|
||||||
EntityRepo $entityRepo,
|
{
|
||||||
UserRepo $userRepo,
|
$this->bookRepo = $bookRepo;
|
||||||
ExportService $exportService,
|
|
||||||
EntityContextManager $entityContextManager,
|
|
||||||
ImageRepo $imageRepo
|
|
||||||
) {
|
|
||||||
$this->entityRepo = $entityRepo;
|
|
||||||
$this->userRepo = $userRepo;
|
|
||||||
$this->exportService = $exportService;
|
|
||||||
$this->entityContextManager = $entityContextManager;
|
$this->entityContextManager = $entityContextManager;
|
||||||
$this->imageRepo = $imageRepo;
|
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display a listing of the book.
|
* Display a listing of the book.
|
||||||
* @return Response
|
|
||||||
*/
|
*/
|
||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
$view = setting()->getUser($this->currentUser, 'books_view_type', config('app.views.books'));
|
$view = setting()->getForCurrentUser('books_view_type', config('app.views.books'));
|
||||||
$sort = setting()->getUser($this->currentUser, 'books_sort', 'name');
|
$sort = setting()->getForCurrentUser('books_sort', 'name');
|
||||||
$order = setting()->getUser($this->currentUser, 'books_sort_order', 'asc');
|
$order = setting()->getForCurrentUser('books_sort_order', 'asc');
|
||||||
$sortOptions = [
|
|
||||||
'name' => trans('common.sort_name'),
|
|
||||||
'created_at' => trans('common.sort_created_at'),
|
|
||||||
'updated_at' => trans('common.sort_updated_at'),
|
|
||||||
];
|
|
||||||
|
|
||||||
$books = $this->entityRepo->getAllPaginated('book', 18, $sort, $order);
|
$books = $this->bookRepo->getAllPaginated(18, $sort, $order);
|
||||||
$recents = $this->signedIn ? $this->entityRepo->getRecentlyViewed('book', 4, 0) : false;
|
$recents = $this->isSignedIn() ? $this->bookRepo->getRecentlyViewed(4) : false;
|
||||||
$popular = $this->entityRepo->getPopular('book', 4, 0);
|
$popular = $this->bookRepo->getPopular(4);
|
||||||
$new = $this->entityRepo->getRecentlyCreated('book', 4, 0);
|
$new = $this->bookRepo->getRecentlyCreated(4);
|
||||||
|
|
||||||
$this->entityContextManager->clearShelfContext();
|
$this->entityContextManager->clearShelfContext();
|
||||||
|
|
||||||
|
@ -74,25 +53,22 @@ class BookController extends Controller
|
||||||
'view' => $view,
|
'view' => $view,
|
||||||
'sort' => $sort,
|
'sort' => $sort,
|
||||||
'order' => $order,
|
'order' => $order,
|
||||||
'sortOptions' => $sortOptions,
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show the form for creating a new book.
|
* Show the form for creating a new book.
|
||||||
* @param string $shelfSlug
|
|
||||||
* @return Response
|
|
||||||
* @throws \BookStack\Exceptions\NotFoundException
|
|
||||||
*/
|
*/
|
||||||
public function create(string $shelfSlug = null)
|
public function create(string $shelfSlug = null)
|
||||||
{
|
{
|
||||||
|
$this->checkPermission('book-create-all');
|
||||||
|
|
||||||
$bookshelf = null;
|
$bookshelf = null;
|
||||||
if ($shelfSlug !== null) {
|
if ($shelfSlug !== null) {
|
||||||
$bookshelf = $this->entityRepo->getBySlug('bookshelf', $shelfSlug);
|
$bookshelf = Bookshelf::visible()->where('slug', '=', $shelfSlug)->firstOrFail();
|
||||||
$this->checkOwnablePermission('bookshelf-update', $bookshelf);
|
$this->checkOwnablePermission('bookshelf-update', $bookshelf);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->checkPermission('book-create-all');
|
|
||||||
$this->setPageTitle(trans('entities.books_create'));
|
$this->setPageTitle(trans('entities.books_create'));
|
||||||
return view('books.create', [
|
return view('books.create', [
|
||||||
'bookshelf' => $bookshelf
|
'bookshelf' => $bookshelf
|
||||||
|
@ -101,12 +77,8 @@ class BookController extends Controller
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Store a newly created book in storage.
|
* Store a newly created book in storage.
|
||||||
*
|
* @throws ImageUploadException
|
||||||
* @param Request $request
|
* @throws ValidationException
|
||||||
* @param string $shelfSlug
|
|
||||||
* @return Response
|
|
||||||
* @throws \BookStack\Exceptions\NotFoundException
|
|
||||||
* @throws \BookStack\Exceptions\ImageUploadException
|
|
||||||
*/
|
*/
|
||||||
public function store(Request $request, string $shelfSlug = null)
|
public function store(Request $request, string $shelfSlug = null)
|
||||||
{
|
{
|
||||||
|
@ -114,21 +86,21 @@ class BookController extends Controller
|
||||||
$this->validate($request, [
|
$this->validate($request, [
|
||||||
'name' => 'required|string|max:255',
|
'name' => 'required|string|max:255',
|
||||||
'description' => 'string|max:1000',
|
'description' => 'string|max:1000',
|
||||||
'image' => $this->imageRepo->getImageValidationRules(),
|
'image' => $this->getImageValidationRules(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$bookshelf = null;
|
$bookshelf = null;
|
||||||
if ($shelfSlug !== null) {
|
if ($shelfSlug !== null) {
|
||||||
$bookshelf = $this->entityRepo->getBySlug('bookshelf', $shelfSlug);
|
$bookshelf = Bookshelf::visible()->where('slug', '=', $shelfSlug)->firstOrFail();
|
||||||
$this->checkOwnablePermission('bookshelf-update', $bookshelf);
|
$this->checkOwnablePermission('bookshelf-update', $bookshelf);
|
||||||
}
|
}
|
||||||
|
|
||||||
$book = $this->entityRepo->createFromInput('book', $request->all());
|
$book = $this->bookRepo->create($request->all());
|
||||||
$this->bookUpdateActions($book, $request);
|
$this->bookRepo->updateCoverImage($book, $request->file('image', null));
|
||||||
Activity::add($book, 'book_create', $book->id);
|
Activity::add($book, 'book_create', $book->id);
|
||||||
|
|
||||||
if ($bookshelf) {
|
if ($bookshelf) {
|
||||||
$this->entityRepo->appendBookToShelf($bookshelf, $book);
|
$bookshelf->appendBook($book);
|
||||||
Activity::add($bookshelf, 'bookshelf_update');
|
Activity::add($bookshelf, 'bookshelf_update');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -137,17 +109,11 @@ class BookController extends Controller
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display the specified book.
|
* 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);
|
$book = $this->bookRepo->getBySlug($slug);
|
||||||
$this->checkOwnablePermission('book-view', $book);
|
$bookChildren = (new BookContents($book))->getTree(true);
|
||||||
|
|
||||||
$bookChildren = $this->entityRepo->getBookChildren($book);
|
|
||||||
|
|
||||||
Views::add($book);
|
Views::add($book);
|
||||||
if ($request->has('shelf')) {
|
if ($request->has('shelf')) {
|
||||||
|
@ -165,12 +131,10 @@ class BookController extends Controller
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show the form for editing the specified book.
|
* 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->checkOwnablePermission('book-update', $book);
|
||||||
$this->setPageTitle(trans('entities.books_edit_named', ['bookName'=>$book->getShortName()]));
|
$this->setPageTitle(trans('entities.books_edit_named', ['bookName'=>$book->getShortName()]));
|
||||||
return view('books.edit', ['book' => $book, 'current' => $book]);
|
return view('books.edit', ['book' => $book, 'current' => $book]);
|
||||||
|
@ -178,254 +142,83 @@ class BookController extends Controller
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the specified book in storage.
|
* Update the specified book in storage.
|
||||||
* @param Request $request
|
* @throws ImageUploadException
|
||||||
* @param $slug
|
* @throws ValidationException
|
||||||
* @return Response
|
* @throws Throwable
|
||||||
* @throws \BookStack\Exceptions\ImageUploadException
|
|
||||||
* @throws \BookStack\Exceptions\NotFoundException
|
|
||||||
*/
|
*/
|
||||||
public function update(Request $request, string $slug)
|
public function update(Request $request, string $slug)
|
||||||
{
|
{
|
||||||
$book = $this->entityRepo->getBySlug('book', $slug);
|
$book = $this->bookRepo->getBySlug($slug);
|
||||||
$this->checkOwnablePermission('book-update', $book);
|
$this->checkOwnablePermission('book-update', $book);
|
||||||
$this->validate($request, [
|
$this->validate($request, [
|
||||||
'name' => 'required|string|max:255',
|
'name' => 'required|string|max:255',
|
||||||
'description' => 'string|max:1000',
|
'description' => 'string|max:1000',
|
||||||
'image' => $this->imageRepo->getImageValidationRules(),
|
'image' => $this->getImageValidationRules(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$book = $this->entityRepo->updateFromInput('book', $book, $request->all());
|
$book = $this->bookRepo->update($book, $request->all());
|
||||||
$this->bookUpdateActions($book, $request);
|
$resetCover = $request->has('image_reset');
|
||||||
|
$this->bookRepo->updateCoverImage($book, $request->file('image', null), $resetCover);
|
||||||
|
|
||||||
Activity::add($book, 'book_update', $book->id);
|
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);
|
|
||||||
});
|
|
||||||
|
|
||||||
return redirect($book->getUrl());
|
return redirect($book->getUrl());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove the specified book from storage.
|
* Shows the page to confirm deletion.
|
||||||
* @param $bookSlug
|
|
||||||
* @return Response
|
|
||||||
*/
|
*/
|
||||||
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);
|
$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);
|
* Remove the specified book from the system.
|
||||||
}
|
* @throws Throwable
|
||||||
$this->entityRepo->destroyBook($book);
|
* @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');
|
return redirect('/books');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show the Restrictions view.
|
* Show the permissions view.
|
||||||
* @param $bookSlug
|
|
||||||
* @return \Illuminate\Contracts\View\Factory|\Illuminate\View\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);
|
$this->checkOwnablePermission('restrictions-manage', $book);
|
||||||
$roles = $this->userRepo->getRestrictableRoles();
|
|
||||||
return view('books.permissions', [
|
return view('books.permissions', [
|
||||||
'book' => $book,
|
'book' => $book,
|
||||||
'roles' => $roles
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set the restrictions for this book.
|
* Set the restrictions for this book.
|
||||||
* @param $bookSlug
|
* @throws Throwable
|
||||||
* @param Request $request
|
|
||||||
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
|
|
||||||
* @throws \BookStack\Exceptions\NotFoundException
|
|
||||||
* @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->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());
|
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