Compare commits

..

1 Commits

Author SHA1 Message Date
Dan Brown
c49454da28 Added crude example of captcha usage 2019-09-09 21:19:08 +01:00
983 changed files with 21991 additions and 51133 deletions

View File

@@ -1,24 +1,14 @@
# This file, when named as ".env" in the root of your BookStack install
# folder, is used for the core configuration of the application.
# By default this file contains the most common required options but
# a full list of options can be found in the '.env.example.complete' file.
# NOTE: If any of your values contain a space or a hash you will need to
# wrap the entire value in quotes. (eg. MAIL_FROM_NAME="BookStack Mailer")
# Application key
# Used for encryption where needed.
# Run `php artisan key:generate` to generate a valid key.
APP_KEY=SomeRandomString
# Application URL
# Remove the hash below and set a URL if using BookStack behind
# a proxy, if using a third-party authentication option.
# This must be the root URL that you want to host BookStack on.
# All URLs in BookStack will be generated using this value
# to ensure URLs generated are consistent and secure.
# If you change this in the future you may need to run a command
# to update stored URLs in the database. Command example:
# php artisan bookstack:update-url https://old.example.com https://new.example.com
APP_URL=https://example.com
# All URL's in BookStack will be generated using this value.
#APP_URL=https://example.com
# Database details
DB_HOST=localhost
@@ -27,18 +17,15 @@ DB_USERNAME=database_username
DB_PASSWORD=database_user_password
# Mail system to use
# Can be 'smtp' or 'sendmail'
# Can be 'smtp', 'mail' or 'sendmail'
MAIL_DRIVER=smtp
# Mail sender details
MAIL_FROM_NAME="BookStack"
MAIL_FROM=bookstack@example.com
# SMTP mail options
# These settings can be checked using the "Send a Test Email"
# feature found in the "Settings > Maintenance" area of the system.
MAIL_HOST=localhost
MAIL_PORT=1025
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
MAIL_ENCRYPTION=null
# A full list of options can be found in the '.env.example.complete' file.

View File

@@ -37,11 +37,6 @@ APP_AUTO_LANG_PUBLIC=true
# Valid timezone values can be found here: https://www.php.net/manual/en/timezones.php
APP_TIMEZONE=UTC
# Application theme
# Used to specific a themes/<APP_THEME> folder where BookStack UI
# overrides can be made. Defaults to disabled.
APP_THEME=false
# Database details
# Host can contain a port (localhost:3306) or a separate DB_PORT option can be used.
DB_HOST=localhost
@@ -94,7 +89,7 @@ REDIS_SERVERS=127.0.0.1:6379:0
# Queue driver to use
# Queue not really currently used but may be configurable in the future.
# Would advise not to change this for now.
QUEUE_CONNECTION=sync
QUEUE_DRIVER=sync
# Storage system to use
# Can be 'local', 'local_secure' or 's3'
@@ -126,7 +121,7 @@ STORAGE_S3_ENDPOINT=https://my-custom-s3-compatible.service.com:8001
STORAGE_URL=false
# Authentication method to use
# Can be 'standard', 'ldap' or 'saml2'
# Can be 'standard' or 'ldap'
AUTH_METHOD=standard
# Social authentication configuration
@@ -196,11 +191,9 @@ LDAP_PASS=false
LDAP_USER_FILTER=false
LDAP_VERSION=false
LDAP_TLS_INSECURE=false
LDAP_ID_ATTRIBUTE=uid
LDAP_EMAIL_ATTRIBUTE=mail
LDAP_DISPLAY_NAME_ATTRIBUTE=cn
LDAP_FOLLOW_REFERRALS=true
LDAP_DUMP_USER_DETAILS=false
# LDAP group sync configuration
# Refer to https://www.bookstackapp.com/docs/admin/ldap-auth/
@@ -208,26 +201,6 @@ LDAP_USER_TO_GROUPS=false
LDAP_GROUP_ATTRIBUTE="memberOf"
LDAP_REMOVE_FROM_GROUPS=false
# SAML authentication configuration
# Refer to https://www.bookstackapp.com/docs/admin/saml2-auth/
SAML2_NAME=SSO
SAML2_EMAIL_ATTRIBUTE=email
SAML2_DISPLAY_NAME_ATTRIBUTES=username
SAML2_EXTERNAL_ID_ATTRIBUTE=null
SAML2_IDP_ENTITYID=null
SAML2_IDP_SSO=null
SAML2_IDP_SLO=null
SAML2_IDP_x509=null
SAML2_ONELOGIN_OVERRIDES=null
SAML2_DUMP_USER_DETAILS=false
SAML2_AUTOLOAD_METADATA=false
# SAML group sync configuration
# Refer to https://www.bookstackapp.com/docs/admin/saml2-auth/
SAML2_USER_TO_GROUPS=false
SAML2_GROUP_ATTRIBUTE=group
SAML2_REMOVE_FROM_GROUPS=false
# Disable default third-party services such as Gravatar and Draw.IO
# Service-specific options will override this option
DISABLE_EXTERNAL_SERVICES=false
@@ -238,10 +211,7 @@ DISABLE_EXTERNAL_SERVICES=false
# Example: AVATAR_URL=https://seccdn.libravatar.org/avatar/${hash}?s=${size}&d=identicon
AVATAR_URL=
# Enable diagrams.net integration
# Can simply be true/false to enable/disable the integration.
# Alternatively, It can be URL to the diagrams.net instance you want to use.
# For URLs, The following URL parameters should be included: embed=1&proto=json&spin=1
# Enable Draw.io integration
DRAWIO=true
# Default item listing view
@@ -255,14 +225,6 @@ APP_VIEWS_BOOKSHELVES=grid
# If set to 'false' a limit will not be enforced.
REVISION_LIMIT=50
# Recycle Bin Lifetime
# The number of days that content will remain in the recycle bin before
# being considered for auto-removal. It is not a guarantee that content will
# be removed after this time.
# Set to 0 for no recycle bin functionality.
# Set to -1 for unlimited recycle bin lifetime.
RECYCLE_BIN_LIFETIME=30
# Allow <script> tags in page content
# Note, if set to 'true' the page editor may still escape scripts.
ALLOW_CONTENT_SCRIPTS=false
@@ -273,23 +235,3 @@ ALLOW_CONTENT_SCRIPTS=false
# Contents of the robots.txt file can be overridden, making this option obsolete.
ALLOW_ROBOTS=null
# A list of hosts that BookStack can be iframed within.
# Space separated if multiple. BookStack host domain is auto-inferred.
# For Example: ALLOWED_IFRAME_HOSTS="https://example.com https://a.example.com"
# Setting this option will also auto-adjust cookies to be SameSite=None.
ALLOWED_IFRAME_HOSTS=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
# Enable the logging of failed email+password logins with the given message.
# The default log channel below uses the php 'error_log' function which commonly
# results in messages being output to the webserver error logs.
# The message can contain a %u parameter which will be replaced with the login
# user identifier (Username or email).
LOG_FAILED_LOGIN_MESSAGE=false
LOG_FAILED_LOGIN_CHANNEL=errorlog_plain_webserver

3
.github/FUNDING.yml vendored
View File

@@ -1,3 +0,0 @@
# These are supported funding model platforms
github: [ssddanbrown]

View File

@@ -1,17 +0,0 @@
---
name: New API Endpoint or Feature
about: Request a new endpoint or API feature be added
labels: ":nut_and_bolt: API Request"
---
#### API Endpoint or Feature
Clearly describe what you'd like to have added to the API.
#### Use-Case
Explain the use-case that you're working-on that requires the above request.
#### Additional Context
If required, add any other context about the feature request here.

View File

@@ -1,5 +1,5 @@
---
name: Bug Report
name: Bug report
about: Create a report to help us improve
---

View File

@@ -1,5 +1,5 @@
---
name: Feature Request
name: Feature request
about: Suggest an idea for this project
---

View File

@@ -1,13 +0,0 @@
---
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._

View File

@@ -1,135 +0,0 @@
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; German Informal
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 Informal; German
Gustav Kånåhols (Kurbitz) :: Swedish
Vuong Trung Hieu (fpooon) :: Vietnamese
Emil Petersen (emoyly) :: Danish
mrjaboozy :: Slovenian
Statium :: Russian
Mikkel Struntze (MStruntze) :: Danish
kostefun :: Russian
Tuyen.NG (tuyendev) :: Vietnamese
Ghost_chu (dbguichu) :: Chinese Simplified
Ziipen :: Danish
Samuel Schwarz (Guiph7quan) :: Czech
Aleph (toishoki) :: Turkish
Julio Alberto García (Yllelder) :: Spanish
Rafael (raribeir) :: Portuguese, Brazilian
Hiroyuki Odake (dakesan) :: Japanese
Alex Lee (qianmengnet) :: Chinese Simplified
swinn37 :: French
Hasan Özbey (the-turk) :: Turkish
rcy :: Swedish
Ali Yasir Yılmaz (ayyilmaz) :: Turkish
scureza :: Italian
Biepa :: German Informal; German
syecu :: Chinese Simplified
Lap1t0r :: French
Thinkverse (thinkverse) :: Swedish
alef (toishoki) :: Turkish
Robbert Feunekes (Muukuro) :: Dutch
seohyeon.joo :: Korean
Orenda (OREDNA) :: Bulgarian
Marek Pavelka (marapavelka) :: Czech
Venkinovec :: Czech
Tommy Ku (tommyku) :: Chinese Traditional; Japanese
Michał Bielejewski (bielej) :: Polish
jozefrebjak :: Slovak
Ikhwan Koo (Ikhwan.Koo) :: Korean
Whay (remkovdhoef) :: Dutch
jc7115 :: Chinese Traditional
주서현 (seohyeon.joo) :: Korean
ReadySystems :: Arabic
HFinch :: German; German Informal
brechtgijsens :: Dutch
Lowkey (v587ygq) :: Chinese Simplified
sdl-blue :: German Informal
sqlik :: Polish
Roy van Schaijk (royvanschaijk) :: Dutch
Simsimpicpic :: French
Zenahr Barzani (Zenahr) :: German; Japanese; Dutch; German Informal
tatsuya.info :: Japanese
fadiapp :: Arabic
Jakub Bouček (jakubboucek) :: Czech
Marco (cdrfun) :: German
10935336 :: Chinese Simplified
孟繁阳 (FanyangMeng) :: Chinese Simplified
Andrej Močan (andrejm) :: Slovenian
gilane9_ :: Arabic
Raed alnahdi (raednahdi) :: Arabic
Xiphoseer :: German
MerlinSVK (merlinsvk) :: Slovak
Kauê Sena (kaue.sena.ks) :: Portuguese, Brazilian
MatthieuParis :: French
Douradinho :: Portuguese, Brazilian
Gaku Yaguchi (tama11) :: Japanese
johnroyer :: Chinese Traditional

View File

@@ -1,54 +0,0 @@
name: phpunit
on:
push:
branches:
- master
- release
pull_request:
branches:
- '*'
- '*/*'
- '!l10n_master'
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
php: [7.2, 7.4]
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: Start Database
run: |
sudo /etc/init.d/mysql start
- 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

View File

@@ -1,58 +0,0 @@
name: test-migrations
on:
push:
branches:
- master
- release
pull_request:
branches:
- '*'
- '*/*'
- '!l10n_master'
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
php: [7.2, 7.4]
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: Start MySQL
run: |
sudo /etc/init.d/mysql start
- name: Create database & user
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
run: composer install --prefer-dist --no-interaction --ansi
- name: Start migration test
run: |
php${{ matrix.php }} artisan migrate --force -n --database=mysql_testing
- name: Start migration:rollback test
run: |
php${{ matrix.php }} artisan migrate:rollback --force -n --database=mysql_testing
- name: Start migration rerun test
run: |
php${{ matrix.php }} artisan migrate --force -n --database=mysql_testing

10
.gitignore vendored
View File

@@ -5,10 +5,10 @@ Homestead.yaml
.idea
npm-debug.log
yarn-error.log
/public/dist/*.map
/public/dist
/public/plugins
/public/css/*.map
/public/js/*.map
/public/css
/public/js
/public/bower
/public/build/
/storage/images
@@ -21,6 +21,4 @@ nbproject
.buildpath
.project
.settings/
webpack-stats.json
.phpunit.result.cache
.DS_Store
webpack-stats.json

28
.travis.yml Normal file
View File

@@ -0,0 +1,28 @@
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

View File

@@ -1,6 +1,6 @@
The MIT License (MIT)
Copyright (c) 2020 Dan Brown and the BookStack Project contributors
Copyright (c) 2018 Dan Brown and the BookStack Project contributors
https://github.com/BookStackApp/BookStack/graphs/contributors
Permission is hereby granted, free of charge, to any person obtaining a copy

View File

@@ -3,19 +3,13 @@
namespace BookStack\Actions;
use BookStack\Auth\User;
use BookStack\Entities\Models\Entity;
use BookStack\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
/**
* @property string $type
* @property User $user
* @property Entity $entity
* @property string $detail
* @property string $entity_type
* @property int $entity_id
* @property int $user_id
* @property string key
* @property \User user
* @property \Entity entity
* @property string extra
*/
class Activity extends Model
{
@@ -33,35 +27,29 @@ class Activity extends Model
/**
* Get the user this activity relates to.
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function user(): BelongsTo
public function user()
{
return $this->belongsTo(User::class);
}
/**
* Returns text from the language files, Looks up by using the activity key.
* Returns text from the language files, Looks up by using the
* activity key.
*/
public function getText(): string
public function getText()
{
return trans('activities.' . $this->type);
}
/**
* Check if this activity is intended to be for an entity.
*/
public function isForEntity(): bool
{
return Str::startsWith($this->type, [
'page_', 'chapter_', 'book_', 'bookshelf_'
]);
return trans('activities.' . $this->key);
}
/**
* Checks if another Activity matches the general information of another.
* @param $activityB
* @return bool
*/
public function isSimilarTo(Activity $activityB): bool
public function isSimilarTo($activityB)
{
return [$this->type, $this->entity_type, $this->entity_id] === [$activityB->type, $activityB->entity_type, $activityB->entity_id];
return [$this->key, $this->entity_type, $this->entity_id] === [$activityB->key, $activityB->entity_type, $activityB->entity_id];
}
}

View File

@@ -1,89 +1,96 @@
<?php namespace BookStack\Actions;
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Auth\User;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Interfaces\Loggable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Facades\Log;
use BookStack\Entities\Entity;
use Session;
class ActivityService
{
protected $activity;
protected $user;
protected $permissionService;
/**
* ActivityService constructor.
* @param \BookStack\Actions\Activity $activity
* @param PermissionService $permissionService
*/
public function __construct(Activity $activity, PermissionService $permissionService)
{
$this->activity = $activity;
$this->permissionService = $permissionService;
$this->user = user();
}
/**
* Add activity data to database for an entity.
* Add activity data to database.
* @param Entity $entity
* @param $activityKey
* @param int $bookId
* @param bool $extra
*/
public function addForEntity(Entity $entity, string $type)
public function add(Entity $entity, $activityKey, $bookId = 0, $extra = false)
{
$activity = $this->newActivityForUser($type);
$entity->activity()->save($activity);
$this->setNotification($type);
}
/**
* Add a generic activity event to the database.
* @param string|Loggable $detail
*/
public function add(string $type, $detail = '')
{
if ($detail instanceof Loggable) {
$detail = $detail->logDescriptor();
$activity = $this->activity->newInstance();
$activity->user_id = $this->user->id;
$activity->book_id = $bookId;
$activity->key = strtolower($activityKey);
if ($extra !== false) {
$activity->extra = $extra;
}
$activity = $this->newActivityForUser($type);
$activity->detail = $detail;
$activity->save();
$this->setNotification($type);
$entity->activity()->save($activity);
$this->setNotification($activityKey);
}
/**
* Get a new activity instance for the current user.
* Adds a activity history with a message & without binding to a entity.
* @param $activityKey
* @param int $bookId
* @param bool|false $extra
*/
protected function newActivityForUser(string $type): Activity
public function addMessage($activityKey, $bookId = 0, $extra = false)
{
return $this->activity->newInstance()->forceFill([
'type' => strtolower($type),
'user_id' => user()->id,
]);
$this->activity->user_id = $this->user->id;
$this->activity->book_id = $bookId;
$this->activity->key = strtolower($activityKey);
if ($extra !== false) {
$this->activity->extra = $extra;
}
$this->activity->save();
$this->setNotification($activityKey);
}
/**
* Removes the entity attachment from each of its activities
* and instead uses the 'extra' field with the entities name.
* Used when an entity is deleted.
* @param Entity $entity
* @return mixed
*/
public function removeEntity(Entity $entity)
{
$entity->activity()->update([
'detail' => $entity->name,
'entity_id' => null,
'entity_type' => null,
]);
$activities = $entity->activity;
foreach ($activities as $activity) {
$activity->extra = $entity->name;
$activity->entity_id = 0;
$activity->entity_type = null;
$activity->save();
}
return $activities;
}
/**
* Gets the latest activity.
* @param int $count
* @param int $page
* @return array
*/
public function latest(int $count = 20, int $page = 0): array
public function latest($count = 20, $page = 0)
{
$activityList = $this->permissionService
->filterRestrictedEntityRelations($this->activity, 'activities', 'entity_id', 'entity_type')
->orderBy('created_at', 'desc')
->with(['user', 'entity'])
->skip($count * $page)
->take($count)
->get();
->orderBy('created_at', 'desc')->with('user', 'entity')->skip($count * $page)->take($count)->get();
return $this->filterSimilar($activityList);
}
@@ -91,33 +98,24 @@ class ActivityService
/**
* Gets the latest activity for an entity, Filtering out similar
* items to prevent a message activity list.
* @param Entity $entity
* @param int $count
* @param int $page
* @return array
*/
public function entityActivity(Entity $entity, int $count = 20, int $page = 1): array
public function entityActivity($entity, $count = 20, $page = 1)
{
/** @var [string => int[]] $queryIds */
$queryIds = [$entity->getMorphClass() => [$entity->id]];
if ($entity->isA('book')) {
$queryIds[(new Chapter)->getMorphClass()] = $entity->chapters()->visible()->pluck('id');
$query = $this->activity->where('book_id', '=', $entity->id);
} else {
$query = $this->activity->where('entity_type', '=', $entity->getMorphClass())
->where('entity_id', '=', $entity->id);
}
if ($entity->isA('book') || $entity->isA('chapter')) {
$queryIds[(new Page)->getMorphClass()] = $entity->pages()->visible()->pluck('id');
}
$query = $this->activity->newQuery();
$query->where(function (Builder $query) use ($queryIds) {
foreach ($queryIds as $morphClass => $idArr) {
$query->orWhere(function (Builder $innerQuery) use ($morphClass, $idArr) {
$innerQuery->where('entity_type', '=', $morphClass)
->whereIn('entity_id', $idArr);
});
}
});
$activity = $query->orderBy('created_at', 'desc')
->with(['entity' => function (Relation $query) {
$query->withTrashed();
}, 'user.avatar'])
$activity = $this->permissionService
->filterRestrictedEntityRelations($query, 'activities', 'entity_id', 'entity_type')
->orderBy('created_at', 'desc')
->with(['entity', 'user.avatar'])
->skip($count * ($page - 1))
->take($count)
->get();
@@ -126,18 +124,18 @@ class ActivityService
}
/**
* Get latest activity for a user, Filtering out similar items.
* Get latest activity for a user, Filtering out similar
* items.
* @param $user
* @param int $count
* @param int $page
* @return array
*/
public function userActivity(User $user, int $count = 20, int $page = 0): array
public function userActivity($user, $count = 20, $page = 0)
{
$activityList = $this->permissionService
->filterRestrictedEntityRelations($this->activity, 'activities', 'entity_id', 'entity_type')
->orderBy('created_at', 'desc')
->where('user_id', '=', $user->id)
->skip($count * $page)
->take($count)
->get();
->orderBy('created_at', 'desc')->where('user_id', '=', $user->id)->skip($count * $page)->take($count)->get();
return $this->filterSimilar($activityList);
}
@@ -146,47 +144,34 @@ class ActivityService
* @param Activity[] $activities
* @return array
*/
protected function filterSimilar(iterable $activities): array
protected function filterSimilar($activities)
{
$newActivity = [];
$previousItem = null;
$previousItem = false;
foreach ($activities as $activityItem) {
if (!$previousItem || !$activityItem->isSimilarTo($previousItem)) {
if ($previousItem === false) {
$previousItem = $activityItem;
$newActivity[] = $activityItem;
continue;
}
if (!$activityItem->isSimilarTo($previousItem)) {
$newActivity[] = $activityItem;
}
$previousItem = $activityItem;
}
return $newActivity;
}
/**
* Flashes a notification message to the session if an appropriate message is available.
* @param $activityKey
*/
protected function setNotification(string $type)
protected function setNotification($activityKey)
{
$notificationTextKey = 'activities.' . $type . '_notification';
$notificationTextKey = 'activities.' . $activityKey . '_notification';
if (trans()->has($notificationTextKey)) {
$message = trans($notificationTextKey);
session()->flash('success', $message);
Session::flash('success', $message);
}
}
/**
* Log out a failed login attempt, Providing the given username
* as part of the message if the '%u' string is used.
*/
public function logFailedLogin(string $username)
{
$message = config('logging.failed_login.message');
if (!$message) {
return;
}
$message = str_replace("%u", $username, $message);
$channel = config('logging.failed_login.channel');
Log::channel($channel)->warning($message);
}
}

View File

@@ -1,51 +0,0 @@
<?php namespace BookStack\Actions;
class ActivityType
{
const PAGE_CREATE = 'page_create';
const PAGE_UPDATE = 'page_update';
const PAGE_DELETE = 'page_delete';
const PAGE_RESTORE = 'page_restore';
const PAGE_MOVE = 'page_move';
const CHAPTER_CREATE = 'chapter_create';
const CHAPTER_UPDATE = 'chapter_update';
const CHAPTER_DELETE = 'chapter_delete';
const CHAPTER_MOVE = 'chapter_move';
const BOOK_CREATE = 'book_create';
const BOOK_UPDATE = 'book_update';
const BOOK_DELETE = 'book_delete';
const BOOK_SORT = 'book_sort';
const BOOKSHELF_CREATE = 'bookshelf_create';
const BOOKSHELF_UPDATE = 'bookshelf_update';
const BOOKSHELF_DELETE = 'bookshelf_delete';
const COMMENTED_ON = 'commented_on';
const PERMISSIONS_UPDATE = 'permissions_update';
const SETTINGS_UPDATE = 'settings_update';
const MAINTENANCE_ACTION_RUN = 'maintenance_action_run';
const RECYCLE_BIN_EMPTY = 'recycle_bin_empty';
const RECYCLE_BIN_RESTORE = 'recycle_bin_restore';
const RECYCLE_BIN_DESTROY = 'recycle_bin_destroy';
const USER_CREATE = 'user_create';
const USER_UPDATE = 'user_update';
const USER_DELETE = 'user_delete';
const API_TOKEN_CREATE = 'api_token_create';
const API_TOKEN_UPDATE = 'api_token_update';
const API_TOKEN_DELETE = 'api_token_delete';
const ROLE_CREATE = 'role_create';
const ROLE_UPDATE = 'role_update';
const ROLE_DELETE = 'role_delete';
const AUTH_PASSWORD_RESET = 'auth_password_reset_request';
const AUTH_PASSWORD_RESET_UPDATE = 'auth_password_reset_update';
const AUTH_LOGIN = 'auth_login';
const AUTH_REGISTER = 'auth_register';
}

View File

@@ -1,34 +1,26 @@
<?php namespace BookStack\Actions;
use BookStack\Model;
use BookStack\Traits\HasCreatorAndUpdater;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use BookStack\Ownable;
/**
* @property string text
* @property string html
* @property int|null parent_id
* @property int local_id
*/
class Comment extends Model
class Comment extends Ownable
{
use HasCreatorAndUpdater;
protected $fillable = ['text', 'parent_id'];
protected $fillable = ['text', 'html', 'parent_id'];
protected $appends = ['created', 'updated'];
/**
* Get the entity that this comment belongs to
* @return \Illuminate\Database\Eloquent\Relations\MorphTo
*/
public function entity(): MorphTo
public function entity()
{
return $this->morphTo('entity');
}
/**
* Check if a comment has been updated since creation.
* @return bool
*/
public function isUpdated(): bool
public function isUpdated()
{
return $this->updated_at->timestamp > $this->created_at->timestamp;
}

View File

@@ -1,21 +1,23 @@
<?php namespace BookStack\Actions;
use BookStack\Entities\Models\Entity;
use League\CommonMark\CommonMarkConverter;
use BookStack\Facades\Activity as ActivityService;
use BookStack\Entities\Entity;
/**
* Class CommentRepo
* @package BookStack\Repos
*/
class CommentRepo
{
/**
* @var Comment $comment
* @var \BookStack\Actions\Comment $comment
*/
protected $comment;
/**
* CommentRepo constructor.
* @param \BookStack\Actions\Comment $comment
*/
public function __construct(Comment $comment)
{
$this->comment = $comment;
@@ -23,72 +25,65 @@ class CommentRepo
/**
* Get a comment by ID.
* @param $id
* @return \BookStack\Actions\Comment|\Illuminate\Database\Eloquent\Model
*/
public function getById(int $id): Comment
public function getById($id)
{
return $this->comment->newQuery()->findOrFail($id);
}
/**
* Create a new comment on an entity.
* @param \BookStack\Entities\Entity $entity
* @param array $data
* @return \BookStack\Actions\Comment
*/
public function create(Entity $entity, string $text, ?int $parent_id): Comment
public function create(Entity $entity, $data = [])
{
$userId = user()->id;
$comment = $this->comment->newInstance();
$comment->text = $text;
$comment->html = $this->commentToHtml($text);
$comment = $this->comment->newInstance($data);
$comment->created_by = $userId;
$comment->updated_by = $userId;
$comment->local_id = $this->getNextLocalId($entity);
$comment->parent_id = $parent_id;
$entity->comments()->save($comment);
ActivityService::addForEntity($entity, ActivityType::COMMENTED_ON);
return $comment;
}
/**
* Update an existing comment.
* @param \BookStack\Actions\Comment $comment
* @param array $input
* @return mixed
*/
public function update(Comment $comment, string $text): Comment
public function update($comment, $input)
{
$comment->updated_by = user()->id;
$comment->text = $text;
$comment->html = $this->commentToHtml($text);
$comment->save();
$comment->update($input);
return $comment;
}
/**
* Delete a comment from the system.
* @param \BookStack\Actions\Comment $comment
* @return mixed
*/
public function delete(Comment $comment)
public function delete($comment)
{
$comment->delete();
}
/**
* Convert the given comment markdown text to HTML.
*/
public function commentToHtml(string $commentText): string
{
$converter = new CommonMarkConverter([
'html_input' => 'strip',
'max_nesting_level' => 10,
'allow_unsafe_links' => false,
]);
return $converter->convertToHtml($commentText);
return $comment->delete();
}
/**
* Get the next local ID relative to the linked entity.
* @param \BookStack\Entities\Entity $entity
* @return int
*/
protected function getNextLocalId(Entity $entity): int
protected function getNextLocalId(Entity $entity)
{
$comments = $entity->comments(false)->orderBy('local_id', 'desc')->first();
return ($comments->local_id ?? 0) + 1;
if ($comments === null) {
return 1;
}
return $comments->local_id + 1;
}
}

View File

@@ -2,10 +2,13 @@
use BookStack\Model;
/**
* Class Attribute
* @package BookStack
*/
class Tag extends Model
{
protected $fillable = ['name', 'value', 'order'];
protected $hidden = ['id', 'entity_id', 'entity_type', 'created_at', 'updated_at'];
/**
* Get the entity that this tag belongs to

View File

@@ -1,32 +1,72 @@
<?php namespace BookStack\Actions;
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Entities\Models\Entity;
use DB;
use Illuminate\Support\Collection;
use BookStack\Entities\Entity;
/**
* Class TagRepo
* @package BookStack\Repos
*/
class TagRepo
{
protected $tag;
protected $entity;
protected $permissionService;
/**
* TagRepo constructor.
* @param \BookStack\Actions\Tag $attr
* @param \BookStack\Entities\Entity $ent
* @param \BookStack\Auth\Permissions\PermissionService $ps
*/
public function __construct(Tag $tag, PermissionService $ps)
public function __construct(Tag $attr, Entity $ent, PermissionService $ps)
{
$this->tag = $tag;
$this->tag = $attr;
$this->entity = $ent;
$this->permissionService = $ps;
}
/**
* Get an entity instance of its particular type.
* @param $entityType
* @param $entityId
* @param string $action
* @return \Illuminate\Database\Eloquent\Model|null|static
*/
public function getEntity($entityType, $entityId, $action = 'view')
{
$entityInstance = $this->entity->getEntityInstance($entityType);
$searchQuery = $entityInstance->where('id', '=', $entityId)->with('tags');
$searchQuery = $this->permissionService->enforceEntityRestrictions($entityType, $searchQuery, $action);
return $searchQuery->first();
}
/**
* Get all tags for a particular entity.
* @param string $entityType
* @param int $entityId
* @return mixed
*/
public function getForEntity($entityType, $entityId)
{
$entity = $this->getEntity($entityType, $entityId);
if ($entity === null) {
return collect();
}
return $entity->tags;
}
/**
* Get tag name suggestions from scanning existing tag names.
* If no search term is given the 50 most popular tag names are provided.
* @param $searchTerm
* @return array
*/
public function getNameSuggestions(?string $searchTerm): Collection
public function getNameSuggestions($searchTerm = false)
{
$query = $this->tag->select('*', DB::raw('count(*) as count'))->groupBy('name');
$query = $this->tag->select('*', \DB::raw('count(*) as count'))->groupBy('name');
if ($searchTerm) {
$query = $query->where('name', 'LIKE', $searchTerm . '%')->orderBy('name', 'desc');
@@ -42,10 +82,13 @@ class TagRepo
* Get tag value suggestions from scanning existing tag values.
* If no search is given the 50 most popular values are provided.
* Passing a tagName will only find values for a tags with a particular name.
* @param $searchTerm
* @param $tagName
* @return array
*/
public function getValueSuggestions(?string $searchTerm, ?string $tagName): Collection
public function getValueSuggestions($searchTerm = false, $tagName = false)
{
$query = $this->tag->select('*', DB::raw('count(*) as count'))->groupBy('value');
$query = $this->tag->select('*', \DB::raw('count(*) as count'))->groupBy('value');
if ($searchTerm) {
$query = $query->where('value', 'LIKE', $searchTerm . '%')->orderBy('value', 'desc');
@@ -53,7 +96,7 @@ class TagRepo
$query = $query->orderBy('count', 'desc')->take(50);
}
if ($tagName) {
if ($tagName !== false) {
$query = $query->where('name', '=', $tagName);
}
@@ -63,28 +106,35 @@ class TagRepo
/**
* Save an array of tags to an entity
* @param \BookStack\Entities\Entity $entity
* @param array $tags
* @return array|\Illuminate\Database\Eloquent\Collection
*/
public function saveTagsToEntity(Entity $entity, array $tags = []): iterable
public function saveTagsToEntity(Entity $entity, $tags = [])
{
$entity->tags()->delete();
$newTags = collect($tags)->filter(function ($tag) {
return boolval(trim($tag['name']));
})->map(function ($tag) {
return $this->newInstanceFromInput($tag);
})->all();
$newTags = [];
foreach ($tags as $tag) {
if (trim($tag['name']) === '') {
continue;
}
$newTags[] = $this->newInstanceFromInput($tag);
}
return $entity->tags()->saveMany($newTags);
}
/**
* Create a new Tag instance from user input.
* Input must be an array with a 'name' and an optional 'value' key.
* @param $input
* @return \BookStack\Actions\Tag
*/
protected function newInstanceFromInput(array $input): Tag
protected function newInstanceFromInput($input)
{
$name = trim($input['name']);
$value = isset($input['value']) ? trim($input['value']) : '';
return $this->tag->newInstance(['name' => $name, 'value' => $value]);
// Any other modification or cleanup required can go here
$values = ['name' => $name, 'value' => $value];
return $this->tag->newInstance($values);
}
}

View File

@@ -1,10 +1,8 @@
<?php namespace BookStack\Actions;
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Entity;
use BookStack\Entities\EntityProvider;
use DB;
use Illuminate\Support\Collection;
class ViewService
@@ -15,8 +13,8 @@ class ViewService
/**
* ViewService constructor.
* @param View $view
* @param PermissionService $permissionService
* @param \BookStack\Actions\View $view
* @param \BookStack\Auth\Permissions\PermissionService $permissionService
* @param EntityProvider $entityProvider
*/
public function __construct(View $view, PermissionService $permissionService, EntityProvider $entityProvider)
@@ -28,7 +26,7 @@ class ViewService
/**
* Add a view to the given entity.
* @param \BookStack\Entities\Models\Entity $entity
* @param Entity $entity
* @return int
*/
public function add(Entity $entity)
@@ -45,7 +43,7 @@ class ViewService
}
// Otherwise create new view count
$entity->views()->save($this->view->newInstance([
$entity->views()->save($this->view->create([
'user_id' => $user->id,
'views' => 1
]));
@@ -61,12 +59,12 @@ class ViewService
* @param string $action - used for permission checking
* @return Collection
*/
public function getPopular(int $count = 10, int $page = 0, array $filterModels = null, string $action = 'view')
public function getPopular(int $count = 10, int $page = 0, $filterModels = null, string $action = 'view')
{
$skipCount = $count * $page;
$query = $this->permissionService
->filterRestrictedEntityRelations($this->view, 'views', 'viewable_id', 'viewable_type', $action)
->select('*', 'viewable_id', 'viewable_type', DB::raw('SUM(views) as view_count'))
->select('*', 'viewable_id', 'viewable_type', \DB::raw('SUM(views) as view_count'))
->groupBy('viewable_id', 'viewable_type')
->orderBy('view_count', 'desc');
@@ -74,36 +72,34 @@ class ViewService
$query->whereIn('viewable_type', $this->entityProvider->getMorphClasses($filterModels));
}
return $query->with('viewable')
->skip($skipCount)
->take($count)
->get()
->pluck('viewable')
->filter();
return $query->with('viewable')->skip($skipCount)->take($count)->get()->pluck('viewable');
}
/**
* Get all recently viewed entities for the current user.
* @param int $count
* @param int $page
* @param Entity|bool $filterModel
* @return mixed
*/
public function getUserRecentlyViewed(int $count = 10, int $page = 1)
public function getUserRecentlyViewed($count = 10, $page = 0, $filterModel = false)
{
$user = user();
if ($user === null || $user->isDefault()) {
return collect();
}
$all = collect();
/** @var Entity $instance */
foreach ($this->entityProvider->all() as $name => $instance) {
$items = $instance::visible()->withLastView()
->orderBy('last_viewed_at', 'desc')
->skip($count * ($page - 1))
->take($count)
->get();
$all = $all->concat($items);
}
$query = $this->permissionService
->filterRestrictedEntityRelations($this->view, 'views', 'viewable_id', 'viewable_type');
return $all->sortByDesc('last_viewed_at')->slice(0, $count);
if ($filterModel) {
$query = $query->where('viewable_type', '=', $filterModel->getMorphClass());
}
$query = $query->where('user_id', '=', $user->id);
$viewables = $query->with('viewable')->orderBy('updated_at', 'desc')
->skip($count * $page)->take($count)->get()->pluck('viewable');
return $viewables;
}
/**

View File

@@ -1,146 +0,0 @@
<?php namespace BookStack\Api;
use BookStack\Http\Controllers\Api\ApiController;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Str;
use ReflectionClass;
use ReflectionException;
use ReflectionMethod;
class ApiDocsGenerator
{
protected $reflectionClasses = [];
protected $controllerClasses = [];
/**
* Load the docs form the cache if existing
* otherwise generate and store in the cache.
*/
public static function generateConsideringCache(): 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 static())->generate();
Cache::put($cacheKey, $docs, 60 * 24);
}
return $docs;
}
/**
* Generate API documentation.
*/
protected 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 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,
'controller_method_kebab' => Str::kebab($controllerMethod),
'base_model' => $baseModelName,
];
});
}
}

View File

@@ -1,49 +0,0 @@
<?php namespace BookStack\Api;
use BookStack\Auth\User;
use BookStack\Interfaces\Loggable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
/**
* Class ApiToken
* @property int $id
* @property string $token_id
* @property string $secret
* @property string $name
* @property Carbon $expires_at
* @property User $user
*/
class ApiToken extends Model implements Loggable
{
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');
}
/**
* @inheritdoc
*/
public function logDescriptor(): string
{
return "({$this->id}) {$this->name}; User: {$this->user->logDescriptor()}";
}
}

View File

@@ -1,166 +0,0 @@
<?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;
}
}

View File

@@ -1,138 +0,0 @@
<?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()
{
$filteredQuery = $this->filterQuery($this->query);
$total = $filteredQuery->count();
$data = $this->fetchData($filteredQuery);
return response()->json([
'data' => $data,
'total' => $total,
]);
}
/**
* Fetch the data to return in the response.
*/
protected function fetchData(Builder $query): Collection
{
$query = $this->countAndOffsetQuery($query);
$query = $this->sortQuery($query);
return $query->get($this->fields);
}
/**
* Apply any filtering operations found in the request.
*/
protected function filterQuery(Builder $query): Builder
{
$query = clone $query;
$requestFilters = $this->request->get('filter', []);
if (!is_array($requestFilters)) {
return $query;
}
$queryFilters = collect($requestFilters)->map(function ($value, $key) {
return $this->requestFilterToQueryFilter($key, $value);
})->filter(function ($value) {
return !is_null($value);
})->values()->toArray();
return $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 sortQuery(Builder $query): Builder
{
$query = clone $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;
}
return $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 countAndOffsetQuery(Builder $query): Builder
{
$query = clone $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);
return $query->skip($offset)->take($count);
}
}

View File

@@ -13,11 +13,7 @@ class Application extends \Illuminate\Foundation\Application
*/
public function configPath($path = '')
{
return $this->basePath
. DIRECTORY_SEPARATOR
. 'app'
. DIRECTORY_SEPARATOR
. 'Config'
. ($path ? DIRECTORY_SEPARATOR.$path : $path);
return $this->basePath.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'Config'.($path ? DIRECTORY_SEPARATOR.$path : $path);
}
}
}

View File

@@ -36,4 +36,5 @@ class EmailConfirmationService extends UserTokenService
return setting('registration-confirmation')
|| setting('registration-restrict');
}
}

View File

@@ -1,75 +0,0 @@
<?php namespace BookStack\Auth\Access;
use BookStack\Auth\Role;
use BookStack\Auth\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
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.
*/
protected function matchGroupsToSystemsRoles(array $groupNames): Collection
{
foreach ($groupNames as $i => $groupName) {
$groupNames[$i] = str_replace(' ', '-', trim(strtolower($groupName)));
}
$roles = Role::query()->get(['id', 'external_auth_id', 'display_name']);
$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);
}
}
}

View File

@@ -1,303 +0,0 @@
<?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.
*/
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;
}
}

View File

@@ -1,123 +0,0 @@
<?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']);
if (isset($userDetails['uid'])) {
$this->lastAttempted = $this->provider->retrieveByCredentials([
'external_auth_id' => $userDetails['uid']
]);
}
return $this->ldapService->validateUserCredentials($userDetails, $credentials['password']);
}
/**
* Attempt to authenticate a user using the given credentials.
*
* @param array $credentials
* @param bool $remember
* @return bool
* @throws LoginAttemptException
* @throws LdapException
*/
public function attempt(array $credentials = [], $remember = false)
{
$username = $credentials['username'];
$userDetails = $this->ldapService->getUserDetails($username);
$user = null;
if (isset($userDetails['uid'])) {
$this->lastAttempted = $user = $this->provider->retrieveByCredentials([
'external_auth_id' => $userDetails['uid']
]);
}
if (!$this->ldapService->validateUserCredentials($userDetails, $credentials['password'])) {
return false;
}
if (is_null($user)) {
try {
$user = $this->createNewFromLdapAndCreds($userDetails, $credentials);
} catch (UserRegistrationException $exception) {
throw new LoginAttemptException($exception->message);
}
}
// 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);
}
}

View File

@@ -1,38 +0,0 @@
<?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.
*/
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;
}
}

View File

@@ -4,6 +4,7 @@
* Class Ldap
* An object-orientated thin abstraction wrapper for common PHP LDAP functions.
* Allows the standard LDAP functions to be mocked for testing.
* @package BookStack\Services
*/
class Ldap
{

View File

@@ -1,29 +1,37 @@
<?php namespace BookStack\Auth\Access;
use BookStack\Auth\Access;
use BookStack\Auth\Role;
use BookStack\Auth\User;
use BookStack\Exceptions\JsonDebugException;
use BookStack\Auth\UserRepo;
use BookStack\Exceptions\LdapException;
use ErrorException;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Database\Eloquent\Builder;
/**
* Class LdapService
* Handles any app-specific LDAP tasks.
* @package BookStack\Services
*/
class LdapService extends ExternalAuthService
class LdapService
{
protected $ldap;
protected $ldapConnection;
protected $config;
protected $userRepo;
protected $enabled;
/**
* LdapService constructor.
* @param Ldap $ldap
* @param \BookStack\Auth\UserRepo $userRepo
*/
public function __construct(Ldap $ldap)
public function __construct(Access\Ldap $ldap, UserRepo $userRepo)
{
$this->ldap = $ldap;
$this->config = config('services.ldap');
$this->userRepo = $userRepo;
$this->enabled = config('auth.method') === 'ldap';
}
@@ -37,21 +45,17 @@ class LdapService extends ExternalAuthService
}
/**
* 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
*/
private function getUserWithAttributes(string $userName, array $attributes): ?array
private function getUserWithAttributes($userName, $attributes)
{
$ldapConnection = $this->getConnection();
$this->bindSystemUser($ldapConnection);
// Clean attributes
foreach ($attributes as $index => $attribute) {
if (strpos($attribute, 'BIN;') === 0) {
$attributes[$index] = substr($attribute, strlen('BIN;'));
}
}
// Find user
$userFilter = $this->buildFilter($this->config['user_filter'], ['user' => $userName]);
$baseDn = $this->config['base_dn'];
@@ -69,78 +73,69 @@ class LdapService extends ExternalAuthService
/**
* Get the details of a user from LDAP using the given username.
* User found via configurable user filter.
* @param $userName
* @return array|null
* @throws LdapException
*/
public function getUserDetails(string $userName): ?array
public function getUserDetails($userName)
{
$idAttr = $this->config['id_attribute'];
$emailAttr = $this->config['email_attribute'];
$displayNameAttr = $this->config['display_name_attribute'];
$user = $this->getUserWithAttributes($userName, ['cn', 'dn', $idAttr, $emailAttr, $displayNameAttr]);
$user = $this->getUserWithAttributes($userName, ['cn', 'uid', 'dn', $emailAttr, $displayNameAttr]);
if ($user === null) {
return null;
}
$userCn = $this->getUserResponseProperty($user, 'cn', null);
$formatted = [
'uid' => $this->getUserResponseProperty($user, $idAttr, $user['dn']),
return [
'uid' => $this->getUserResponseProperty($user, 'uid', $user['dn']),
'name' => $this->getUserResponseProperty($user, $displayNameAttr, $userCn),
'dn' => $user['dn'],
'email' => $this->getUserResponseProperty($user, $emailAttr, null),
];
if ($this->config['dump_user_details']) {
throw new JsonDebugException([
'details_from_ldap' => $user,
'details_bookstack_parsed' => $formatted,
]);
}
return $formatted;
}
/**
* Get a property from an LDAP user response fetch.
* Handles properties potentially being part of an array.
* If the given key is prefixed with 'BIN;', that indicator will be stripped
* from the key and any fetched values will be converted from binary to hex.
* @param array $userDetails
* @param string $propertyKey
* @param $defaultValue
* @return mixed
*/
protected function getUserResponseProperty(array $userDetails, string $propertyKey, $defaultValue)
{
$isBinary = strpos($propertyKey, 'BIN;') === 0;
$propertyKey = strtolower($propertyKey);
$value = $defaultValue;
if ($isBinary) {
$propertyKey = substr($propertyKey, strlen('BIN;'));
}
if (isset($userDetails[$propertyKey])) {
$value = (is_array($userDetails[$propertyKey]) ? $userDetails[$propertyKey][0] : $userDetails[$propertyKey]);
if ($isBinary) {
$value = bin2hex($value);
}
return (is_array($userDetails[$propertyKey]) ? $userDetails[$propertyKey][0] : $userDetails[$propertyKey]);
}
return $value;
return $defaultValue;
}
/**
* Check if the given credentials are valid for the given user.
* @param Authenticatable $user
* @param string $username
* @param string $password
* @return bool
* @throws LdapException
*/
public function validateUserCredentials(?array $ldapUserDetails, string $password): bool
public function validateUserCredentials(Authenticatable $user, $username, $password)
{
if (is_null($ldapUserDetails)) {
$ldapUser = $this->getUserDetails($username);
if ($ldapUser === null) {
return false;
}
if ($ldapUser['uid'] !== $user->external_auth_id) {
return false;
}
$ldapConnection = $this->getConnection();
try {
$ldapBind = $this->ldap->bind($ldapConnection, $ldapUserDetails['dn'], $password);
} catch (ErrorException $e) {
$ldapBind = $this->ldap->bind($ldapConnection, $ldapUser['dn'], $password);
} catch (\ErrorException $e) {
$ldapBind = false;
}
@@ -210,10 +205,12 @@ class LdapService extends ExternalAuthService
}
/**
* Parse a LDAP server string and return the host and port for a connection.
* Is flexible to formats such as 'ldap.example.com:8069' or 'ldaps://ldap.example.com'.
* Parse a LDAP server string and return the host and port for
* a connection. Is flexible to formats such as 'ldap.example.com:8069' or 'ldaps://ldap.example.com'
* @param $serverString
* @return array
*/
protected function parseServerString(string $serverString): array
protected function parseServerString($serverString)
{
$serverNameParts = explode(':', $serverString);
@@ -230,8 +227,11 @@ class LdapService extends ExternalAuthService
/**
* Build a filter string by injecting common variables.
* @param string $filterString
* @param array $attrs
* @return string
*/
protected function buildFilter(string $filterString, array $attrs): string
protected function buildFilter($filterString, array $attrs)
{
$newAttrs = [];
foreach ($attrs as $key => $attrText) {
@@ -242,10 +242,12 @@ class LdapService extends ExternalAuthService
}
/**
* 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
*/
public function getUserGroups(string $userName): array
public function getUserGroups($userName)
{
$groupsAttr = $this->config['group_attribute'];
$user = $this->getUserWithAttributes($userName, [$groupsAttr]);
@@ -260,36 +262,40 @@ class LdapService extends ExternalAuthService
}
/**
* 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
*/
private function getGroupsRecursive(array $groupsArray, array $checked): array
private function getGroupsRecursive($groupsArray, $checked)
{
$groupsToAdd = [];
$groups_to_add = [];
foreach ($groupsArray as $groupName) {
if (in_array($groupName, $checked)) {
continue;
}
$parentGroups = $this->getGroupGroups($groupName);
$groupsToAdd = array_merge($groupsToAdd, $parentGroups);
$groupsToAdd = $this->getGroupGroups($groupName);
$groups_to_add = array_merge($groups_to_add, $groupsToAdd);
$checked[] = $groupName;
}
$groupsArray = array_unique(array_merge($groupsArray, $groups_to_add), SORT_REGULAR);
$groupsArray = array_unique(array_merge($groupsArray, $groupsToAdd), SORT_REGULAR);
if (empty($groupsToAdd)) {
if (!empty($groups_to_add)) {
return $this->getGroupsRecursive($groupsArray, $checked);
} else {
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
*/
private function getGroupGroups(string $groupName): array
private function getGroupGroups($groupName)
{
$ldapConnection = $this->getConnection();
$this->bindSystemUser($ldapConnection);
@@ -306,14 +312,17 @@ class LdapService extends ExternalAuthService
return [];
}
return $this->groupFilter($groups[0]);
$groupGroups = $this->groupFilter($groups[0]);
return $groupGroups;
}
/**
* Filter out LDAP CN and DN language in a ldap search return.
* Gets the base CN (common name) of the string.
* Filter out LDAP CN and DN language in a ldap search return
* Gets the base CN (common name) of the string
* @param array $userGroupSearchResponse
* @return array
*/
protected function groupFilter(array $userGroupSearchResponse): array
protected function groupFilter(array $userGroupSearchResponse)
{
$groupsAttr = strtolower($this->config['group_attribute']);
$ldapGroups = [];
@@ -334,12 +343,73 @@ class LdapService extends ExternalAuthService
}
/**
* 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
*/
public function syncGroups(User $user, string $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);
}
}

View File

@@ -1,113 +0,0 @@
<?php namespace BookStack\Auth\Access;
use BookStack\Actions\ActivityType;
use BookStack\Auth\SocialAccount;
use BookStack\Auth\User;
use BookStack\Auth\UserRepo;
use BookStack\Exceptions\UserRegistrationException;
use BookStack\Facades\Activity;
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]), '/login');
}
// Create the user
$newUser = $this->userRepo->registerNew($userData, $emailConfirmed);
// Assign social account if given
if ($socialAccount) {
$newUser->socialAccounts()->save($socialAccount);
}
Activity::add(ActivityType::AUTH_REGISTER, $socialAccount ?? $newUser);
// Start email confirmation flow if required
if ($this->emailConfirmationService->confirmationRequired() && !$emailConfirmed) {
$newUser->save();
try {
$this->emailConfirmationService->sendConfirmation($newUser);
session()->flash('sent-email-confirmation', true);
} 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);
}
}
}

View File

@@ -1,380 +0,0 @@
<?php namespace BookStack\Auth\Access;
use BookStack\Actions\ActivityType;
use BookStack\Auth\User;
use BookStack\Exceptions\JsonDebugException;
use BookStack\Exceptions\SamlException;
use BookStack\Exceptions\UserRegistrationException;
use BookStack\Facades\Activity;
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 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);
Activity::add(ActivityType::AUTH_LOGIN, "saml2; {$user->logDescriptor()}");
return $user;
}
}

View File

@@ -1,17 +1,12 @@
<?php namespace BookStack\Auth\Access;
use BookStack\Actions\ActivityType;
use BookStack\Auth\SocialAccount;
use BookStack\Auth\UserRepo;
use BookStack\Exceptions\SocialDriverNotConfigured;
use BookStack\Exceptions\SocialSignInAccountNotUsed;
use BookStack\Exceptions\UserRegistrationException;
use BookStack\Facades\Activity;
use Illuminate\Support\Str;
use Laravel\Socialite\Contracts\Factory as Socialite;
use Laravel\Socialite\Contracts\Provider;
use Laravel\Socialite\Contracts\User as SocialUser;
use Symfony\Component\HttpFoundation\RedirectResponse;
class SocialAuthService
{
@@ -24,6 +19,9 @@ class SocialAuthService
/**
* SocialAuthService constructor.
* @param \BookStack\Auth\UserRepo $userRepo
* @param Socialite $socialite
* @param SocialAccount $socialAccount
*/
public function __construct(UserRepo $userRepo, Socialite $socialite, SocialAccount $socialAccount)
{
@@ -35,9 +33,11 @@ class SocialAuthService
/**
* Start the social login path.
* @param string $socialDriver
* @return \Symfony\Component\HttpFoundation\RedirectResponse
* @throws SocialDriverNotConfigured
*/
public function startLogIn(string $socialDriver): RedirectResponse
public function startLogIn($socialDriver)
{
$driver = $this->validateDriver($socialDriver);
return $this->getSocialDriver($driver)->redirect();
@@ -45,9 +45,11 @@ class SocialAuthService
/**
* Start the social registration process
* @param string $socialDriver
* @return \Symfony\Component\HttpFoundation\RedirectResponse
* @throws SocialDriverNotConfigured
*/
public function startRegister(string $socialDriver): RedirectResponse
public function startRegister($socialDriver)
{
$driver = $this->validateDriver($socialDriver);
return $this->getSocialDriver($driver)->redirect();
@@ -55,9 +57,12 @@ class SocialAuthService
/**
* Handle the social registration process on callback.
* @param string $socialDriver
* @param SocialUser $socialUser
* @return SocialUser
* @throws UserRegistrationException
*/
public function handleRegistrationCallback(string $socialDriver, SocialUser $socialUser): SocialUser
public function handleRegistrationCallback(string $socialDriver, SocialUser $socialUser)
{
// Check social account has not already been used
if ($this->socialAccount->where('driver_id', '=', $socialUser->getId())->exists()) {
@@ -66,7 +71,7 @@ class SocialAuthService
if ($this->userRepo->getByEmail($socialUser->getEmail())) {
$email = $socialUser->getEmail();
throw new UserRegistrationException(trans('errors.error_user_exists_different_creds', ['email' => $email]), '/login');
throw new UserRegistrationException(trans('errors.social_account_in_use', ['socialAccount'=>$socialDriver, 'email' => $email]), '/login');
}
return $socialUser;
@@ -74,9 +79,11 @@ class SocialAuthService
/**
* Get the social user details via the social driver.
* @param string $socialDriver
* @return SocialUser
* @throws SocialDriverNotConfigured
*/
public function getSocialUser(string $socialDriver): SocialUser
public function getSocialUser(string $socialDriver)
{
$driver = $this->validateDriver($socialDriver);
return $this->socialite->driver($driver)->user();
@@ -84,9 +91,12 @@ class SocialAuthService
/**
* Handle the login process on a oAuth callback.
* @param $socialDriver
* @param SocialUser $socialUser
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
* @throws SocialSignInAccountNotUsed
*/
public function handleLoginCallback(string $socialDriver, SocialUser $socialUser)
public function handleLoginCallback($socialDriver, SocialUser $socialUser)
{
$socialId = $socialUser->getId();
@@ -94,13 +104,11 @@ class SocialAuthService
$socialAccount = $this->socialAccount->where('driver_id', '=', $socialId)->first();
$isLoggedIn = auth()->check();
$currentUser = user();
$titleCaseDriver = Str::title($socialDriver);
// When a user is not logged in and a matching SocialAccount exists,
// Simply log the user into the application.
if (!$isLoggedIn && $socialAccount !== null) {
auth()->login($socialAccount->user);
Activity::add(ActivityType::AUTH_LOGIN, $socialAccount);
return redirect()->intended('/');
}
@@ -109,26 +117,26 @@ class SocialAuthService
if ($isLoggedIn && $socialAccount === null) {
$this->fillSocialAccount($socialDriver, $socialUser);
$currentUser->socialAccounts()->save($this->socialAccount);
session()->flash('success', trans('settings.users_social_connected', ['socialAccount' => $titleCaseDriver]));
session()->flash('success', trans('settings.users_social_connected', ['socialAccount' => title_case($socialDriver)]));
return redirect($currentUser->getEditUrl());
}
// When a user is logged in and the social account exists and is already linked to the current user.
if ($isLoggedIn && $socialAccount !== null && $socialAccount->user->id === $currentUser->id) {
session()->flash('error', trans('errors.social_account_existing', ['socialAccount' => $titleCaseDriver]));
session()->flash('error', trans('errors.social_account_existing', ['socialAccount' => title_case($socialDriver)]));
return redirect($currentUser->getEditUrl());
}
// When a user is logged in, A social account exists but the users do not match.
if ($isLoggedIn && $socialAccount !== null && $socialAccount->user->id != $currentUser->id) {
session()->flash('error', trans('errors.social_account_already_used_existing', ['socialAccount' => $titleCaseDriver]));
session()->flash('error', trans('errors.social_account_already_used_existing', ['socialAccount' => title_case($socialDriver)]));
return redirect($currentUser->getEditUrl());
}
// Otherwise let the user know this social account is not used by anyone.
$message = trans('errors.social_account_not_used', ['socialAccount' => $titleCaseDriver]);
if (setting('registration-enabled') && config('auth.method') !== 'ldap' && config('auth.method') !== 'saml2') {
$message .= trans('errors.social_account_register_instructions', ['socialAccount' => $titleCaseDriver]);
$message = trans('errors.social_account_not_used', ['socialAccount' => title_case($socialDriver)]);
if (setting('registration-enabled')) {
$message .= trans('errors.social_account_register_instructions', ['socialAccount' => title_case($socialDriver)]);
}
throw new SocialSignInAccountNotUsed($message, '/login');
@@ -136,18 +144,20 @@ class SocialAuthService
/**
* Ensure the social driver is correct and supported.
*
* @param $socialDriver
* @return string
* @throws SocialDriverNotConfigured
*/
protected function validateDriver(string $socialDriver): string
private function validateDriver($socialDriver)
{
$driver = trim(strtolower($socialDriver));
if (!in_array($driver, $this->validSocialDrivers)) {
abort(404, trans('errors.social_driver_not_found'));
}
if (!$this->checkDriverConfigured($driver)) {
throw new SocialDriverNotConfigured(trans('errors.social_driver_not_configured', ['socialAccount' => Str::title($socialDriver)]));
throw new SocialDriverNotConfigured(trans('errors.social_driver_not_configured', ['socialAccount' => title_case($socialDriver)]));
}
return $driver;
@@ -155,8 +165,10 @@ class SocialAuthService
/**
* Check a social driver has been configured correctly.
* @param $driver
* @return bool
*/
protected function checkDriverConfigured(string $driver): bool
private function checkDriverConfigured($driver)
{
$lowerName = strtolower($driver);
$configPrefix = 'services.' . $lowerName . '.';
@@ -166,48 +178,55 @@ class SocialAuthService
/**
* Gets the names of the active social drivers.
* @return array
*/
public function getActiveDrivers(): array
public function getActiveDrivers()
{
$activeDrivers = [];
foreach ($this->validSocialDrivers as $driverKey) {
if ($this->checkDriverConfigured($driverKey)) {
$activeDrivers[$driverKey] = $this->getDriverName($driverKey);
}
}
return $activeDrivers;
}
/**
* Get the presentational name for a driver.
* @param $driver
* @return mixed
*/
public function getDriverName(string $driver): string
public function getDriverName($driver)
{
return config('services.' . strtolower($driver) . '.name');
}
/**
* Check if the current config for the given driver allows auto-registration.
* @param string $driver
* @return bool
*/
public function driverAutoRegisterEnabled(string $driver): bool
public function driverAutoRegisterEnabled(string $driver)
{
return config('services.' . strtolower($driver) . '.auto_register') === true;
}
/**
* Check if the current config for the given driver allow email address auto-confirmation.
* @param string $driver
* @return bool
*/
public function driverAutoConfirmEmailEnabled(string $driver): bool
public function driverAutoConfirmEmailEnabled(string $driver)
{
return config('services.' . strtolower($driver) . '.auto_confirm') === true;
}
/**
* Fill and return a SocialAccount from the given driver name and SocialUser.
* @param string $socialDriver
* @param SocialUser $socialUser
* @return SocialAccount
*/
public function fillSocialAccount(string $socialDriver, SocialUser $socialUser): SocialAccount
public function fillSocialAccount($socialDriver, $socialUser)
{
$this->socialAccount->fill([
'driver' => $socialDriver,
@@ -219,26 +238,28 @@ class SocialAuthService
/**
* Detach a social account from a user.
* @param $socialDriver
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
*/
public function detachSocialAccount(string $socialDriver)
public function detachSocialAccount($socialDriver)
{
user()->socialAccounts()->where('driver', '=', $socialDriver)->delete();
session()->flash('success', trans('settings.users_social_disconnected', ['socialAccount' => title_case($socialDriver)]));
return redirect(user()->getEditUrl());
}
/**
* Provide redirect options per service for the Laravel Socialite driver
* @param $driverName
* @return \Laravel\Socialite\Contracts\Provider
*/
public function getSocialDriver(string $driverName): Provider
public function getSocialDriver(string $driverName)
{
$driver = $this->socialite->driver($driverName);
if ($driverName === 'google' && config('services.google.select_account')) {
$driver->with(['prompt' => 'select_account']);
}
if ($driverName === 'azure') {
$driver->with(['resource' => 'https://graph.windows.net']);
}
return $driver;
}

View File

@@ -19,4 +19,5 @@ class UserInviteService extends UserTokenService
$token = $this->createTokenForUser($user);
$user->notify(new UserInvite($token));
}
}

View File

@@ -5,7 +5,6 @@ use BookStack\Exceptions\UserTokenExpiredException;
use BookStack\Exceptions\UserTokenNotFoundException;
use Carbon\Carbon;
use Illuminate\Database\Connection as Database;
use Illuminate\Support\Str;
use stdClass;
class UserTokenService
@@ -74,9 +73,9 @@ class UserTokenService
*/
protected function generateToken() : string
{
$token = Str::random(24);
$token = str_random(24);
while ($this->tokenExists($token)) {
$token = Str::random(25);
$token = str_random(25);
}
return $token;
}
@@ -131,4 +130,5 @@ class UserTokenService
return Carbon::now()->subHours($this->expiryTime)
->gt(new Carbon($tokenEntry->created_at));
}
}
}

View File

@@ -1,28 +1,27 @@
<?php namespace BookStack\Auth\Permissions;
use BookStack\Auth\Role;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Entity;
use BookStack\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphOne;
class JointPermission extends Model
{
protected $primaryKey = null;
public $timestamps = false;
/**
* Get the role that this points to.
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function role(): BelongsTo
public function role()
{
return $this->belongsTo(Role::class);
}
/**
* Get the entity this points to.
* @return \Illuminate\Database\Eloquent\Relations\MorphOne
*/
public function entity(): MorphOne
public function entity()
{
return $this->morphOne(Entity::class, 'entity');
}

View File

@@ -2,12 +2,13 @@
use BookStack\Auth\Permissions;
use BookStack\Auth\Role;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Book;
use BookStack\Entities\Bookshelf;
use BookStack\Entities\Chapter;
use BookStack\Entities\Entity;
use BookStack\Entities\EntityProvider;
use BookStack\Model;
use BookStack\Traits\HasCreatorAndUpdater;
use BookStack\Traits\HasOwner;
use BookStack\Entities\Page;
use BookStack\Ownable;
use Illuminate\Database\Connection;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Builder as QueryBuilder;
@@ -50,6 +51,11 @@ class PermissionService
/**
* PermissionService constructor.
* @param JointPermission $jointPermission
* @param EntityPermission $entityPermission
* @param Role $role
* @param Connection $db
* @param EntityProvider $entityProvider
*/
public function __construct(
JointPermission $jointPermission,
@@ -76,7 +82,7 @@ class PermissionService
/**
* Prepare the local entity cache and ensure it's empty
* @param \BookStack\Entities\Models\Entity[] $entities
* @param \BookStack\Entities\Entity[] $entities
*/
protected function readyEntityCache($entities = [])
{
@@ -113,7 +119,7 @@ class PermissionService
/**
* Get a chapter via ID, Checks local cache
* @param $chapterId
* @return \BookStack\Entities\Models\Book
* @return \BookStack\Entities\Book
*/
protected function getChapter($chapterId)
{
@@ -170,7 +176,7 @@ class PermissionService
});
// Chunk through all bookshelves
$this->entityProvider->bookshelf->newQuery()->withTrashed()->select(['id', 'restricted', 'owned_by'])
$this->entityProvider->bookshelf->newQuery()->select(['id', 'restricted', 'created_by'])
->chunk(50, function ($shelves) use ($roles) {
$this->buildJointPermissionsForShelves($shelves, $roles);
});
@@ -182,11 +188,11 @@ class PermissionService
*/
protected function bookFetchQuery()
{
return $this->entityProvider->book->withTrashed()->newQuery()
->select(['id', 'restricted', 'owned_by'])->with(['chapters' => function ($query) {
$query->withTrashed()->select(['id', 'restricted', 'owned_by', 'book_id']);
return $this->entityProvider->book->newQuery()
->select(['id', 'restricted', 'created_by'])->with(['chapters' => function ($query) {
$query->select(['id', 'restricted', 'created_by', 'book_id']);
}, 'pages' => function ($query) {
$query->withTrashed()->select(['id', 'restricted', 'owned_by', 'book_id', 'chapter_id']);
$query->select(['id', 'restricted', 'created_by', 'book_id', 'chapter_id']);
}]);
}
@@ -209,6 +215,7 @@ class PermissionService
* @param Collection $books
* @param array $roles
* @param bool $deleteOld
* @throws \Throwable
*/
protected function buildJointPermissionsForBooks($books, $roles, $deleteOld = false)
{
@@ -232,7 +239,7 @@ class PermissionService
/**
* Rebuild the entity jointPermissions for a particular entity.
* @param \BookStack\Entities\Models\Entity $entity
* @param \BookStack\Entities\Entity $entity
* @throws \Throwable
*/
public function buildJointPermissionsForEntity(Entity $entity)
@@ -288,7 +295,7 @@ class PermissionService
});
// Chunk through all bookshelves
$this->entityProvider->bookshelf->newQuery()->select(['id', 'restricted', 'owned_by'])
$this->entityProvider->bookshelf->newQuery()->select(['id', 'restricted', 'created_by'])
->chunk(50, function ($shelves) use ($roles) {
$this->buildJointPermissionsForShelves($shelves, $roles);
});
@@ -327,7 +334,7 @@ class PermissionService
/**
* Delete all of the entity jointPermissions for a list of entities.
* @param \BookStack\Entities\Models\Entity[] $entities
* @param \BookStack\Entities\Entity[] $entities
* @throws \Throwable
*/
protected function deleteManyJointPermissionsForEntities($entities)
@@ -408,7 +415,7 @@ class PermissionService
/**
* Get the actions related to an entity.
* @param \BookStack\Entities\Models\Entity $entity
* @param \BookStack\Entities\Entity $entity
* @return array
*/
protected function getActions(Entity $entity)
@@ -494,7 +501,7 @@ class PermissionService
/**
* Create an array of data with the information of an entity jointPermissions.
* Used to build data for bulk insertion.
* @param \BookStack\Entities\Models\Entity $entity
* @param \BookStack\Entities\Entity $entity
* @param Role $role
* @param $action
* @param $permissionAll
@@ -510,19 +517,21 @@ class PermissionService
'action' => $action,
'has_permission' => $permissionAll,
'has_permission_own' => $permissionOwn,
'owned_by' => $entity->getRawAttribute('owned_by')
'created_by' => $entity->getRawAttribute('created_by')
];
}
/**
* Checks if an entity has a restriction set upon it.
* @param HasCreatorAndUpdater|HasOwner $ownable
* @param Ownable $ownable
* @param $permission
* @return bool
*/
public function checkOwnableUserAccess(Model $ownable, string $permission): bool
public function checkOwnableUserAccess(Ownable $ownable, $permission)
{
$explodedPermission = explode('-', $permission);
$baseQuery = $ownable->newQuery()->where('id', '=', $ownable->id);
$baseQuery = $ownable->where('id', '=', $ownable->id);
$action = end($explodedPermission);
$this->currentAction = $action;
@@ -533,8 +542,7 @@ class PermissionService
$allPermission = $this->currentUser() && $this->currentUser()->can($permission . '-all');
$ownPermission = $this->currentUser() && $this->currentUser()->can($permission . '-own');
$this->currentAction = 'view';
$ownerField = ($ownable instanceof Entity) ? 'owned_by' : 'created_by';
$isOwner = $this->currentUser() && $this->currentUser()->id === $ownable->$ownerField;
$isOwner = $this->currentUser() && $this->currentUser()->id === $ownable->created_by;
return ($allPermission || ($isOwner && $ownPermission));
}
@@ -567,7 +575,7 @@ class PermissionService
$query->where('has_permission', '=', 1)
->orWhere(function ($query2) use ($userId) {
$query2->where('has_permission_own', '=', 1)
->where('owned_by', '=', $userId);
->where('created_by', '=', $userId);
});
});
@@ -584,7 +592,7 @@ class PermissionService
/**
* Check if an entity has restrictions set on itself or its
* parent tree.
* @param \BookStack\Entities\Models\Entity $entity
* @param \BookStack\Entities\Entity $entity
* @param $action
* @return bool|mixed
*/
@@ -616,7 +624,7 @@ class PermissionService
$query->where('has_permission', '=', true)
->orWhere(function ($query) {
$query->where('has_permission_own', '=', true)
->where('owned_by', '=', $this->currentUser()->id);
->where('created_by', '=', $this->currentUser()->id);
});
});
});
@@ -626,46 +634,48 @@ class PermissionService
}
/**
* Limited the given entity query so that the query will only
* return items that the user has permission for the given ability.
* Get the children of a book in an efficient single query, Filtered by the permission system.
* @param integer $book_id
* @param bool $filterDrafts
* @param bool $fetchPageContent
* @return QueryBuilder
*/
public function restrictEntityQuery(Builder $query, string $ability = 'view'): Builder
public function bookChildrenQuery($book_id, $filterDrafts = false, $fetchPageContent = false)
{
$this->clean();
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('owned_by', '=', $this->currentUser()->id);
});
$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);
/**
* 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('owned_by', '=', $this->currentUser()->id);
// Add joint permission filter
$whereQuery = $this->db->table('joint_permissions as jp')->selectRaw('COUNT(*)')
->whereRaw('jp.entity_id=U.id')->whereRaw('jp.entity_type=U.entity_type')
->where('jp.action', '=', 'view')->whereIn('jp.role_id', $this->getRoles())
->where(function ($query) {
$query->where('jp.has_permission', '=', 1)->orWhere(function ($query) {
$query->where('jp.has_permission_own', '=', 1)->where('jp.created_by', '=', $this->currentUser()->id);
});
});
});
$query->whereRaw("({$whereQuery->toSql()}) > 0")->mergeBindings($whereQuery);
$query->orderBy('draft', 'desc')->orderBy('priority', 'asc');
$this->clean();
return $query;
}
/**
* Add restrictions for a generic entity
* @param string $entityType
* @param Builder|\BookStack\Entities\Models\Entity $query
* @param Builder|\BookStack\Entities\Entity $query
* @param string $action
* @return Builder
*/
@@ -674,11 +684,12 @@ class PermissionService
if (strtolower($entityType) === 'page') {
// Prevent drafts being visible to others.
$query = $query->where(function ($query) {
$query->where('draft', '=', false)
->orWhere(function ($query) {
$query->where('draft', '=', true)
->where('owned_by', '=', $this->currentUser()->id);
$query->where('draft', '=', false);
if ($this->currentUser()) {
$query->orWhere(function ($query) {
$query->where('draft', '=', true)->where('created_by', '=', $this->currentUser()->id);
});
}
});
}
@@ -711,7 +722,7 @@ class PermissionService
->where(function ($query) {
$query->where('has_permission', '=', true)->orWhere(function ($query) {
$query->where('has_permission_own', '=', true)
->where('owned_by', '=', $this->currentUser()->id);
->where('created_by', '=', $this->currentUser()->id);
});
});
});
@@ -747,7 +758,7 @@ class PermissionService
->where(function ($query) {
$query->where('has_permission', '=', true)->orWhere(function ($query) {
$query->where('has_permission_own', '=', true)
->where('owned_by', '=', $this->currentUser()->id);
->where('created_by', '=', $this->currentUser()->id);
});
});
});

View File

@@ -1,11 +1,8 @@
<?php namespace BookStack\Auth\Permissions;
use BookStack\Actions\ActivityType;
use BookStack\Auth\Permissions;
use BookStack\Auth\Role;
use BookStack\Exceptions\PermissionsException;
use BookStack\Facades\Activity;
use Exception;
use Illuminate\Database\Eloquent\Collection;
class PermissionsRepo
{
@@ -18,8 +15,11 @@ class PermissionsRepo
/**
* PermissionsRepo constructor.
* @param RolePermission $permission
* @param Role $role
* @param \BookStack\Auth\Permissions\PermissionService $permissionService
*/
public function __construct(RolePermission $permission, Role $role, PermissionService $permissionService)
public function __construct(RolePermission $permission, Role $role, Permissions\PermissionService $permissionService)
{
$this->permission = $permission;
$this->role = $role;
@@ -28,51 +28,64 @@ class PermissionsRepo
/**
* Get all the user roles from the system.
* @return \Illuminate\Database\Eloquent\Collection|static[]
*/
public function getAllRoles(): Collection
public function getAllRoles()
{
return $this->role->all();
}
/**
* Get all the roles except for the provided one.
* @param Role $role
* @return mixed
*/
public function getAllRolesExcept(Role $role): Collection
public function getAllRolesExcept(Role $role)
{
return $this->role->where('id', '!=', $role->id)->get();
}
/**
* Get a role via its ID.
* @param $id
* @return mixed
*/
public function getRoleById($id): Role
public function getRoleById($id)
{
return $this->role->newQuery()->findOrFail($id);
return $this->role->findOrFail($id);
}
/**
* Save a new role into the system.
* @param array $roleData
* @return Role
*/
public function saveNewRole(array $roleData): Role
public function saveNewRole($roleData)
{
$role = $this->role->newInstance($roleData);
$role->name = str_replace(' ', '-', strtolower($roleData['display_name']));
// Prevent duplicate names
while ($this->role->where('name', '=', $role->name)->count() > 0) {
$role->name .= strtolower(str_random(2));
}
$role->save();
$permissions = isset($roleData['permissions']) ? array_keys($roleData['permissions']) : [];
$this->assignRolePermissions($role, $permissions);
$this->permissionService->buildJointPermissionForRole($role);
Activity::add(ActivityType::ROLE_CREATE, $role);
return $role;
}
/**
* Updates an existing role.
* Ensure Admin role always have core permissions.
* @param $roleId
* @param $roleData
* @throws PermissionsException
*/
public function updateRole($roleId, array $roleData)
public function updateRole($roleId, $roleData)
{
/** @var Role $role */
$role = $this->role->newQuery()->findOrFail($roleId);
$role = $this->role->findOrFail($roleId);
$permissions = isset($roleData['permissions']) ? array_keys($roleData['permissions']) : [];
if ($role->system_name === 'admin') {
@@ -90,24 +103,20 @@ class PermissionsRepo
$role->fill($roleData);
$role->save();
$this->permissionService->buildJointPermissionForRole($role);
Activity::add(ActivityType::ROLE_UPDATE, $role);
}
/**
* Assign an list of permission names to an role.
* @param Role $role
* @param array $permissionNameArray
*/
protected function assignRolePermissions(Role $role, array $permissionNameArray = [])
public function assignRolePermissions(Role $role, $permissionNameArray = [])
{
$permissions = [];
$permissionNameArray = array_values($permissionNameArray);
if ($permissionNameArray) {
$permissions = $this->permission->newQuery()
->whereIn('name', $permissionNameArray)
->pluck('id')
->toArray();
if ($permissionNameArray && count($permissionNameArray) > 0) {
$permissions = $this->permission->whereIn('name', $permissionNameArray)->pluck('id')->toArray();
}
$role->permissions()->sync($permissions);
}
@@ -116,31 +125,30 @@ class PermissionsRepo
* Check it's not an admin role or set as default before deleting.
* If an migration Role ID is specified the users assign to the current role
* will be added to the role of the specified id.
* @param $roleId
* @param $migrateRoleId
* @throws PermissionsException
* @throws Exception
*/
public function deleteRole($roleId, $migrateRoleId)
{
/** @var Role $role */
$role = $this->role->newQuery()->findOrFail($roleId);
$role = $this->role->findOrFail($roleId);
// Prevent deleting admin role or default registration role.
if ($role->system_name && in_array($role->system_name, $this->systemRoles)) {
throw new PermissionsException(trans('errors.role_system_cannot_be_deleted'));
} else if ($role->id === intval(setting('registration-role'))) {
} else if ($role->id == setting('registration-role')) {
throw new PermissionsException(trans('errors.role_registration_default_cannot_delete'));
}
if ($migrateRoleId) {
$newRole = $this->role->newQuery()->find($migrateRoleId);
$newRole = $this->role->find($migrateRoleId);
if ($newRole) {
$users = $role->users()->pluck('id')->toArray();
$users = $role->users->pluck('id')->toArray();
$newRole->users()->sync($users);
}
}
$this->permissionService->deleteJointPermissionsForRole($role);
Activity::add(ActivityType::ROLE_DELETE, $role);
$role->delete();
}
}

View File

@@ -3,9 +3,6 @@
use BookStack\Auth\Role;
use BookStack\Model;
/**
* @property int $id
*/
class RolePermission extends Model
{
/**

View File

@@ -2,21 +2,9 @@
use BookStack\Auth\Permissions\JointPermission;
use BookStack\Auth\Permissions\RolePermission;
use BookStack\Interfaces\Loggable;
use BookStack\Model;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* Class Role
* @property int $id
* @property string $display_name
* @property string $description
* @property string $external_auth_id
* @property string $system_name
*/
class Role extends Model implements Loggable
class Role extends Model
{
protected $fillable = ['display_name', 'description', 'external_auth_id'];
@@ -24,15 +12,16 @@ class Role extends Model implements Loggable
/**
* The roles that belong to the role.
*/
public function users(): BelongsToMany
public function users()
{
return $this->belongsToMany(User::class)->orderBy('name', 'asc');
}
/**
* Get all related JointPermissions.
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function jointPermissions(): HasMany
public function jointPermissions()
{
return $this->hasMany(JointPermission::class);
}
@@ -40,15 +29,17 @@ class Role extends Model implements Loggable
/**
* The RolePermissions that belong to the role.
*/
public function permissions(): BelongsToMany
public function permissions()
{
return $this->belongsToMany(RolePermission::class, 'permission_role', 'role_id', 'permission_id');
}
/**
* Check if this role has a permission.
* @param $permissionName
* @return bool
*/
public function hasPermission(string $permissionName): bool
public function hasPermission($permissionName)
{
$permissions = $this->getRelationValue('permissions');
foreach ($permissions as $permission) {
@@ -61,6 +52,7 @@ class Role extends Model implements Loggable
/**
* Add a permission to this role.
* @param RolePermission $permission
*/
public function attachPermission(RolePermission $permission)
{
@@ -69,49 +61,39 @@ class Role extends Model implements Loggable
/**
* Detach a single permission from this role.
* @param RolePermission $permission
*/
public function detachPermission(RolePermission $permission)
{
$this->permissions()->detach([$permission->id]);
$this->permissions()->detach($permission->id);
}
/**
* Get the role of the specified display name.
* Get the role object for the specified role.
* @param $roleName
* @return Role
*/
public static function getRole(string $displayName): ?Role
public static function getRole($roleName)
{
return static::query()->where('display_name', '=', $displayName)->first();
return static::where('name', '=', $roleName)->first();
}
/**
* Get the role object for the specified system role.
* @param $roleName
* @return Role
*/
public static function getSystemRole(string $systemName): ?Role
public static function getSystemRole($roleName)
{
return static::query()->where('system_name', '=', $systemName)->first();
return static::where('system_name', '=', $roleName)->first();
}
/**
* Get all visible roles
* @return mixed
*/
public static function visible(): Collection
public static function visible()
{
return static::query()->where('hidden', '=', false)->orderBy('name')->get();
}
/**
* Get the roles that can be restricted.
*/
public static function restrictable(): Collection
{
return static::query()->where('system_name', '!=', 'admin')->get();
}
/**
* @inheritdoc
*/
public function logDescriptor(): string
{
return "({$this->id}) {$this->display_name}";
return static::where('hidden', '=', false)->orderBy('name')->get();
}
}

View File

@@ -1,14 +1,8 @@
<?php namespace BookStack\Auth;
use BookStack\Interfaces\Loggable;
use BookStack\Model;
/**
* Class SocialAccount
* @property string $driver
* @property User $user
*/
class SocialAccount extends Model implements Loggable
class SocialAccount extends Model
{
protected $fillable = ['user_id', 'driver', 'driver_id', 'timestamps'];
@@ -17,12 +11,4 @@ class SocialAccount extends Model implements Loggable
{
return $this->belongsTo(User::class);
}
/**
* @inheritDoc
*/
public function logDescriptor(): string
{
return "{$this->driver}; {$this->user->logDescriptor()}";
}
}

View File

@@ -1,25 +1,19 @@
<?php namespace BookStack\Auth;
use BookStack\Api\ApiToken;
use BookStack\Interfaces\Loggable;
use BookStack\Model;
use BookStack\Notifications\ResetPassword;
use BookStack\Uploads\Image;
use Carbon\Carbon;
use Exception;
use Illuminate\Auth\Authenticatable;
use Illuminate\Auth\Passwords\CanResetPassword;
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Collection;
/**
* Class User
* @package BookStack\Auth
* @property string $id
* @property string $name
* @property string $email
@@ -31,7 +25,7 @@ use Illuminate\Support\Collection;
* @property string $external_auth_id
* @property string $system_name
*/
class User extends Model implements AuthenticatableContract, CanResetPasswordContract, Loggable
class User extends Model implements AuthenticatableContract, CanResetPasswordContract
{
use Authenticatable, CanResetPassword, Notifiable;
@@ -47,41 +41,25 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
*/
protected $fillable = ['name', 'email'];
protected $casts = ['last_activity_at' => 'datetime'];
/**
* The attributes excluded from the model's JSON form.
* @var array
*/
protected $hidden = [
'password', 'remember_token', 'system_name', 'email_confirmed', 'external_auth_id', 'email',
'created_at', 'updated_at', 'image_id',
];
protected $hidden = ['password', 'remember_token'];
/**
* This holds the user's permissions when loaded.
* @var ?Collection
* @var array
*/
protected $permissions;
/**
* This holds the default user when loaded.
* @var null|User
*/
protected static $defaultUser = null;
/**
* Returns the default public user.
* @return User
*/
public static function getDefault()
{
if (!is_null(static::$defaultUser)) {
return static::$defaultUser;
}
static::$defaultUser = static::where('system_name', '=', 'public')->first();
return static::$defaultUser;
return static::where('system_name', '=', 'public')->first();
}
/**
@@ -107,10 +85,12 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
/**
* Check if the user has a role.
* @param $role
* @return mixed
*/
public function hasRole($roleId): bool
public function hasRole($role)
{
return $this->roles->pluck('id')->contains($roleId);
return $this->roles->pluck('name')->contains($role);
}
/**
@@ -124,67 +104,57 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
}
/**
* Attach the default system role to this user.
* Get all permissions belonging to a the current user.
* @param bool $cache
* @return \Illuminate\Database\Eloquent\Relations\HasManyThrough
*/
public function attachDefaultRole(): void
public function permissions($cache = true)
{
$roleId = setting('registration-role');
if ($roleId && $this->roles()->where('id', '=', $roleId)->count() === 0) {
$this->roles()->attach($roleId);
if (isset($this->permissions) && $cache) {
return $this->permissions;
}
$this->load('roles.permissions');
$permissions = $this->roles->map(function ($role) {
return $role->permissions;
})->flatten()->unique();
$this->permissions = $permissions;
return $permissions;
}
/**
* Check if the user has a particular permission.
* @param $permissionName
* @return bool
*/
public function can(string $permissionName): bool
public function can($permissionName)
{
if ($this->email === 'guest') {
return false;
}
return $this->permissions()->contains($permissionName);
}
/**
* Get all permissions belonging to a the current user.
*/
protected function permissions(): Collection
{
if (isset($this->permissions)) {
return $this->permissions;
}
$this->permissions = $this->newQuery()->getConnection()->table('role_user', 'ru')
->select('role_permissions.name as name')->distinct()
->leftJoin('permission_role', 'ru.role_id', '=', 'permission_role.role_id')
->leftJoin('role_permissions', 'permission_role.permission_id', '=', 'role_permissions.id')
->where('ru.user_id', '=', $this->id)
->get()
->pluck('name');
return $this->permissions;
}
/**
* Clear any cached permissions on this instance.
*/
public function clearPermissionCache()
{
$this->permissions = null;
return $this->permissions()->pluck('name')->contains($permissionName);
}
/**
* Attach a role to this user.
* @param Role $role
*/
public function attachRole(Role $role)
{
$this->roles()->attach($role->id);
$this->attachRoleId($role->id);
}
/**
* Attach a role id to this user.
* @param $id
*/
public function attachRoleId($id)
{
$this->roles()->attach($id);
}
/**
* Get the social account associated with this user.
* @return HasMany
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function socialAccounts()
{
@@ -221,7 +191,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
try {
$avatar = $this->avatar ? url($this->avatar->getThumb($size, $size, false)) : $default;
} catch (Exception $err) {
} catch (\Exception $err) {
$avatar = $default;
}
return $avatar;
@@ -229,47 +199,27 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
/**
* Get the avatar for the user.
* @return BelongsTo
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function avatar()
{
return $this->belongsTo(Image::class, 'image_id');
}
/**
* Get the API tokens assigned to this user.
*/
public function apiTokens(): HasMany
{
return $this->hasMany(ApiToken::class);
}
/**
* Get the last activity time for this user.
*/
public function scopeWithLastActivityAt(Builder $query)
{
$query->addSelect(['activities.created_at as last_activity_at'])
->leftJoinSub(function (\Illuminate\Database\Query\Builder $query) {
$query->from('activities')->select('user_id')
->selectRaw('max(created_at) as created_at')
->groupBy('user_id');
}, 'activities', 'users.id', '=', 'activities.user_id');
}
/**
* Get the url for editing this user.
* @return string
*/
public function getEditUrl(string $path = ''): string
public function getEditUrl()
{
$uri = '/settings/users/' . $this->id . '/' . trim($path, '/');
return url(rtrim($uri, '/'));
return url('/settings/users/' . $this->id);
}
/**
* Get the url that links to this user's profile.
* @return mixed
*/
public function getProfileUrl(): string
public function getProfileUrl()
{
return url('/user/' . $this->id);
}
@@ -302,12 +252,4 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
{
$this->notify(new ResetPassword($token));
}
/**
* @inheritdoc
*/
public function logDescriptor(): string
{
return "({$this->id}) {$this->name}";
}
}

View File

@@ -1,69 +1,70 @@
<?php namespace BookStack\Auth;
use Activity;
use BookStack\Entities\EntityProvider;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Repos\EntityRepo;
use BookStack\Exceptions\NotFoundException;
use BookStack\Exceptions\UserUpdateException;
use BookStack\Uploads\Image;
use BookStack\Uploads\UserAvatars;
use Exception;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Pagination\LengthAwarePaginator;
use Images;
use Log;
class UserRepo
{
protected $userAvatar;
protected $user;
protected $role;
protected $entityRepo;
/**
* UserRepo constructor.
* @param User $user
* @param Role $role
* @param EntityRepo $entityRepo
*/
public function __construct(UserAvatars $userAvatar)
public function __construct(User $user, Role $role, EntityRepo $entityRepo)
{
$this->userAvatar = $userAvatar;
$this->user = $user;
$this->role = $role;
$this->entityRepo = $entityRepo;
}
/**
* Get a user by their email address.
* @param string $email
* @return User|null
*/
public function getByEmail(string $email): ?User
public function getByEmail($email)
{
return User::query()->where('email', '=', $email)->first();
return $this->user->where('email', '=', $email)->first();
}
/**
* Get a user by their ID.
* @param int $id
* @return User
*/
public function getById(int $id): User
public function getById($id)
{
return User::query()->findOrFail($id);
return $this->user->newQuery()->findOrFail($id);
}
/**
* Get all the users with their permissions.
* @return Builder|static
*/
public function getAllUsers(): Collection
public function getAllUsers()
{
return User::query()->with('roles', 'avatar')->orderBy('name', 'asc')->get();
return $this->user->with('roles', 'avatar')->orderBy('name', 'asc')->get();
}
/**
* Get all the users with their permissions in a paginated format.
* @param int $count
* @param $sortData
* @return Builder|static
*/
public function getAllUsersPaginatedAndSorted(int $count, array $sortData): LengthAwarePaginator
public function getAllUsersPaginatedAndSorted($count, $sortData)
{
$sort = $sortData['sort'];
$query = User::query()->select(['*'])
->withLastActivityAt()
->with(['roles', 'avatar'])
->orderBy($sort, $sortData['order']);
$query = $this->user->with('roles', 'avatar')->orderBy($sortData['sort'], $sortData['order']);
if ($sortData['search']) {
$term = '%' . $sortData['search'] . '%';
@@ -78,24 +79,41 @@ class UserRepo
/**
* Creates a new user and attaches a role to them.
* @param array $data
* @param boolean $verifyEmail
* @return \BookStack\Auth\User
*/
public function registerNew(array $data, bool $emailConfirmed = false): User
public function registerNew(array $data, $verifyEmail = false)
{
$user = $this->create($data, $emailConfirmed);
$user->attachDefaultRole();
$user = $this->create($data, $verifyEmail);
$this->attachDefaultRole($user);
$this->downloadAndAssignUserAvatar($user);
return $user;
}
/**
* Give a user the default role. Used when creating a new user.
* @param User $user
*/
public function attachDefaultRole(User $user)
{
$roleId = setting('registration-role');
if ($roleId !== false && $user->roles()->where('id', '=', $roleId)->count() === 0) {
$user->attachRoleId($roleId);
}
}
/**
* Assign a user to a system-level role.
* @param User $user
* @param $systemRoleName
* @throws NotFoundException
*/
public function attachSystemRole(User $user, string $systemRoleName)
public function attachSystemRole(User $user, $systemRoleName)
{
$role = Role::getSystemRole($systemRoleName);
if (is_null($role)) {
$role = $this->role->newQuery()->where('system_name', '=', $systemRoleName)->first();
if ($role === null) {
throw new NotFoundException("Role '{$systemRoleName}' not found");
}
$user->attachRole($role);
@@ -103,23 +121,26 @@ class UserRepo
/**
* Checks if the give user is the only admin.
* @param \BookStack\Auth\User $user
* @return bool
*/
public function isOnlyAdmin(User $user): bool
public function isOnlyAdmin(User $user)
{
if (!$user->hasSystemRole('admin')) {
return false;
}
$adminRole = Role::getSystemRole('admin');
if ($adminRole->users()->count() > 1) {
$adminRole = $this->role->getSystemRole('admin');
if ($adminRole->users->count() > 1) {
return false;
}
return true;
}
/**
* Set the assigned user roles via an array of role IDs.
* @param User $user
* @param array $roles
* @throws UserUpdateException
*/
public function setUserRoles(User $user, array $roles)
@@ -134,11 +155,14 @@ class UserRepo
/**
* Check if the given user is the last admin and their new roles no longer
* contains the admin role.
* @param User $user
* @param array $newRoles
* @return bool
*/
protected function demotingLastAdmin(User $user, array $newRoles) : bool
{
if ($this->isOnlyAdmin($user)) {
$adminRole = Role::getSystemRole('admin');
$adminRole = $this->role->getSystemRole('admin');
if (!in_array(strval($adminRole->id), $newRoles)) {
return true;
}
@@ -149,118 +173,123 @@ class UserRepo
/**
* Create a new basic instance of user.
* @param array $data
* @param boolean $verifyEmail
* @return \BookStack\Auth\User
*/
public function create(array $data, bool $emailConfirmed = false): User
public function create(array $data, $verifyEmail = false)
{
$details = [
return $this->user->forceCreate([
'name' => $data['name'],
'email' => $data['email'],
'password' => bcrypt($data['password']),
'email_confirmed' => $emailConfirmed,
'external_auth_id' => $data['external_auth_id'] ?? '',
];
return User::query()->forceCreate($details);
'email_confirmed' => $verifyEmail
]);
}
/**
* Remove the given user from storage, Delete all related content.
* @param \BookStack\Auth\User $user
* @throws Exception
*/
public function destroy(User $user, ?int $newOwnerId = null)
public function destroy(User $user)
{
$user->socialAccounts()->delete();
$user->apiTokens()->delete();
$user->delete();
// Delete user profile images
$profileImages = Image::query()->where('type', '=', 'user')
->where('uploaded_to', '=', $user->id)
->get();
$profileImages = Image::where('type', '=', 'user')->where('uploaded_to', '=', $user->id)->get();
foreach ($profileImages as $image) {
Images::destroy($image);
}
if (!empty($newOwnerId)) {
$newOwner = User::query()->find($newOwnerId);
if (!is_null($newOwner)) {
$this->migrateOwnership($user, $newOwner);
}
}
}
/**
* Migrate ownership of items in the system from one user to another.
*/
protected function migrateOwnership(User $fromUser, User $toUser)
{
$entities = (new EntityProvider)->all();
foreach ($entities as $instance) {
$instance->newQuery()->where('owned_by', '=', $fromUser->id)
->update(['owned_by' => $toUser->id]);
}
}
/**
* Get the latest activity for a user.
* @param \BookStack\Auth\User $user
* @param int $count
* @param int $page
* @return array
*/
public function getActivity(User $user, int $count = 20, int $page = 0): array
public function getActivity(User $user, $count = 20, $page = 0)
{
return Activity::userActivity($user, $count, $page);
}
/**
* Get the recently created content for this given user.
* @param \BookStack\Auth\User $user
* @param int $count
* @return mixed
*/
public function getRecentlyCreated(User $user, int $count = 20): array
public function getRecentlyCreated(User $user, $count = 20)
{
$query = function (Builder $query) use ($user, $count) {
return $query->orderBy('created_at', 'desc')
->where('created_by', '=', $user->id)
->take($count)
->get();
$createdByUserQuery = function (Builder $query) use ($user) {
$query->where('created_by', '=', $user->id);
};
return [
'pages' => $query(Page::visible()->where('draft', '=', false)),
'chapters' => $query(Chapter::visible()),
'books' => $query(Book::visible()),
'shelves' => $query(Bookshelf::visible()),
'pages' => $this->entityRepo->getRecentlyCreated('page', $count, 0, $createdByUserQuery),
'chapters' => $this->entityRepo->getRecentlyCreated('chapter', $count, 0, $createdByUserQuery),
'books' => $this->entityRepo->getRecentlyCreated('book', $count, 0, $createdByUserQuery),
'shelves' => $this->entityRepo->getRecentlyCreated('bookshelf', $count, 0, $createdByUserQuery)
];
}
/**
* Get asset created counts for the give user.
* @param \BookStack\Auth\User $user
* @return array
*/
public function getAssetCounts(User $user): array
public function getAssetCounts(User $user)
{
$createdBy = ['created_by' => $user->id];
return [
'pages' => Page::visible()->where($createdBy)->count(),
'chapters' => Chapter::visible()->where($createdBy)->count(),
'books' => Book::visible()->where($createdBy)->count(),
'shelves' => Bookshelf::visible()->where($createdBy)->count(),
'pages' => $this->entityRepo->getUserTotalCreated('page', $user),
'chapters' => $this->entityRepo->getUserTotalCreated('chapter', $user),
'books' => $this->entityRepo->getUserTotalCreated('book', $user),
'shelves' => $this->entityRepo->getUserTotalCreated('bookshelf', $user),
];
}
/**
* Get the roles in the system that are assignable to a user.
* @return mixed
*/
public function getAllRoles(): Collection
public function getAllRoles()
{
return Role::query()->orderBy('display_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.
* Returns early if avatars disabled or not set in config.
* @param User $user
* @return bool
*/
public function downloadAndAssignUserAvatar(User $user): void
public function downloadAndAssignUserAvatar(User $user)
{
if (!Images::avatarFetchEnabled()) {
return false;
}
try {
$this->userAvatar->fetchAndAssignToUser($user);
$avatar = Images::saveUserAvatar($user);
$user->avatar()->associate($avatar);
$user->save();
return true;
} catch (Exception $e) {
Log::error('Failed to save user avatar image');
\Log::error('Failed to save user avatar image');
return false;
}
}
}

View File

@@ -1,23 +0,0 @@
<?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)
];

View File

@@ -31,13 +31,6 @@ return [
// If set to false then a limit will not be enforced.
'revision_limit' => env('REVISION_LIMIT', 50),
// The number of days that content will remain in the recycle bin before
// being considered for auto-removal. It is not a guarantee that content will
// be removed after this time.
// Set to 0 for no recycle bin functionality.
// Set to -1 for unlimited recycle bin lifetime.
'recycle_bin_lifetime' => env('RECYCLE_BIN_LIFETIME', 30),
// Allow <script> tags to entered within page content.
// <script> tags are escaped by default.
// Even when overridden the WYSIWYG editor may still escape script content.
@@ -52,10 +45,6 @@ return [
// and used by BookStack in URL generation.
'url' => env('APP_URL', '') === 'http://bookstack.dev' ? '' : env('APP_URL', ''),
// A list of hosts that BookStack can be iframed within.
// Space separated if multiple. BookStack host domain is auto-inferred.
'iframe_hosts' => env('ALLOWED_IFRAME_HOSTS', null),
// Application timezone for back-end date functions.
'timezone' => env('APP_TIMEZONE', 'UTC'),
@@ -63,14 +52,11 @@ return [
'locale' => env('APP_LANG', 'en'),
// Locales available
'locales' => ['en', 'ar', 'bg', 'cs', 'da', 'de', 'de_informal', 'es', 'es_AR', 'fa', 'fr', 'he', 'hu', 'it', 'ja', 'ko', 'nl', 'nb', 'pt', 'pt_BR', 'sk', 'sl', 'sv', 'pl', 'ru', 'th', 'tr', 'uk', 'vi', 'zh_CN', 'zh_TW',],
'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'],
// Application Fallback Locale
'fallback_locale' => 'en',
// Faker Locale
'faker_locale' => 'en_GB',
// Enable right-to-left text control.
'rtl' => false,
@@ -86,6 +72,10 @@ return [
// Encryption cipher
'cipher' => 'AES-256-CBC',
// Logging configuration
// Options: single, daily, syslog, errorlog
'log' => env('APP_LOGGING', 'single'),
// Application Services Provides
'providers' => [
@@ -117,6 +107,7 @@ return [
Barryvdh\DomPDF\ServiceProvider::class,
Barryvdh\Snappy\ServiceProvider::class,
// BookStack replacement service providers (Extends Laravel)
BookStack\Providers\PaginationServiceProvider::class,
BookStack\Providers\TranslationServiceProvider::class,
@@ -128,7 +119,6 @@ return [
BookStack\Providers\EventServiceProvider::class,
BookStack\Providers\RouteServiceProvider::class,
BookStack\Providers\CustomFacadeProvider::class,
BookStack\Providers\CustomValidationServiceProvider::class,
],
/*
@@ -147,7 +137,6 @@ return [
// Laravel
'App' => Illuminate\Support\Facades\App::class,
'Arr' => Illuminate\Support\Arr::class,
'Artisan' => Illuminate\Support\Facades\Artisan::class,
'Auth' => Illuminate\Support\Facades\Auth::class,
'Blade' => Illuminate\Support\Facades\Blade::class,
@@ -177,7 +166,6 @@ return [
'Schema' => Illuminate\Support\Facades\Schema::class,
'Session' => Illuminate\Support\Facades\Session::class,
'Storage' => Illuminate\Support\Facades\Storage::class,
'Str' => Illuminate\Support\Str::class,
'URL' => Illuminate\Support\Facades\URL::class,
'Validator' => Illuminate\Support\Facades\Validator::class,
'View' => Illuminate\Support\Facades\View::class,
@@ -193,7 +181,6 @@ return [
'Setting' => BookStack\Facades\Setting::class,
'Views' => BookStack\Facades\Views::class,
'Images' => BookStack\Facades\Images::class,
'Permissions' => BookStack\Facades\Permissions::class,
],

View File

@@ -11,14 +11,14 @@
return [
// Method of authentication to use
// Options: standard, ldap, saml2
// Options: standard, ldap
'method' => env('AUTH_METHOD', 'standard'),
// Authentication Defaults
// This option controls the default authentication "guard" and password
// reset options for your application.
'defaults' => [
'guard' => env('AUTH_METHOD', 'standard'),
'guard' => 'web',
'passwords' => 'users',
],
@@ -26,22 +26,16 @@ return [
// All authentication drivers have a user provider. This defines how the
// users are actually retrieved out of your database or other storage
// mechanisms used by this application to persist your user's data.
// Supported drivers: "session", "api-token", "ldap-session"
// Supported: "session", "token"
'guards' => [
'standard' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
'ldap' => [
'driver' => 'ldap-session',
'provider' => 'external',
],
'saml2' => [
'driver' => 'saml2-session',
'provider' => 'external',
],
'api' => [
'driver' => 'api-token',
'driver' => 'token',
'provider' => 'users',
],
],
@@ -49,15 +43,17 @@ return [
// All authentication drivers have a user provider. This defines how the
// users are actually retrieved out of your database or other storage
// mechanisms used by this application to persist your user's data.
// Supported: database, eloquent, ldap
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => \BookStack\Auth\User::class,
],
'external' => [
'driver' => 'external-users',
'driver' => env('AUTH_METHOD', 'standard') === 'standard' ? 'eloquent' : env('AUTH_METHOD'),
'model' => \BookStack\Auth\User::class,
],
// 'users' => [
// 'driver' => 'database',
// 'table' => 'users',
// ],
],
// Resetting Passwords
@@ -73,4 +69,4 @@ return [
],
],
];
];

View File

@@ -24,13 +24,9 @@ return [
'pusher' => [
'driver' => 'pusher',
'key' => env('PUSHER_APP_KEY'),
'secret' => env('PUSHER_APP_SECRET'),
'key' => env('PUSHER_KEY'),
'secret' => env('PUSHER_SECRET'),
'app_id' => env('PUSHER_APP_ID'),
'options' => [
'cluster' => env('PUSHER_APP_CLUSTER'),
'useTLS' => true,
],
],
'redis' => [
@@ -42,11 +38,6 @@ return [
'driver' => 'log',
],
'null' => [
'driver' => 'null',
],
],
];

View File

@@ -14,12 +14,8 @@ if (env('CACHE_DRIVER') === 'memcached') {
$memcachedServers = explode(',', trim(env('MEMCACHED_SERVERS', '127.0.0.1:11211:100'), ','));
foreach ($memcachedServers as $index => $memcachedServer) {
$memcachedServerDetails = explode(':', $memcachedServer);
if (count($memcachedServerDetails) < 2) {
$memcachedServerDetails[] = '11211';
}
if (count($memcachedServerDetails) < 3) {
$memcachedServerDetails[] = '100';
}
if (count($memcachedServerDetails) < 2) $memcachedServerDetails[] = '11211';
if (count($memcachedServerDetails) < 3) $memcachedServerDetails[] = '100';
$memcachedServers[$index] = array_combine($memcachedServerKeys, $memcachedServerDetails);
}
}
@@ -66,6 +62,6 @@ return [
// Cache key prefix
// Used to prevent collisions in shared cache systems.
'prefix' => env('CACHE_PREFIX', 'bookstack_cache'),
'prefix' => env('CACHE_PREFIX', 'bookstack'),
];

View File

@@ -11,9 +11,10 @@
// REDIS
// Split out configuration into an array
if (env('REDIS_SERVERS', false)) {
$redisDefaults = ['host' => '127.0.0.1', 'port' => '6379', 'database' => '0', 'password' => null];
$redisServers = explode(',', trim(env('REDIS_SERVERS', '127.0.0.1:6379:0'), ','));
$redisConfig = ['client' => 'predis'];
$redisConfig = [];
$cluster = count($redisServers) > 1;
if ($cluster) {
@@ -58,9 +59,14 @@ return [
// Many of those shown here are unsupported by BookStack.
'connections' => [
'sqlite' => [
'driver' => 'sqlite',
'database' => storage_path('database.sqlite'),
'prefix' => '',
],
'mysql' => [
'driver' => 'mysql',
'url' => env('DATABASE_URL'),
'host' => $mysql_host,
'database' => env('DB_DATABASE', 'forge'),
'username' => env('DB_USERNAME', 'forge'),
@@ -70,28 +76,43 @@ return [
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '',
'prefix_indexes' => true,
'strict' => false,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'mysql_testing' => [
'driver' => 'mysql',
'url' => env('TEST_DATABASE_URL'),
'host' => '127.0.0.1',
'database' => 'bookstack-test',
'username' => env('MYSQL_USER', 'bookstack-test'),
'password' => env('MYSQL_PASSWORD', 'bookstack-test'),
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'charset' => 'utf8',
'collation' => 'utf8_unicode_ci',
'prefix' => '',
'prefix_indexes' => true,
'strict' => false,
],
'pgsql' => [
'driver' => 'pgsql',
'host' => env('DB_HOST', 'localhost'),
'database' => env('DB_DATABASE', 'forge'),
'username' => env('DB_USERNAME', 'forge'),
'password' => env('DB_PASSWORD', ''),
'charset' => 'utf8',
'prefix' => '',
'schema' => 'public',
],
'sqlsrv' => [
'driver' => 'sqlsrv',
'host' => env('DB_HOST', 'localhost'),
'database' => env('DB_DATABASE', 'forge'),
'username' => env('DB_USERNAME', 'forge'),
'password' => env('DB_PASSWORD', ''),
'charset' => 'utf8',
'prefix' => '',
],
],
// Migration Repository Table

View File

@@ -79,7 +79,6 @@ return [
'files' => false, // Show the included files
'config' => false, // Display config settings
'cache' => false, // Display cache events
'models' => true, // Display models
],
// Configure some DataCollectors

View File

@@ -69,7 +69,7 @@ return [
* should be an absolute path.
* This is only checked on command line call by dompdf.php, but not by
* direct class use like:
* $dompdf = new DOMPDF(); $dompdf->load_html($htmldata); $dompdf->render(); $pdfdata = $dompdf->output();
* $dompdf = new DOMPDF(); $dompdf->load_html($htmldata); $dompdf->render(); $pdfdata = $dompdf->output();
*/
"DOMPDF_CHROOT" => realpath(base_path()),

View File

@@ -42,6 +42,13 @@ return [
'root' => storage_path(),
],
'ftp' => [
'driver' => 'ftp',
'host' => 'ftp.example.com',
'username' => 'your-username',
'password' => 'your-password',
],
's3' => [
'driver' => 's3',
'key' => env('STORAGE_S3_KEY', 'your-key'),
@@ -52,6 +59,16 @@ return [
'use_path_style_endpoint' => env('STORAGE_S3_ENDPOINT', null) !== null,
],
'rackspace' => [
'driver' => 'rackspace',
'username' => 'your-username',
'key' => 'your-key',
'container' => 'your-container',
'endpoint' => 'https://identity.api.rackspacecloud.com/v2.0/',
'region' => 'IAD',
'url_type' => 'publicURL',
],
],
];

View File

@@ -1,37 +0,0 @@
<?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,
],
];

View File

@@ -1,112 +0,0 @@
<?php
use Monolog\Formatter\LineFormatter;
use Monolog\Handler\ErrorLogHandler;
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',
],
// Custom errorlog implementation that logs out a plain,
// non-formatted message intended for the webserver log.
'errorlog_plain_webserver' => [
'driver' => 'monolog',
'level' => 'debug',
'handler' => ErrorLogHandler::class,
'handler_with' => [4],
'formatter' => LineFormatter::class,
'formatter_with' => [
'format' => "%message%",
],
],
'null' => [
'driver' => 'monolog',
'handler' => NullHandler::class,
],
// Testing channel
// Uses a shared testing instance during tests
// so that logs can be checked against.
'testing' => [
'driver' => 'testing',
],
],
// Failed Login Message
// Allows a configurable message to be logged when a login request fails.
'failed_login' => [
'message' => env('LOG_FAILED_LOGIN_MESSAGE', null),
'channel' => env('LOG_FAILED_LOGIN_CHANNEL', 'errorlog_plain_webserver'),
],
];

View File

@@ -23,7 +23,7 @@ return [
// Global "From" address & name
'from' => [
'address' => env('MAIL_FROM', 'mail@bookstackapp.com'),
'name' => env('MAIL_FROM_NAME', 'BookStack')
'name' => env('MAIL_FROM_NAME','BookStack')
],
// Email encryption protocol
@@ -46,10 +46,4 @@ 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'),
];

View File

@@ -12,12 +12,11 @@ return [
// Default driver to use for the queue
// Options: null, sync, redis
'default' => env('QUEUE_CONNECTION', 'sync'),
'default' => env('QUEUE_DRIVER', 'sync'),
// Queue connection configuration
'connections' => [
'sync' => [
'driver' => 'sync',
],
@@ -26,15 +25,38 @@ return [
'driver' => 'database',
'table' => 'jobs',
'queue' => 'default',
'retry_after' => 90,
'expire' => 60,
],
'beanstalkd' => [
'driver' => 'beanstalkd',
'host' => 'localhost',
'queue' => 'default',
'ttr' => 60,
],
'sqs' => [
'driver' => 'sqs',
'key' => 'your-public-key',
'secret' => 'your-secret-key',
'queue' => 'your-queue-url',
'region' => 'us-east-1',
],
'iron' => [
'driver' => 'iron',
'host' => 'mq-aws-us-east-1.iron.io',
'token' => 'your-token',
'project' => 'your-project-id',
'queue' => 'your-queue-name',
'encrypt' => true,
],
'redis' => [
'driver' => 'redis',
'connection' => 'default',
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => 90,
'block_for' => null,
'queue' => 'default',
'expire' => 60,
],
],

View File

@@ -1,144 +0,0 @@
<?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' => 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',
],
// 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>',
// )
// ),
],
],
];

View File

@@ -22,6 +22,23 @@ return [
// Callback URL for social authentication methods
'callback_url' => env('APP_URL', false),
'mailgun' => [
'domain' => '',
'secret' => '',
],
'ses' => [
'key' => '',
'secret' => '',
'region' => 'us-east-1',
],
'stripe' => [
'model' => \BookStack\Auth\User::class,
'key' => '',
'secret' => '',
],
'github' => [
'client_id' => env('GITHUB_APP_ID', false),
'client_secret' => env('GITHUB_APP_SECRET', false),
@@ -81,8 +98,8 @@ return [
'okta' => [
'client_id' => env('OKTA_APP_ID'),
'client_secret' => env('OKTA_APP_SECRET'),
'redirect' => env('APP_URL') . '/login/service/okta/callback',
'base_url' => env('OKTA_BASE_URL'),
'redirect' => env('APP_URL') . '/login/service/okta/callback',
'base_url' => env('OKTA_BASE_URL'),
'name' => 'Okta',
'auto_register' => env('OKTA_AUTO_REGISTER', false),
'auto_confirm' => env('OKTA_AUTO_CONFIRM_EMAIL', false),
@@ -118,20 +135,18 @@ return [
'ldap' => [
'server' => env('LDAP_SERVER', false),
'dump_user_details' => env('LDAP_DUMP_USER_DETAILS', false),
'dn' => env('LDAP_DN', false),
'pass' => env('LDAP_PASS', false),
'base_dn' => env('LDAP_BASE_DN', false),
'user_filter' => env('LDAP_USER_FILTER', '(&(uid=${user}))'),
'version' => env('LDAP_VERSION', false),
'id_attribute' => env('LDAP_ID_ATTRIBUTE', 'uid'),
'email_attribute' => env('LDAP_EMAIL_ATTRIBUTE', 'mail'),
'display_name_attribute' => env('LDAP_DISPLAY_NAME_ATTRIBUTE', 'cn'),
'follow_referrals' => env('LDAP_FOLLOW_REFERRALS', false),
'user_to_groups' => env('LDAP_USER_TO_GROUPS', false),
'group_attribute' => env('LDAP_GROUP_ATTRIBUTE', 'memberOf'),
'remove_from_groups' => env('LDAP_REMOVE_FROM_GROUPS', false),
'tls_insecure' => env('LDAP_TLS_INSECURE', false),
],
'user_to_groups' => env('LDAP_USER_TO_GROUPS',false),
'group_attribute' => env('LDAP_GROUP_ATTRIBUTE', 'memberOf'),
'remove_from_groups' => env('LDAP_REMOVE_FROM_GROUPS',false),
'tls_insecure' => env('LDAP_TLS_INSECURE', false),
]
];

View File

@@ -1,7 +1,5 @@
<?php
use \Illuminate\Support\Str;
/**
* Session configuration options.
*
@@ -37,18 +35,13 @@ return [
// Session database table, if database driver is in use
'table' => 'sessions',
// Session Cache Store
// When using the "apc" or "memcached" session drivers, you may specify a
// cache store that should be used for these sessions. This value must
// correspond with one of the application's configured cache stores.
'store' => null,
// Session Sweeping Lottery
// Some session drivers must manually sweep their storage location to get
// rid of old sessions from storage. Here are the chances that it will
// happen on a given request. By default, the odds are 2 out of 100.
'lottery' => [2, 100],
// Session Cookie Name
// Here you may change the name of the cookie used to identify a session
// instance by ID. The name specified here will get used every time a
@@ -71,8 +64,7 @@ return [
// By setting this option to true, session cookies will only be sent back
// to the server if the browser has a HTTPS connection. This will keep
// the cookie from being sent to you if it can not be done securely.
'secure' => env('SESSION_SECURE_COOKIE', null)
?? Str::startsWith(env('APP_URL'), 'https:'),
'secure' => env('SESSION_SECURE_COOKIE', false),
// HTTP Access Only
// Setting this value to true will prevent JavaScript from accessing the
@@ -83,6 +75,6 @@ return [
// This option determines how your cookies behave when cross-site requests
// take place, and can be used to mitigate CSRF attacks. By default, we
// do not enable this as other CSRF protection services are in place.
// Options: lax, strict, none
'same_site' => 'lax',
// Options: lax, strict
'same_site' => null,
];

View File

@@ -16,12 +16,7 @@ return [
'app-editor' => 'wysiwyg',
'app-color' => '#206ea7',
'app-color-light' => 'rgba(32,110,167,0.15)',
'bookshelf-color' => '#a94747',
'book-color' => '#077b70',
'chapter-color' => '#af4d0d',
'page-color' => '#206ea7',
'page-draft-color' => '#7e50b1',
'app-custom-head' => false,
'registration-enabled' => false,
];
];

View File

@@ -13,9 +13,7 @@ return [
'enabled' => true,
'binary' => file_exists(base_path('wkhtmltopdf')) ? base_path('wkhtmltopdf') : env('WKHTMLTOPDF', false),
'timeout' => false,
'options' => [
'outline' => true
],
'options' => [],
'env' => [],
],
'image' => [

View File

@@ -14,8 +14,8 @@ class CleanupImages extends Command
* @var string
*/
protected $signature = 'bookstack:cleanup-images
{--a|all : Also delete images that are only used in old revisions}
{--f|force : Actually run the deletions, Defaults to a dry-run}
{--a|all : Include images that are used in page revisions}
{--f|force : Actually run the deletions}
';
/**

View File

@@ -2,7 +2,7 @@
namespace BookStack\Console\Commands;
use BookStack\Entities\Models\PageRevision;
use BookStack\Entities\PageRevision;
use Illuminate\Console\Command;
class ClearRevisions extends Command

View File

@@ -18,7 +18,7 @@ class ClearViews extends Command
*
* @var string
*/
protected $description = 'Clear all view-counts for all entities';
protected $description = 'Clear all view-counts for all entities.';
/**
* Create a new command instance.

View File

@@ -1,88 +0,0 @@
<?php
namespace BookStack\Console\Commands;
use BookStack\Entities\Models\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.');
}
}

View File

@@ -28,6 +28,8 @@ class CreateAdmin extends Command
/**
* Create a new command instance.
*
* @param UserRepo $userRepo
*/
public function __construct(UserRepo $userRepo)
{

View File

@@ -25,7 +25,7 @@ class DeleteUsers extends Command
*
* @var string
*/
protected $description = 'Delete users that are not "admin" or system users';
protected $description = 'Delete users that are not "admin" or system users.';
public function __construct(User $user, UserRepo $userRepo)
{

View File

@@ -1,61 +0,0 @@
<?php
namespace BookStack\Console\Commands;
use BookStack\Actions\Comment;
use BookStack\Actions\CommentRepo;
use Illuminate\Console\Command;
class RegenerateCommentContent extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'bookstack:regenerate-comment-content {--database= : The database connection to use.}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Regenerate the stored HTML of all comments';
/**
* @var CommentRepo
*/
protected $commentRepo;
/**
* Create a new command instance.
*/
public function __construct(CommentRepo $commentRepo)
{
$this->commentRepo = $commentRepo;
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$connection = \DB::getDefaultConnection();
if ($this->option('database') !== null) {
\DB::setDefaultConnection($this->option('database'));
}
Comment::query()->chunk(100, function ($comments) {
foreach ($comments as $comment) {
$comment->html = $this->commentRepo->commentToHtml($comment->text);
$comment->save();
}
});
\DB::setDefaultConnection($connection);
$this->comment('Comment HTML content has been regenerated');
}
}

View File

@@ -30,6 +30,8 @@ class RegeneratePermissions extends Command
/**
* Create a new command instance.
*
* @param \BookStack\Auth\\BookStack\Auth\Permissions\PermissionService $permissionService
*/
public function __construct(PermissionService $permissionService)
{

View File

@@ -2,8 +2,7 @@
namespace BookStack\Console\Commands;
use BookStack\Entities\Tools\SearchIndex;
use DB;
use BookStack\Entities\SearchService;
use Illuminate\Console\Command;
class RegenerateSearch extends Command
@@ -22,15 +21,17 @@ class RegenerateSearch extends Command
*/
protected $description = 'Re-index all content for searching';
protected $searchIndex;
protected $searchService;
/**
* Create a new command instance.
*
* @param \BookStack\Entities\SearchService $searchService
*/
public function __construct(SearchIndex $searchIndex)
public function __construct(SearchService $searchService)
{
parent::__construct();
$this->searchIndex = $searchIndex;
$this->searchService = $searchService;
}
/**
@@ -40,13 +41,14 @@ class RegenerateSearch extends Command
*/
public function handle()
{
$connection = DB::getDefaultConnection();
$connection = \DB::getDefaultConnection();
if ($this->option('database') !== null) {
DB::setDefaultConnection($this->option('database'));
\DB::setDefaultConnection($this->option('database'));
$this->searchService->setConnection(\DB::connection($this->option('database')));
}
$this->searchIndex->indexAllEntities();
DB::setDefaultConnection($connection);
$this->searchService->indexAllEntities();
\DB::setDefaultConnection($connection);
$this->comment('Search index regenerated');
}
}

View File

@@ -1,91 +0,0 @@
<?php
namespace BookStack\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Database\Connection;
class UpdateUrl extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'bookstack:update-url
{oldUrl : URL to replace}
{newUrl : URL to use as the replacement}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Find and replace the given URLs in your BookStack database';
protected $db;
/**
* Create a new command instance.
*
* @return void
*/
public function __construct(Connection $db)
{
$this->db = $db;
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$oldUrl = str_replace("'", '', $this->argument('oldUrl'));
$newUrl = str_replace("'", '', $this->argument('newUrl'));
$urlPattern = '/https?:\/\/(.+)/';
if (!preg_match($urlPattern, $oldUrl) || !preg_match($urlPattern, $newUrl)) {
$this->error("The given urls are expected to be full urls starting with http:// or https://");
return 1;
}
if (!$this->checkUserOkayToProceed($oldUrl, $newUrl)) {
return 1;
}
$columnsToUpdateByTable = [
"attachments" => ["path"],
"pages" => ["html", "text", "markdown"],
"images" => ["url"],
"comments" => ["html", "text"],
];
foreach ($columnsToUpdateByTable as $table => $columns) {
foreach ($columns as $column) {
$changeCount = $this->db->table($table)->update([
$column => $this->db->raw("REPLACE({$column}, '{$oldUrl}', '{$newUrl}')")
]);
$this->info("Updated {$changeCount} rows in {$table}->{$column}");
}
}
$this->info("URL update procedure complete.");
return 0;
}
/**
* Warn the user of the dangers of this operation.
* Returns a boolean indicating if they've accepted the warnings.
*/
protected function checkUserOkayToProceed(string $oldUrl, string $newUrl): bool
{
$dangerWarning = "This will search for \"{$oldUrl}\" in your database and replace it with \"{$newUrl}\".\n";
$dangerWarning .= "Are you sure you want to proceed?";
$backupConfirmation = "This operation could cause issues if used incorrectly. Have you made a backup of your existing database?";
return $this->confirm($dangerWarning) && $this->confirm($backupConfirmation);
}
}

118
app/Entities/Book.php Normal file
View File

@@ -0,0 +1,118 @@
<?php namespace BookStack\Entities;
use BookStack\Uploads\Image;
class Book extends Entity
{
public $searchFactor = 2;
protected $fillable = ['name', 'description', 'image_id'];
/**
* Get the morph class for this model.
* @return string
*/
public function getMorphClass()
{
return 'BookStack\\Book';
}
/**
* Get the url for this book.
* @param string|bool $path
* @return string
*/
public function getUrl($path = false)
{
if ($path !== false) {
return url('/books/' . urlencode($this->slug) . '/' . trim($path, '/'));
}
return url('/books/' . urlencode($this->slug));
}
/**
* Returns book cover image, if book cover not exists return default cover image.
* @param int $width - Width of the image
* @param int $height - Height of the image
* @return string
*/
public function getBookCover($width = 440, $height = 250)
{
$default = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
if (!$this->image_id) {
return $default;
}
try {
$cover = $this->cover ? url($this->cover->getThumb($width, $height, false)) : $default;
} catch (\Exception $err) {
$cover = $default;
}
return $cover;
}
/**
* Get the cover image of the book
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function cover()
{
return $this->belongsTo(Image::class, 'image_id');
}
/**
* Get all pages within this book.
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function pages()
{
return $this->hasMany(Page::class);
}
/**
* Get the direct child pages of this book.
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function directPages()
{
return $this->pages()->where('chapter_id', '=', '0');
}
/**
* Get all chapters within this book.
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function chapters()
{
return $this->hasMany(Chapter::class);
}
/**
* Get the shelves this book is contained within.
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
public function shelves()
{
return $this->belongsToMany(Bookshelf::class, 'bookshelves_books', 'book_id', 'bookshelf_id');
}
/**
* Get an excerpt of this book's description to the specified length or less.
* @param int $length
* @return string
*/
public function getExcerpt(int $length = 100)
{
$description = $this->description;
return mb_strlen($description) > $length ? mb_substr($description, 0, $length-3) . '...' : $description;
}
/**
* Return a generalised, common raw query that can be 'unioned' across entities.
* @return string
*/
public function entityRawQuery()
{
return "'BookStack\\\\Book' as entity_type, id, id as entity_id, slug, name, {$this->textField} as text,'' as html, '0' as book_id, '0' as priority, '0' as chapter_id, '0' as draft, created_by, updated_by, updated_at, created_at";
}
}

View File

@@ -1,10 +1,8 @@
<?php namespace BookStack\Entities\Models;
<?php namespace BookStack\Entities;
use BookStack\Uploads\Image;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Bookshelf extends Entity implements HasCoverImage
class Bookshelf extends Entity
{
protected $table = 'bookshelves';
@@ -12,7 +10,14 @@ class Bookshelf extends Entity implements HasCoverImage
protected $fillable = ['name', 'description', 'image_id'];
protected $hidden = ['restricted', 'image_id', 'deleted_at'];
/**
* Get the morph class for this model.
* @return string
*/
public function getMorphClass()
{
return 'BookStack\\Bookshelf';
}
/**
* Get the books in this shelf.
@@ -26,20 +31,17 @@ class Bookshelf extends Entity implements HasCoverImage
->orderBy('order', 'asc');
}
/**
* Related books that are visible to the current user.
*/
public function visibleBooks(): BelongsToMany
{
return $this->books()->visible();
}
/**
* Get the url for this bookshelf.
* @param string|bool $path
* @return string
*/
public function getUrl(string $path = ''): string
public function getUrl($path = false)
{
return url('/shelves/' . implode('/', [urlencode($this->slug), trim($path, '/')]));
if ($path !== false) {
return url('/shelves/' . urlencode($this->slug) . '/' . trim($path, '/'));
}
return url('/shelves/' . urlencode($this->slug));
}
/**
@@ -66,18 +68,31 @@ class Bookshelf extends Entity implements HasCoverImage
/**
* Get the cover image of the shelf
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function cover(): BelongsTo
public function cover()
{
return $this->belongsTo(Image::class, 'image_id');
}
/**
* Get the type of the image model that is used when storing a cover image.
* Get an excerpt of this book's description to the specified length or less.
* @param int $length
* @return string
*/
public function coverImageTypeKey(): string
public function getExcerpt(int $length = 100)
{
return 'cover_shelf';
$description = $this->description;
return mb_strlen($description) > $length ? mb_substr($description, 0, $length-3) . '...' : $description;
}
/**
* Return a generalised, common raw query that can be 'unioned' across entities.
* @return string
*/
public function entityRawQuery()
{
return "'BookStack\\\\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";
}
/**
@@ -85,22 +100,8 @@ class Bookshelf extends Entity implements HasCoverImage
* @param Book $book
* @return bool
*/
public function contains(Book $book): bool
public function contains(Book $book)
{
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]);
}
}

View File

@@ -1,7 +1,5 @@
<?php namespace BookStack\Entities;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Tools\ShelfContext;
use Illuminate\View\View;
class BreadcrumbsViewComposer
@@ -11,9 +9,9 @@ class BreadcrumbsViewComposer
/**
* BreadcrumbsViewComposer constructor.
* @param ShelfContext $entityContextManager
* @param EntityContextManager $entityContextManager
*/
public function __construct(ShelfContext $entityContextManager)
public function __construct(EntityContextManager $entityContextManager)
{
$this->entityContextManager = $entityContextManager;
}
@@ -25,9 +23,8 @@ class BreadcrumbsViewComposer
public function compose(View $view)
{
$crumbs = $view->getData()['crumbs'];
$firstCrumb = $crumbs[0] ?? null;
if ($firstCrumb instanceof Book) {
$shelf = $this->entityContextManager->getContextualShelfForBook($firstCrumb);
if (array_first($crumbs) instanceof Book) {
$shelf = $this->entityContextManager->getContextualShelfForBook(array_first($crumbs));
if ($shelf) {
array_unshift($crumbs, $shelf);
$view->with('crumbs', $crumbs);

82
app/Entities/Chapter.php Normal file
View File

@@ -0,0 +1,82 @@
<?php namespace BookStack\Entities;
class Chapter extends Entity
{
public $searchFactor = 1.3;
protected $fillable = ['name', 'description', 'priority', 'book_id'];
/**
* Get the morph class for this model.
* @return string
*/
public function getMorphClass()
{
return 'BookStack\\Chapter';
}
/**
* Get the book this chapter is within.
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function book()
{
return $this->belongsTo(Book::class);
}
/**
* Get the pages that this chapter contains.
* @param string $dir
* @return mixed
*/
public function pages($dir = 'ASC')
{
return $this->hasMany(Page::class)->orderBy('priority', $dir);
}
/**
* Get the url of this chapter.
* @param string|bool $path
* @return string
*/
public function getUrl($path = false)
{
$bookSlug = $this->getAttribute('bookSlug') ? $this->getAttribute('bookSlug') : $this->book->slug;
$fullPath = '/books/' . urlencode($bookSlug) . '/chapter/' . urlencode($this->slug);
if ($path !== false) {
$fullPath .= '/' . trim($path, '/');
}
return url($fullPath);
}
/**
* Get an excerpt of this chapter's description to the specified length or less.
* @param int $length
* @return string
*/
public function getExcerpt(int $length = 100)
{
$description = $this->text ?? $this->description;
return mb_strlen($description) > $length ? mb_substr($description, 0, $length-3) . '...' : $description;
}
/**
* Return a generalised, common raw query that can be 'unioned' across entities.
* @return string
*/
public function entityRawQuery()
{
return "'BookStack\\\\Chapter' as entity_type, id, id as entity_id, slug, name, {$this->textField} as text, '' as html, book_id, priority, '0' as chapter_id, '0' as draft, created_by, updated_by, updated_at, created_at";
}
/**
* Check if this chapter has any child pages.
* @return bool
*/
public function hasChildren()
{
return count($this->pages) > 0;
}
}

258
app/Entities/Entity.php Normal file
View File

@@ -0,0 +1,258 @@
<?php namespace BookStack\Entities;
use BookStack\Actions\Activity;
use BookStack\Actions\Comment;
use BookStack\Actions\Tag;
use BookStack\Actions\View;
use BookStack\Auth\Permissions\EntityPermission;
use BookStack\Auth\Permissions\JointPermission;
use BookStack\Ownable;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Relations\MorphMany;
/**
* Class Entity
* The base class for book-like items such as pages, chapters & books.
* This is not a database model in itself but extended.
*
* @property integer $id
* @property string $name
* @property string $slug
* @property Carbon $created_at
* @property Carbon $updated_at
* @property int $created_by
* @property int $updated_by
* @property boolean $restricted
*
* @package BookStack\Entities
*/
class Entity extends Ownable
{
/**
* @var string - Name of property where the main text content is found
*/
public $textField = 'description';
/**
* @var float - Multiplier for search indexing.
*/
public $searchFactor = 1.0;
/**
* Get the morph class for this model.
* Set here since, due to folder changes, the namespace used
* in the database no longer matches the class namespace.
* @return string
*/
public function getMorphClass()
{
return 'BookStack\\Entity';
}
/**
* Compares this entity to another given entity.
* Matches by comparing class and id.
* @param $entity
* @return bool
*/
public function matches($entity)
{
return [get_class($this), $this->id] === [get_class($entity), $entity->id];
}
/**
* Checks if an entity matches or contains another given entity.
* @param Entity $entity
* @return bool
*/
public function matchesOrContains(Entity $entity)
{
$matches = [get_class($this), $this->id] === [get_class($entity), $entity->id];
if ($matches) {
return true;
}
if (($entity->isA('chapter') || $entity->isA('page')) && $this->isA('book')) {
return $entity->book_id === $this->id;
}
if ($entity->isA('page') && $this->isA('chapter')) {
return $entity->chapter_id === $this->id;
}
return false;
}
/**
* Gets the activity objects for this entity.
* @return \Illuminate\Database\Eloquent\Relations\MorphMany
*/
public function activity()
{
return $this->morphMany(Activity::class, 'entity')->orderBy('created_at', 'desc');
}
/**
* Get View objects for this entity.
*/
public function views()
{
return $this->morphMany(View::class, 'viewable');
}
public function viewCountQuery()
{
return $this->views()->selectRaw('viewable_id, sum(views) as view_count')->groupBy('viewable_id');
}
/**
* Get the Tag models that have been user assigned to this entity.
* @return \Illuminate\Database\Eloquent\Relations\MorphMany
*/
public function tags()
{
return $this->morphMany(Tag::class, 'entity')->orderBy('order', 'asc');
}
/**
* Get the comments for an entity
* @param bool $orderByCreated
* @return MorphMany
*/
public function comments($orderByCreated = true)
{
$query = $this->morphMany(Comment::class, 'entity');
return $orderByCreated ? $query->orderBy('created_at', 'asc') : $query;
}
/**
* Get the related search terms.
* @return \Illuminate\Database\Eloquent\Relations\MorphMany
*/
public function searchTerms()
{
return $this->morphMany(SearchTerm::class, 'entity');
}
/**
* Get this entities restrictions.
*/
public function permissions()
{
return $this->morphMany(EntityPermission::class, 'restrictable');
}
/**
* Check if this entity has a specific restriction set against it.
* @param $role_id
* @param $action
* @return bool
*/
public function hasRestriction($role_id, $action)
{
return $this->permissions()->where('role_id', '=', $role_id)
->where('action', '=', $action)->count() > 0;
}
/**
* Get the entity jointPermissions this is connected to.
* @return \Illuminate\Database\Eloquent\Relations\MorphMany
*/
public function jointPermissions()
{
return $this->morphMany(JointPermission::class, 'entity');
}
/**
* Allows checking of the exact class, Used to check entity type.
* Cleaner method for is_a.
* @param $type
* @return bool
*/
public static function isA($type)
{
return static::getType() === strtolower($type);
}
/**
* Get entity type.
* @return mixed
*/
public static function getType()
{
return strtolower(static::getClassName());
}
/**
* Get an instance of an entity of the given type.
* @param $type
* @return Entity
*/
public static function getEntityInstance($type)
{
$types = ['Page', 'Book', 'Chapter', 'Bookshelf'];
$className = str_replace([' ', '-', '_'], '', ucwords($type));
if (!in_array($className, $types)) {
return null;
}
return app('BookStack\\Entities\\' . $className);
}
/**
* Gets a limited-length version of the entities name.
* @param int $length
* @return string
*/
public function getShortName($length = 25)
{
if (mb_strlen($this->name) <= $length) {
return $this->name;
}
return mb_substr($this->name, 0, $length - 3) . '...';
}
/**
* Get the body text of this entity.
* @return mixed
*/
public function getText()
{
return $this->{$this->textField};
}
/**
* Get an excerpt of this entity's descriptive content to the specified length.
* @param int $length
* @return mixed
*/
public function getExcerpt(int $length = 100)
{
$text = $this->getText();
if (mb_strlen($text) > $length) {
$text = mb_substr($text, 0, $length-3) . '...';
}
return trim($text);
}
/**
* Return a generalised, common raw query that can be 'unioned' across entities.
* @return string
*/
public function entityRawQuery()
{
return '';
}
/**
* Get the url of this entity
* @param $path
* @return string
*/
public function getUrl($path = '/')
{
return $path;
}
}

View File

@@ -0,0 +1,60 @@
<?php namespace BookStack\Entities;
use BookStack\Entities\Repos\EntityRepo;
use Illuminate\Session\Store;
class EntityContextManager
{
protected $session;
protected $entityRepo;
protected $KEY_SHELF_CONTEXT_ID = 'context_bookshelf_id';
/**
* EntityContextManager constructor.
* @param Store $session
* @param EntityRepo $entityRepo
*/
public function __construct(Store $session, EntityRepo $entityRepo)
{
$this->session = $session;
$this->entityRepo = $entityRepo;
}
/**
* Get the current bookshelf context for the given book.
* @param Book $book
* @return Bookshelf|null
*/
public function getContextualShelfForBook(Book $book)
{
$contextBookshelfId = $this->session->get($this->KEY_SHELF_CONTEXT_ID, null);
if (is_int($contextBookshelfId)) {
/** @var Bookshelf $shelf */
$shelf = $this->entityRepo->getById('bookshelf', $contextBookshelfId);
if ($shelf && $shelf->contains($book)) {
return $shelf;
}
}
return null;
}
/**
* Store the current contextual shelf ID.
* @param int $shelfId
*/
public function setShelfContext(int $shelfId)
{
$this->session->put($this->KEY_SHELF_CONTEXT_ID, $shelfId);
}
/**
* Clear the session stored shelf context id.
*/
public function clearShelfContext()
{
$this->session->forget($this->KEY_SHELF_CONTEXT_ID);
}
}

View File

@@ -1,18 +1,13 @@
<?php namespace BookStack\Entities;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Models\PageRevision;
/**
* Class EntityProvider
*
* Provides access to the core entity models.
* Wrapped up in this provider since they are often used together
* so this is a neater alternative to injecting all in individually.
*
* @package BookStack\Entities
*/
class EntityProvider
{
@@ -42,22 +37,34 @@ class EntityProvider
*/
public $pageRevision;
public function __construct()
{
$this->bookshelf = new Bookshelf();
$this->book = new Book();
$this->chapter = new Chapter();
$this->page = new Page();
$this->pageRevision = new PageRevision();
/**
* EntityProvider constructor.
* @param Bookshelf $bookshelf
* @param Book $book
* @param Chapter $chapter
* @param Page $page
* @param PageRevision $pageRevision
*/
public function __construct(
Bookshelf $bookshelf,
Book $book,
Chapter $chapter,
Page $page,
PageRevision $pageRevision
) {
$this->bookshelf = $bookshelf;
$this->book = $book;
$this->chapter = $chapter;
$this->page = $page;
$this->pageRevision = $pageRevision;
}
/**
* Fetch all core entity types as an associated array
* with their basic names as the keys.
* @return array<Entity>
* @return Entity[]
*/
public function all(): array
public function all()
{
return [
'bookshelf' => $this->bookshelf,
@@ -69,8 +76,10 @@ class EntityProvider
/**
* Get an entity instance by it's basic name.
* @param string $type
* @return Entity
*/
public function get(string $type): Entity
public function get(string $type)
{
$type = strtolower($type);
return $this->all()[$type];
@@ -78,9 +87,15 @@ class EntityProvider
/**
* Get the morph classes, as an array, for a single or multiple types.
* @param string|array $types
* @return array<string>
*/
public function getMorphClasses(array $types): array
public function getMorphClasses($types)
{
if (is_string($types)) {
$types = [$types];
}
$morphClasses = [];
foreach ($types as $type) {
$model = $this->get($type);

View File

@@ -1,149 +1,155 @@
<?php namespace BookStack\Entities\Tools;
<?php namespace BookStack\Entities;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Repos\EntityRepo;
use BookStack\Uploads\ImageService;
use DomPDF;
use Exception;
use SnappyPDF;
use Throwable;
class ExportFormatter
class ExportService
{
protected $entityRepo;
protected $imageService;
/**
* ExportService constructor.
* @param EntityRepo $entityRepo
* @param ImageService $imageService
*/
public function __construct(ImageService $imageService)
public function __construct(EntityRepo $entityRepo, ImageService $imageService)
{
$this->entityRepo = $entityRepo;
$this->imageService = $imageService;
}
/**
* Convert a page to a self-contained HTML file.
* Includes required CSS & image content. Images are base64 encoded into the HTML.
* @throws Throwable
* @param \BookStack\Entities\Page $page
* @return mixed|string
* @throws \Throwable
*/
public function pageToContainedHtml(Page $page)
{
$page->html = (new PageContent($page))->render();
$pageHtml = view('pages.export', [
'page' => $page,
'format' => 'html',
$this->entityRepo->renderPage($page);
$pageHtml = view('pages/export', [
'page' => $page
])->render();
return $this->containHtml($pageHtml);
}
/**
* Convert a chapter to a self-contained HTML file.
* @throws Throwable
* @param \BookStack\Entities\Chapter $chapter
* @return mixed|string
* @throws \Throwable
*/
public function chapterToContainedHtml(Chapter $chapter)
{
$pages = $chapter->getVisiblePages();
$pages = $this->entityRepo->getChapterChildren($chapter);
$pages->each(function ($page) {
$page->html = (new PageContent($page))->render();
$page->html = $this->entityRepo->renderPage($page);
});
$html = view('chapters.export', [
$html = view('chapters/export', [
'chapter' => $chapter,
'pages' => $pages,
'format' => 'html',
'pages' => $pages
])->render();
return $this->containHtml($html);
}
/**
* Convert a book to a self-contained HTML file.
* @throws Throwable
* @param Book $book
* @return mixed|string
* @throws \Throwable
*/
public function bookToContainedHtml(Book $book)
{
$bookTree = (new BookContents($book))->getTree(false, true);
$html = view('books.export', [
$bookTree = $this->entityRepo->getBookChildren($book, true, true);
$html = view('books/export', [
'book' => $book,
'bookChildren' => $bookTree,
'format' => 'html',
'bookChildren' => $bookTree
])->render();
return $this->containHtml($html);
}
/**
* Convert a page to a PDF file.
* @throws Throwable
* @param Page $page
* @return mixed|string
* @throws \Throwable
*/
public function pageToPdf(Page $page)
{
$page->html = (new PageContent($page))->render();
$html = view('pages.export', [
'page' => $page,
'format' => 'pdf',
$this->entityRepo->renderPage($page);
$html = view('pages/pdf', [
'page' => $page
])->render();
return $this->htmlToPdf($html);
}
/**
* Convert a chapter to a PDF file.
* @throws Throwable
* @param \BookStack\Entities\Chapter $chapter
* @return mixed|string
* @throws \Throwable
*/
public function chapterToPdf(Chapter $chapter)
{
$pages = $chapter->getVisiblePages();
$pages = $this->entityRepo->getChapterChildren($chapter);
$pages->each(function ($page) {
$page->html = (new PageContent($page))->render();
$page->html = $this->entityRepo->renderPage($page);
});
$html = view('chapters.export', [
$html = view('chapters/export', [
'chapter' => $chapter,
'pages' => $pages,
'format' => 'pdf',
'pages' => $pages
])->render();
return $this->htmlToPdf($html);
}
/**
* Convert a book to a PDF file.
* @throws Throwable
* Convert a book to a PDF file
* @param \BookStack\Entities\Book $book
* @return string
* @throws \Throwable
*/
public function bookToPdf(Book $book)
{
$bookTree = (new BookContents($book))->getTree(false, true);
$html = view('books.export', [
$bookTree = $this->entityRepo->getBookChildren($book, true, true);
$html = view('books/export', [
'book' => $book,
'bookChildren' => $bookTree,
'format' => 'pdf',
'bookChildren' => $bookTree
])->render();
return $this->htmlToPdf($html);
}
/**
* Convert normal web-page HTML to a PDF.
* @throws Exception
* Convert normal webpage HTML to a PDF.
* @param $html
* @return string
* @throws \Exception
*/
protected function htmlToPdf(string $html): string
protected function htmlToPdf($html)
{
$containedHtml = $this->containHtml($html);
$useWKHTML = config('snappy.pdf.binary') !== false;
if ($useWKHTML) {
$pdf = SnappyPDF::loadHTML($containedHtml);
$pdf = \SnappyPDF::loadHTML($containedHtml);
$pdf->setOption('print-media-type', true);
} else {
$pdf = DomPDF::loadHTML($containedHtml);
$pdf = \DomPDF::loadHTML($containedHtml);
}
return $pdf->output();
}
/**
* Bundle of the contents of a html file to be self-contained.
* @throws Exception
* @param $htmlContent
* @return mixed|string
* @throws \Exception
*/
protected function containHtml(string $htmlContent): string
protected function containHtml($htmlContent)
{
$imageTagsOutput = [];
preg_match_all("/\<img.*?src\=(\'|\")(.*?)(\'|\").*?\>/i", $htmlContent, $imageTagsOutput);
preg_match_all("/\<img.*src\=(\'|\")(.*?)(\'|\").*?\>/i", $htmlContent, $imageTagsOutput);
// Replace image src with base64 encoded image strings
if (isset($imageTagsOutput[0]) && count($imageTagsOutput[0]) > 0) {
@@ -182,10 +188,12 @@ class ExportFormatter
/**
* Converts the page contents into simple plain text.
* This method filters any bad looking content to provide a nice final output.
* @param Page $page
* @return mixed
*/
public function pageToPlainText(Page $page): string
public function pageToPlainText(Page $page)
{
$html = (new PageContent($page))->render();
$html = $this->entityRepo->renderPage($page);
$text = strip_tags($html);
// Replace multiple spaces with single spaces
$text = preg_replace('/\ {2,}/', ' ', $text);
@@ -199,12 +207,14 @@ class ExportFormatter
/**
* Convert a chapter into a plain text string.
* @param \BookStack\Entities\Chapter $chapter
* @return string
*/
public function chapterToPlainText(Chapter $chapter): string
public function chapterToPlainText(Chapter $chapter)
{
$text = $chapter->name . "\n\n";
$text .= $chapter->description . "\n\n";
foreach ($chapter->getVisiblePages() as $page) {
foreach ($chapter->pages as $page) {
$text .= $this->pageToPlainText($page);
}
return $text;
@@ -212,10 +222,12 @@ class ExportFormatter
/**
* Convert a book into a plain text string.
* @param Book $book
* @return string
*/
public function bookToPlainText(Book $book): string
public function bookToPlainText(Book $book)
{
$bookTree = (new BookContents($book))->getTree(false, false);
$bookTree = $this->entityRepo->getBookChildren($book, true, true);
$text = $book->name . "\n\n";
foreach ($bookTree as $bookChild) {
if ($bookChild->isA('chapter')) {

View File

@@ -1,114 +0,0 @@
<?php namespace BookStack\Entities\Models;
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
* @property string $description
* @property int $image_id
* @property Image|null $cover
*/
class Book extends Entity implements HasCoverImage
{
public $searchFactor = 2;
protected $fillable = ['name', 'description'];
protected $hidden = ['restricted', 'pivot', 'image_id', 'deleted_at'];
/**
* Get the url for this book.
*/
public function getUrl(string $path = ''): string
{
return url('/books/' . implode('/', [urlencode($this->slug), trim($path, '/')]));
}
/**
* Returns book cover image, if book cover not exists return default cover image.
* @param int $width - Width of the image
* @param int $height - Height of the image
* @return string
*/
public function getBookCover($width = 440, $height = 250)
{
$default = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
if (!$this->image_id) {
return $default;
}
try {
$cover = $this->cover ? url($this->cover->getThumb($width, $height, false)) : $default;
} catch (Exception $err) {
$cover = $default;
}
return $cover;
}
/**
* Get the cover image of the book
*/
public function cover(): BelongsTo
{
return $this->belongsTo(Image::class, 'image_id');
}
/**
* Get the type of the image model that is used when storing a cover image.
*/
public function coverImageTypeKey(): string
{
return 'cover_book';
}
/**
* Get all pages within this book.
* @return HasMany
*/
public function pages()
{
return $this->hasMany(Page::class);
}
/**
* Get the direct child pages of this book.
* @return HasMany
*/
public function directPages()
{
return $this->pages()->where('chapter_id', '=', '0');
}
/**
* Get all chapters within this book.
* @return HasMany
*/
public function chapters()
{
return $this->hasMany(Chapter::class);
}
/**
* Get the shelves this book is contained within.
* @return BelongsToMany
*/
public function shelves()
{
return $this->belongsToMany(Bookshelf::class, 'bookshelves_books', 'book_id', 'bookshelf_id');
}
/**
* Get the direct child items within this book.
* @return Collection
*/
public function getDirectChildren(): Collection
{
$pages = $this->directPages()->visible()->get();
$chapters = $this->chapters()->visible()->get();
return $pages->concat($chapters)->sortBy('priority')->sortByDesc('draft');
}
}

View File

@@ -1,59 +0,0 @@
<?php namespace BookStack\Entities\Models;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Book;
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)
*/
abstract 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.
*/
public function book(): BelongsTo
{
return $this->belongsTo(Book::class)->withTrashed();
}
/**
* 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 all child pages if a chapter
if ($this instanceof Chapter) {
foreach ($this->pages as $page) {
$page->changeBook($newBookId);
}
}
return $this;
}
}

View File

@@ -1,52 +0,0 @@
<?php namespace BookStack\Entities\Models;
use Illuminate\Support\Collection;
/**
* Class Chapter
* @property Collection<Page> $pages
*/
class Chapter extends BookChild
{
public $searchFactor = 1.3;
protected $fillable = ['name', 'description', 'priority', 'book_id'];
protected $hidden = ['restricted', 'pivot', 'deleted_at'];
/**
* Get the pages that this chapter contains.
* @param string $dir
* @return mixed
*/
public function pages($dir = 'ASC')
{
return $this->hasMany(Page::class)->orderBy('priority', $dir);
}
/**
* Get the url of this chapter.
*/
public function getUrl($path = ''): string
{
$parts = [
'books',
urlencode($this->getAttribute('bookSlug') ?? $this->book->slug),
'chapter',
urlencode($this->slug),
trim($path, '/'),
];
return url('/' . implode('/', $parts));
}
/**
* Get the visible pages in this chapter.
*/
public function getVisiblePages(): Collection
{
return $this->pages()->visible()
->orderBy('draft', 'desc')
->orderBy('priority', 'asc')
->get();
}
}

View File

@@ -1,48 +0,0 @@
<?php namespace BookStack\Entities\Models;
use BookStack\Auth\User;
use BookStack\Entities\Models\Entity;
use BookStack\Interfaces\Loggable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class Deletion extends Model implements Loggable
{
/**
* Get the related deletable record.
*/
public function deletable(): MorphTo
{
return $this->morphTo('deletable')->withTrashed();
}
/**
* The the user that performed the deletion.
*/
public function deleter(): BelongsTo
{
return $this->belongsTo(User::class, 'deleted_by');
}
/**
* Create a new deletion record for the provided entity.
*/
public static function createForEntity(Entity $entity): Deletion
{
$record = (new self())->forceFill([
'deleted_by' => user()->id,
'deletable_type' => $entity->getMorphClass(),
'deletable_id' => $entity->id,
]);
$record->save();
return $record;
}
public function logDescriptor(): string
{
$deletable = $this->deletable()->first();
return "Deletion ({$this->id}) for {$deletable->getType()} ({$deletable->id}) {$deletable->name}";
}
}

View File

@@ -1,299 +0,0 @@
<?php namespace BookStack\Entities\Models;
use BookStack\Actions\Activity;
use BookStack\Actions\Comment;
use BookStack\Actions\Tag;
use BookStack\Actions\View;
use BookStack\Auth\Permissions\EntityPermission;
use BookStack\Auth\Permissions\JointPermission;
use BookStack\Entities\Tools\SearchIndex;
use BookStack\Entities\Tools\SlugGenerator;
use BookStack\Facades\Permissions;
use BookStack\Model;
use BookStack\Traits\HasCreatorAndUpdater;
use BookStack\Traits\HasOwner;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* Class Entity
* The base class for book-like items such as pages, chapters & books.
* This is not a database model in itself but extended.
*
* @property int $id
* @property string $name
* @property string $slug
* @property Carbon $created_at
* @property Carbon $updated_at
* @property int $created_by
* @property int $updated_by
* @property boolean $restricted
* @property Collection $tags
* @method static Entity|Builder visible()
* @method static Entity|Builder hasPermission(string $permission)
* @method static Builder withLastView()
* @method static Builder withViewCount()
*/
abstract class Entity extends Model
{
use SoftDeletes;
use HasCreatorAndUpdater;
use HasOwner;
/**
* @var string - Name of property where the main text content is found
*/
public $textField = 'description';
/**
* @var float - Multiplier for search indexing.
*/
public $searchFactor = 1.0;
/**
* Get the entities that are visible to the current user.
*/
public function scopeVisible(Builder $query): Builder
{
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]);
}
/**
* Compares this entity to another given entity.
* Matches by comparing class and id.
*/
public function matches(Entity $entity): bool
{
return [get_class($this), $this->id] === [get_class($entity), $entity->id];
}
/**
* Checks if the current entity matches or contains the given.
*/
public function matchesOrContains(Entity $entity): bool
{
if ($this->matches($entity)) {
return true;
}
if (($entity->isA('chapter') || $entity->isA('page')) && $this->isA('book')) {
return $entity->book_id === $this->id;
}
if ($entity->isA('page') && $this->isA('chapter')) {
return $entity->chapter_id === $this->id;
}
return false;
}
/**
* Gets the activity objects for this entity.
*/
public function activity(): MorphMany
{
return $this->morphMany(Activity::class, 'entity')
->orderBy('created_at', 'desc');
}
/**
* Get View objects for this entity.
*/
public function views(): MorphMany
{
return $this->morphMany(View::class, 'viewable');
}
/**
* Get the Tag models that have been user assigned to this entity.
*/
public function tags(): MorphMany
{
return $this->morphMany(Tag::class, 'entity')->orderBy('order', 'asc');
}
/**
* Get the comments for an entity
*/
public function comments(bool $orderByCreated = true): MorphMany
{
$query = $this->morphMany(Comment::class, 'entity');
return $orderByCreated ? $query->orderBy('created_at', 'asc') : $query;
}
/**
* Get the related search terms.
*/
public function searchTerms(): MorphMany
{
return $this->morphMany(SearchTerm::class, 'entity');
}
/**
* Get this entities restrictions.
*/
public function permissions(): MorphMany
{
return $this->morphMany(EntityPermission::class, 'restrictable');
}
/**
* Check if this entity has a specific restriction set against it.
*/
public function hasRestriction(int $role_id, string $action): bool
{
return $this->permissions()->where('role_id', '=', $role_id)
->where('action', '=', $action)->count() > 0;
}
/**
* Get the entity jointPermissions this is connected to.
*/
public function jointPermissions(): MorphMany
{
return $this->morphMany(JointPermission::class, 'entity');
}
/**
* Get the related delete records for this entity.
*/
public function deletions(): MorphMany
{
return $this->morphMany(Deletion::class, 'deletable');
}
/**
* Check if this instance or class is a certain type of entity.
* Examples of $type are 'page', 'book', 'chapter'
*/
public static function isA(string $type): bool
{
return static::getType() === strtolower($type);
}
/**
* Get the entity type as a simple lowercase word.
*/
public static function getType(): string
{
$className = array_slice(explode('\\', static::class), -1, 1)[0];
return strtolower($className);
}
/**
* Gets a limited-length version of the entities name.
*/
public function getShortName(int $length = 25): string
{
if (mb_strlen($this->name) <= $length) {
return $this->name;
}
return mb_substr($this->name, 0, $length - 3) . '...';
}
/**
* Get the body text of this entity.
*/
public function getText(): string
{
return $this->{$this->textField} ?? '';
}
/**
* Get an excerpt of this entity's descriptive content to the specified length.
*/
public function getExcerpt(int $length = 100): string
{
$text = $this->getText();
if (mb_strlen($text) > $length) {
$text = mb_substr($text, 0, $length-3) . '...';
}
return trim($text);
}
/**
* Get the url of this entity
*/
abstract public function getUrl(string $path = '/'): string;
/**
* Get the parent entity if existing.
* This is the "static" parent and does not include dynamic
* relations such as shelves to books.
*/
public function getParent(): ?Entity
{
if ($this->isA('page')) {
return $this->chapter_id ? $this->chapter()->withTrashed()->first() : $this->book()->withTrashed()->first();
}
if ($this->isA('chapter')) {
return $this->book()->withTrashed()->first();
}
return null;
}
/**
* Rebuild the permissions for this entity.
*/
public function rebuildPermissions()
{
/** @noinspection PhpUnhandledExceptionInspection */
Permissions::buildJointPermissionsForEntity(clone $this);
}
/**
* Index the current entity for search
*/
public function indexForSearch()
{
app(SearchIndex::class)->indexEntity(clone $this);
}
/**
* Generate and set a new URL slug for this model.
*/
public function refreshSlug(): string
{
$this->slug = (new SlugGenerator)->generate($this);
return $this->slug;
}
}

View File

@@ -1,20 +0,0 @@
<?php
namespace BookStack\Entities\Models;
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;
}

View File

@@ -1,129 +0,0 @@
<?php namespace BookStack\Entities\Models;
use BookStack\Entities\Tools\PageContent;
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
* @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', 'priority', 'markdown'];
protected $simpleAttributes = ['name', 'id', 'slug'];
public $textField = 'text';
protected $hidden = ['html', 'markdown', 'text', 'restricted', 'pivot', 'deleted_at'];
protected $casts = [
'draft' => 'boolean',
'template' => 'boolean',
];
/**
* Get the entities that are visible to the current user.
*/
public function scopeVisible(Builder $query): Builder
{
$query = Permissions::enforceDraftVisiblityOnQuery($query);
return parent::scopeVisible($query);
}
/**
* Converts this page into a simplified array.
* @return mixed
*/
public function toSimpleArray()
{
$array = array_intersect_key($this->toArray(), array_flip($this->simpleAttributes));
$array['url'] = $this->getUrl();
return $array;
}
/**
* Get the chapter that this page is in, If applicable.
* @return BelongsTo
*/
public function chapter()
{
return $this->belongsTo(Chapter::class);
}
/**
* Check if this page has a chapter.
* @return bool
*/
public function hasChapter()
{
return $this->chapter()->count() > 0;
}
/**
* Get the associated page revisions, ordered by created date.
* @return mixed
*/
public function revisions()
{
return $this->hasMany(PageRevision::class)->where('type', '=', 'version')->orderBy('created_at', 'desc')->orderBy('id', 'desc');
}
/**
* Get the attachments assigned to this page.
* @return HasMany
*/
public function attachments()
{
return $this->hasMany(Attachment::class, 'uploaded_to')->orderBy('order', 'asc');
}
/**
* Get the url of this page.
*/
public function getUrl($path = ''): string
{
$parts = [
'books',
urlencode($this->getAttribute('bookSlug') ?? $this->book->slug),
$this->draft ? 'draft' : 'page',
$this->draft ? $this->id : urlencode($this->slug),
trim($path, '/'),
];
return url('/' . implode('/', $parts));
}
/**
* Get the current revision for the page if existing
* @return PageRevision|null
*/
public function getCurrentRevision()
{
return $this->revisions()->first();
}
/**
* Get this page for JSON display.
*/
public function forJsonDisplay(): Page
{
$refreshed = $this->refresh()->unsetRelations()->load(['tags', 'createdBy', 'updatedBy', 'ownedBy']);
$refreshed->setHidden(array_diff($refreshed->getHidden(), ['html', 'markdown']));
$refreshed->html = (new PageContent($refreshed))->render();
return $refreshed;
}
}

124
app/Entities/Page.php Normal file
View File

@@ -0,0 +1,124 @@
<?php namespace BookStack\Entities;
use BookStack\Uploads\Attachment;
class Page extends Entity
{
protected $fillable = ['name', 'html', 'priority', 'markdown'];
protected $simpleAttributes = ['name', 'id', 'slug'];
public $textField = 'text';
/**
* Get the morph class for this model.
* @return string
*/
public function getMorphClass()
{
return 'BookStack\\Page';
}
/**
* Converts this page into a simplified array.
* @return mixed
*/
public function toSimpleArray()
{
$array = array_intersect_key($this->toArray(), array_flip($this->simpleAttributes));
$array['url'] = $this->getUrl();
return $array;
}
/**
* Get the book this page sits in.
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function book()
{
return $this->belongsTo(Book::class);
}
/**
* Get the parent item
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function parent()
{
return $this->chapter_id ? $this->chapter() : $this->book();
}
/**
* Get the chapter that this page is in, If applicable.
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function chapter()
{
return $this->belongsTo(Chapter::class);
}
/**
* Check if this page has a chapter.
* @return bool
*/
public function hasChapter()
{
return $this->chapter()->count() > 0;
}
/**
* Get the associated page revisions, ordered by created date.
* @return mixed
*/
public function revisions()
{
return $this->hasMany(PageRevision::class)->where('type', '=', 'version')->orderBy('created_at', 'desc');
}
/**
* Get the attachments assigned to this page.
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function attachments()
{
return $this->hasMany(Attachment::class, 'uploaded_to')->orderBy('order', 'asc');
}
/**
* Get the url for this page.
* @param string|bool $path
* @return string
*/
public function getUrl($path = false)
{
$bookSlug = $this->getAttribute('bookSlug') ? $this->getAttribute('bookSlug') : $this->book->slug;
$midText = $this->draft ? '/draft/' : '/page/';
$idComponent = $this->draft ? $this->id : urlencode($this->slug);
if ($path !== false) {
return url('/books/' . urlencode($bookSlug) . $midText . $idComponent . '/' . trim($path, '/'));
}
return url('/books/' . urlencode($bookSlug) . $midText . $idComponent);
}
/**
* Return a generalised, common raw query that can be 'unioned' across entities.
* @param bool $withContent
* @return string
*/
public function entityRawQuery($withContent = false)
{
$htmlQuery = $withContent ? 'html' : "'' as html";
return "'BookStack\\\\Page' as entity_type, id, id as entity_id, slug, name, {$this->textField} as text, {$htmlQuery}, book_id, priority, chapter_id, draft, created_by, updated_by, updated_at, created_at";
}
/**
* Get the current revision for the page if existing
* @return \BookStack\Entities\PageRevision|null
*/
public function getCurrentRevision()
{
return $this->revisions()->first();
}
}

View File

@@ -1,23 +1,8 @@
<?php namespace BookStack\Entities\Models;
<?php namespace BookStack\Entities;
use BookStack\Auth\User;
use BookStack\Entities\Models\Page;
use BookStack\Model;
use Carbon\Carbon;
/**
* Class PageRevision
* @property int $page_id
* @property string $slug
* @property string $book_slug
* @property int $created_by
* @property Carbon $created_at
* @property string $type
* @property string $summary
* @property string $markdown
* @property string $html
* @property int $revision_number
*/
class PageRevision extends Model
{
protected $fillable = ['name', 'html', 'text', 'markdown', 'summary'];
@@ -56,18 +41,13 @@ class PageRevision extends Model
/**
* Get the previous revision for the same page if existing
* @return \BookStack\Entities\PageRevision|null
* @return \BookStack\PageRevision|null
*/
public function getPrevious()
{
$id = static::newQuery()->where('page_id', '=', $this->page_id)
->where('id', '<', $this->id)
->max('id');
if ($id) {
return static::query()->find($id);
if ($id = static::where('page_id', '=', $this->page_id)->where('id', '<', $this->id)->max('id')) {
return static::find($id);
}
return null;
}

View File

@@ -1,93 +0,0 @@
<?php
namespace BookStack\Entities\Repos;
use BookStack\Actions\ActivityType;
use BookStack\Actions\TagRepo;
use BookStack\Auth\User;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\HasCoverImage;
use BookStack\Exceptions\ImageUploadException;
use BookStack\Facades\Activity;
use BookStack\Uploads\ImageRepo;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Collection;
class BaseRepo
{
protected $tagRepo;
protected $imageRepo;
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,
'owned_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, 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();
}
}
}

View File

@@ -1,129 +0,0 @@
<?php namespace BookStack\Entities\Repos;
use BookStack\Actions\ActivityType;
use BookStack\Actions\TagRepo;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Tools\TrashCan;
use BookStack\Exceptions\ImageUploadException;
use BookStack\Exceptions\NotFoundException;
use BookStack\Facades\Activity;
use BookStack\Uploads\ImageRepo;
use Exception;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Collection;
class BookRepo
{
protected $baseRepo;
protected $tagRepo;
protected $imageRepo;
/**
* BookRepo constructor.
*/
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()->with('cover')->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);
Activity::addForEntity($book, ActivityType::BOOK_CREATE);
return $book;
}
/**
* Update the given book.
*/
public function update(Book $book, array $input): Book
{
$this->baseRepo->update($book, $input);
Activity::addForEntity($book, ActivityType::BOOK_UPDATE);
return $book;
}
/**
* Update the given book's cover image, or clear it.
* @throws ImageUploadException
* @throws Exception
*/
public function updateCoverImage(Book $book, ?UploadedFile $coverImage, bool $removeImage = false)
{
$this->baseRepo->updateCoverImage($book, $coverImage, $removeImage);
}
/**
* Remove a book from the system.
* @throws Exception
*/
public function destroy(Book $book)
{
$trashCan = new TrashCan();
$trashCan->softDestroyBook($book);
Activity::addForEntity($book, ActivityType::BOOK_DELETE);
$trashCan->autoClearOld();
}
}

View File

@@ -1,176 +0,0 @@
<?php namespace BookStack\Entities\Repos;
use BookStack\Actions\ActivityType;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Tools\TrashCan;
use BookStack\Exceptions\ImageUploadException;
use BookStack\Exceptions\NotFoundException;
use BookStack\Facades\Activity;
use Exception;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Collection;
class BookshelfRepo
{
protected $baseRepo;
/**
* BookshelfRepo constructor.
*/
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', 'cover'])
->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);
Activity::addForEntity($shelf, ActivityType::BOOKSHELF_CREATE);
return $shelf;
}
/**
* Update an existing shelf in the system using the given input.
*/
public function update(Bookshelf $shelf, array $input, ?array $bookIds): Bookshelf
{
$this->baseRepo->update($shelf, $input);
if (!is_null($bookIds)) {
$this->updateBooks($shelf, $bookIds);
}
Activity::addForEntity($shelf, ActivityType::BOOKSHELF_UPDATE);
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, bool $removeImage = false)
{
$this->baseRepo->updateCoverImage($shelf, $coverImage, $removeImage);
}
/**
* 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->softDestroyShelf($shelf);
Activity::addForEntity($shelf, ActivityType::BOOKSHELF_DELETE);
$trashCan->autoClearOld();
}
}

View File

@@ -1,105 +0,0 @@
<?php namespace BookStack\Entities\Repos;
use BookStack\Actions\ActivityType;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Tools\BookContents;
use BookStack\Entities\Tools\TrashCan;
use BookStack\Exceptions\MoveOperationException;
use BookStack\Exceptions\NotFoundException;
use BookStack\Facades\Activity;
use Exception;
use Illuminate\Support\Collection;
class ChapterRepo
{
protected $baseRepo;
/**
* ChapterRepo constructor.
*/
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);
Activity::addForEntity($chapter, ActivityType::CHAPTER_CREATE);
return $chapter;
}
/**
* Update the given chapter.
*/
public function update(Chapter $chapter, array $input): Chapter
{
$this->baseRepo->update($chapter, $input);
Activity::addForEntity($chapter, ActivityType::CHAPTER_UPDATE);
return $chapter;
}
/**
* Remove a chapter from the system.
* @throws Exception
*/
public function destroy(Chapter $chapter)
{
$trashCan = new TrashCan();
$trashCan->softDestroyChapter($chapter);
Activity::addForEntity($chapter, ActivityType::CHAPTER_DELETE);
$trashCan->autoClearOld();
}
/**
* 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');
}
/** @var Book $parent */
$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();
Activity::addForEntity($chapter, ActivityType::CHAPTER_MOVE);
return $parent;
}
}

View File

@@ -0,0 +1,924 @@
<?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;
}
}

View File

@@ -1,197 +1,93 @@
<?php namespace BookStack\Entities\Repos;
use BookStack\Actions\ActivityType;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Tools\BookContents;
use BookStack\Entities\Tools\PageContent;
use BookStack\Entities\Tools\TrashCan;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Models\PageRevision;
use BookStack\Exceptions\MoveOperationException;
use BookStack\Exceptions\NotFoundException;
use BookStack\Exceptions\PermissionsException;
use BookStack\Facades\Activity;
use Exception;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator;
use BookStack\Entities\Book;
use BookStack\Entities\Chapter;
use BookStack\Entities\Entity;
use BookStack\Entities\Page;
use BookStack\Entities\PageRevision;
use Carbon\Carbon;
use DOMDocument;
use DOMElement;
use DOMXPath;
use Illuminate\Support\Collection;
class PageRepo
class PageRepo extends EntityRepo
{
protected $baseRepo;
/**
* PageRepo constructor.
* Get page by slug.
* @param string $pageSlug
* @param string $bookSlug
* @return Page
* @throws \BookStack\Exceptions\NotFoundException
*/
public function __construct(BaseRepo $baseRepo)
public function getPageBySlug(string $pageSlug, string $bookSlug)
{
$this->baseRepo = $baseRepo;
return $this->getBySlug('page', $pageSlug, $bookSlug);
}
/**
* Get a page by ID.
* @throws NotFoundException
* Search through page revisions and retrieve the last page in the
* current book that has a slug equal to the one given.
* @param string $pageSlug
* @param string $bookSlug
* @return null|Page
*/
public function getById(int $id, array $relations = ['book']): Page
public function getPageByOldSlug(string $pageSlug, string $bookSlug)
{
$page = Page::visible()->with($relations)->find($id);
if (!$page) {
throw new NotFoundException(trans('errors.page_not_found'));
}
return $page;
}
/**
* Get a page its book and own slug.
* @throws NotFoundException
*/
public function getBySlug(string $bookSlug, string $pageSlug): Page
{
$page = Page::visible()->whereSlugs($bookSlug, $pageSlug)->first();
if (!$page) {
throw new NotFoundException(trans('errors.page_not_found'));
}
return $page;
}
/**
* Get a page by its old slug but checking the revisions table
* for the last revision that matched the given page and book slug.
*/
public function getByOldSlug(string $bookSlug, string $pageSlug): ?Page
{
$revision = PageRevision::query()
->whereHas('page', function (Builder $query) {
$query->visible();
$revision = $this->entityProvider->pageRevision->where('slug', '=', $pageSlug)
->whereHas('page', function ($query) {
$this->permissionService->enforceEntityRestrictions('page', $query);
})
->where('slug', '=', $pageSlug)
->where('type', '=', 'version')
->where('book_slug', '=', $bookSlug)
->orderBy('created_at', 'desc')
->with('page')
->first();
return $revision ? $revision->page : null;
->with('page')->first();
return $revision !== null ? $revision->page : null;
}
/**
* Get pages that have been marked as a template.
* Updates a page with any fillable data and saves it into the database.
* @param Page $page
* @param int $book_id
* @param array $input
* @return Page
* @throws \Exception
*/
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,
'owned_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);
$this->updateTemplateStatusAndContentFromInput($draft, $input);
$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();
$draft->refresh();
Activity::addForEntity($draft, ActivityType::PAGE_CREATE);
return $draft;
}
/**
* Update a page in the system.
*/
public function update(Page $page, array $input): Page
public function updatePage(Page $page, int $book_id, array $input)
{
// Hold the old details to compare later
$oldHtml = $page->html;
$oldName = $page->name;
$this->updateTemplateStatusAndContentFromInput($page, $input);
$this->baseRepo->update($page, $input);
// Prevent slug being updated if no name change
if ($page->name !== $input['name']) {
$page->slug = $this->findSuitableSlug('page', $input['name'], $page->id, $book_id);
}
// Save page tags if present
if (isset($input['tags'])) {
$this->tagRepo->saveTagsToEntity($page, $input['tags']);
}
if (isset($input['template']) && userCan('templates-manage')) {
$page->template = ($input['template'] === 'true');
}
// Update with new details
$page->revision_count++;
$userId = user()->id;
$page->fill($input);
$page->html = $this->formatHtml($input['html']);
$page->text = $this->pageToPlainText($page);
if (setting('app-editor') !== 'markdown') {
$page->markdown = '';
}
$page->updated_by = $userId;
$page->revision_count++;
$page->save();
// Remove all update drafts for this user & page.
$this->getUserDraftQuery($page)->delete();
$this->userUpdatePageDraftsQuery($page, $userId)->delete();
// Save a revision after updating
$summary = $input['summary'] ?? null;
@@ -199,35 +95,24 @@ class PageRepo
$this->savePageRevision($page, $summary);
}
Activity::addForEntity($page, ActivityType::PAGE_UPDATE);
$this->searchService->indexEntity($page);
return $page;
}
protected function updateTemplateStatusAndContentFromInput(Page $page, array $input)
{
if (isset($input['template']) && userCan('templates-manage')) {
$page->template = ($input['template'] === 'true');
}
$pageContent = new PageContent($page);
if (!empty($input['markdown'] ?? '')) {
$pageContent->setNewMarkdown($input['markdown']);
} else {
$pageContent->setNewHTML($input['html']);
}
}
/**
* Saves a page revision into the system.
* @param Page $page
* @param null|string $summary
* @return PageRevision
* @throws \Exception
*/
protected function savePageRevision(Page $page, string $summary = null): PageRevision
public function savePageRevision(Page $page, string $summary = null)
{
$revision = new PageRevision($page->getAttributes());
$revision = $this->entityProvider->pageRevision->newInstance($page->toArray());
if (setting('app-editor') !== 'markdown') {
$revision->markdown = '';
}
$revision->page_id = $page->id;
$revision->slug = $page->slug;
$revision->book_slug = $page->book->slug;
@@ -238,28 +123,164 @@ class PageRepo
$revision->revision_number = $page->revision_count;
$revision->save();
$this->deleteOldRevisions($page);
$revisionLimit = config('app.revision_limit');
if ($revisionLimit !== false) {
$revisionsToDelete = $this->entityProvider->pageRevision->where('page_id', '=', $page->id)
->orderBy('created_at', 'desc')->skip(intval($revisionLimit))->take(10)->get(['id']);
if ($revisionsToDelete->count() > 0) {
$this->entityProvider->pageRevision->whereIn('id', $revisionsToDelete->pluck('id'))->delete();
}
}
return $revision;
}
/**
* Save a page update draft.
* Formats a page's html to be tagged correctly within the system.
* @param string $htmlText
* @return string
*/
public function updatePageDraft(Page $page, array $input)
protected function formatHtml(string $htmlText)
{
if ($htmlText == '') {
return $htmlText;
}
libxml_use_internal_errors(true);
$doc = new DOMDocument();
$doc->loadHTML(mb_convert_encoding($htmlText, 'HTML-ENTITIES', 'UTF-8'));
$container = $doc->documentElement;
$body = $container->childNodes->item(0);
$childNodes = $body->childNodes;
// Set ids on top-level nodes
$idMap = [];
foreach ($childNodes as $index => $childNode) {
$this->setUniqueId($childNode, $idMap);
}
// Ensure no duplicate ids within child items
$xPath = new DOMXPath($doc);
$idElems = $xPath->query('//body//*//*[@id]');
foreach ($idElems as $domElem) {
$this->setUniqueId($domElem, $idMap);
}
// Generate inner html as a string
$html = '';
foreach ($childNodes as $childNode) {
$html .= $doc->saveHTML($childNode);
}
return $html;
}
/**
* Set a unique id on the given DOMElement.
* A map for existing ID's should be passed in to check for current existence.
* @param DOMElement $element
* @param array $idMap
*/
protected function setUniqueId($element, array &$idMap)
{
if (get_class($element) !== 'DOMElement') {
return;
}
// Overwrite id if not a BookStack custom id
$existingId = $element->getAttribute('id');
if (strpos($existingId, 'bkmrk') === 0 && !isset($idMap[$existingId])) {
$idMap[$existingId] = true;
return;
}
// Create an unique id for the element
// Uses the content as a basis to ensure output is the same every time
// the same content is passed through.
$contentId = 'bkmrk-' . mb_substr(strtolower(preg_replace('/\s+/', '-', trim($element->nodeValue))), 0, 20);
$newId = urlencode($contentId);
$loopIndex = 0;
while (isset($idMap[$newId])) {
$newId = urlencode($contentId . '-' . $loopIndex);
$loopIndex++;
}
$element->setAttribute('id', $newId);
$idMap[$newId] = true;
}
/**
* Get the plain text version of a page's content.
* @param \BookStack\Entities\Page $page
* @return string
*/
protected function pageToPlainText(Page $page) : string
{
$html = $this->renderPage($page, true);
return strip_tags($html);
}
/**
* Get a new draft page instance.
* @param Book $book
* @param Chapter|null $chapter
* @return \BookStack\Entities\Page
* @throws \Throwable
*/
public function getDraftPage(Book $book, Chapter $chapter = null)
{
$page = $this->entityProvider->page->newInstance();
$page->name = trans('entities.pages_initial_name');
$page->created_by = user()->id;
$page->updated_by = user()->id;
$page->draft = true;
if ($chapter) {
$page->chapter_id = $chapter->id;
}
$book->pages()->save($page);
$page = $this->entityProvider->page->find($page->id);
$this->permissionService->buildJointPermissionsForEntity($page);
return $page;
}
/**
* Save a page update draft.
* @param Page $page
* @param array $data
* @return PageRevision|Page
*/
public function updatePageDraft(Page $page, array $data = [])
{
// If the page itself is a draft simply update that
if ($page->draft) {
if (isset($input['html'])) {
(new PageContent($page))->setNewHTML($input['html']);
$page->fill($data);
if (isset($data['html'])) {
$page->text = $this->pageToPlainText($page);
}
$page->fill($input);
$page->save();
return $page;
}
// Otherwise save the data to a revision
$draft = $this->getPageRevisionToUpdate($page);
$draft->fill($input);
$userId = user()->id;
$drafts = $this->userUpdatePageDraftsQuery($page, $userId)->get();
if ($drafts->count() > 0) {
$draft = $drafts->first();
} else {
$draft = $this->entityProvider->pageRevision->newInstance();
$draft->page_id = $page->id;
$draft->slug = $page->slug;
$draft->book_slug = $page->book->slug;
$draft->created_by = $userId;
$draft->type = 'update_draft';
}
$draft->fill($data);
if (setting('app-editor') !== 'markdown') {
$draft->markdown = '';
}
@@ -269,84 +290,225 @@ class PageRepo
}
/**
* Destroy a page from the system.
* @throws Exception
* Publish a draft page to make it a normal page.
* Sets the slug and updates the content.
* @param Page $draftPage
* @param array $input
* @return Page
* @throws \Exception
*/
public function destroy(Page $page)
public function publishPageDraft(Page $draftPage, array $input)
{
$trashCan = new TrashCan();
$trashCan->softDestroyPage($page);
Activity::addForEntity($page, ActivityType::PAGE_DELETE);
$trashCan->autoClearOld();
$draftPage->fill($input);
// Save page tags if present
if (isset($input['tags'])) {
$this->tagRepo->saveTagsToEntity($draftPage, $input['tags']);
}
if (isset($input['template']) && userCan('templates-manage')) {
$draftPage->template = ($input['template'] === 'true');
}
$draftPage->slug = $this->findSuitableSlug('page', $draftPage->name, false, $draftPage->book->id);
$draftPage->html = $this->formatHtml($input['html']);
$draftPage->text = $this->pageToPlainText($draftPage);
$draftPage->draft = false;
$draftPage->revision_count = 1;
$draftPage->save();
$this->savePageRevision($draftPage, trans('entities.pages_initial_revision'));
$this->searchService->indexEntity($draftPage);
return $draftPage;
}
/**
* The base query for getting user update drafts.
* @param Page $page
* @param $userId
* @return mixed
*/
protected function userUpdatePageDraftsQuery(Page $page, int $userId)
{
return $this->entityProvider->pageRevision->where('created_by', '=', $userId)
->where('type', 'update_draft')
->where('page_id', '=', $page->id)
->orderBy('created_at', 'desc');
}
/**
* Get the latest updated draft revision for a particular page and user.
* @param Page $page
* @param $userId
* @return PageRevision|null
*/
public function getUserPageDraft(Page $page, int $userId)
{
return $this->userUpdatePageDraftsQuery($page, $userId)->first();
}
/**
* Get the notification message that informs the user that they are editing a draft page.
* @param PageRevision $draft
* @return string
*/
public function getUserPageDraftMessage(PageRevision $draft)
{
$message = trans('entities.pages_editing_draft_notification', ['timeDiff' => $draft->updated_at->diffForHumans()]);
if ($draft->page->updated_at->timestamp <= $draft->updated_at->timestamp) {
return $message;
}
return $message . "\n" . trans('entities.pages_draft_edited_notification');
}
/**
* A query to check for active update drafts on a particular page.
* @param Page $page
* @param int $minRange
* @return mixed
*/
protected function activePageEditingQuery(Page $page, int $minRange = null)
{
$query = $this->entityProvider->pageRevision->where('type', '=', 'update_draft')
->where('page_id', '=', $page->id)
->where('updated_at', '>', $page->updated_at)
->where('created_by', '!=', user()->id)
->with('createdBy');
if ($minRange !== null) {
$query = $query->where('updated_at', '>=', Carbon::now()->subMinutes($minRange));
}
return $query;
}
/**
* Check if a page is being actively editing.
* Checks for edits since last page updated.
* Passing in a minuted range will check for edits
* within the last x minutes.
* @param Page $page
* @param int $minRange
* @return bool
*/
public function isPageEditingActive(Page $page, int $minRange = null)
{
$draftSearch = $this->activePageEditingQuery($page, $minRange);
return $draftSearch->count() > 0;
}
/**
* Get a notification message concerning the editing activity on a particular page.
* @param Page $page
* @param int $minRange
* @return string
*/
public function getPageEditingActiveMessage(Page $page, int $minRange = null)
{
$pageDraftEdits = $this->activePageEditingQuery($page, $minRange)->get();
$userMessage = $pageDraftEdits->count() > 1 ? trans('entities.pages_draft_edit_active.start_a', ['count' => $pageDraftEdits->count()]): trans('entities.pages_draft_edit_active.start_b', ['userName' => $pageDraftEdits->first()->createdBy->name]);
$timeMessage = $minRange === null ? trans('entities.pages_draft_edit_active.time_a') : trans('entities.pages_draft_edit_active.time_b', ['minCount'=>$minRange]);
return trans('entities.pages_draft_edit_active.message', ['start' => $userMessage, 'time' => $timeMessage]);
}
/**
* Parse the headers on the page to get a navigation menu
* @param string $pageContent
* @return array
*/
public function getPageNav(string $pageContent)
{
if ($pageContent == '') {
return [];
}
libxml_use_internal_errors(true);
$doc = new DOMDocument();
$doc->loadHTML(mb_convert_encoding($pageContent, 'HTML-ENTITIES', 'UTF-8'));
$xPath = new DOMXPath($doc);
$headers = $xPath->query("//h1|//h2|//h3|//h4|//h5|//h6");
if (is_null($headers)) {
return [];
}
$tree = collect($headers)->map(function($header) {
$text = trim(str_replace("\xc2\xa0", '', $header->nodeValue));
$text = mb_substr($text, 0, 100);
return [
'nodeName' => strtolower($header->nodeName),
'level' => intval(str_replace('h', '', $header->nodeName)),
'link' => '#' . $header->getAttribute('id'),
'text' => $text,
];
})->filter(function($header) {
return mb_strlen($header['text']) > 0;
});
// Shift headers if only smaller headers have been used
$levelChange = ($tree->pluck('level')->min() - 1);
$tree = $tree->map(function ($header) use ($levelChange) {
$header['level'] -= ($levelChange);
return $header;
});
return $tree->toArray();
}
/**
* Restores a revision's content back into a page.
* @param Page $page
* @param Book $book
* @param int $revisionId
* @return Page
* @throws \Exception
*/
public function restoreRevision(Page $page, int $revisionId): Page
public function restorePageRevision(Page $page, Book $book, int $revisionId)
{
$page->revision_count++;
$this->savePageRevision($page);
$revision = $page->revisions()->where('id', '=', $revisionId)->first();
$page->fill($revision->toArray());
$content = new PageContent($page);
$content->setNewHTML($revision->html);
$page->slug = $this->findSuitableSlug('page', $page->name, $page->id, $book->id);
$page->text = $this->pageToPlainText($page);
$page->updated_by = user()->id;
$page->refreshSlug();
$page->save();
$page->indexForSearch();
$summary = trans('entities.pages_revision_restored_from', ['id' => strval($revisionId), 'summary' => $revision->summary]);
$this->savePageRevision($page, $summary);
Activity::addForEntity($page, ActivityType::PAGE_RESTORE);
$this->searchService->indexEntity($page);
return $page;
}
/**
* Move the given page into a new parent book or chapter.
* The $parentIdentifier must be a string of the following format:
* 'book:<id>' (book:5)
* @throws MoveOperationException
* @throws PermissionsException
* Change the page's parent to the given entity.
* @param Page $page
* @param Entity $parent
* @throws \Throwable
*/
public function move(Page $page, string $parentIdentifier): Entity
public function changePageParent(Page $page, Entity $parent)
{
$parent = $this->findParentByIdentifier($parentIdentifier);
if ($parent === null) {
throw new MoveOperationException('Book or chapter to move page into not found');
$book = $parent->isA('book') ? $parent : $parent->book;
$page->chapter_id = $parent->isA('chapter') ? $parent->id : 0;
$page->save();
if ($page->book->id !== $book->id) {
$page = $this->changeBook('page', $book->id, $page);
}
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();
Activity::addForEntity($page, ActivityType::PAGE_MOVE);
return $parent;
$page->load('book');
$this->permissionService->buildJointPermissionsForEntity($book);
}
/**
* Copy an existing page in the system.
* Optionally providing a new parent via string identifier and a new name.
* @throws MoveOperationException
* @throws PermissionsException
* Create a copy of a page in a new location with a new name.
* @param \BookStack\Entities\Page $page
* @param \BookStack\Entities\Entity $newParent
* @param string $newName
* @return \BookStack\Entities\Page
* @throws \Throwable
*/
public function copy(Page $page, string $parentIdentifier = null, string $newName = null): Page
public function copyPage(Page $page, Entity $newParent, string $newName = '')
{
$parent = $parentIdentifier ? $this->findParentByIdentifier($parentIdentifier) : $page->getParent();
if ($parent === null) {
throw new MoveOperationException('Book or chapter to move page into not found');
}
if (!userCan('page-create', $parent)) {
throw new PermissionsException('User does not have permission to create a page within the new parent');
}
$copyPage = $this->getNewDraftPage($parent);
$newBook = $newParent->isA('book') ? $newParent : $newParent->book;
$newChapter = $newParent->isA('chapter') ? $newParent : null;
$copyPage = $this->getDraftPage($newBook, $newChapter);
$pageData = $page->getAttributes();
// Update name
@@ -362,109 +524,38 @@ class PageRepo
}
}
return $this->publishDraft($copyPage, $pageData);
// Set priority
if ($newParent->isA('chapter')) {
$pageData['priority'] = $this->getNewChapterPriority($newParent);
} else {
$pageData['priority'] = $this->getNewBookPriority($newParent);
}
return $this->publishPageDraft($copyPage, $pageData);
}
/**
* Find a page parent entity via a identifier string in the format:
* {type}:{id}
* Example: (book:5)
* @throws MoveOperationException
* Get pages that have been marked as templates.
* @param int $count
* @param int $page
* @param string $search
* @return \Illuminate\Contracts\Pagination\LengthAwarePaginator
*/
protected function findParentByIdentifier(string $identifier): ?Entity
public function getPageTemplates(int $count = 10, int $page = 1, string $search = '')
{
$stringExploded = explode(':', $identifier);
$entityType = $stringExploded[0];
$entityId = intval($stringExploded[1]);
$query = $this->entityQuery('page')
->where('template', '=', true)
->orderBy('name', 'asc')
->skip( ($page - 1) * $count)
->take($count);
if ($entityType !== 'book' && $entityType !== 'chapter') {
throw new MoveOperationException('Pages can only be in books or chapters');
if ($search) {
$query->where('name', 'like', '%' . $search . '%');
}
$parentClass = $entityType === 'book' ? Book::class : Chapter::class;
return $parentClass::visible()->where('id', '=', $entityId)->first();
}
$paginator = $query->paginate($count, ['*'], 'page', $page);
$paginator->withPath('/templates');
/**
* 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
{
$parent = $page->getParent();
if ($parent instanceof Chapter) {
$lastPage = $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');
return $paginator;
}
}

View File

@@ -1,17 +1,18 @@
<?php namespace BookStack\Entities\Tools;
<?php namespace BookStack\Entities;
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Entities\EntityProvider;
use BookStack\Entities\Models\Entity;
use Illuminate\Database\Connection;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
use Illuminate\Database\Query\Builder;
use Illuminate\Database\Query\JoinClause;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
class SearchRunner
class SearchService
{
/**
* @var SearchTerm
*/
protected $searchTerm;
/**
* @var EntityProvider
@@ -35,28 +36,49 @@ class SearchRunner
*/
protected $queryOperators = ['<=', '>=', '=', '<', '>', 'like', '!='];
public function __construct(EntityProvider $entityProvider, Connection $db, PermissionService $permissionService)
/**
* SearchService constructor.
* @param SearchTerm $searchTerm
* @param EntityProvider $entityProvider
* @param Connection $db
* @param PermissionService $permissionService
*/
public function __construct(SearchTerm $searchTerm, EntityProvider $entityProvider, Connection $db, PermissionService $permissionService)
{
$this->searchTerm = $searchTerm;
$this->entityProvider = $entityProvider;
$this->db = $db;
$this->permissionService = $permissionService;
}
/**
* Search all entities in the system.
* The provided count is for each entity to search,
* Total returned could can be larger and not guaranteed.
* Set the database connection
* @param Connection $connection
*/
public function searchEntities(SearchOptions $searchOpts, string $entityType = 'all', int $page = 1, int $count = 20, string $action = 'view'): array
public function setConnection(Connection $connection)
{
$this->db = $connection;
}
/**
* Search all entities in the system.
* @param string $searchString
* @param string $entityType
* @param int $page
* @param int $count - Count of each entity to search, Total returned could can be larger and not guaranteed.
* @param string $action
* @return array[int, Collection];
*/
public function searchEntities($searchString, $entityType = 'all', $page = 1, $count = 20, $action = 'view')
{
$terms = $this->parseSearchString($searchString);
$entityTypes = array_keys($this->entityProvider->all());
$entityTypesToSearch = $entityTypes;
if ($entityType !== 'all') {
$entityTypesToSearch = $entityType;
} else if (isset($searchOpts->filters['type'])) {
$entityTypesToSearch = explode('|', $searchOpts->filters['type']);
} else if (isset($terms['filters']['type'])) {
$entityTypesToSearch = explode('|', $terms['filters']['type']);
}
$results = collect();
@@ -67,8 +89,8 @@ class SearchRunner
if (!in_array($entityType, $entityTypes)) {
continue;
}
$search = $this->searchEntityTable($searchOpts, $entityType, $page, $count, $action);
$entityTotal = $this->searchEntityTable($searchOpts, $entityType, $page, $count, $action, true);
$search = $this->searchEntityTable($terms, $entityType, $page, $count, $action);
$entityTotal = $this->searchEntityTable($terms, $entityType, $page, $count, $action, true);
if ($entityTotal > $page * $count) {
$hasMore = true;
}
@@ -80,51 +102,60 @@ class SearchRunner
'total' => $total,
'count' => count($results),
'has_more' => $hasMore,
'results' => $results->sortByDesc('score')->values(),
'results' => $results->sortByDesc('score')->values()
];
}
/**
* Search a book for entities
* @param integer $bookId
* @param string $searchString
* @return Collection
*/
public function searchBook(int $bookId, string $searchString): Collection
public function searchBook($bookId, $searchString)
{
$opts = SearchOptions::fromString($searchString);
$terms = $this->parseSearchString($searchString);
$entityTypes = ['page', 'chapter'];
$entityTypesToSearch = isset($opts->filters['type']) ? explode('|', $opts->filters['type']) : $entityTypes;
$entityTypesToSearch = isset($terms['filters']['type']) ? explode('|', $terms['filters']['type']) : $entityTypes;
$results = collect();
foreach ($entityTypesToSearch as $entityType) {
if (!in_array($entityType, $entityTypes)) {
continue;
}
$search = $this->buildEntitySearchQuery($opts, $entityType)->where('book_id', '=', $bookId)->take(20)->get();
$search = $this->buildEntitySearchQuery($terms, $entityType)->where('book_id', '=', $bookId)->take(20)->get();
$results = $results->merge($search);
}
return $results->sortByDesc('score')->take(20);
}
/**
* Search a chapter for entities
* Search a book for entities
* @param integer $chapterId
* @param string $searchString
* @return Collection
*/
public function searchChapter(int $chapterId, string $searchString): Collection
public function searchChapter($chapterId, $searchString)
{
$opts = SearchOptions::fromString($searchString);
$pages = $this->buildEntitySearchQuery($opts, 'page')->where('chapter_id', '=', $chapterId)->take(20)->get();
$terms = $this->parseSearchString($searchString);
$pages = $this->buildEntitySearchQuery($terms, 'page')->where('chapter_id', '=', $chapterId)->take(20)->get();
return $pages->sortByDesc('score');
}
/**
* Search across a particular entity type.
* Setting getCount = true will return the total
* matching instead of the items themselves.
* @param array $terms
* @param string $entityType
* @param int $page
* @param int $count
* @param string $action
* @param bool $getCount Return the total count of the search
* @return \Illuminate\Database\Eloquent\Collection|int|static[]
*/
protected function searchEntityTable(SearchOptions $searchOpts, string $entityType = 'page', int $page = 1, int $count = 20, string $action = 'view', bool $getCount = false)
public function searchEntityTable($terms, $entityType = 'page', $page = 1, $count = 20, $action = 'view', $getCount = false)
{
$query = $this->buildEntitySearchQuery($searchOpts, $entityType, $action);
$query = $this->buildEntitySearchQuery($terms, $entityType, $action);
if ($getCount) {
return $query->count();
}
@@ -135,44 +166,51 @@ class SearchRunner
/**
* Create a search query for an entity
* @param array $terms
* @param string $entityType
* @param string $action
* @return EloquentBuilder
*/
protected function buildEntitySearchQuery(SearchOptions $searchOpts, string $entityType = 'page', string $action = 'view'): EloquentBuilder
protected function buildEntitySearchQuery($terms, $entityType = 'page', $action = 'view')
{
$entity = $this->entityProvider->get($entityType);
$entitySelect = $entity->newQuery();
// Handle normal search terms
if (count($searchOpts->searches) > 0) {
$rawScoreSum = $this->db->raw('SUM(score) as score');
$subQuery = $this->db->table('search_terms')->select('entity_id', 'entity_type', $rawScoreSum);
if (count($terms['search']) > 0) {
$subQuery = $this->db->table('search_terms')->select('entity_id', 'entity_type', \DB::raw('SUM(score) as score'));
$subQuery->where('entity_type', '=', $entity->getMorphClass());
$subQuery->where(function (Builder $query) use ($searchOpts) {
foreach ($searchOpts->searches as $inputTerm) {
$subQuery->where(function (Builder $query) use ($terms) {
foreach ($terms['search'] as $inputTerm) {
$query->orWhere('term', 'like', $inputTerm .'%');
}
})->groupBy('entity_type', 'entity_id');
$entitySelect->join($this->db->raw('(' . $subQuery->toSql() . ') as s'), function (JoinClause $join) {
$entitySelect->join(\DB::raw('(' . $subQuery->toSql() . ') as s'), function (JoinClause $join) {
$join->on('id', '=', 'entity_id');
})->selectRaw($entity->getTable().'.*, s.score')->orderBy('score', 'desc');
$entitySelect->mergeBindings($subQuery);
}
// Handle exact term matching
foreach ($searchOpts->exacts as $inputTerm) {
$entitySelect->where(function (EloquentBuilder $query) use ($inputTerm, $entity) {
$query->where('name', 'like', '%'.$inputTerm .'%')
->orWhere($entity->textField, 'like', '%'.$inputTerm .'%');
if (count($terms['exact']) > 0) {
$entitySelect->where(function (EloquentBuilder $query) use ($terms, $entity) {
foreach ($terms['exact'] as $inputTerm) {
$query->where(function (EloquentBuilder $query) use ($inputTerm, $entity) {
$query->where('name', 'like', '%'.$inputTerm .'%')
->orWhere($entity->textField, 'like', '%'.$inputTerm .'%');
});
}
});
}
// Handle tag searches
foreach ($searchOpts->tags as $inputTerm) {
foreach ($terms['tags'] as $inputTerm) {
$this->applyTagSearch($entitySelect, $inputTerm);
}
// Handle filters
foreach ($searchOpts->filters as $filterTerm => $filterValue) {
$functionName = Str::camel('filter_' . $filterTerm);
foreach ($terms['filters'] as $filterTerm => $filterValue) {
$functionName = camel_case('filter_' . $filterTerm);
if (method_exists($this, $functionName)) {
$this->$functionName($entitySelect, $entity, $filterValue);
}
@@ -181,10 +219,60 @@ class SearchRunner
return $this->permissionService->enforceEntityRestrictions($entityType, $entitySelect, $action);
}
/**
* Parse a search string into components.
* @param $searchString
* @return array
*/
protected function parseSearchString($searchString)
{
$terms = [
'search' => [],
'exact' => [],
'tags' => [],
'filters' => []
];
$patterns = [
'exact' => '/"(.*?)"/',
'tags' => '/\[(.*?)\]/',
'filters' => '/\{(.*?)\}/'
];
// Parse special terms
foreach ($patterns as $termType => $pattern) {
$matches = [];
preg_match_all($pattern, $searchString, $matches);
if (count($matches) > 0) {
$terms[$termType] = $matches[1];
$searchString = preg_replace($pattern, '', $searchString);
}
}
// Parse standard terms
foreach (explode(' ', trim($searchString)) as $searchTerm) {
if ($searchTerm !== '') {
$terms['search'][] = $searchTerm;
}
}
// Split filter values out
$splitFilters = [];
foreach ($terms['filters'] as $filter) {
$explodedFilter = explode(':', $filter, 2);
$splitFilters[$explodedFilter[0]] = (count($explodedFilter) > 1) ? $explodedFilter[1] : '';
}
$terms['filters'] = $splitFilters;
return $terms;
}
/**
* Get the available query operators as a regex escaped list.
* @return mixed
*/
protected function getRegexEscapedOperators(): string
protected function getRegexEscapedOperators()
{
$escapedOperators = [];
foreach ($this->queryOperators as $operator) {
@@ -195,8 +283,11 @@ class SearchRunner
/**
* Apply a tag search term onto a entity query.
* @param EloquentBuilder $query
* @param string $tagTerm
* @return mixed
*/
protected function applyTagSearch(EloquentBuilder $query, string $tagTerm): EloquentBuilder
protected function applyTagSearch(EloquentBuilder $query, $tagTerm)
{
preg_match("/^(.*?)((".$this->getRegexEscapedOperators().")(.*?))?$/", $tagTerm, $tagSplit);
$query->whereHas('tags', function (EloquentBuilder $query) use ($tagSplit) {
@@ -224,6 +315,103 @@ class SearchRunner
return $query;
}
/**
* Index the given entity.
* @param Entity $entity
*/
public function indexEntity(Entity $entity)
{
$this->deleteEntityTerms($entity);
$nameTerms = $this->generateTermArrayFromText($entity->name, 5 * $entity->searchFactor);
$bodyTerms = $this->generateTermArrayFromText($entity->getText(), 1 * $entity->searchFactor);
$terms = array_merge($nameTerms, $bodyTerms);
foreach ($terms as $index => $term) {
$terms[$index]['entity_type'] = $entity->getMorphClass();
$terms[$index]['entity_id'] = $entity->id;
}
$this->searchTerm->newQuery()->insert($terms);
}
/**
* Index multiple Entities at once
* @param \BookStack\Entities\Entity[] $entities
*/
protected function indexEntities($entities)
{
$terms = [];
foreach ($entities as $entity) {
$nameTerms = $this->generateTermArrayFromText($entity->name, 5 * $entity->searchFactor);
$bodyTerms = $this->generateTermArrayFromText($entity->getText(), 1 * $entity->searchFactor);
foreach (array_merge($nameTerms, $bodyTerms) as $term) {
$term['entity_id'] = $entity->id;
$term['entity_type'] = $entity->getMorphClass();
$terms[] = $term;
}
}
$chunkedTerms = array_chunk($terms, 500);
foreach ($chunkedTerms as $termChunk) {
$this->searchTerm->newQuery()->insert($termChunk);
}
}
/**
* Delete and re-index the terms for all entities in the system.
*/
public function indexAllEntities()
{
$this->searchTerm->truncate();
foreach ($this->entityProvider->all() as $entityModel) {
$selectFields = ['id', 'name', $entityModel->textField];
$entityModel->newQuery()->select($selectFields)->chunk(1000, function ($entities) {
$this->indexEntities($entities);
});
}
}
/**
* Delete related Entity search terms.
* @param Entity $entity
*/
public function deleteEntityTerms(Entity $entity)
{
$entity->searchTerms()->delete();
}
/**
* Create a scored term array from the given text.
* @param $text
* @param float|int $scoreAdjustment
* @return array
*/
protected function generateTermArrayFromText($text, $scoreAdjustment = 1)
{
$tokenMap = []; // {TextToken => OccurrenceCount}
$splitChars = " \n\t.,!?:;()[]{}<>`'\"";
$token = strtok($text, $splitChars);
while ($token !== false) {
if (!isset($tokenMap[$token])) {
$tokenMap[$token] = 0;
}
$tokenMap[$token]++;
$token = strtok($splitChars);
}
$terms = [];
foreach ($tokenMap as $token => $count) {
$terms[] = [
'term' => $token,
'score' => $count * $scoreAdjustment
];
}
return $terms;
}
/**
* Custom entity search filters
*/
@@ -326,7 +514,7 @@ class SearchRunner
protected function filterSortBy(EloquentBuilder $query, Entity $model, $input)
{
$functionName = Str::camel('sort_by_' . $input);
$functionName = camel_case('sort_by_' . $input);
if (method_exists($this, $functionName)) {
$this->$functionName($query, $model);
}

View File

@@ -1,4 +1,4 @@
<?php namespace BookStack\Entities\Models;
<?php namespace BookStack\Entities;
use BookStack\Model;

View File

@@ -1,210 +0,0 @@
<?php namespace BookStack\Entities\Tools;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Exceptions\SortOperationException;
use Illuminate\Support\Collection;
class BookContents
{
/**
* @var Book
*/
protected $book;
/**
* BookContents constructor.
*/
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.
*/
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('visible_pages', collect($pages)->sortBy($this->bookChildSortFunc()));
} else {
$lonePages = $lonePages->concat($pages);
}
});
$chapters->whereNull('visible_pages')->each(function (Chapter $chapter) {
$chapter->setAttribute('visible_pages', collect([]));
});
$all->each(function (Entity $entity) use ($renderPages) {
$entity->setRelation('book', $this->book);
if ($renderPages && $entity->isA('page')) {
$entity->html = (new PageContent($entity))->render();
}
});
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;
}
}

Some files were not shown because too many files have changed in this diff Show More