mirror of
https://github.com/BookStackApp/BookStack.git
synced 2026-02-13 19:06:31 +03:00
Compare commits
126 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
751772b87a | ||
|
|
76e30869e1 | ||
|
|
f3ee8f2d4c | ||
|
|
ea406690f5 | ||
|
|
465d405926 | ||
|
|
1097c61d6d | ||
|
|
def2d61ad8 | ||
|
|
8b0f5e7000 | ||
|
|
1e88e8086f | ||
|
|
d48ac0a37d | ||
|
|
3edc9fe9eb | ||
|
|
616c62703e | ||
|
|
3eeb1e7d08 | ||
|
|
0d43b50f9d | ||
|
|
6bcfac6751 | ||
|
|
68489e5b44 | ||
|
|
fe0e307313 | ||
|
|
9985046685 | ||
|
|
53ec794e53 | ||
|
|
328d2514c4 | ||
|
|
de2756dd95 | ||
|
|
1f97047799 | ||
|
|
c870c10e38 | ||
|
|
49fa21c1e2 | ||
|
|
9f87423584 | ||
|
|
08fbd39fcb | ||
|
|
5f75a9f32c | ||
|
|
3750922c3e | ||
|
|
4b0d1ddf39 | ||
|
|
ecd56917e7 | ||
|
|
e22c9cae91 | ||
|
|
a6c20c321f | ||
|
|
e12012a6fc | ||
|
|
73b4c6d947 | ||
|
|
9e11fc33fa | ||
|
|
ff46d81681 | ||
|
|
1f202f6dbc | ||
|
|
bccf4653cb | ||
|
|
31eec34b5d | ||
|
|
44f3508171 | ||
|
|
2e39e45886 | ||
|
|
458aa72c2f | ||
|
|
78bf044a7a | ||
|
|
e5f0b4dd85 | ||
|
|
311a12b7ef | ||
|
|
d9e2bddee4 | ||
|
|
6578ac0b4a | ||
|
|
09c6d6c722 | ||
|
|
ad48cd3e48 | ||
|
|
e305ba14d9 | ||
|
|
2c3f453c1f | ||
|
|
b87e97f99e | ||
|
|
e5377d5f46 | ||
|
|
ff1ee2d71f | ||
|
|
c029741a17 | ||
|
|
ac83c349da | ||
|
|
fefcaa21e7 | ||
|
|
6a36db3cde | ||
|
|
c9352bfd42 | ||
|
|
9c457d9ffe | ||
|
|
7837b8c4ee | ||
|
|
87a5340a05 | ||
|
|
c076ca408c | ||
|
|
1ac11c1852 | ||
|
|
5f1ee5fb0e | ||
|
|
a9f02550f0 | ||
|
|
7590ecd37c | ||
|
|
2c0fdf83c1 | ||
|
|
2ed0317129 | ||
|
|
2f6ff07347 | ||
|
|
18f406d97b | ||
|
|
76fcbd3752 | ||
|
|
6e4132121c | ||
|
|
f5fefbdb06 | ||
|
|
a46b248cf4 | ||
|
|
8213ea9a71 | ||
|
|
03211ebea6 | ||
|
|
2bacc3c967 | ||
|
|
02dc3154e3 | ||
|
|
b6aa232205 | ||
|
|
b383f5776d | ||
|
|
3bfd26bf86 | ||
|
|
9d6f574494 | ||
|
|
d41452f39c | ||
|
|
14b6cd1091 | ||
|
|
8dc9689c6d | ||
|
|
181ae6d055 | ||
|
|
573c4e26d5 | ||
|
|
4e107b9160 | ||
|
|
10305a4446 | ||
|
|
a5fa745749 | ||
|
|
9023f78cdc | ||
|
|
8bc3e0f31a | ||
|
|
afed379c5c | ||
|
|
540119f133 | ||
|
|
d5de28c444 | ||
|
|
7a2e39212e | ||
|
|
715dee2d0e | ||
|
|
0f55d776a6 | ||
|
|
d617dba61c | ||
|
|
ca202c1819 | ||
|
|
76d02cd472 | ||
|
|
118e31608a | ||
|
|
418fd9037f | ||
|
|
9d7ce59b18 | ||
|
|
71e7dd5894 | ||
|
|
3502abdd49 | ||
|
|
31514bae06 | ||
|
|
19bfc8ad37 | ||
|
|
8f1f73defa | ||
|
|
bf4a3b73f8 | ||
|
|
00c0815808 | ||
|
|
b61f950560 | ||
|
|
8a6cf0cdec | ||
|
|
24bad5034a | ||
|
|
29ddb6e1b9 | ||
|
|
2ff90e2ff0 | ||
|
|
9666c8c0f7 | ||
|
|
58df3ad956 | ||
|
|
04ecc128a2 | ||
|
|
87d1d3423b | ||
|
|
d3ec38bee3 | ||
|
|
413cac23ae | ||
|
|
3c26e7b727 | ||
|
|
2a2d0aa15b | ||
|
|
12a9a45747 |
17
.env.example
17
.env.example
@@ -1,3 +1,11 @@
|
||||
# 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.
|
||||
@@ -5,7 +13,7 @@ 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.
|
||||
# a proxy or if using a third-party authentication option.
|
||||
# This must be the root URL that you want to host BookStack on.
|
||||
# All URL's in BookStack will be generated using this value.
|
||||
#APP_URL=https://example.com
|
||||
@@ -25,11 +33,10 @@ 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
|
||||
|
||||
|
||||
# A full list of options can be found in the '.env.example.complete' file.
|
||||
MAIL_ENCRYPTION=null
|
||||
@@ -238,9 +238,9 @@ DISABLE_EXTERNAL_SERVICES=false
|
||||
# Example: AVATAR_URL=https://seccdn.libravatar.org/avatar/${hash}?s=${size}&d=identicon
|
||||
AVATAR_URL=
|
||||
|
||||
# Enable draw.io integration
|
||||
# Enable diagrams.net integration
|
||||
# Can simply be true/false to enable/disable the integration.
|
||||
# Alternatively, It can be URL to the draw.io instance you want to use.
|
||||
# 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
|
||||
DRAWIO=true
|
||||
|
||||
@@ -270,4 +270,12 @@ 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
|
||||
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
|
||||
|
||||
26
.github/translators.txt
vendored
26
.github/translators.txt
vendored
@@ -61,7 +61,7 @@ Rodrigo Saczuk Niz (rodrigoniz) :: Portuguese, Brazilian
|
||||
aekramer :: Dutch
|
||||
JachuPL :: Polish
|
||||
milesteg :: Hungarian
|
||||
Beenbag :: German
|
||||
Beenbag :: German; German Informal
|
||||
Lett3rs :: Danish
|
||||
Julian (julian.henneberg) :: German; German Informal
|
||||
3GNWn :: Danish
|
||||
@@ -98,3 +98,27 @@ 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
|
||||
|
||||
58
.github/workflows/test-migrations.yml
vendored
Normal file
58
.github/workflows/test-migrations.yml
vendored
Normal file
@@ -0,0 +1,58 @@
|
||||
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
|
||||
@@ -4,6 +4,7 @@ use BookStack\Auth\Permissions\PermissionService;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Entities\Entity;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ActivityService
|
||||
{
|
||||
@@ -49,7 +50,7 @@ class ActivityService
|
||||
protected function newActivityForUser(string $key, ?int $bookId = null): Activity
|
||||
{
|
||||
return $this->activity->newInstance()->forceFill([
|
||||
'key' => strtolower($key),
|
||||
'key' => strtolower($key),
|
||||
'user_id' => $this->user->id,
|
||||
'book_id' => $bookId ?? 0,
|
||||
]);
|
||||
@@ -64,8 +65,8 @@ class ActivityService
|
||||
{
|
||||
$activities = $entity->activity()->get();
|
||||
$entity->activity()->update([
|
||||
'extra' => $entity->name,
|
||||
'entity_id' => 0,
|
||||
'extra' => $entity->name,
|
||||
'entity_id' => 0,
|
||||
'entity_type' => '',
|
||||
]);
|
||||
return $activities;
|
||||
@@ -99,7 +100,7 @@ class ActivityService
|
||||
$query = $this->activity->newQuery()->where('entity_type', '=', $entity->getMorphClass())
|
||||
->where('entity_id', '=', $entity->id);
|
||||
}
|
||||
|
||||
|
||||
$activity = $this->permissionService
|
||||
->filterRestrictedEntityRelations($query, 'activities', 'entity_id', 'entity_type')
|
||||
->orderBy('created_at', 'desc')
|
||||
@@ -159,4 +160,20 @@ class ActivityService
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,15 @@
|
||||
|
||||
use BookStack\Ownable;
|
||||
|
||||
/**
|
||||
* @property string text
|
||||
* @property string html
|
||||
* @property int|null parent_id
|
||||
* @property int local_id
|
||||
*/
|
||||
class Comment extends Ownable
|
||||
{
|
||||
protected $fillable = ['text', 'html', 'parent_id'];
|
||||
protected $fillable = ['text', 'parent_id'];
|
||||
protected $appends = ['created', 'updated'];
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
<?php namespace BookStack\Actions;
|
||||
|
||||
use BookStack\Entities\Entity;
|
||||
use League\CommonMark\CommonMarkConverter;
|
||||
|
||||
/**
|
||||
* Class CommentRepo
|
||||
* @package BookStack\Repos
|
||||
*/
|
||||
class CommentRepo
|
||||
{
|
||||
|
||||
/**
|
||||
* @var \BookStack\Actions\Comment $comment
|
||||
* @var Comment $comment
|
||||
*/
|
||||
protected $comment;
|
||||
|
||||
/**
|
||||
* CommentRepo constructor.
|
||||
* @param \BookStack\Actions\Comment $comment
|
||||
*/
|
||||
|
||||
public function __construct(Comment $comment)
|
||||
{
|
||||
$this->comment = $comment;
|
||||
@@ -25,65 +22,71 @@ class CommentRepo
|
||||
|
||||
/**
|
||||
* Get a comment by ID.
|
||||
* @param $id
|
||||
* @return \BookStack\Actions\Comment|\Illuminate\Database\Eloquent\Model
|
||||
*/
|
||||
public function getById($id)
|
||||
public function getById(int $id): Comment
|
||||
{
|
||||
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, $data = [])
|
||||
public function create(Entity $entity, string $text, ?int $parent_id): Comment
|
||||
{
|
||||
$userId = user()->id;
|
||||
$comment = $this->comment->newInstance($data);
|
||||
$comment = $this->comment->newInstance();
|
||||
|
||||
$comment->text = $text;
|
||||
$comment->html = $this->commentToHtml($text);
|
||||
$comment->created_by = $userId;
|
||||
$comment->updated_by = $userId;
|
||||
$comment->local_id = $this->getNextLocalId($entity);
|
||||
$comment->parent_id = $parent_id;
|
||||
|
||||
$entity->comments()->save($comment);
|
||||
return $comment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing comment.
|
||||
* @param \BookStack\Actions\Comment $comment
|
||||
* @param array $input
|
||||
* @return mixed
|
||||
*/
|
||||
public function update($comment, $input)
|
||||
public function update(Comment $comment, string $text): Comment
|
||||
{
|
||||
$comment->updated_by = user()->id;
|
||||
$comment->update($input);
|
||||
$comment->text = $text;
|
||||
$comment->html = $this->commentToHtml($text);
|
||||
$comment->save();
|
||||
return $comment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a comment from the system.
|
||||
* @param \BookStack\Actions\Comment $comment
|
||||
* @return mixed
|
||||
*/
|
||||
public function delete($comment)
|
||||
public function delete(Comment $comment)
|
||||
{
|
||||
return $comment->delete();
|
||||
$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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the next local ID relative to the linked entity.
|
||||
* @param \BookStack\Entities\Entity $entity
|
||||
* @return int
|
||||
*/
|
||||
protected function getNextLocalId(Entity $entity)
|
||||
protected function getNextLocalId(Entity $entity): int
|
||||
{
|
||||
$comments = $entity->comments(false)->orderBy('local_id', 'desc')->first();
|
||||
if ($comments === null) {
|
||||
return 1;
|
||||
}
|
||||
return $comments->local_id + 1;
|
||||
return ($comments->local_id ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use BookStack\Model;
|
||||
class Tag extends Model
|
||||
{
|
||||
protected $fillable = ['name', 'value', 'order'];
|
||||
protected $hidden = ['id', 'entity_id', 'entity_type'];
|
||||
|
||||
/**
|
||||
* Get the entity that this tag belongs to
|
||||
|
||||
@@ -2,71 +2,31 @@
|
||||
|
||||
use BookStack\Auth\Permissions\PermissionService;
|
||||
use BookStack\Entities\Entity;
|
||||
use DB;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* 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 $attr, Entity $ent, PermissionService $ps)
|
||||
public function __construct(Tag $tag, PermissionService $ps)
|
||||
{
|
||||
$this->tag = $attr;
|
||||
$this->entity = $ent;
|
||||
$this->tag = $tag;
|
||||
$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($searchTerm = false)
|
||||
public function getNameSuggestions(?string $searchTerm): Collection
|
||||
{
|
||||
$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');
|
||||
@@ -82,13 +42,10 @@ 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($searchTerm = false, $tagName = false)
|
||||
public function getValueSuggestions(?string $searchTerm, ?string $tagName): Collection
|
||||
{
|
||||
$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');
|
||||
@@ -96,7 +53,7 @@ class TagRepo
|
||||
$query = $query->orderBy('count', 'desc')->take(50);
|
||||
}
|
||||
|
||||
if ($tagName !== false) {
|
||||
if ($tagName) {
|
||||
$query = $query->where('name', '=', $tagName);
|
||||
}
|
||||
|
||||
@@ -106,35 +63,28 @@ 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, $tags = [])
|
||||
public function saveTagsToEntity(Entity $entity, array $tags = []): iterable
|
||||
{
|
||||
$entity->tags()->delete();
|
||||
$newTags = [];
|
||||
foreach ($tags as $tag) {
|
||||
if (trim($tag['name']) === '') {
|
||||
continue;
|
||||
}
|
||||
$newTags[] = $this->newInstanceFromInput($tag);
|
||||
}
|
||||
|
||||
$newTags = collect($tags)->filter(function ($tag) {
|
||||
return boolval(trim($tag['name']));
|
||||
})->map(function ($tag) {
|
||||
return $this->newInstanceFromInput($tag);
|
||||
})->all();
|
||||
|
||||
return $entity->tags()->saveMany($newTags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new Tag instance from user input.
|
||||
* @param $input
|
||||
* @return \BookStack\Actions\Tag
|
||||
* Input must be an array with a 'name' and an optional 'value' key.
|
||||
*/
|
||||
protected function newInstanceFromInput($input)
|
||||
protected function newInstanceFromInput(array $input): Tag
|
||||
{
|
||||
$name = trim($input['name']);
|
||||
$value = isset($input['value']) ? trim($input['value']) : '';
|
||||
// Any other modification or cleanup required can go here
|
||||
$values = ['name' => $name, 'value' => $value];
|
||||
return $this->tag->newInstance($values);
|
||||
return $this->tag->newInstance(['name' => $name, 'value' => $value]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Auth\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ExternalAuthService
|
||||
{
|
||||
@@ -39,22 +41,14 @@ class ExternalAuthService
|
||||
/**
|
||||
* Match an array of group names to BookStack system roles.
|
||||
* Formats group names to be lower-case and hyphenated.
|
||||
* @param array $groupNames
|
||||
* @return \Illuminate\Support\Collection
|
||||
*/
|
||||
protected function matchGroupsToSystemsRoles(array $groupNames)
|
||||
protected function matchGroupsToSystemsRoles(array $groupNames): Collection
|
||||
{
|
||||
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();
|
||||
|
||||
$roles = Role::query()->get(['id', 'external_auth_id', 'display_name']);
|
||||
$matchedRoles = $roles->filter(function (Role $role) use ($groupNames) {
|
||||
return $this->roleMatchesGroupNames($role, $groupNames);
|
||||
});
|
||||
|
||||
@@ -57,7 +57,7 @@ class RegistrationService
|
||||
// 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]));
|
||||
throw new UserRegistrationException(trans('errors.error_user_exists_different_creds', ['email' => $userEmail]), '/login');
|
||||
}
|
||||
|
||||
// Create the user
|
||||
@@ -71,15 +71,15 @@ class RegistrationService
|
||||
// Start email confirmation flow if required
|
||||
if ($this->emailConfirmationService->confirmationRequired() && !$emailConfirmed) {
|
||||
$newUser->save();
|
||||
$message = '';
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
throw new UserRegistrationException($message, '/register/confirm');
|
||||
}
|
||||
|
||||
return $newUser;
|
||||
@@ -106,13 +106,4 @@ class RegistrationService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias to the UserRepo method of the same name.
|
||||
* Attaches the default system role, if configured, to the given user.
|
||||
*/
|
||||
public function attachDefaultRole(User $user): void
|
||||
{
|
||||
$this->userRepo->attachDefaultRole($user);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -311,7 +311,6 @@ class Saml2Service extends ExternalAuthService
|
||||
|
||||
/**
|
||||
* Get the user from the database for the specified details.
|
||||
* @throws SamlException
|
||||
* @throws UserRegistrationException
|
||||
*/
|
||||
protected function getOrRegisterUser(array $userDetails): ?User
|
||||
|
||||
@@ -3,25 +3,26 @@
|
||||
use BookStack\Auth\Role;
|
||||
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()
|
||||
public function role(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Role::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the entity this points to.
|
||||
* @return \Illuminate\Database\Eloquent\Relations\MorphOne
|
||||
*/
|
||||
public function entity()
|
||||
public function entity(): MorphOne
|
||||
{
|
||||
return $this->morphOne(Entity::class, 'entity');
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<?php namespace BookStack\Auth\Permissions;
|
||||
|
||||
use BookStack\Auth\Permissions;
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Exceptions\PermissionsException;
|
||||
use Exception;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class PermissionsRepo
|
||||
@@ -16,11 +17,8 @@ class PermissionsRepo
|
||||
|
||||
/**
|
||||
* PermissionsRepo constructor.
|
||||
* @param RolePermission $permission
|
||||
* @param Role $role
|
||||
* @param \BookStack\Auth\Permissions\PermissionService $permissionService
|
||||
*/
|
||||
public function __construct(RolePermission $permission, Role $role, Permissions\PermissionService $permissionService)
|
||||
public function __construct(RolePermission $permission, Role $role, PermissionService $permissionService)
|
||||
{
|
||||
$this->permission = $permission;
|
||||
$this->role = $role;
|
||||
@@ -29,46 +27,34 @@ class PermissionsRepo
|
||||
|
||||
/**
|
||||
* Get all the user roles from the system.
|
||||
* @return \Illuminate\Database\Eloquent\Collection|static[]
|
||||
*/
|
||||
public function getAllRoles()
|
||||
public function getAllRoles(): Collection
|
||||
{
|
||||
return $this->role->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all the roles except for the provided one.
|
||||
* @param Role $role
|
||||
* @return mixed
|
||||
*/
|
||||
public function getAllRolesExcept(Role $role)
|
||||
public function getAllRolesExcept(Role $role): Collection
|
||||
{
|
||||
return $this->role->where('id', '!=', $role->id)->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a role via its ID.
|
||||
* @param $id
|
||||
* @return mixed
|
||||
*/
|
||||
public function getRoleById($id)
|
||||
public function getRoleById($id): Role
|
||||
{
|
||||
return $this->role->findOrFail($id);
|
||||
return $this->role->newQuery()->findOrFail($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a new role into the system.
|
||||
* @param array $roleData
|
||||
* @return Role
|
||||
*/
|
||||
public function saveNewRole($roleData)
|
||||
public function saveNewRole(array $roleData): Role
|
||||
{
|
||||
$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']) : [];
|
||||
@@ -80,13 +66,11 @@ class PermissionsRepo
|
||||
/**
|
||||
* Updates an existing role.
|
||||
* Ensure Admin role always have core permissions.
|
||||
* @param $roleId
|
||||
* @param $roleData
|
||||
* @throws PermissionsException
|
||||
*/
|
||||
public function updateRole($roleId, $roleData)
|
||||
public function updateRole($roleId, array $roleData)
|
||||
{
|
||||
$role = $this->role->findOrFail($roleId);
|
||||
/** @var Role $role */
|
||||
$role = $this->role->newQuery()->findOrFail($roleId);
|
||||
|
||||
$permissions = isset($roleData['permissions']) ? array_keys($roleData['permissions']) : [];
|
||||
if ($role->system_name === 'admin') {
|
||||
@@ -108,16 +92,19 @@ class PermissionsRepo
|
||||
|
||||
/**
|
||||
* Assign an list of permission names to an role.
|
||||
* @param Role $role
|
||||
* @param array $permissionNameArray
|
||||
*/
|
||||
public function assignRolePermissions(Role $role, $permissionNameArray = [])
|
||||
public function assignRolePermissions(Role $role, array $permissionNameArray = [])
|
||||
{
|
||||
$permissions = [];
|
||||
$permissionNameArray = array_values($permissionNameArray);
|
||||
if ($permissionNameArray && count($permissionNameArray) > 0) {
|
||||
$permissions = $this->permission->whereIn('name', $permissionNameArray)->pluck('id')->toArray();
|
||||
|
||||
if ($permissionNameArray) {
|
||||
$permissions = $this->permission->newQuery()
|
||||
->whereIn('name', $permissionNameArray)
|
||||
->pluck('id')
|
||||
->toArray();
|
||||
}
|
||||
|
||||
$role->permissions()->sync($permissions);
|
||||
}
|
||||
|
||||
@@ -126,13 +113,13 @@ 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)
|
||||
{
|
||||
$role = $this->role->findOrFail($roleId);
|
||||
/** @var Role $role */
|
||||
$role = $this->role->newQuery()->findOrFail($roleId);
|
||||
|
||||
// Prevent deleting admin role or default registration role.
|
||||
if ($role->system_name && in_array($role->system_name, $this->systemRoles)) {
|
||||
@@ -142,9 +129,9 @@ class PermissionsRepo
|
||||
}
|
||||
|
||||
if ($migrateRoleId) {
|
||||
$newRole = $this->role->find($migrateRoleId);
|
||||
$newRole = $this->role->newQuery()->find($migrateRoleId);
|
||||
if ($newRole) {
|
||||
$users = $role->users->pluck('id')->toArray();
|
||||
$users = $role->users()->pluck('id')->toArray();
|
||||
$newRole->users()->sync($users);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Model;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
*/
|
||||
class RolePermission extends Model
|
||||
{
|
||||
/**
|
||||
|
||||
@@ -3,13 +3,16 @@
|
||||
use BookStack\Auth\Permissions\JointPermission;
|
||||
use BookStack\Auth\Permissions\RolePermission;
|
||||
use BookStack\Model;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* Class Role
|
||||
* @property int $id
|
||||
* @property string $display_name
|
||||
* @property string $description
|
||||
* @property string $external_auth_id
|
||||
* @package BookStack\Auth
|
||||
* @property string $system_name
|
||||
*/
|
||||
class Role extends Model
|
||||
{
|
||||
@@ -26,9 +29,8 @@ class Role extends Model
|
||||
|
||||
/**
|
||||
* Get all related JointPermissions.
|
||||
* @return \Illuminate\Database\Eloquent\Relations\HasMany
|
||||
*/
|
||||
public function jointPermissions()
|
||||
public function jointPermissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(JointPermission::class);
|
||||
}
|
||||
@@ -43,10 +45,8 @@ class Role extends Model
|
||||
|
||||
/**
|
||||
* Check if this role has a permission.
|
||||
* @param $permissionName
|
||||
* @return bool
|
||||
*/
|
||||
public function hasPermission($permissionName)
|
||||
public function hasPermission(string $permissionName): bool
|
||||
{
|
||||
$permissions = $this->getRelationValue('permissions');
|
||||
foreach ($permissions as $permission) {
|
||||
@@ -59,7 +59,6 @@ class Role extends Model
|
||||
|
||||
/**
|
||||
* Add a permission to this role.
|
||||
* @param RolePermission $permission
|
||||
*/
|
||||
public function attachPermission(RolePermission $permission)
|
||||
{
|
||||
@@ -68,7 +67,6 @@ class Role extends Model
|
||||
|
||||
/**
|
||||
* Detach a single permission from this role.
|
||||
* @param RolePermission $permission
|
||||
*/
|
||||
public function detachPermission(RolePermission $permission)
|
||||
{
|
||||
@@ -76,39 +74,33 @@ class Role extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the role object for the specified role.
|
||||
* @param $roleName
|
||||
* @return Role
|
||||
* Get the role of the specified display name.
|
||||
*/
|
||||
public static function getRole($roleName)
|
||||
public static function getRole(string $displayName): ?Role
|
||||
{
|
||||
return static::query()->where('name', '=', $roleName)->first();
|
||||
return static::query()->where('display_name', '=', $displayName)->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the role object for the specified system role.
|
||||
* @param $roleName
|
||||
* @return Role
|
||||
*/
|
||||
public static function getSystemRole($roleName)
|
||||
public static function getSystemRole(string $systemName): ?Role
|
||||
{
|
||||
return static::query()->where('system_name', '=', $roleName)->first();
|
||||
return static::query()->where('system_name', '=', $systemName)->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all visible roles
|
||||
* @return mixed
|
||||
*/
|
||||
public static function visible()
|
||||
public static function visible(): Collection
|
||||
{
|
||||
return static::query()->where('hidden', '=', false)->orderBy('name')->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the roles that can be restricted.
|
||||
* @return \Illuminate\Database\Eloquent\Builder[]|\Illuminate\Database\Eloquent\Collection
|
||||
*/
|
||||
public static function restrictable()
|
||||
public static function restrictable(): Collection
|
||||
{
|
||||
return static::query()->where('system_name', '!=', 'admin')->get();
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
|
||||
*/
|
||||
protected $hidden = [
|
||||
'password', 'remember_token', 'system_name', 'email_confirmed', 'external_auth_id', 'email',
|
||||
'created_at', 'updated_at',
|
||||
'created_at', 'updated_at', 'image_id',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -101,12 +101,10 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
|
||||
|
||||
/**
|
||||
* Check if the user has a role.
|
||||
* @param $role
|
||||
* @return mixed
|
||||
*/
|
||||
public function hasRole($role)
|
||||
public function hasRole($roleId): bool
|
||||
{
|
||||
return $this->roles->pluck('name')->contains($role);
|
||||
return $this->roles->pluck('id')->contains($roleId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -163,7 +161,6 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
|
||||
|
||||
/**
|
||||
* Attach a role to this user.
|
||||
* @param Role $role
|
||||
*/
|
||||
public function attachRole(Role $role)
|
||||
{
|
||||
|
||||
@@ -238,7 +238,7 @@ class UserRepo
|
||||
*/
|
||||
public function getAllRoles()
|
||||
{
|
||||
return $this->role->newQuery()->orderBy('name', 'asc')->get();
|
||||
return $this->role->newQuery()->orderBy('display_name', 'asc')->get();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -52,7 +52,7 @@ return [
|
||||
'locale' => env('APP_LANG', 'en'),
|
||||
|
||||
// Locales available
|
||||
'locales' => ['en', 'ar', 'cs', 'da', 'de', 'de_informal', 'es', 'es_AR', 'fa', 'fr', 'he', 'hu', 'it', 'ja', 'ko', 'nl', 'pt', 'pt_BR', 'sk', 'sl', 'sv', 'pl', 'ru', 'th', 'tr', 'uk', 'vi', 'zh_CN', 'zh_TW',],
|
||||
'locales' => ['en', 'ar', 'bg', 'cs', 'da', 'de', 'de_informal', 'es', 'es_AR', 'fa', 'fr', 'he', 'hu', 'it', 'ja', 'ko', 'nl', 'pt', 'pt_BR', 'sk', 'sl', 'sv', 'pl', 'ru', 'th', 'tr', 'uk', 'vi', 'zh_CN', 'zh_TW',],
|
||||
|
||||
// Application Fallback Locale
|
||||
'fallback_locale' => 'en',
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
use Monolog\Formatter\LineFormatter;
|
||||
use Monolog\Handler\ErrorLogHandler;
|
||||
use Monolog\Handler\NullHandler;
|
||||
use Monolog\Handler\StreamHandler;
|
||||
|
||||
@@ -73,10 +75,38 @@ return [
|
||||
'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'),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -101,7 +101,7 @@ return [
|
||||
'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' => '',
|
||||
'responseUrl' => null,
|
||||
// SAML protocol binding to be used when returning the <Response>
|
||||
// message. Onelogin Toolkit supports for this endpoint the
|
||||
// HTTP-Redirect binding only
|
||||
|
||||
61
app/Console/Commands/RegenerateCommentContent.php
Normal file
61
app/Console/Commands/RegenerateCommentContent.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?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');
|
||||
}
|
||||
}
|
||||
@@ -30,8 +30,6 @@ class RegeneratePermissions extends Command
|
||||
|
||||
/**
|
||||
* Create a new command instance.
|
||||
*
|
||||
* @param \BookStack\Auth\\BookStack\Auth\Permissions\PermissionService $permissionService
|
||||
*/
|
||||
public function __construct(PermissionService $permissionService)
|
||||
{
|
||||
|
||||
@@ -19,7 +19,7 @@ class Book extends Entity implements HasCoverImage
|
||||
public $searchFactor = 2;
|
||||
|
||||
protected $fillable = ['name', 'description'];
|
||||
protected $hidden = ['restricted', 'pivot'];
|
||||
protected $hidden = ['restricted', 'pivot', 'image_id'];
|
||||
|
||||
/**
|
||||
* Get the url for this book.
|
||||
|
||||
@@ -12,7 +12,7 @@ class Bookshelf extends Entity implements HasCoverImage
|
||||
|
||||
protected $fillable = ['name', 'description', 'image_id'];
|
||||
|
||||
protected $hidden = ['restricted'];
|
||||
protected $hidden = ['restricted', 'image_id'];
|
||||
|
||||
/**
|
||||
* Get the books in this shelf.
|
||||
|
||||
@@ -12,6 +12,7 @@ class Chapter extends BookChild
|
||||
public $searchFactor = 1.3;
|
||||
|
||||
protected $fillable = ['name', 'description', 'priority', 'book_id'];
|
||||
protected $hidden = ['restricted', 'pivot'];
|
||||
|
||||
/**
|
||||
* Get the pages that this chapter contains.
|
||||
|
||||
@@ -201,12 +201,10 @@ class Entity extends Ownable
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows checking of the exact class, Used to check entity type.
|
||||
* Cleaner method for is_a.
|
||||
* @param $type
|
||||
* @return bool
|
||||
* Check if this instance or class is a certain type of entity.
|
||||
* Examples of $type are 'page', 'book', 'chapter'
|
||||
*/
|
||||
public static function isA($type)
|
||||
public static function isA(string $type): bool
|
||||
{
|
||||
return static::getType() === strtolower($type);
|
||||
}
|
||||
@@ -238,10 +236,8 @@ class Entity extends Ownable
|
||||
|
||||
/**
|
||||
* Gets a limited-length version of the entities name.
|
||||
* @param int $length
|
||||
* @return string
|
||||
*/
|
||||
public function getShortName($length = 25)
|
||||
public function getShortName(int $length = 25): string
|
||||
{
|
||||
if (mb_strlen($this->name) <= $length) {
|
||||
return $this->name;
|
||||
@@ -288,7 +284,7 @@ class Entity extends Ownable
|
||||
public function rebuildPermissions()
|
||||
{
|
||||
/** @noinspection PhpUnhandledExceptionInspection */
|
||||
Permissions::buildJointPermissionsForEntity($this);
|
||||
Permissions::buildJointPermissionsForEntity(clone $this);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -297,7 +293,7 @@ class Entity extends Ownable
|
||||
public function indexForSearch()
|
||||
{
|
||||
$searchService = app()->make(SearchService::class);
|
||||
$searchService->indexEntity($this);
|
||||
$searchService->indexEntity(clone $this);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -41,7 +41,6 @@ class BookContents
|
||||
|
||||
/**
|
||||
* Get the contents as a sorted collection tree.
|
||||
* TODO - Support $renderPages option
|
||||
*/
|
||||
public function getTree(bool $showDrafts = false, bool $renderPages = false): Collection
|
||||
{
|
||||
@@ -60,8 +59,12 @@ class BookContents
|
||||
}
|
||||
});
|
||||
|
||||
$all->each(function (Entity $entity) {
|
||||
$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());
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
use BookStack\Entities\Page;
|
||||
use DOMDocument;
|
||||
use DOMElement;
|
||||
use DOMNodeList;
|
||||
use DOMXPath;
|
||||
|
||||
@@ -44,18 +43,24 @@ class PageContent
|
||||
$container = $doc->documentElement;
|
||||
$body = $container->childNodes->item(0);
|
||||
$childNodes = $body->childNodes;
|
||||
$xPath = new DOMXPath($doc);
|
||||
|
||||
// Set ids on top-level nodes
|
||||
$idMap = [];
|
||||
foreach ($childNodes as $index => $childNode) {
|
||||
$this->setUniqueId($childNode, $idMap);
|
||||
[$oldId, $newId] = $this->setUniqueId($childNode, $idMap);
|
||||
if ($newId && $newId !== $oldId) {
|
||||
$this->updateLinks($xPath, '#' . $oldId, '#' . $newId);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure no duplicate ids within child items
|
||||
$xPath = new DOMXPath($doc);
|
||||
$idElems = $xPath->query('//body//*//*[@id]');
|
||||
foreach ($idElems as $domElem) {
|
||||
$this->setUniqueId($domElem, $idMap);
|
||||
[$oldId, $newId] = $this->setUniqueId($domElem, $idMap);
|
||||
if ($newId && $newId !== $oldId) {
|
||||
$this->updateLinks($xPath, '#' . $oldId, '#' . $newId);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate inner html as a string
|
||||
@@ -67,23 +72,34 @@ class PageContent
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the all links to the $old location to instead point to $new.
|
||||
*/
|
||||
protected function updateLinks(DOMXPath $xpath, string $old, string $new)
|
||||
{
|
||||
$old = str_replace('"', '', $old);
|
||||
$matchingLinks = $xpath->query('//body//*//*[@href="'.$old.'"]');
|
||||
foreach ($matchingLinks as $domElem) {
|
||||
$domElem->setAttribute('href', $new);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Returns a pair of strings in the format [old_id, new_id]
|
||||
*/
|
||||
protected function setUniqueId($element, array &$idMap)
|
||||
protected function setUniqueId(\DOMNode $element, array &$idMap): array
|
||||
{
|
||||
if (get_class($element) !== 'DOMElement') {
|
||||
return;
|
||||
return ['', ''];
|
||||
}
|
||||
|
||||
// Overwrite id if not a BookStack custom id
|
||||
// Stop if there's an existing valid id that has not already been used.
|
||||
$existingId = $element->getAttribute('id');
|
||||
if (strpos($existingId, 'bkmrk') === 0 && !isset($idMap[$existingId])) {
|
||||
$idMap[$existingId] = true;
|
||||
return;
|
||||
return [$existingId, $existingId];
|
||||
}
|
||||
|
||||
// Create an unique id for the element
|
||||
@@ -100,6 +116,7 @@ class PageContent
|
||||
|
||||
$element->setAttribute('id', $newId);
|
||||
$idMap[$newId] = true;
|
||||
return [$existingId, $newId];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,7 +125,7 @@ class PageContent
|
||||
protected function toPlainText(): string
|
||||
{
|
||||
$html = $this->render(true);
|
||||
return strip_tags($html);
|
||||
return html_entity_decode(strip_tags($html));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,12 +21,14 @@ use Permissions;
|
||||
*/
|
||||
class Page extends BookChild
|
||||
{
|
||||
protected $fillable = ['name', 'html', 'priority', 'markdown'];
|
||||
protected $fillable = ['name', 'priority', 'markdown'];
|
||||
|
||||
protected $simpleAttributes = ['name', 'id', 'slug'];
|
||||
|
||||
public $textField = 'text';
|
||||
|
||||
protected $hidden = ['html', 'markdown', 'text', 'restricted', 'pivot'];
|
||||
|
||||
/**
|
||||
* Get the entities that are visible to the current user.
|
||||
*/
|
||||
|
||||
@@ -28,8 +28,10 @@ class BookshelfRepo
|
||||
*/
|
||||
public function getAllPaginated(int $count = 20, string $sort = 'name', string $order = 'asc'): LengthAwarePaginator
|
||||
{
|
||||
return Bookshelf::visible()->with('visibleBooks')
|
||||
->orderBy($sort, $order)->paginate($count);
|
||||
return Bookshelf::visible()
|
||||
->with('visibleBooks')
|
||||
->orderBy($sort, $order)
|
||||
->paginate($count);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -180,12 +180,11 @@ class PageRepo
|
||||
$page->template = ($input['template'] === 'true');
|
||||
}
|
||||
|
||||
$pageContent = new PageContent($page);
|
||||
$pageContent->setNewHTML($input['html']);
|
||||
$this->baseRepo->update($page, $input);
|
||||
|
||||
// Update with new details
|
||||
$page->fill($input);
|
||||
$pageContent = new PageContent($page);
|
||||
$pageContent->setNewHTML($input['html']);
|
||||
$page->revision_count++;
|
||||
|
||||
if (setting('app-editor') !== 'markdown') {
|
||||
@@ -211,7 +210,7 @@ class PageRepo
|
||||
*/
|
||||
protected function savePageRevision(Page $page, string $summary = null)
|
||||
{
|
||||
$revision = new PageRevision($page->toArray());
|
||||
$revision = new PageRevision($page->getAttributes());
|
||||
|
||||
if (setting('app-editor') !== 'markdown') {
|
||||
$revision->markdown = '';
|
||||
@@ -279,7 +278,7 @@ class PageRepo
|
||||
$revision = $page->revisions()->where('id', '=', $revisionId)->first();
|
||||
$page->fill($revision->toArray());
|
||||
$content = new PageContent($page);
|
||||
$content->setNewHTML($page->html);
|
||||
$content->setNewHTML($revision->html);
|
||||
$page->updated_by = user()->id;
|
||||
$page->refreshSlug();
|
||||
$page->save();
|
||||
|
||||
141
app/Entities/SearchOptions.php
Normal file
141
app/Entities/SearchOptions.php
Normal file
@@ -0,0 +1,141 @@
|
||||
<?php namespace BookStack\Entities;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SearchOptions
|
||||
{
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
public $searches = [];
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
public $exacts = [];
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
public $tags = [];
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
public $filters = [];
|
||||
|
||||
/**
|
||||
* Create a new instance from a search string.
|
||||
*/
|
||||
public static function fromString(string $search): SearchOptions
|
||||
{
|
||||
$decoded = static::decode($search);
|
||||
$instance = new static();
|
||||
foreach ($decoded as $type => $value) {
|
||||
$instance->$type = $value;
|
||||
}
|
||||
return $instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new instance from a request.
|
||||
* Will look for a classic string term and use that
|
||||
* Otherwise we'll use the details from an advanced search form.
|
||||
*/
|
||||
public static function fromRequest(Request $request): SearchOptions
|
||||
{
|
||||
if (!$request->has('search') && !$request->has('term')) {
|
||||
return static::fromString('');
|
||||
}
|
||||
|
||||
if ($request->has('term')) {
|
||||
return static::fromString($request->get('term'));
|
||||
}
|
||||
|
||||
$instance = new static();
|
||||
$inputs = $request->only(['search', 'types', 'filters', 'exact', 'tags']);
|
||||
$instance->searches = explode(' ', $inputs['search'] ?? []);
|
||||
$instance->exacts = array_filter($inputs['exact'] ?? []);
|
||||
$instance->tags = array_filter($inputs['tags'] ?? []);
|
||||
foreach (($inputs['filters'] ?? []) as $filterKey => $filterVal) {
|
||||
if (empty($filterVal)) {
|
||||
continue;
|
||||
}
|
||||
$instance->filters[$filterKey] = $filterVal === 'true' ? '' : $filterVal;
|
||||
}
|
||||
if (isset($inputs['types']) && count($inputs['types']) < 4) {
|
||||
$instance->filters['type'] = implode('|', $inputs['types']);
|
||||
}
|
||||
return $instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a search string into an array of terms.
|
||||
*/
|
||||
protected static function decode(string $searchString): array
|
||||
{
|
||||
$terms = [
|
||||
'searches' => [],
|
||||
'exacts' => [],
|
||||
'tags' => [],
|
||||
'filters' => []
|
||||
];
|
||||
|
||||
$patterns = [
|
||||
'exacts' => '/"(.*?)"/',
|
||||
'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['searches'][] = $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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode this instance to a search string.
|
||||
*/
|
||||
public function toString(): string
|
||||
{
|
||||
$string = implode(' ', $this->searches ?? []);
|
||||
|
||||
foreach ($this->exacts as $term) {
|
||||
$string .= ' "' . $term . '"';
|
||||
}
|
||||
|
||||
foreach ($this->tags as $term) {
|
||||
$string .= " [{$term}]";
|
||||
}
|
||||
|
||||
foreach ($this->filters as $filterName => $filterVal) {
|
||||
$string .= ' {' . $filterName . ($filterVal ? ':' . $filterVal : '') . '}';
|
||||
}
|
||||
|
||||
return $string;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -39,10 +39,6 @@ class SearchService
|
||||
|
||||
/**
|
||||
* 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)
|
||||
{
|
||||
@@ -54,7 +50,6 @@ class SearchService
|
||||
|
||||
/**
|
||||
* Set the database connection
|
||||
* @param Connection $connection
|
||||
*/
|
||||
public function setConnection(Connection $connection)
|
||||
{
|
||||
@@ -63,23 +58,18 @@ class SearchService
|
||||
|
||||
/**
|
||||
* 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];
|
||||
* The provided count is for each entity to search,
|
||||
* Total returned could can be larger and not guaranteed.
|
||||
*/
|
||||
public function searchEntities($searchString, $entityType = 'all', $page = 1, $count = 20, $action = 'view')
|
||||
public function searchEntities(SearchOptions $searchOpts, string $entityType = 'all', int $page = 1, int $count = 20, string $action = 'view'): array
|
||||
{
|
||||
$terms = $this->parseSearchString($searchString);
|
||||
$entityTypes = array_keys($this->entityProvider->all());
|
||||
$entityTypesToSearch = $entityTypes;
|
||||
|
||||
if ($entityType !== 'all') {
|
||||
$entityTypesToSearch = $entityType;
|
||||
} else if (isset($terms['filters']['type'])) {
|
||||
$entityTypesToSearch = explode('|', $terms['filters']['type']);
|
||||
} else if (isset($searchOpts->filters['type'])) {
|
||||
$entityTypesToSearch = explode('|', $searchOpts->filters['type']);
|
||||
}
|
||||
|
||||
$results = collect();
|
||||
@@ -90,8 +80,8 @@ class SearchService
|
||||
if (!in_array($entityType, $entityTypes)) {
|
||||
continue;
|
||||
}
|
||||
$search = $this->searchEntityTable($terms, $entityType, $page, $count, $action);
|
||||
$entityTotal = $this->searchEntityTable($terms, $entityType, $page, $count, $action, true);
|
||||
$search = $this->searchEntityTable($searchOpts, $entityType, $page, $count, $action);
|
||||
$entityTotal = $this->searchEntityTable($searchOpts, $entityType, $page, $count, $action, true);
|
||||
if ($entityTotal > $page * $count) {
|
||||
$hasMore = true;
|
||||
}
|
||||
@@ -103,29 +93,26 @@ class SearchService
|
||||
'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($bookId, $searchString)
|
||||
public function searchBook(int $bookId, string $searchString): Collection
|
||||
{
|
||||
$terms = $this->parseSearchString($searchString);
|
||||
$opts = SearchOptions::fromString($searchString);
|
||||
$entityTypes = ['page', 'chapter'];
|
||||
$entityTypesToSearch = isset($terms['filters']['type']) ? explode('|', $terms['filters']['type']) : $entityTypes;
|
||||
$entityTypesToSearch = isset($opts->filters['type']) ? explode('|', $opts->filters['type']) : $entityTypes;
|
||||
|
||||
$results = collect();
|
||||
foreach ($entityTypesToSearch as $entityType) {
|
||||
if (!in_array($entityType, $entityTypes)) {
|
||||
continue;
|
||||
}
|
||||
$search = $this->buildEntitySearchQuery($terms, $entityType)->where('book_id', '=', $bookId)->take(20)->get();
|
||||
$search = $this->buildEntitySearchQuery($opts, $entityType)->where('book_id', '=', $bookId)->take(20)->get();
|
||||
$results = $results->merge($search);
|
||||
}
|
||||
return $results->sortByDesc('score')->take(20);
|
||||
@@ -133,30 +120,23 @@ class SearchService
|
||||
|
||||
/**
|
||||
* Search a book for entities
|
||||
* @param integer $chapterId
|
||||
* @param string $searchString
|
||||
* @return Collection
|
||||
*/
|
||||
public function searchChapter($chapterId, $searchString)
|
||||
public function searchChapter(int $chapterId, string $searchString): Collection
|
||||
{
|
||||
$terms = $this->parseSearchString($searchString);
|
||||
$pages = $this->buildEntitySearchQuery($terms, 'page')->where('chapter_id', '=', $chapterId)->take(20)->get();
|
||||
$opts = SearchOptions::fromString($searchString);
|
||||
$pages = $this->buildEntitySearchQuery($opts, 'page')->where('chapter_id', '=', $chapterId)->take(20)->get();
|
||||
return $pages->sortByDesc('score');
|
||||
}
|
||||
|
||||
/**
|
||||
* Search across a particular entity type.
|
||||
* @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
|
||||
* Setting getCount = true will return the total
|
||||
* matching instead of the items themselves.
|
||||
* @return \Illuminate\Database\Eloquent\Collection|int|static[]
|
||||
*/
|
||||
public function searchEntityTable($terms, $entityType = 'page', $page = 1, $count = 20, $action = 'view', $getCount = false)
|
||||
public function searchEntityTable(SearchOptions $searchOpts, string $entityType = 'page', int $page = 1, int $count = 20, string $action = 'view', bool $getCount = false)
|
||||
{
|
||||
$query = $this->buildEntitySearchQuery($terms, $entityType, $action);
|
||||
$query = $this->buildEntitySearchQuery($searchOpts, $entityType, $action);
|
||||
if ($getCount) {
|
||||
return $query->count();
|
||||
}
|
||||
@@ -167,22 +147,18 @@ class SearchService
|
||||
|
||||
/**
|
||||
* Create a search query for an entity
|
||||
* @param array $terms
|
||||
* @param string $entityType
|
||||
* @param string $action
|
||||
* @return EloquentBuilder
|
||||
*/
|
||||
protected function buildEntitySearchQuery($terms, $entityType = 'page', $action = 'view')
|
||||
protected function buildEntitySearchQuery(SearchOptions $searchOpts, string $entityType = 'page', string $action = 'view'): EloquentBuilder
|
||||
{
|
||||
$entity = $this->entityProvider->get($entityType);
|
||||
$entitySelect = $entity->newQuery();
|
||||
|
||||
// Handle normal search terms
|
||||
if (count($terms['search']) > 0) {
|
||||
if (count($searchOpts->searches) > 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 ($terms) {
|
||||
foreach ($terms['search'] as $inputTerm) {
|
||||
$subQuery->where(function (Builder $query) use ($searchOpts) {
|
||||
foreach ($searchOpts->searches as $inputTerm) {
|
||||
$query->orWhere('term', 'like', $inputTerm .'%');
|
||||
}
|
||||
})->groupBy('entity_type', 'entity_id');
|
||||
@@ -193,9 +169,9 @@ class SearchService
|
||||
}
|
||||
|
||||
// Handle exact term matching
|
||||
if (count($terms['exact']) > 0) {
|
||||
$entitySelect->where(function (EloquentBuilder $query) use ($terms, $entity) {
|
||||
foreach ($terms['exact'] as $inputTerm) {
|
||||
if (count($searchOpts->exacts) > 0) {
|
||||
$entitySelect->where(function (EloquentBuilder $query) use ($searchOpts, $entity) {
|
||||
foreach ($searchOpts->exacts as $inputTerm) {
|
||||
$query->where(function (EloquentBuilder $query) use ($inputTerm, $entity) {
|
||||
$query->where('name', 'like', '%'.$inputTerm .'%')
|
||||
->orWhere($entity->textField, 'like', '%'.$inputTerm .'%');
|
||||
@@ -205,12 +181,12 @@ class SearchService
|
||||
}
|
||||
|
||||
// Handle tag searches
|
||||
foreach ($terms['tags'] as $inputTerm) {
|
||||
foreach ($searchOpts->tags as $inputTerm) {
|
||||
$this->applyTagSearch($entitySelect, $inputTerm);
|
||||
}
|
||||
|
||||
// Handle filters
|
||||
foreach ($terms['filters'] as $filterTerm => $filterValue) {
|
||||
foreach ($searchOpts->filters as $filterTerm => $filterValue) {
|
||||
$functionName = Str::camel('filter_' . $filterTerm);
|
||||
if (method_exists($this, $functionName)) {
|
||||
$this->$functionName($entitySelect, $entity, $filterValue);
|
||||
@@ -220,60 +196,10 @@ class SearchService
|
||||
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()
|
||||
protected function getRegexEscapedOperators(): string
|
||||
{
|
||||
$escapedOperators = [];
|
||||
foreach ($this->queryOperators as $operator) {
|
||||
@@ -284,11 +210,8 @@ class SearchService
|
||||
|
||||
/**
|
||||
* Apply a tag search term onto a entity query.
|
||||
* @param EloquentBuilder $query
|
||||
* @param string $tagTerm
|
||||
* @return mixed
|
||||
*/
|
||||
protected function applyTagSearch(EloquentBuilder $query, $tagTerm)
|
||||
protected function applyTagSearch(EloquentBuilder $query, string $tagTerm): EloquentBuilder
|
||||
{
|
||||
preg_match("/^(.*?)((".$this->getRegexEscapedOperators().")(.*?))?$/", $tagTerm, $tagSplit);
|
||||
$query->whereHas('tags', function (EloquentBuilder $query) use ($tagSplit) {
|
||||
@@ -318,7 +241,6 @@ class SearchService
|
||||
|
||||
/**
|
||||
* Index the given entity.
|
||||
* @param Entity $entity
|
||||
*/
|
||||
public function indexEntity(Entity $entity)
|
||||
{
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php namespace BookStack\Entities;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class SlugGenerator
|
||||
{
|
||||
|
||||
@@ -32,9 +34,7 @@ class SlugGenerator
|
||||
*/
|
||||
protected function formatNameAsSlug(string $name): string
|
||||
{
|
||||
$slug = preg_replace('/[\+\/\\\?\@\}\{\.\,\=\[\]\#\&\!\*\'\;\:\$\%]/', '', mb_strtolower($name));
|
||||
$slug = preg_replace('/\s{2,}/', ' ', $slug);
|
||||
$slug = str_replace(' ', '-', $slug);
|
||||
$slug = Str::slug($name);
|
||||
if ($slug === "") {
|
||||
$slug = substr(md5(rand(1, 500)), 0, 5);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
@@ -26,6 +25,7 @@ class Handler extends ExceptionHandler
|
||||
HttpException::class,
|
||||
ModelNotFoundException::class,
|
||||
ValidationException::class,
|
||||
NotFoundException::class,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,7 +8,7 @@ use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class BooksApiController extends ApiController
|
||||
class BookApiController extends ApiController
|
||||
{
|
||||
|
||||
protected $bookRepo;
|
||||
@@ -17,10 +17,12 @@ class BooksApiController extends ApiController
|
||||
'create' => [
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'string|max:1000',
|
||||
'tags' => 'array',
|
||||
],
|
||||
'update' => [
|
||||
'name' => 'string|min:1|max:255',
|
||||
'description' => 'string|max:1000',
|
||||
'tags' => 'array',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -5,9 +5,8 @@ use BookStack\Entities\ExportService;
|
||||
use BookStack\Entities\Repos\BookRepo;
|
||||
use Throwable;
|
||||
|
||||
class BooksExportApiController extends ApiController
|
||||
class BookExportApiController extends ApiController
|
||||
{
|
||||
|
||||
protected $bookRepo;
|
||||
protected $exportService;
|
||||
|
||||
104
app/Http/Controllers/Api/ChapterApiController.php
Normal file
104
app/Http/Controllers/Api/ChapterApiController.php
Normal file
@@ -0,0 +1,104 @@
|
||||
<?php namespace BookStack\Http\Controllers\Api;
|
||||
|
||||
use BookStack\Entities\Book;
|
||||
use BookStack\Entities\Chapter;
|
||||
use BookStack\Entities\Repos\ChapterRepo;
|
||||
use BookStack\Facades\Activity;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ChapterApiController extends ApiController
|
||||
{
|
||||
protected $chapterRepo;
|
||||
|
||||
protected $rules = [
|
||||
'create' => [
|
||||
'book_id' => 'required|integer',
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'string|max:1000',
|
||||
'tags' => 'array',
|
||||
],
|
||||
'update' => [
|
||||
'book_id' => 'integer',
|
||||
'name' => 'string|min:1|max:255',
|
||||
'description' => 'string|max:1000',
|
||||
'tags' => 'array',
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* ChapterController constructor.
|
||||
*/
|
||||
public function __construct(ChapterRepo $chapterRepo)
|
||||
{
|
||||
$this->chapterRepo = $chapterRepo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a listing of chapters visible to the user.
|
||||
*/
|
||||
public function list()
|
||||
{
|
||||
$chapters = Chapter::visible();
|
||||
return $this->apiListingResponse($chapters, [
|
||||
'id', 'book_id', 'name', 'slug', 'description', 'priority',
|
||||
'created_at', 'updated_at', 'created_by', 'updated_by',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new chapter in the system.
|
||||
*/
|
||||
public function create(Request $request)
|
||||
{
|
||||
$this->validate($request, $this->rules['create']);
|
||||
|
||||
$bookId = $request->get('book_id');
|
||||
$book = Book::visible()->findOrFail($bookId);
|
||||
$this->checkOwnablePermission('chapter-create', $book);
|
||||
|
||||
$chapter = $this->chapterRepo->create($request->all(), $book);
|
||||
Activity::add($chapter, 'chapter_create', $book->id);
|
||||
|
||||
return response()->json($chapter->load(['tags']));
|
||||
}
|
||||
|
||||
/**
|
||||
* View the details of a single chapter.
|
||||
*/
|
||||
public function read(string $id)
|
||||
{
|
||||
$chapter = Chapter::visible()->with(['tags', 'createdBy', 'updatedBy', 'pages' => function (HasMany $query) {
|
||||
$query->visible()->get(['id', 'name', 'slug']);
|
||||
}])->findOrFail($id);
|
||||
return response()->json($chapter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the details of a single chapter.
|
||||
*/
|
||||
public function update(Request $request, string $id)
|
||||
{
|
||||
$chapter = Chapter::visible()->findOrFail($id);
|
||||
$this->checkOwnablePermission('chapter-update', $chapter);
|
||||
|
||||
$updatedChapter = $this->chapterRepo->update($chapter, $request->all());
|
||||
Activity::add($chapter, 'chapter_update', $chapter->book->id);
|
||||
|
||||
return response()->json($updatedChapter->load(['tags']));
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a chapter from the system.
|
||||
*/
|
||||
public function delete(string $id)
|
||||
{
|
||||
$chapter = Chapter::visible()->findOrFail($id);
|
||||
$this->checkOwnablePermission('chapter-delete', $chapter);
|
||||
|
||||
$this->chapterRepo->destroy($chapter);
|
||||
Activity::addMessage('chapter_delete', $chapter->name, $chapter->book->id);
|
||||
|
||||
return response('', 204);
|
||||
}
|
||||
}
|
||||
54
app/Http/Controllers/Api/ChapterExportApiController.php
Normal file
54
app/Http/Controllers/Api/ChapterExportApiController.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php namespace BookStack\Http\Controllers\Api;
|
||||
|
||||
use BookStack\Entities\Chapter;
|
||||
use BookStack\Entities\ExportService;
|
||||
use BookStack\Entities\Repos\BookRepo;
|
||||
use Throwable;
|
||||
|
||||
class ChapterExportApiController extends ApiController
|
||||
{
|
||||
protected $chapterRepo;
|
||||
protected $exportService;
|
||||
|
||||
/**
|
||||
* ChapterExportController constructor.
|
||||
*/
|
||||
public function __construct(BookRepo $chapterRepo, ExportService $exportService)
|
||||
{
|
||||
$this->chapterRepo = $chapterRepo;
|
||||
$this->exportService = $exportService;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Export a chapter as a PDF file.
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function exportPdf(int $id)
|
||||
{
|
||||
$chapter = Chapter::visible()->findOrFail($id);
|
||||
$pdfContent = $this->exportService->chapterToPdf($chapter);
|
||||
return $this->downloadResponse($pdfContent, $chapter->slug . '.pdf');
|
||||
}
|
||||
|
||||
/**
|
||||
* Export a chapter as a contained HTML file.
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function exportHtml(int $id)
|
||||
{
|
||||
$chapter = Chapter::visible()->findOrFail($id);
|
||||
$htmlContent = $this->exportService->chapterToContainedHtml($chapter);
|
||||
return $this->downloadResponse($htmlContent, $chapter->slug . '.html');
|
||||
}
|
||||
|
||||
/**
|
||||
* Export a chapter as a plain text file.
|
||||
*/
|
||||
public function exportPlainText(int $id)
|
||||
{
|
||||
$chapter = Chapter::visible()->findOrFail($id);
|
||||
$textContent = $this->exportService->chapterToPlainText($chapter);
|
||||
return $this->downloadResponse($textContent, $chapter->slug . '.txt');
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use BookStack\Uploads\AttachmentService;
|
||||
use Exception;
|
||||
use Illuminate\Contracts\Filesystem\FileNotFoundException;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\MessageBag;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class AttachmentController extends Controller
|
||||
@@ -60,25 +61,17 @@ class AttachmentController extends Controller
|
||||
/**
|
||||
* Update an uploaded attachment.
|
||||
* @throws ValidationException
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
public function uploadUpdate(Request $request, $attachmentId)
|
||||
{
|
||||
$this->validate($request, [
|
||||
'uploaded_to' => 'required|integer|exists:pages,id',
|
||||
'file' => 'required|file'
|
||||
]);
|
||||
|
||||
$pageId = $request->get('uploaded_to');
|
||||
$page = $this->pageRepo->getById($pageId);
|
||||
$attachment = $this->attachment->findOrFail($attachmentId);
|
||||
|
||||
$this->checkOwnablePermission('page-update', $page);
|
||||
$attachment = $this->attachment->newQuery()->findOrFail($attachmentId);
|
||||
$this->checkOwnablePermission('view', $attachment->page);
|
||||
$this->checkOwnablePermission('page-update', $attachment->page);
|
||||
$this->checkOwnablePermission('attachment-create', $attachment);
|
||||
|
||||
if (intval($pageId) !== intval($attachment->uploaded_to)) {
|
||||
return $this->jsonError(trans('errors.attachment_page_mismatch'));
|
||||
}
|
||||
|
||||
$uploadedFile = $request->file('file');
|
||||
|
||||
@@ -92,57 +85,87 @@ class AttachmentController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the details of an existing file.
|
||||
* @throws ValidationException
|
||||
* @throws NotFoundException
|
||||
* Get the update form for an attachment.
|
||||
* @return \Illuminate\Contracts\Foundation\Application|\Illuminate\Contracts\View\Factory|\Illuminate\View\View
|
||||
*/
|
||||
public function update(Request $request, $attachmentId)
|
||||
public function getUpdateForm(string $attachmentId)
|
||||
{
|
||||
$this->validate($request, [
|
||||
'uploaded_to' => 'required|integer|exists:pages,id',
|
||||
'name' => 'required|string|min:1|max:255',
|
||||
'link' => 'string|min:1|max:255'
|
||||
]);
|
||||
|
||||
$pageId = $request->get('uploaded_to');
|
||||
$page = $this->pageRepo->getById($pageId);
|
||||
$attachment = $this->attachment->findOrFail($attachmentId);
|
||||
|
||||
$this->checkOwnablePermission('page-update', $page);
|
||||
$this->checkOwnablePermission('page-update', $attachment->page);
|
||||
$this->checkOwnablePermission('attachment-create', $attachment);
|
||||
|
||||
if (intval($pageId) !== intval($attachment->uploaded_to)) {
|
||||
return $this->jsonError(trans('errors.attachment_page_mismatch'));
|
||||
return view('attachments.manager-edit-form', [
|
||||
'attachment' => $attachment,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the details of an existing file.
|
||||
*/
|
||||
public function update(Request $request, string $attachmentId)
|
||||
{
|
||||
$attachment = $this->attachment->newQuery()->findOrFail($attachmentId);
|
||||
|
||||
try {
|
||||
$this->validate($request, [
|
||||
'attachment_edit_name' => 'required|string|min:1|max:255',
|
||||
'attachment_edit_url' => 'string|min:1|max:255'
|
||||
]);
|
||||
} catch (ValidationException $exception) {
|
||||
return response()->view('attachments.manager-edit-form', array_merge($request->only(['attachment_edit_name', 'attachment_edit_url']), [
|
||||
'attachment' => $attachment,
|
||||
'errors' => new MessageBag($exception->errors()),
|
||||
]), 422);
|
||||
}
|
||||
|
||||
$attachment = $this->attachmentService->updateFile($attachment, $request->all());
|
||||
return response()->json($attachment);
|
||||
$this->checkOwnablePermission('view', $attachment->page);
|
||||
$this->checkOwnablePermission('page-update', $attachment->page);
|
||||
$this->checkOwnablePermission('attachment-create', $attachment);
|
||||
|
||||
$attachment = $this->attachmentService->updateFile($attachment, [
|
||||
'name' => $request->get('attachment_edit_name'),
|
||||
'link' => $request->get('attachment_edit_url'),
|
||||
]);
|
||||
|
||||
return view('attachments.manager-edit-form', [
|
||||
'attachment' => $attachment,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach a link to a page.
|
||||
* @throws ValidationException
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
public function attachLink(Request $request)
|
||||
{
|
||||
$this->validate($request, [
|
||||
'uploaded_to' => 'required|integer|exists:pages,id',
|
||||
'name' => 'required|string|min:1|max:255',
|
||||
'link' => 'required|string|min:1|max:255'
|
||||
]);
|
||||
$pageId = $request->get('attachment_link_uploaded_to');
|
||||
|
||||
try {
|
||||
$this->validate($request, [
|
||||
'attachment_link_uploaded_to' => 'required|integer|exists:pages,id',
|
||||
'attachment_link_name' => 'required|string|min:1|max:255',
|
||||
'attachment_link_url' => 'required|string|min:1|max:255'
|
||||
]);
|
||||
} catch (ValidationException $exception) {
|
||||
return response()->view('attachments.manager-link-form', array_merge($request->only(['attachment_link_name', 'attachment_link_url']), [
|
||||
'pageId' => $pageId,
|
||||
'errors' => new MessageBag($exception->errors()),
|
||||
]), 422);
|
||||
}
|
||||
|
||||
$pageId = $request->get('uploaded_to');
|
||||
$page = $this->pageRepo->getById($pageId);
|
||||
|
||||
$this->checkPermission('attachment-create-all');
|
||||
$this->checkOwnablePermission('page-update', $page);
|
||||
|
||||
$attachmentName = $request->get('name');
|
||||
$link = $request->get('link');
|
||||
$attachmentName = $request->get('attachment_link_name');
|
||||
$link = $request->get('attachment_link_url');
|
||||
$attachment = $this->attachmentService->saveNewFromLink($attachmentName, $link, $pageId);
|
||||
|
||||
return response()->json($attachment);
|
||||
return view('attachments.manager-link-form', [
|
||||
'pageId' => $pageId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -152,7 +175,9 @@ class AttachmentController extends Controller
|
||||
{
|
||||
$page = $this->pageRepo->getById($pageId);
|
||||
$this->checkOwnablePermission('page-view', $page);
|
||||
return response()->json($page->attachments);
|
||||
return view('attachments.manager-list', [
|
||||
'attachments' => $page->attachments->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -163,14 +188,13 @@ class AttachmentController extends Controller
|
||||
public function sortForPage(Request $request, int $pageId)
|
||||
{
|
||||
$this->validate($request, [
|
||||
'files' => 'required|array',
|
||||
'files.*.id' => 'required|integer',
|
||||
'order' => 'required|array',
|
||||
]);
|
||||
$page = $this->pageRepo->getById($pageId);
|
||||
$this->checkOwnablePermission('page-update', $page);
|
||||
|
||||
$attachments = $request->get('files');
|
||||
$this->attachmentService->updateFileOrderWithinPage($attachments, $pageId);
|
||||
$attachmentOrder = $request->get('order');
|
||||
$this->attachmentService->updateFileOrderWithinPage($attachmentOrder, $pageId);
|
||||
return response()->json(['message' => trans('entities.attachments_order_updated')]);
|
||||
}
|
||||
|
||||
@@ -179,7 +203,7 @@ class AttachmentController extends Controller
|
||||
* @throws FileNotFoundException
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
public function get(int $attachmentId)
|
||||
public function get(string $attachmentId)
|
||||
{
|
||||
$attachment = $this->attachment->findOrFail($attachmentId);
|
||||
try {
|
||||
@@ -200,11 +224,9 @@ class AttachmentController extends Controller
|
||||
|
||||
/**
|
||||
* Delete a specific attachment in the system.
|
||||
* @param $attachmentId
|
||||
* @return mixed
|
||||
* @throws Exception
|
||||
*/
|
||||
public function delete(int $attachmentId)
|
||||
public function delete(string $attachmentId)
|
||||
{
|
||||
$attachment = $this->attachment->findOrFail($attachmentId);
|
||||
$this->checkOwnablePermission('attachment-delete', $attachment);
|
||||
|
||||
51
app/Http/Controllers/AuditLogController.php
Normal file
51
app/Http/Controllers/AuditLogController.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Http\Controllers;
|
||||
|
||||
use BookStack\Actions\Activity;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class AuditLogController extends Controller
|
||||
{
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$this->checkPermission('settings-manage');
|
||||
$this->checkPermission('users-manage');
|
||||
|
||||
$listDetails = [
|
||||
'order' => $request->get('order', 'desc'),
|
||||
'event' => $request->get('event', ''),
|
||||
'sort' => $request->get('sort', 'created_at'),
|
||||
'date_from' => $request->get('date_from', ''),
|
||||
'date_to' => $request->get('date_to', ''),
|
||||
];
|
||||
|
||||
$query = Activity::query()
|
||||
->with(['entity', 'user'])
|
||||
->orderBy($listDetails['sort'], $listDetails['order']);
|
||||
|
||||
if ($listDetails['event']) {
|
||||
$query->where('key', '=', $listDetails['event']);
|
||||
}
|
||||
|
||||
if ($listDetails['date_from']) {
|
||||
$query->where('created_at', '>=', $listDetails['date_from']);
|
||||
}
|
||||
if ($listDetails['date_to']) {
|
||||
$query->where('created_at', '<=', $listDetails['date_to']);
|
||||
}
|
||||
|
||||
$activities = $query->paginate(100);
|
||||
$activities->appends($listDetails);
|
||||
|
||||
$keys = DB::table('activities')->select('key')->distinct()->pluck('key');
|
||||
$this->setPageTitle(trans('settings.audit'));
|
||||
return view('settings.audit', [
|
||||
'activities' => $activities,
|
||||
'listDetails' => $listDetails,
|
||||
'activityKeys' => $keys,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace BookStack\Http\Controllers\Auth;
|
||||
|
||||
use Activity;
|
||||
use BookStack\Auth\Access\SocialAuthService;
|
||||
use BookStack\Exceptions\LoginAttemptEmailNeededException;
|
||||
use BookStack\Exceptions\LoginAttemptException;
|
||||
@@ -76,9 +77,13 @@ class LoginController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
// Store the previous location for redirect after login
|
||||
$previous = url()->previous('');
|
||||
if (setting('app-public') && $previous && $previous !== url('/login')) {
|
||||
redirect()->setIntendedUrl($previous);
|
||||
if ($previous && $previous !== url('/login') && setting('app-public')) {
|
||||
$isPreviousFromInstance = (strpos($previous, url('/')) === 0);
|
||||
if ($isPreviousFromInstance) {
|
||||
redirect()->setIntendedUrl($previous);
|
||||
}
|
||||
}
|
||||
|
||||
return view('auth.login', [
|
||||
@@ -98,6 +103,7 @@ class LoginController extends Controller
|
||||
public function login(Request $request)
|
||||
{
|
||||
$this->validateLogin($request);
|
||||
$username = $request->get($this->username());
|
||||
|
||||
// If the class is using the ThrottlesLogins trait, we can automatically throttle
|
||||
// the login attempts for this application. We'll key this by the username and
|
||||
@@ -106,6 +112,7 @@ class LoginController extends Controller
|
||||
$this->hasTooManyLoginAttempts($request)) {
|
||||
$this->fireLockoutEvent($request);
|
||||
|
||||
Activity::logFailedLogin($username);
|
||||
return $this->sendLockoutResponse($request);
|
||||
}
|
||||
|
||||
@@ -114,6 +121,7 @@ class LoginController extends Controller
|
||||
return $this->sendLoginResponse($request);
|
||||
}
|
||||
} catch (LoginAttemptException $exception) {
|
||||
Activity::logFailedLogin($username);
|
||||
return $this->sendLoginAttemptExceptionResponse($exception, $request);
|
||||
}
|
||||
|
||||
@@ -122,6 +130,7 @@ class LoginController extends Controller
|
||||
// user surpasses their maximum number of attempts they will get locked out.
|
||||
$this->incrementLoginAttempts($request);
|
||||
|
||||
Activity::logFailedLogin($username);
|
||||
return $this->sendFailedLoginResponse($request);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,9 +10,6 @@ class CommentController extends Controller
|
||||
{
|
||||
protected $commentRepo;
|
||||
|
||||
/**
|
||||
* CommentController constructor.
|
||||
*/
|
||||
public function __construct(CommentRepo $commentRepo)
|
||||
{
|
||||
$this->commentRepo = $commentRepo;
|
||||
@@ -23,11 +20,11 @@ class CommentController extends Controller
|
||||
* Save a new comment for a Page
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function savePageComment(Request $request, int $pageId, int $commentId = null)
|
||||
public function savePageComment(Request $request, int $pageId)
|
||||
{
|
||||
$this->validate($request, [
|
||||
'text' => 'required|string',
|
||||
'html' => 'required|string',
|
||||
'parent_id' => 'nullable|integer',
|
||||
]);
|
||||
|
||||
$page = Page::visible()->find($pageId);
|
||||
@@ -35,8 +32,6 @@ class CommentController extends Controller
|
||||
return response('Not found', 404);
|
||||
}
|
||||
|
||||
$this->checkOwnablePermission('page-view', $page);
|
||||
|
||||
// Prevent adding comments to draft pages
|
||||
if ($page->draft) {
|
||||
return $this->jsonError(trans('errors.cannot_add_comment_to_draft'), 400);
|
||||
@@ -44,7 +39,7 @@ class CommentController extends Controller
|
||||
|
||||
// Create a new comment.
|
||||
$this->checkPermission('comment-create-all');
|
||||
$comment = $this->commentRepo->create($page, $request->only(['html', 'text', 'parent_id']));
|
||||
$comment = $this->commentRepo->create($page, $request->get('text'), $request->get('parent_id'));
|
||||
Activity::add($page, 'commented_on', $page->book->id);
|
||||
return view('comments.comment', ['comment' => $comment]);
|
||||
}
|
||||
@@ -57,14 +52,13 @@ class CommentController extends Controller
|
||||
{
|
||||
$this->validate($request, [
|
||||
'text' => 'required|string',
|
||||
'html' => 'required|string',
|
||||
]);
|
||||
|
||||
$comment = $this->commentRepo->getById($commentId);
|
||||
$this->checkOwnablePermission('page-view', $comment->entity);
|
||||
$this->checkOwnablePermission('comment-update', $comment);
|
||||
|
||||
$comment = $this->commentRepo->update($comment, $request->only(['html', 'text']));
|
||||
$comment = $this->commentRepo->update($comment, $request->get('text'));
|
||||
return view('comments.comment', ['comment' => $comment]);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||
use Illuminate\Http\Exceptions\HttpResponseException;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
abstract class Controller extends BaseController
|
||||
{
|
||||
@@ -132,23 +133,6 @@ abstract class Controller extends BaseController
|
||||
return response()->json(['message' => $messageText, 'status' => 'error'], $statusCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the response for when a request fails validation.
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @param array $errors
|
||||
* @return \Symfony\Component\HttpFoundation\Response
|
||||
*/
|
||||
protected function buildFailedValidationResponse(Request $request, array $errors)
|
||||
{
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['validation' => $errors], 422);
|
||||
}
|
||||
|
||||
return redirect()->to($this->getRedirectUrl())
|
||||
->withInput($request->input())
|
||||
->withErrors($errors, $this->errorBag());
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a response that forces a download in the browser.
|
||||
* @param string $content
|
||||
|
||||
@@ -69,11 +69,7 @@ class HomeController extends Controller
|
||||
}
|
||||
|
||||
if ($homepageOption === 'bookshelves') {
|
||||
$shelfRepo = app(BookshelfRepo::class);
|
||||
$shelves = app(BookshelfRepo::class)->getAllPaginated(18, $commonData['sort'], $commonData['order']);
|
||||
foreach ($shelves as $shelf) {
|
||||
$shelf->books = $shelf->visibleBooks;
|
||||
}
|
||||
$data = array_merge($commonData, ['shelves' => $shelves]);
|
||||
return view('common.home-shelves', $data);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,10 @@ class DrawioImageController extends Controller
|
||||
$parentTypeFilter = $request->get('filter_type', null);
|
||||
|
||||
$imgData = $this->imageRepo->getEntityFiltered('drawio', $parentTypeFilter, $page, 24, $uploadedToFilter, $searchTerm);
|
||||
return response()->json($imgData);
|
||||
return view('components.image-manager-list', [
|
||||
'images' => $imgData['images'],
|
||||
'hasMore' => $imgData['has_more'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,6 +75,7 @@ class DrawioImageController extends Controller
|
||||
if ($imageData === null) {
|
||||
return $this->jsonError("Image data could not be found");
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'content' => base64_encode($imageData)
|
||||
]);
|
||||
|
||||
@@ -6,6 +6,7 @@ use BookStack\Exceptions\ImageUploadException;
|
||||
use BookStack\Uploads\ImageRepo;
|
||||
use Illuminate\Http\Request;
|
||||
use BookStack\Http\Controllers\Controller;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class GalleryImageController extends Controller
|
||||
{
|
||||
@@ -13,7 +14,6 @@ class GalleryImageController extends Controller
|
||||
|
||||
/**
|
||||
* GalleryImageController constructor.
|
||||
* @param ImageRepo $imageRepo
|
||||
*/
|
||||
public function __construct(ImageRepo $imageRepo)
|
||||
{
|
||||
@@ -24,8 +24,6 @@ class GalleryImageController extends Controller
|
||||
/**
|
||||
* Get a list of gallery images, in a list.
|
||||
* Can be paged and filtered by entity.
|
||||
* @param Request $request
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
*/
|
||||
public function list(Request $request)
|
||||
{
|
||||
@@ -35,14 +33,15 @@ class GalleryImageController extends Controller
|
||||
$parentTypeFilter = $request->get('filter_type', null);
|
||||
|
||||
$imgData = $this->imageRepo->getEntityFiltered('gallery', $parentTypeFilter, $page, 24, $uploadedToFilter, $searchTerm);
|
||||
return response()->json($imgData);
|
||||
return view('components.image-manager-list', [
|
||||
'images' => $imgData['images'],
|
||||
'hasMore' => $imgData['has_more'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new gallery image in the system.
|
||||
* @param Request $request
|
||||
* @return Illuminate\Http\JsonResponse
|
||||
* @throws \Exception
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function create(Request $request)
|
||||
{
|
||||
|
||||
@@ -6,8 +6,11 @@ use BookStack\Http\Controllers\Controller;
|
||||
use BookStack\Repos\PageRepo;
|
||||
use BookStack\Uploads\Image;
|
||||
use BookStack\Uploads\ImageRepo;
|
||||
use Exception;
|
||||
use Illuminate\Filesystem\Filesystem as File;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class ImageController extends Controller
|
||||
{
|
||||
@@ -17,9 +20,6 @@ class ImageController extends Controller
|
||||
|
||||
/**
|
||||
* ImageController constructor.
|
||||
* @param Image $image
|
||||
* @param File $file
|
||||
* @param ImageRepo $imageRepo
|
||||
*/
|
||||
public function __construct(Image $image, File $file, ImageRepo $imageRepo)
|
||||
{
|
||||
@@ -31,8 +31,6 @@ class ImageController extends Controller
|
||||
|
||||
/**
|
||||
* Provide an image file from storage.
|
||||
* @param string $path
|
||||
* @return mixed
|
||||
*/
|
||||
public function showImage(string $path)
|
||||
{
|
||||
@@ -47,13 +45,10 @@ class ImageController extends Controller
|
||||
|
||||
/**
|
||||
* Update image details
|
||||
* @param Request $request
|
||||
* @param integer $id
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
* @throws ImageUploadException
|
||||
* @throws \Exception
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function update(Request $request, $id)
|
||||
public function update(Request $request, string $id)
|
||||
{
|
||||
$this->validate($request, [
|
||||
'name' => 'required|min:2|string'
|
||||
@@ -64,47 +59,50 @@ class ImageController extends Controller
|
||||
$this->checkOwnablePermission('image-update', $image);
|
||||
|
||||
$image = $this->imageRepo->updateImageDetails($image, $request->all());
|
||||
return response()->json($image);
|
||||
|
||||
$this->imageRepo->loadThumbs($image);
|
||||
return view('components.image-manager-form', [
|
||||
'image' => $image,
|
||||
'dependantPages' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the usage of an image on pages.
|
||||
* Get the form for editing the given image.
|
||||
* @throws Exception
|
||||
*/
|
||||
public function usage(int $id)
|
||||
public function edit(Request $request, string $id)
|
||||
{
|
||||
$image = $this->imageRepo->getById($id);
|
||||
$this->checkImagePermission($image);
|
||||
|
||||
$pages = Page::visible()->where('html', 'like', '%' . $image->url . '%')->get(['id', 'name', 'slug', 'book_id']);
|
||||
foreach ($pages as $page) {
|
||||
$page->url = $page->getUrl();
|
||||
$page->html = '';
|
||||
$page->text = '';
|
||||
if ($request->has('delete')) {
|
||||
$dependantPages = $this->imageRepo->getPagesUsingImage($image);
|
||||
}
|
||||
$result = count($pages) > 0 ? $pages : false;
|
||||
|
||||
return response()->json($result);
|
||||
$this->imageRepo->loadThumbs($image);
|
||||
return view('components.image-manager-form', [
|
||||
'image' => $image,
|
||||
'dependantPages' => $dependantPages ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an image and all thumbnail/image files
|
||||
* @param int $id
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
* @throws \Exception
|
||||
* @throws Exception
|
||||
*/
|
||||
public function destroy($id)
|
||||
public function destroy(string $id)
|
||||
{
|
||||
$image = $this->imageRepo->getById($id);
|
||||
$this->checkOwnablePermission('image-delete', $image);
|
||||
$this->checkImagePermission($image);
|
||||
|
||||
$this->imageRepo->destroyImage($image);
|
||||
return response()->json(trans('components.images_deleted'));
|
||||
return response('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check related page permission and ensure type is drawio or gallery.
|
||||
* @param Image $image
|
||||
*/
|
||||
protected function checkImagePermission(Image $image)
|
||||
{
|
||||
|
||||
68
app/Http/Controllers/MaintenanceController.php
Normal file
68
app/Http/Controllers/MaintenanceController.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Http\Controllers;
|
||||
|
||||
use BookStack\Notifications\TestEmail;
|
||||
use BookStack\Uploads\ImageService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class MaintenanceController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show the page for application maintenance.
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$this->checkPermission('settings-manage');
|
||||
$this->setPageTitle(trans('settings.maint'));
|
||||
|
||||
// Get application version
|
||||
$version = trim(file_get_contents(base_path('version')));
|
||||
|
||||
return view('settings.maintenance', ['version' => $version]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Action to clean-up images in the system.
|
||||
*/
|
||||
public function cleanupImages(Request $request, ImageService $imageService)
|
||||
{
|
||||
$this->checkPermission('settings-manage');
|
||||
|
||||
$checkRevisions = !($request->get('ignore_revisions', 'false') === 'true');
|
||||
$dryRun = !($request->has('confirm'));
|
||||
|
||||
$imagesToDelete = $imageService->deleteUnusedImages($checkRevisions, $dryRun);
|
||||
$deleteCount = count($imagesToDelete);
|
||||
if ($deleteCount === 0) {
|
||||
$this->showWarningNotification(trans('settings.maint_image_cleanup_nothing_found'));
|
||||
return redirect('/settings/maintenance')->withInput();
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
session()->flash('cleanup-images-warning', trans('settings.maint_image_cleanup_warning', ['count' => $deleteCount]));
|
||||
} else {
|
||||
$this->showSuccessNotification(trans('settings.maint_image_cleanup_success', ['count' => $deleteCount]));
|
||||
}
|
||||
|
||||
return redirect('/settings/maintenance#image-cleanup')->withInput();
|
||||
}
|
||||
|
||||
/**
|
||||
* Action to send a test e-mail to the current user.
|
||||
*/
|
||||
public function sendTestEmail()
|
||||
{
|
||||
$this->checkPermission('settings-manage');
|
||||
|
||||
try {
|
||||
user()->notify(new TestEmail());
|
||||
$this->showSuccessNotification(trans('settings.maint_send_test_email_success', ['address' => user()->email]));
|
||||
} catch (\Exception $exception) {
|
||||
$errorMessage = trans('errors.maintenance_test_email_failure') . "\n" . $exception->getMessage();
|
||||
$this->showErrorNotification($errorMessage);
|
||||
}
|
||||
|
||||
return redirect('/settings/maintenance#image-cleanup')->withInput();
|
||||
}
|
||||
}
|
||||
@@ -163,6 +163,8 @@ class PageController extends Controller
|
||||
public function getPageAjax(int $pageId)
|
||||
{
|
||||
$page = $this->pageRepo->getById($pageId);
|
||||
$page->setHidden(array_diff($page->getHidden(), ['html', 'markdown']));
|
||||
$page->addHidden(['book']);
|
||||
return response()->json($page);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php namespace BookStack\Http\Controllers;
|
||||
|
||||
use BookStack\Entities\Managers\PageContent;
|
||||
use BookStack\Entities\Repos\PageRepo;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use BookStack\Facades\Activity;
|
||||
@@ -46,6 +47,9 @@ class PageRevisionController extends Controller
|
||||
}
|
||||
|
||||
$page->fill($revision->toArray());
|
||||
// TODO - Refactor PageContent so we don't need to juggle this
|
||||
$page->html = $revision->html;
|
||||
$page->html = (new PageContent($page))->render();
|
||||
|
||||
$this->setPageTitle(trans('entities.pages_revision_named', ['pageName' => $page->getShortName()]));
|
||||
return view('pages.revision', [
|
||||
@@ -73,6 +77,9 @@ class PageRevisionController extends Controller
|
||||
$diff = (new Htmldiff)->diff($prevContent, $revision->html);
|
||||
|
||||
$page->fill($revision->toArray());
|
||||
// TODO - Refactor PageContent so we don't need to juggle this
|
||||
$page->html = $revision->html;
|
||||
$page->html = (new PageContent($page))->render();
|
||||
$this->setPageTitle(trans('entities.pages_revision_named', ['pageName'=>$page->getShortName()]));
|
||||
|
||||
return view('pages.revision', [
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
use BookStack\Auth\Permissions\PermissionsRepo;
|
||||
use BookStack\Exceptions\PermissionsException;
|
||||
use Exception;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class PermissionController extends Controller
|
||||
{
|
||||
@@ -11,7 +13,6 @@ class PermissionController extends Controller
|
||||
|
||||
/**
|
||||
* PermissionController constructor.
|
||||
* @param \BookStack\Auth\Permissions\PermissionsRepo $permissionsRepo
|
||||
*/
|
||||
public function __construct(PermissionsRepo $permissionsRepo)
|
||||
{
|
||||
@@ -31,7 +32,6 @@ class PermissionController extends Controller
|
||||
|
||||
/**
|
||||
* Show the form to create a new role
|
||||
* @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
|
||||
*/
|
||||
public function createRole()
|
||||
{
|
||||
@@ -41,15 +41,13 @@ class PermissionController extends Controller
|
||||
|
||||
/**
|
||||
* Store a new role in the system.
|
||||
* @param Request $request
|
||||
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
|
||||
*/
|
||||
public function storeRole(Request $request)
|
||||
{
|
||||
$this->checkPermission('user-roles-manage');
|
||||
$this->validate($request, [
|
||||
'display_name' => 'required|min:3|max:200',
|
||||
'description' => 'max:250'
|
||||
'display_name' => 'required|min:3|max:180',
|
||||
'description' => 'max:180'
|
||||
]);
|
||||
|
||||
$this->permissionsRepo->saveNewRole($request->all());
|
||||
@@ -59,11 +57,9 @@ class PermissionController extends Controller
|
||||
|
||||
/**
|
||||
* Show the form for editing a user role.
|
||||
* @param $id
|
||||
* @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
|
||||
* @throws PermissionsException
|
||||
*/
|
||||
public function editRole($id)
|
||||
public function editRole(string $id)
|
||||
{
|
||||
$this->checkPermission('user-roles-manage');
|
||||
$role = $this->permissionsRepo->getRoleById($id);
|
||||
@@ -75,18 +71,14 @@ class PermissionController extends Controller
|
||||
|
||||
/**
|
||||
* Updates a user role.
|
||||
* @param Request $request
|
||||
* @param $id
|
||||
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
|
||||
* @throws PermissionsException
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function updateRole(Request $request, $id)
|
||||
public function updateRole(Request $request, string $id)
|
||||
{
|
||||
$this->checkPermission('user-roles-manage');
|
||||
$this->validate($request, [
|
||||
'display_name' => 'required|min:3|max:200',
|
||||
'description' => 'max:250'
|
||||
'display_name' => 'required|min:3|max:180',
|
||||
'description' => 'max:180'
|
||||
]);
|
||||
|
||||
$this->permissionsRepo->updateRole($id, $request->all());
|
||||
@@ -97,10 +89,8 @@ class PermissionController extends Controller
|
||||
/**
|
||||
* Show the view to delete a role.
|
||||
* Offers the chance to migrate users.
|
||||
* @param $id
|
||||
* @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
|
||||
*/
|
||||
public function showDeleteRole($id)
|
||||
public function showDeleteRole(string $id)
|
||||
{
|
||||
$this->checkPermission('user-roles-manage');
|
||||
$role = $this->permissionsRepo->getRoleById($id);
|
||||
@@ -113,11 +103,9 @@ class PermissionController extends Controller
|
||||
/**
|
||||
* Delete a role from the system,
|
||||
* Migrate from a previous role if set.
|
||||
* @param Request $request
|
||||
* @param $id
|
||||
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
|
||||
* @throws Exception
|
||||
*/
|
||||
public function deleteRole(Request $request, $id)
|
||||
public function deleteRole(Request $request, string $id)
|
||||
{
|
||||
$this->checkPermission('user-roles-manage');
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ use BookStack\Entities\Bookshelf;
|
||||
use BookStack\Entities\Entity;
|
||||
use BookStack\Entities\Managers\EntityContext;
|
||||
use BookStack\Entities\SearchService;
|
||||
use BookStack\Entities\SearchOptions;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SearchController extends Controller
|
||||
@@ -33,20 +34,22 @@ class SearchController extends Controller
|
||||
*/
|
||||
public function search(Request $request)
|
||||
{
|
||||
$searchTerm = $request->get('term');
|
||||
$this->setPageTitle(trans('entities.search_for_term', ['term' => $searchTerm]));
|
||||
$searchOpts = SearchOptions::fromRequest($request);
|
||||
$fullSearchString = $searchOpts->toString();
|
||||
$this->setPageTitle(trans('entities.search_for_term', ['term' => $fullSearchString]));
|
||||
|
||||
$page = intval($request->get('page', '0')) ?: 1;
|
||||
$nextPageLink = url('/search?term=' . urlencode($searchTerm) . '&page=' . ($page+1));
|
||||
$nextPageLink = url('/search?term=' . urlencode($fullSearchString) . '&page=' . ($page+1));
|
||||
|
||||
$results = $this->searchService->searchEntities($searchTerm, 'all', $page, 20);
|
||||
$results = $this->searchService->searchEntities($searchOpts, 'all', $page, 20);
|
||||
|
||||
return view('search.all', [
|
||||
'entities' => $results['results'],
|
||||
'totalResults' => $results['total'],
|
||||
'searchTerm' => $searchTerm,
|
||||
'searchTerm' => $fullSearchString,
|
||||
'hasNextPage' => $results['has_more'],
|
||||
'nextPageLink' => $nextPageLink
|
||||
'nextPageLink' => $nextPageLink,
|
||||
'options' => $searchOpts,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -84,7 +87,7 @@ class SearchController extends Controller
|
||||
// Search for entities otherwise show most popular
|
||||
if ($searchTerm !== false) {
|
||||
$searchTerm .= ' {type:'. implode('|', $entityTypes) .'}';
|
||||
$entities = $this->searchService->searchEntities($searchTerm, 'all', 1, 20, $permission)['results'];
|
||||
$entities = $this->searchService->searchEntities(SearchOptions::fromString($searchTerm), 'all', 1, 20, $permission)['results'];
|
||||
} else {
|
||||
$entities = $this->viewService->getPopular(20, 0, $entityTypes, $permission);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
<?php namespace BookStack\Http\Controllers;
|
||||
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Notifications\TestEmail;
|
||||
use BookStack\Uploads\ImageRepo;
|
||||
use BookStack\Uploads\ImageService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SettingController extends Controller
|
||||
@@ -74,63 +72,4 @@ class SettingController extends Controller
|
||||
$redirectLocation = '/settings#' . $request->get('section', '');
|
||||
return redirect(rtrim($redirectLocation, '#'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the page for application maintenance.
|
||||
*/
|
||||
public function showMaintenance()
|
||||
{
|
||||
$this->checkPermission('settings-manage');
|
||||
$this->setPageTitle(trans('settings.maint'));
|
||||
|
||||
// Get application version
|
||||
$version = trim(file_get_contents(base_path('version')));
|
||||
|
||||
return view('settings.maintenance', ['version' => $version]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Action to clean-up images in the system.
|
||||
*/
|
||||
public function cleanupImages(Request $request, ImageService $imageService)
|
||||
{
|
||||
$this->checkPermission('settings-manage');
|
||||
|
||||
$checkRevisions = !($request->get('ignore_revisions', 'false') === 'true');
|
||||
$dryRun = !($request->has('confirm'));
|
||||
|
||||
$imagesToDelete = $imageService->deleteUnusedImages($checkRevisions, $dryRun);
|
||||
$deleteCount = count($imagesToDelete);
|
||||
if ($deleteCount === 0) {
|
||||
$this->showWarningNotification(trans('settings.maint_image_cleanup_nothing_found'));
|
||||
return redirect('/settings/maintenance')->withInput();
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
session()->flash('cleanup-images-warning', trans('settings.maint_image_cleanup_warning', ['count' => $deleteCount]));
|
||||
} else {
|
||||
$this->showSuccessNotification(trans('settings.maint_image_cleanup_success', ['count' => $deleteCount]));
|
||||
}
|
||||
|
||||
return redirect('/settings/maintenance#image-cleanup')->withInput();
|
||||
}
|
||||
|
||||
/**
|
||||
* Action to send a test e-mail to the current user.
|
||||
*/
|
||||
public function sendTestEmail()
|
||||
{
|
||||
$this->checkPermission('settings-manage');
|
||||
|
||||
try {
|
||||
user()->notify(new TestEmail());
|
||||
$this->showSuccessNotification(trans('settings.maint_send_test_email_success', ['address' => user()->email]));
|
||||
} catch (\Exception $exception) {
|
||||
$errorMessage = trans('errors.maintenance_test_email_failure') . "\n" . $exception->getMessage();
|
||||
$this->showErrorNotification($errorMessage);
|
||||
}
|
||||
|
||||
|
||||
return redirect('/settings/maintenance#image-cleanup')->withInput();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ class TagController extends Controller
|
||||
|
||||
/**
|
||||
* TagController constructor.
|
||||
* @param $tagRepo
|
||||
*/
|
||||
public function __construct(TagRepo $tagRepo)
|
||||
{
|
||||
@@ -18,39 +17,23 @@ class TagController extends Controller
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all the Tags for a particular entity
|
||||
* @param $entityType
|
||||
* @param $entityId
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
*/
|
||||
public function getForEntity($entityType, $entityId)
|
||||
{
|
||||
$tags = $this->tagRepo->getForEntity($entityType, $entityId);
|
||||
return response()->json($tags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tag name suggestions from a given search term.
|
||||
* @param Request $request
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
*/
|
||||
public function getNameSuggestions(Request $request)
|
||||
{
|
||||
$searchTerm = $request->get('search', false);
|
||||
$searchTerm = $request->get('search', null);
|
||||
$suggestions = $this->tagRepo->getNameSuggestions($searchTerm);
|
||||
return response()->json($suggestions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tag value suggestions from a given search term.
|
||||
* @param Request $request
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
*/
|
||||
public function getValueSuggestions(Request $request)
|
||||
{
|
||||
$searchTerm = $request->get('search', false);
|
||||
$tagName = $request->get('name', false);
|
||||
$searchTerm = $request->get('search', null);
|
||||
$tagName = $request->get('name', null);
|
||||
$suggestions = $this->tagRepo->getValueSuggestions($searchTerm, $tagName);
|
||||
return response()->json($suggestions);
|
||||
}
|
||||
|
||||
@@ -66,8 +66,8 @@ class UserController extends Controller
|
||||
{
|
||||
$this->checkPermission('users-manage');
|
||||
$validationRules = [
|
||||
'name' => 'required',
|
||||
'email' => 'required|email|unique:users,email'
|
||||
'name' => 'required',
|
||||
'email' => 'required|email|unique:users,email'
|
||||
];
|
||||
|
||||
$authMethod = config('auth.method');
|
||||
|
||||
@@ -35,9 +35,9 @@ class ApiAuthenticate
|
||||
{
|
||||
// Return if the user is already found to be signed in via session-based auth.
|
||||
// This is to make it easy to browser the API via browser after just logging into the system.
|
||||
if (signedInUser()) {
|
||||
if (signedInUser() || session()->isStarted()) {
|
||||
$this->ensureEmailConfirmedIfRequested();
|
||||
if (!auth()->user()->can('access-api')) {
|
||||
if (!user()->can('access-api')) {
|
||||
throw new ApiAuthException(trans('errors.api_user_no_api_permission'), 403);
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -44,6 +44,10 @@ class Authenticate
|
||||
], 401);
|
||||
}
|
||||
|
||||
if (session()->get('sent-email-confirmation') === true) {
|
||||
return redirect('/register/confirm');
|
||||
}
|
||||
|
||||
return redirect('/register/confirm/awaiting');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ class Localization
|
||||
*/
|
||||
protected $localeMap = [
|
||||
'ar' => 'ar',
|
||||
'bg' => 'bg_BG',
|
||||
'da' => 'da_DK',
|
||||
'de' => 'de_DE',
|
||||
'de_informal' => 'de_DE',
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
use BookStack\Auth\User;
|
||||
|
||||
/**
|
||||
* @property int created_by
|
||||
* @property int updated_by
|
||||
*/
|
||||
abstract class Ownable extends Model
|
||||
{
|
||||
/**
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
use BookStack\Entities\Page;
|
||||
use BookStack\Ownable;
|
||||
|
||||
/**
|
||||
* @property int id
|
||||
* @property string name
|
||||
* @property string path
|
||||
* @property string extension
|
||||
* @property bool external
|
||||
*/
|
||||
class Attachment extends Ownable
|
||||
{
|
||||
protected $fillable = ['name', 'order'];
|
||||
@@ -30,13 +37,28 @@ class Attachment extends Ownable
|
||||
|
||||
/**
|
||||
* Get the url of this file.
|
||||
* @return string
|
||||
*/
|
||||
public function getUrl()
|
||||
public function getUrl(): string
|
||||
{
|
||||
if ($this->external && strpos($this->path, 'http') !== 0) {
|
||||
return $this->path;
|
||||
}
|
||||
return url('/attachments/' . $this->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a HTML link to this attachment.
|
||||
*/
|
||||
public function htmlLink(): string
|
||||
{
|
||||
return '<a target="_blank" href="'.e($this->getUrl()).'">'.e($this->name).'</a>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a markdown link to this attachment.
|
||||
*/
|
||||
public function markdownLink(): string
|
||||
{
|
||||
return '['. $this->name .']('. $this->getUrl() .')';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,14 +109,14 @@ class AttachmentService extends UploadService
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the file ordering for a listing of attached files.
|
||||
* @param array $attachmentList
|
||||
* @param $pageId
|
||||
* Updates the ordering for a listing of attached files.
|
||||
*/
|
||||
public function updateFileOrderWithinPage($attachmentList, $pageId)
|
||||
public function updateFileOrderWithinPage(array $attachmentOrder, string $pageId)
|
||||
{
|
||||
foreach ($attachmentList as $index => $attachment) {
|
||||
Attachment::where('uploaded_to', '=', $pageId)->where('id', '=', $attachment['id'])->update(['order' => $index]);
|
||||
foreach ($attachmentOrder as $index => $attachmentId) {
|
||||
Attachment::query()->where('uploaded_to', '=', $pageId)
|
||||
->where('id', '=', $attachmentId)
|
||||
->update(['order' => $index]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -185,7 +185,7 @@ class ImageRepo
|
||||
* Load thumbnails onto an image object.
|
||||
* @throws Exception
|
||||
*/
|
||||
protected function loadThumbs(Image $image)
|
||||
public function loadThumbs(Image $image)
|
||||
{
|
||||
$image->thumbs = [
|
||||
'gallery' => $this->getThumbnail($image, 150, 150, false),
|
||||
@@ -219,4 +219,20 @@ class ImageRepo
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user visible pages using the given image.
|
||||
*/
|
||||
public function getPagesUsingImage(Image $image): array
|
||||
{
|
||||
$pages = Page::visible()
|
||||
->where('html', 'like', '%' . $image->url . '%')
|
||||
->get(['id', 'name', 'slug', 'book_id']);
|
||||
|
||||
foreach ($pages as $page) {
|
||||
$page->url = $page->getUrl();
|
||||
}
|
||||
|
||||
return $pages->all();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,29 +124,24 @@ class ImageService extends UploadService
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a new image
|
||||
* @param string $imageName
|
||||
* @param string $imageData
|
||||
* @param string $type
|
||||
* @param int $uploadedTo
|
||||
* @return Image
|
||||
* Save a new image into storage.
|
||||
* @throws ImageUploadException
|
||||
*/
|
||||
private function saveNew($imageName, $imageData, $type, $uploadedTo = 0)
|
||||
private function saveNew(string $imageName, string $imageData, string $type, int $uploadedTo = 0): Image
|
||||
{
|
||||
$storage = $this->getStorage($type);
|
||||
$secureUploads = setting('app-secure-images');
|
||||
$imageName = str_replace(' ', '-', $imageName);
|
||||
$fileName = $this->cleanImageFileName($imageName);
|
||||
|
||||
$imagePath = '/uploads/images/' . $type . '/' . Date('Y-m') . '/';
|
||||
|
||||
while ($storage->exists($imagePath . $imageName)) {
|
||||
$imageName = Str::random(3) . $imageName;
|
||||
while ($storage->exists($imagePath . $fileName)) {
|
||||
$fileName = Str::random(3) . $fileName;
|
||||
}
|
||||
|
||||
$fullPath = $imagePath . $imageName;
|
||||
$fullPath = $imagePath . $fileName;
|
||||
if ($secureUploads) {
|
||||
$fullPath = $imagePath . Str::random(16) . '-' . $imageName;
|
||||
$fullPath = $imagePath . Str::random(16) . '-' . $fileName;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -175,6 +170,23 @@ class ImageService extends UploadService
|
||||
return $image;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up an image file name to be both URL and storage safe.
|
||||
*/
|
||||
protected function cleanImageFileName(string $name): string
|
||||
{
|
||||
$name = str_replace(' ', '-', $name);
|
||||
$nameParts = explode('.', $name);
|
||||
$extension = array_pop($nameParts);
|
||||
$name = implode('.', $nameParts);
|
||||
$name = Str::slug($name);
|
||||
|
||||
if (strlen($name) === 0) {
|
||||
$name = Str::random(10);
|
||||
}
|
||||
|
||||
return $name . '.' . $extension;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the image is a gif. Returns true if it is, else false.
|
||||
@@ -223,6 +235,7 @@ class ImageService extends UploadService
|
||||
$storage->setVisibility($thumbFilePath, 'public');
|
||||
$this->cache->put('images-' . $image->id . '-' . $thumbFilePath, $thumbFilePath, 60 * 60 * 72);
|
||||
|
||||
|
||||
return $this->getPublicUrl($thumbFilePath);
|
||||
}
|
||||
|
||||
@@ -292,11 +305,9 @@ class ImageService extends UploadService
|
||||
|
||||
/**
|
||||
* Destroys an image at the given path.
|
||||
* Searches for image thumbnails in addition to main provided path..
|
||||
* @param string $path
|
||||
* @return bool
|
||||
* Searches for image thumbnails in addition to main provided path.
|
||||
*/
|
||||
protected function destroyImagesFromPath(string $path)
|
||||
protected function destroyImagesFromPath(string $path): bool
|
||||
{
|
||||
$storage = $this->getStorage();
|
||||
|
||||
@@ -306,8 +317,7 @@ class ImageService extends UploadService
|
||||
|
||||
// Delete image files
|
||||
$imagesToDelete = $allImages->filter(function ($imagePath) use ($imageFileName) {
|
||||
$expectedIndex = strlen($imagePath) - strlen($imageFileName);
|
||||
return strpos($imagePath, $imageFileName) === $expectedIndex;
|
||||
return basename($imagePath) === $imageFileName;
|
||||
});
|
||||
$storage->delete($imagesToDelete->all());
|
||||
|
||||
|
||||
@@ -153,10 +153,6 @@ function icon(string $name, array $attrs = []): string
|
||||
* Generate a url with multiple parameters for sorting purposes.
|
||||
* Works out the logic to set the correct sorting direction
|
||||
* Discards empty parameters and allows overriding.
|
||||
* @param string $path
|
||||
* @param array $data
|
||||
* @param array $overrideData
|
||||
* @return string
|
||||
*/
|
||||
function sortUrl(string $path, array $data, array $overrideData = []): string
|
||||
{
|
||||
@@ -166,7 +162,7 @@ function sortUrl(string $path, array $data, array $overrideData = []): string
|
||||
// Change sorting direction is already sorted on current attribute
|
||||
if (isset($overrideData['sort']) && $overrideData['sort'] === $data['sort']) {
|
||||
$queryData['order'] = ($data['order'] === 'asc') ? 'desc' : 'asc';
|
||||
} else {
|
||||
} elseif (isset($overrideData['sort'])) {
|
||||
$queryData['order'] = 'asc';
|
||||
}
|
||||
|
||||
|
||||
@@ -13,15 +13,18 @@
|
||||
"ext-mbstring": "*",
|
||||
"ext-tidy": "*",
|
||||
"ext-xml": "*",
|
||||
"barryvdh/laravel-dompdf": "^0.8.5",
|
||||
"barryvdh/laravel-snappy": "^0.4.5",
|
||||
"barryvdh/laravel-dompdf": "^0.8.6",
|
||||
"barryvdh/laravel-snappy": "^0.4.7",
|
||||
"doctrine/dbal": "^2.9",
|
||||
"facade/ignition": "^1.4",
|
||||
"fideloper/proxy": "^4.0",
|
||||
"gathercontent/htmldiff": "^0.2.1",
|
||||
"intervention/image": "^2.5",
|
||||
"laravel/framework": "^6.12",
|
||||
"laravel/framework": "^6.18",
|
||||
"laravel/socialite": "^4.3.2",
|
||||
"league/commonmark": "^1.4",
|
||||
"league/flysystem-aws-s3-v3": "^1.0",
|
||||
"nunomaduro/collision": "^3.0",
|
||||
"onelogin/php-saml": "^3.3",
|
||||
"predis/predis": "^1.1",
|
||||
"socialiteproviders/discord": "^2.0",
|
||||
@@ -29,9 +32,7 @@
|
||||
"socialiteproviders/microsoft-azure": "^3.0",
|
||||
"socialiteproviders/okta": "^1.0",
|
||||
"socialiteproviders/slack": "^3.0",
|
||||
"socialiteproviders/twitch": "^5.0",
|
||||
"facade/ignition": "^1.4",
|
||||
"nunomaduro/collision": "^3.0"
|
||||
"socialiteproviders/twitch": "^5.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"barryvdh/laravel-debugbar": "^3.2.8",
|
||||
|
||||
2197
composer.lock
generated
2197
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class DropJointPermissionsId extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::table('joint_permissions', function (Blueprint $table) {
|
||||
$table->dropColumn('id');
|
||||
$table->primary(['role_id', 'entity_type', 'entity_id', 'action'], 'joint_primary');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::table('joint_permissions', function (Blueprint $table) {
|
||||
$table->dropPrimary(['role_id', 'entity_type', 'entity_id', 'action']);
|
||||
});
|
||||
|
||||
Schema::table('joint_permissions', function (Blueprint $table) {
|
||||
$table->increments('id')->unsigned();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class RemoveRoleNameField extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::table('roles', function (Blueprint $table) {
|
||||
$table->dropColumn('name');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::table('roles', function (Blueprint $table) {
|
||||
$table->string('name')->index();
|
||||
});
|
||||
|
||||
DB::table('roles')->update([
|
||||
"name" => DB::raw("lower(replace(`display_name`, ' ', '-'))"),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class AddActivityIndexes extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::table('activities', function(Blueprint $table) {
|
||||
$table->index('key');
|
||||
$table->index('created_at');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::table('activities', function(Blueprint $table) {
|
||||
$table->dropIndex('activities_key_index');
|
||||
$table->dropIndex('activities_created_at_index');
|
||||
});
|
||||
}
|
||||
}
|
||||
9
dev/api/requests/chapters-create.json
Normal file
9
dev/api/requests/chapters-create.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"book_id": 1,
|
||||
"name": "My fantastic new chapter",
|
||||
"description": "This is a great new chapter that I've created via the API",
|
||||
"tags": [
|
||||
{"name": "Category", "value": "Top Content"},
|
||||
{"name": "Rating", "value": "Highest"}
|
||||
]
|
||||
}
|
||||
9
dev/api/requests/chapters-update.json
Normal file
9
dev/api/requests/chapters-update.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"book_id": 1,
|
||||
"name": "My fantastic updated chapter",
|
||||
"description": "This is an updated chapter that I've altered via the API",
|
||||
"tags": [
|
||||
{"name": "Category", "value": "Kinda Good Content"},
|
||||
{"name": "Rating", "value": "Medium"}
|
||||
]
|
||||
}
|
||||
@@ -7,15 +7,12 @@
|
||||
"updated_at": "2020-01-12 14:11:51",
|
||||
"created_by": {
|
||||
"id": 1,
|
||||
"name": "Admin",
|
||||
"image_id": 48
|
||||
"name": "Admin"
|
||||
},
|
||||
"updated_by": {
|
||||
"id": 1,
|
||||
"name": "Admin",
|
||||
"image_id": 48
|
||||
"name": "Admin"
|
||||
},
|
||||
"image_id": 452,
|
||||
"tags": [
|
||||
{
|
||||
"id": 13,
|
||||
|
||||
38
dev/api/responses/chapters-create.json
Normal file
38
dev/api/responses/chapters-create.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"book_id": 1,
|
||||
"priority": 6,
|
||||
"name": "My fantastic new chapter",
|
||||
"description": "This is a great new chapter that I've created via the API",
|
||||
"created_by": 1,
|
||||
"updated_by": 1,
|
||||
"slug": "my-fantastic-new-chapter",
|
||||
"updated_at": "2020-05-22 22:59:55",
|
||||
"created_at": "2020-05-22 22:59:55",
|
||||
"id": 74,
|
||||
"book": {
|
||||
"id": 1,
|
||||
"name": "BookStack User Guide",
|
||||
"slug": "bookstack-user-guide",
|
||||
"description": "This is a general guide on using BookStack on a day-to-day basis.",
|
||||
"created_at": "2019-05-05 21:48:46",
|
||||
"updated_at": "2019-12-11 20:57:31",
|
||||
"created_by": 1,
|
||||
"updated_by": 1
|
||||
},
|
||||
"tags": [
|
||||
{
|
||||
"name": "Category",
|
||||
"value": "Top Content",
|
||||
"order": 0,
|
||||
"created_at": "2020-05-22 22:59:55",
|
||||
"updated_at": "2020-05-22 22:59:55"
|
||||
},
|
||||
{
|
||||
"name": "Rating",
|
||||
"value": "Highest",
|
||||
"order": 0,
|
||||
"created_at": "2020-05-22 22:59:55",
|
||||
"updated_at": "2020-05-22 22:59:55"
|
||||
}
|
||||
]
|
||||
}
|
||||
29
dev/api/responses/chapters-list.json
Normal file
29
dev/api/responses/chapters-list.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"book_id": 1,
|
||||
"name": "Content Creation",
|
||||
"slug": "content-creation",
|
||||
"description": "How to create documentation on whatever subject you need to write about.",
|
||||
"priority": 3,
|
||||
"created_at": "2019-05-05 21:49:56",
|
||||
"updated_at": "2019-09-28 11:24:23",
|
||||
"created_by": 1,
|
||||
"updated_by": 1
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"book_id": 1,
|
||||
"name": "Managing Content",
|
||||
"slug": "managing-content",
|
||||
"description": "How to keep things organised and orderly in the system for easier navigation and better user experience.",
|
||||
"priority": 5,
|
||||
"created_at": "2019-05-05 21:58:07",
|
||||
"updated_at": "2019-10-17 15:05:34",
|
||||
"created_by": 3,
|
||||
"updated_by": 3
|
||||
}
|
||||
],
|
||||
"total": 40
|
||||
}
|
||||
59
dev/api/responses/chapters-read.json
Normal file
59
dev/api/responses/chapters-read.json
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"id": 1,
|
||||
"book_id": 1,
|
||||
"slug": "content-creation",
|
||||
"name": "Content Creation",
|
||||
"description": "How to create documentation on whatever subject you need to write about.",
|
||||
"priority": 3,
|
||||
"created_at": "2019-05-05 21:49:56",
|
||||
"updated_at": "2019-09-28 11:24:23",
|
||||
"created_by": {
|
||||
"id": 1,
|
||||
"name": "Admin"
|
||||
},
|
||||
"updated_by": {
|
||||
"id": 1,
|
||||
"name": "Admin"
|
||||
},
|
||||
"tags": [
|
||||
{
|
||||
"name": "Category",
|
||||
"value": "Guide",
|
||||
"order": 0,
|
||||
"created_at": "2020-05-22 22:51:51",
|
||||
"updated_at": "2020-05-22 22:51:51"
|
||||
}
|
||||
],
|
||||
"pages": [
|
||||
{
|
||||
"id": 1,
|
||||
"book_id": 1,
|
||||
"chapter_id": 1,
|
||||
"name": "How to create page content",
|
||||
"slug": "how-to-create-page-content",
|
||||
"priority": 0,
|
||||
"created_at": "2019-05-05 21:49:58",
|
||||
"updated_at": "2019-08-26 14:32:59",
|
||||
"created_by": 1,
|
||||
"updated_by": 1,
|
||||
"draft": 0,
|
||||
"revision_count": 2,
|
||||
"template": 0
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"book_id": 1,
|
||||
"chapter_id": 1,
|
||||
"name": "Good book structure",
|
||||
"slug": "good-book-structure",
|
||||
"priority": 1,
|
||||
"created_at": "2019-05-05 22:01:55",
|
||||
"updated_at": "2019-06-06 12:03:04",
|
||||
"created_by": 3,
|
||||
"updated_by": 3,
|
||||
"draft": 0,
|
||||
"revision_count": 1,
|
||||
"template": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
38
dev/api/responses/chapters-update.json
Normal file
38
dev/api/responses/chapters-update.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"id": 75,
|
||||
"book_id": 1,
|
||||
"slug": "my-fantastic-updated-chapter",
|
||||
"name": "My fantastic updated chapter",
|
||||
"description": "This is an updated chapter that I've altered via the API",
|
||||
"priority": 7,
|
||||
"created_at": "2020-05-22 23:03:35",
|
||||
"updated_at": "2020-05-22 23:07:20",
|
||||
"created_by": 1,
|
||||
"updated_by": 1,
|
||||
"book": {
|
||||
"id": 1,
|
||||
"name": "BookStack User Guide",
|
||||
"slug": "bookstack-user-guide",
|
||||
"description": "This is a general guide on using BookStack on a day-to-day basis.",
|
||||
"created_at": "2019-05-05 21:48:46",
|
||||
"updated_at": "2019-12-11 20:57:31",
|
||||
"created_by": 1,
|
||||
"updated_by": 1
|
||||
},
|
||||
"tags": [
|
||||
{
|
||||
"name": "Category",
|
||||
"value": "Kinda Good Content",
|
||||
"order": 0,
|
||||
"created_at": "2020-05-22 23:07:20",
|
||||
"updated_at": "2020-05-22 23:07:20"
|
||||
},
|
||||
{
|
||||
"name": "Rating",
|
||||
"value": "Medium",
|
||||
"order": 0,
|
||||
"created_at": "2020-05-22 23:07:20",
|
||||
"updated_at": "2020-05-22 23:07:20"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -5,15 +5,12 @@
|
||||
"description": "This is my shelf with some books",
|
||||
"created_by": {
|
||||
"id": 1,
|
||||
"name": "Admin",
|
||||
"image_id": 48
|
||||
"name": "Admin"
|
||||
},
|
||||
"updated_by": {
|
||||
"id": 1,
|
||||
"name": "Admin",
|
||||
"image_id": 48
|
||||
"name": "Admin"
|
||||
},
|
||||
"image_id": 501,
|
||||
"created_at": "2020-04-10 13:24:09",
|
||||
"updated_at": "2020-04-10 13:31:04",
|
||||
"tags": [
|
||||
|
||||
99
dev/docs/components.md
Normal file
99
dev/docs/components.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# JavaScript Components
|
||||
|
||||
This document details the format for JavaScript components in BookStack. This is a really simple class-based setup with a few helpers provided.
|
||||
|
||||
#### Defining a Component in JS
|
||||
|
||||
```js
|
||||
class Dropdown {
|
||||
setup() {
|
||||
this.toggle = this.$refs.toggle;
|
||||
this.menu = this.$refs.menu;
|
||||
|
||||
this.speed = parseInt(this.$opts.speed);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
All usage of $refs, $manyRefs and $opts should be done at the top of the `setup` function so any requirements can be easily seen.
|
||||
|
||||
#### Using a Component in HTML
|
||||
|
||||
A component is used like so:
|
||||
|
||||
```html
|
||||
<div component="dropdown"></div>
|
||||
|
||||
<!-- or, for multiple -->
|
||||
|
||||
<div components="dropdown image-picker"></div>
|
||||
```
|
||||
|
||||
The names will be parsed and new component instance will be created if a matching name is found in the `components/index.js` componentMapping.
|
||||
|
||||
#### Element References
|
||||
|
||||
Within a component you'll often need to refer to other element instances. This can be done like so:
|
||||
|
||||
```html
|
||||
<div component="dropdown">
|
||||
<span refs="dropdown@toggle othercomponent@handle">View more</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
You can then access the span element as `this.$refs.toggle` in your component.
|
||||
|
||||
#### Component Options
|
||||
|
||||
```html
|
||||
<div component="dropdown"
|
||||
option:dropdown:delay="500"
|
||||
option:dropdown:show>
|
||||
</div>
|
||||
```
|
||||
|
||||
Will result with `this.$opts` being:
|
||||
|
||||
```json
|
||||
{
|
||||
"delay": "500",
|
||||
"show": ""
|
||||
}
|
||||
```
|
||||
|
||||
#### Global Helpers
|
||||
|
||||
There are various global helper libraries which can be used in components:
|
||||
|
||||
```js
|
||||
// HTTP service
|
||||
window.$http.get(url, params);
|
||||
window.$http.post(url, data);
|
||||
window.$http.put(url, data);
|
||||
window.$http.delete(url, data);
|
||||
window.$http.patch(url, data);
|
||||
|
||||
// Global event system
|
||||
// Emit a global event
|
||||
window.$events.emit(eventName, eventData);
|
||||
// Listen to a global event
|
||||
window.$events.listen(eventName, callback);
|
||||
// Show a success message
|
||||
window.$events.success(message);
|
||||
// Show an error message
|
||||
window.$events.error(message);
|
||||
// Show validation errors, if existing, as an error notification
|
||||
window.$events.showValidationErrors(error);
|
||||
|
||||
// Translator
|
||||
// Take the given plural text and count to decide on what plural option
|
||||
// to use, Similar to laravel's trans_choice function but instead
|
||||
// takes the direction directly instead of a translation key.
|
||||
window.trans_plural(translationString, count, replacements);
|
||||
|
||||
// Component System
|
||||
// Parse and initialise any components from the given root el down.
|
||||
window.components.init(rootEl);
|
||||
// Get the first active component of the given name
|
||||
window.components.first(name);
|
||||
```
|
||||
5765
package-lock.json
generated
5765
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
37
package.json
37
package.json
@@ -1,36 +1,33 @@
|
||||
{
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "webpack",
|
||||
"production": "NODE_ENV=production webpack && rm -f ./public/dist/*styles.js",
|
||||
"build-profile": "NODE_ENV=production webpack --profile --json > webpack-stats.json && rm -f ./public/dist/*styles.js",
|
||||
"build:css:dev": "sass ./resources/sass:./public/dist",
|
||||
"build:css:watch": "sass ./resources/sass:./public/dist --watch",
|
||||
"build:css:production": "sass ./resources/sass:./public/dist -s compressed",
|
||||
"build:js:dev": "esbuild --bundle ./resources/js/index.js --outfile=public/dist/app.js --sourcemap --target=es2019 --main-fields=module,main",
|
||||
"build:js:watch": "chokidar \"./resources/**/*.js\" -c \"npm run build:js:dev\"",
|
||||
"build:js:production": "NODE_ENV=production esbuild --bundle ./resources/js/index.js --outfile=public/dist/app.js --sourcemap --target=es2019 --main-fields=module,main --minify",
|
||||
"build": "npm-run-all --parallel build:*:dev",
|
||||
"production": "npm-run-all --parallel build:*:production",
|
||||
"dev": "npm-run-all --parallel watch livereload",
|
||||
"watch": "webpack --watch",
|
||||
"watch": "npm-run-all --parallel build:*:watch",
|
||||
"livereload": "livereload ./public/dist/",
|
||||
"permissions": "chown -R $USER:$USER bootstrap/cache storage public/uploads"
|
||||
},
|
||||
"devDependencies": {
|
||||
"css-loader": "^3.4.2",
|
||||
"chokidar-cli": "^2.1.0",
|
||||
"esbuild": "0.7.8",
|
||||
"livereload": "^0.9.1",
|
||||
"mini-css-extract-plugin": "^0.9.0",
|
||||
"node-sass": "^4.13.1",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"sass-loader": "^8.0.2",
|
||||
"style-loader": "^1.1.3",
|
||||
"webpack": "^4.42.1",
|
||||
"webpack-cli": "^3.3.11"
|
||||
"punycode": "^2.1.1",
|
||||
"sass": "^1.26.11"
|
||||
},
|
||||
"dependencies": {
|
||||
"clipboard": "^2.0.6",
|
||||
"codemirror": "^5.52.2",
|
||||
"dropzone": "^5.7.0",
|
||||
"markdown-it": "^10.0.0",
|
||||
"codemirror": "^5.58.1",
|
||||
"dropzone": "^5.7.2",
|
||||
"markdown-it": "^11.0.1",
|
||||
"markdown-it-task-lists": "^2.1.1",
|
||||
"sortablejs": "^1.10.2",
|
||||
"vue": "^2.6.11",
|
||||
"vuedraggable": "^2.23.2"
|
||||
},
|
||||
"browser": {
|
||||
"vue": "vue/dist/vue.common.js"
|
||||
"sortablejs": "^1.12.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<description>The coding standard for BookStack.</description>
|
||||
<file>app</file>
|
||||
<exclude-pattern>*/migrations/*</exclude-pattern>
|
||||
<exclude-pattern>*/tests/*</exclude-pattern>
|
||||
<arg value="np"/>
|
||||
<rule ref="PSR2"/>
|
||||
</ruleset>
|
||||
@@ -51,5 +51,7 @@
|
||||
<server name="DEBUGBAR_ENABLED" value="false"/>
|
||||
<server name="SAML2_ENABLED" value="false"/>
|
||||
<server name="API_REQUESTS_PER_MIN" value="180"/>
|
||||
<server name="LOG_FAILED_LOGIN_MESSAGE" value=""/>
|
||||
<server name="LOG_FAILED_LOGIN_CHANNEL" value="testing"/>
|
||||
</php>
|
||||
</phpunit>
|
||||
|
||||
@@ -11,9 +11,10 @@
|
||||
|
||||
# Redirect Trailing Slashes If Not A Folder...
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule ^(.*)/$ /$1 [L,R=301]
|
||||
RewriteCond %{REQUEST_URI} (.+)/$
|
||||
RewriteRule ^ %1 [L,R=301]
|
||||
|
||||
# Handle Front Controller...
|
||||
# Send Requests To Front Controller...
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteRule ^ index.php [L]
|
||||
|
||||
95
public/dist/app.js
vendored
95
public/dist/app.js
vendored
File diff suppressed because one or more lines are too long
3
public/dist/export-styles.css
vendored
3
public/dist/export-styles.css
vendored
File diff suppressed because one or more lines are too long
3
public/dist/print-styles.css
vendored
3
public/dist/print-styles.css
vendored
@@ -1,2 +1 @@
|
||||
:root{--color-primary: #206ea7;--color-primary-light: rgba(32,110,167,0.15);--color-page: #206ea7;--color-page-draft: #7e50b1;--color-chapter: #af4d0d;--color-book: #077b70;--color-bookshelf: #a94747}header{display:none}html,body{font-size:12px;background-color:#FFF}.page-content{margin:0 auto}.print-hidden{display:none !important}.tri-layout-container{grid-template-columns:1fr;grid-template-areas:"b";margin-inline-start:0;margin-inline-end:0;display:block}.card{box-shadow:none}.content-wrap.card{padding-inline-start:0;padding-inline-end:0}
|
||||
|
||||
:root{--color-primary: #206ea7;--color-primary-light: rgba(32,110,167,0.15);--color-page: #206ea7;--color-page-draft: #7e50b1;--color-chapter: #af4d0d;--color-book: #077b70;--color-bookshelf: #a94747}header{display:none}html,body{font-size:12px;background-color:#fff}.page-content{margin:0 auto}.print-hidden{display:none !important}.tri-layout-container{grid-template-columns:1fr;grid-template-areas:"b";margin-inline-start:0;margin-inline-end:0;display:block}.card{box-shadow:none}.content-wrap.card{padding-inline-start:0;padding-inline-end:0}/*# sourceMappingURL=print-styles.css.map */
|
||||
|
||||
3
public/dist/styles.css
vendored
3
public/dist/styles.css
vendored
File diff suppressed because one or more lines are too long
@@ -51,7 +51,7 @@ All development on BookStack is currently done on the master branch. When it's t
|
||||
|
||||
* [Node.js](https://nodejs.org/en/) v10.0+
|
||||
|
||||
This project uses SASS for CSS development and this is built, along with the JavaScript, using webpack. The below npm commands can be used to install the dependencies & run the build tasks:
|
||||
This project uses SASS for CSS development and this is built, along with the JavaScript, using a range of npm scripts. The below npm commands can be used to install the dependencies & run the build tasks:
|
||||
|
||||
``` bash
|
||||
# Install NPM Dependencies
|
||||
@@ -157,8 +157,7 @@ These are the great open-source projects used to help build BookStack:
|
||||
* [Laravel](http://laravel.com/)
|
||||
* [TinyMCE](https://www.tinymce.com/)
|
||||
* [CodeMirror](https://codemirror.net)
|
||||
* [Vue.js](http://vuejs.org/)
|
||||
* [Sortable](https://github.com/SortableJS/Sortable) & [Vue.Draggable](https://github.com/SortableJS/Vue.Draggable)
|
||||
* [Sortable](https://github.com/SortableJS/Sortable)
|
||||
* [Google Material Icons](https://material.io/icons/)
|
||||
* [Dropzone.js](http://www.dropzonejs.com/)
|
||||
* [clipboard.js](https://clipboardjs.com/)
|
||||
@@ -169,6 +168,6 @@ These are the great open-source projects used to help build BookStack:
|
||||
* [Snappy (WKHTML2PDF)](https://github.com/barryvdh/laravel-snappy)
|
||||
* [Laravel IDE helper](https://github.com/barryvdh/laravel-ide-helper)
|
||||
* [WKHTMLtoPDF](http://wkhtmltopdf.org/index.html)
|
||||
* [Draw.io](https://github.com/jgraph/drawio)
|
||||
* [diagrams.net](https://github.com/jgraph/drawio)
|
||||
* [Laravel Stats](https://github.com/stefanzweifel/laravel-stats)
|
||||
* [OneLogin's SAML PHP Toolkit](https://github.com/onelogin/php-saml)
|
||||
54
resources/js/components/add-remove-rows.js
Normal file
54
resources/js/components/add-remove-rows.js
Normal file
@@ -0,0 +1,54 @@
|
||||
import {onChildEvent} from "../services/dom";
|
||||
import {uniqueId} from "../services/util";
|
||||
|
||||
/**
|
||||
* AddRemoveRows
|
||||
* Allows easy row add/remove controls onto a table.
|
||||
* Needs a model row to use when adding a new row.
|
||||
* @extends {Component}
|
||||
*/
|
||||
class AddRemoveRows {
|
||||
setup() {
|
||||
this.modelRow = this.$refs.model;
|
||||
this.addButton = this.$refs.add;
|
||||
this.removeSelector = this.$opts.removeSelector;
|
||||
this.rowSelector = this.$opts.rowSelector;
|
||||
this.setupListeners();
|
||||
}
|
||||
|
||||
setupListeners() {
|
||||
this.addButton.addEventListener('click', this.add.bind(this));
|
||||
|
||||
onChildEvent(this.$el, this.removeSelector, 'click', (e) => {
|
||||
const row = e.target.closest(this.rowSelector);
|
||||
row.remove();
|
||||
});
|
||||
}
|
||||
|
||||
// For external use
|
||||
add() {
|
||||
const clone = this.modelRow.cloneNode(true);
|
||||
clone.classList.remove('hidden');
|
||||
this.setClonedInputNames(clone);
|
||||
this.modelRow.parentNode.insertBefore(clone, this.modelRow);
|
||||
window.components.init(clone);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the HTML names of a clone to be unique if required.
|
||||
* Names can use placeholder values. For exmaple, a model row
|
||||
* may have name="tags[randrowid][name]".
|
||||
* These are the available placeholder values:
|
||||
* - randrowid - An random string ID, applied the same across the row.
|
||||
* @param {HTMLElement} clone
|
||||
*/
|
||||
setClonedInputNames(clone) {
|
||||
const rowId = uniqueId();
|
||||
const randRowIdElems = clone.querySelectorAll(`[name*="randrowid"]`);
|
||||
for (const elem of randRowIdElems) {
|
||||
elem.name = elem.name.split('randrowid').join(rowId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default AddRemoveRows;
|
||||
32
resources/js/components/ajax-delete-row.js
Normal file
32
resources/js/components/ajax-delete-row.js
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* AjaxDelete
|
||||
* @extends {Component}
|
||||
*/
|
||||
import {onSelect} from "../services/dom";
|
||||
|
||||
class AjaxDeleteRow {
|
||||
setup() {
|
||||
this.row = this.$el;
|
||||
this.url = this.$opts.url;
|
||||
this.deleteButtons = this.$manyRefs.delete;
|
||||
|
||||
onSelect(this.deleteButtons, this.runDelete.bind(this));
|
||||
}
|
||||
|
||||
runDelete() {
|
||||
this.row.style.opacity = '0.7';
|
||||
this.row.style.pointerEvents = 'none';
|
||||
|
||||
window.$http.delete(this.url).then(resp => {
|
||||
if (typeof resp.data === 'object' && resp.data.message) {
|
||||
window.$events.emit('success', resp.data.message);
|
||||
}
|
||||
this.row.remove();
|
||||
}).catch(err => {
|
||||
this.row.style.opacity = null;
|
||||
this.row.style.pointerEvents = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default AjaxDeleteRow;
|
||||
82
resources/js/components/ajax-form.js
Normal file
82
resources/js/components/ajax-form.js
Normal file
@@ -0,0 +1,82 @@
|
||||
import {onEnterPress, onSelect} from "../services/dom";
|
||||
|
||||
/**
|
||||
* Ajax Form
|
||||
* Will handle button clicks or input enter press events and submit
|
||||
* the data over ajax. Will always expect a partial HTML view to be returned.
|
||||
* Fires an 'ajax-form-success' event when submitted successfully.
|
||||
*
|
||||
* Will handle a real form if that's what the component is added to
|
||||
* otherwise will act as a fake form element.
|
||||
*
|
||||
* @extends {Component}
|
||||
*/
|
||||
class AjaxForm {
|
||||
setup() {
|
||||
this.container = this.$el;
|
||||
this.responseContainer = this.container;
|
||||
this.url = this.$opts.url;
|
||||
this.method = this.$opts.method || 'post';
|
||||
this.successMessage = this.$opts.successMessage;
|
||||
this.submitButtons = this.$manyRefs.submit || [];
|
||||
|
||||
if (this.$opts.responseContainer) {
|
||||
this.responseContainer = this.container.closest(this.$opts.responseContainer);
|
||||
}
|
||||
|
||||
this.setupListeners();
|
||||
}
|
||||
|
||||
setupListeners() {
|
||||
|
||||
if (this.container.tagName === 'FORM') {
|
||||
this.container.addEventListener('submit', this.submitRealForm.bind(this));
|
||||
return;
|
||||
}
|
||||
|
||||
onEnterPress(this.container, event => {
|
||||
this.submitFakeForm();
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
this.submitButtons.forEach(button => onSelect(button, this.submitFakeForm.bind(this)));
|
||||
}
|
||||
|
||||
submitFakeForm() {
|
||||
const fd = new FormData();
|
||||
const inputs = this.container.querySelectorAll(`[name]`);
|
||||
for (const input of inputs) {
|
||||
fd.append(input.getAttribute('name'), input.value);
|
||||
}
|
||||
this.submit(fd);
|
||||
}
|
||||
|
||||
submitRealForm(event) {
|
||||
event.preventDefault();
|
||||
const fd = new FormData(this.container);
|
||||
this.submit(fd);
|
||||
}
|
||||
|
||||
async submit(formData) {
|
||||
this.responseContainer.style.opacity = '0.7';
|
||||
this.responseContainer.style.pointerEvents = 'none';
|
||||
|
||||
try {
|
||||
const resp = await window.$http[this.method.toLowerCase()](this.url, formData);
|
||||
this.$emit('success', {formData});
|
||||
this.responseContainer.innerHTML = resp.data;
|
||||
if (this.successMessage) {
|
||||
window.$events.emit('success', this.successMessage);
|
||||
}
|
||||
} catch (err) {
|
||||
this.responseContainer.innerHTML = err.data;
|
||||
}
|
||||
|
||||
window.components.init(this.responseContainer);
|
||||
this.responseContainer.style.opacity = null;
|
||||
this.responseContainer.style.pointerEvents = null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default AjaxForm;
|
||||
79
resources/js/components/attachments.js
Normal file
79
resources/js/components/attachments.js
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Attachments
|
||||
* @extends {Component}
|
||||
*/
|
||||
import {showLoading} from "../services/dom";
|
||||
|
||||
class Attachments {
|
||||
|
||||
setup() {
|
||||
this.container = this.$el;
|
||||
this.pageId = this.$opts.pageId;
|
||||
this.editContainer = this.$refs.editContainer;
|
||||
this.listContainer = this.$refs.listContainer;
|
||||
this.mainTabs = this.$refs.mainTabs;
|
||||
this.list = this.$refs.list;
|
||||
|
||||
this.setupListeners();
|
||||
}
|
||||
|
||||
setupListeners() {
|
||||
const reloadListBound = this.reloadList.bind(this);
|
||||
this.container.addEventListener('dropzone-success', reloadListBound);
|
||||
this.container.addEventListener('ajax-form-success', reloadListBound);
|
||||
|
||||
this.container.addEventListener('sortable-list-sort', event => {
|
||||
this.updateOrder(event.detail.ids);
|
||||
});
|
||||
|
||||
this.container.addEventListener('event-emit-select-edit', event => {
|
||||
this.startEdit(event.detail.id);
|
||||
});
|
||||
|
||||
this.container.addEventListener('event-emit-select-edit-back', event => {
|
||||
this.stopEdit();
|
||||
});
|
||||
|
||||
this.container.addEventListener('event-emit-select-insert', event => {
|
||||
const insertContent = event.target.closest('[data-drag-content]').getAttribute('data-drag-content');
|
||||
const contentTypes = JSON.parse(insertContent);
|
||||
window.$events.emit('editor::insert', {
|
||||
html: contentTypes['text/html'],
|
||||
markdown: contentTypes['text/plain'],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
reloadList() {
|
||||
this.stopEdit();
|
||||
this.mainTabs.components.tabs.show('items');
|
||||
window.$http.get(`/attachments/get/page/${this.pageId}`).then(resp => {
|
||||
this.list.innerHTML = resp.data;
|
||||
window.components.init(this.list);
|
||||
});
|
||||
}
|
||||
|
||||
updateOrder(idOrder) {
|
||||
window.$http.put(`/attachments/sort/page/${this.pageId}`, {order: idOrder}).then(resp => {
|
||||
window.$events.emit('success', resp.data.message);
|
||||
});
|
||||
}
|
||||
|
||||
async startEdit(id) {
|
||||
this.editContainer.classList.remove('hidden');
|
||||
this.listContainer.classList.add('hidden');
|
||||
|
||||
showLoading(this.editContainer);
|
||||
const resp = await window.$http.get(`/attachments/edit/${id}`);
|
||||
this.editContainer.innerHTML = resp.data;
|
||||
window.components.init(this.editContainer);
|
||||
}
|
||||
|
||||
stopEdit() {
|
||||
this.editContainer.classList.add('hidden');
|
||||
this.listContainer.classList.remove('hidden');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default Attachments;
|
||||
152
resources/js/components/auto-suggest.js
Normal file
152
resources/js/components/auto-suggest.js
Normal file
@@ -0,0 +1,152 @@
|
||||
import {escapeHtml} from "../services/util";
|
||||
import {onChildEvent} from "../services/dom";
|
||||
|
||||
const ajaxCache = {};
|
||||
|
||||
/**
|
||||
* AutoSuggest
|
||||
* @extends {Component}
|
||||
*/
|
||||
class AutoSuggest {
|
||||
setup() {
|
||||
this.parent = this.$el.parentElement;
|
||||
this.container = this.$el;
|
||||
this.type = this.$opts.type;
|
||||
this.url = this.$opts.url;
|
||||
this.input = this.$refs.input;
|
||||
this.list = this.$refs.list;
|
||||
|
||||
this.lastPopulated = 0;
|
||||
this.setupListeners();
|
||||
}
|
||||
|
||||
setupListeners() {
|
||||
this.input.addEventListener('input', this.requestSuggestions.bind(this));
|
||||
this.input.addEventListener('focus', this.requestSuggestions.bind(this));
|
||||
this.input.addEventListener('keydown', event => {
|
||||
if (event.key === 'Tab') {
|
||||
this.hideSuggestions();
|
||||
}
|
||||
});
|
||||
|
||||
this.input.addEventListener('blur', this.hideSuggestionsIfFocusedLost.bind(this));
|
||||
this.container.addEventListener('keydown', this.containerKeyDown.bind(this));
|
||||
|
||||
onChildEvent(this.list, 'button', 'click', (event, el) => {
|
||||
this.selectSuggestion(el.textContent);
|
||||
});
|
||||
onChildEvent(this.list, 'button', 'keydown', (event, el) => {
|
||||
if (event.key === 'Enter') {
|
||||
this.selectSuggestion(el.textContent);
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
selectSuggestion(value) {
|
||||
this.input.value = value;
|
||||
this.lastPopulated = Date.now();
|
||||
this.input.focus();
|
||||
this.input.dispatchEvent(new Event('input', {bubbles: true}));
|
||||
this.input.dispatchEvent(new Event('change', {bubbles: true}));
|
||||
this.hideSuggestions();
|
||||
}
|
||||
|
||||
containerKeyDown(event) {
|
||||
if (event.key === 'Enter') event.preventDefault();
|
||||
if (this.list.classList.contains('hidden')) return;
|
||||
|
||||
// Down arrow
|
||||
if (event.key === 'ArrowDown') {
|
||||
this.moveFocus(true);
|
||||
event.preventDefault();
|
||||
}
|
||||
// Up Arrow
|
||||
else if (event.key === 'ArrowUp') {
|
||||
this.moveFocus(false);
|
||||
event.preventDefault();
|
||||
}
|
||||
// Escape key
|
||||
else if (event.key === 'Escape') {
|
||||
this.hideSuggestions();
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
moveFocus(forward = true) {
|
||||
const focusables = Array.from(this.container.querySelectorAll('input,button'));
|
||||
const index = focusables.indexOf(document.activeElement);
|
||||
const newFocus = focusables[index + (forward ? 1 : -1)];
|
||||
if (newFocus) {
|
||||
newFocus.focus()
|
||||
}
|
||||
}
|
||||
|
||||
async requestSuggestions() {
|
||||
if (Date.now() - this.lastPopulated < 50) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nameFilter = this.getNameFilterIfNeeded();
|
||||
const search = this.input.value.slice(0, 3).toLowerCase();
|
||||
const suggestions = await this.loadSuggestions(search, nameFilter);
|
||||
let toShow = suggestions.slice(0, 6);
|
||||
if (search.length > 0) {
|
||||
toShow = suggestions.filter(val => {
|
||||
return val.toLowerCase().includes(search);
|
||||
}).slice(0, 6);
|
||||
}
|
||||
|
||||
this.displaySuggestions(toShow);
|
||||
}
|
||||
|
||||
getNameFilterIfNeeded() {
|
||||
if (this.type !== 'value') return null;
|
||||
return this.parent.querySelector('input').value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {String} search
|
||||
* @param {String|null} nameFilter
|
||||
* @returns {Promise<Object|String|*>}
|
||||
*/
|
||||
async loadSuggestions(search, nameFilter = null) {
|
||||
const params = {search, name: nameFilter};
|
||||
const cacheKey = `${this.url}:${JSON.stringify(params)}`;
|
||||
|
||||
if (ajaxCache[cacheKey]) {
|
||||
return ajaxCache[cacheKey];
|
||||
}
|
||||
|
||||
const resp = await window.$http.get(this.url, params);
|
||||
ajaxCache[cacheKey] = resp.data;
|
||||
return resp.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {String[]} suggestions
|
||||
*/
|
||||
displaySuggestions(suggestions) {
|
||||
if (suggestions.length === 0) {
|
||||
return this.hideSuggestions();
|
||||
}
|
||||
|
||||
this.list.innerHTML = suggestions.map(value => `<li><button type="button">${escapeHtml(value)}</button></li>`).join('');
|
||||
this.list.style.display = 'block';
|
||||
for (const button of this.list.querySelectorAll('button')) {
|
||||
button.addEventListener('blur', this.hideSuggestionsIfFocusedLost.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
hideSuggestions() {
|
||||
this.list.style.display = 'none';
|
||||
}
|
||||
|
||||
hideSuggestionsIfFocusedLost(event) {
|
||||
if (!this.container.contains(event.relatedTarget)) {
|
||||
this.hideSuggestions();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default AutoSuggest;
|
||||
@@ -1,4 +1,4 @@
|
||||
import {Sortable, MultiDrag} from "sortablejs";
|
||||
import Sortable from "sortablejs";
|
||||
|
||||
// Auto sort control
|
||||
const sortOperations = {
|
||||
@@ -43,7 +43,6 @@ class BookSort {
|
||||
this.input = elem.querySelector('[book-sort-input]');
|
||||
|
||||
const initialSortBox = elem.querySelector('.sort-box');
|
||||
Sortable.mount(new MultiDrag());
|
||||
this.setupBookSortable(initialSortBox);
|
||||
this.setupSortPresets();
|
||||
|
||||
|
||||
117
resources/js/components/code-editor.js
Normal file
117
resources/js/components/code-editor.js
Normal file
@@ -0,0 +1,117 @@
|
||||
import Code from "../services/code";
|
||||
import {onChildEvent, onEnterPress, onSelect} from "../services/dom";
|
||||
|
||||
/**
|
||||
* Code Editor
|
||||
* @extends {Component}
|
||||
*/
|
||||
class CodeEditor {
|
||||
|
||||
setup() {
|
||||
this.container = this.$refs.container;
|
||||
this.popup = this.$el;
|
||||
this.editorInput = this.$refs.editor;
|
||||
this.languageLinks = this.$manyRefs.languageLink;
|
||||
this.saveButton = this.$refs.saveButton;
|
||||
this.languageInput = this.$refs.languageInput;
|
||||
this.historyDropDown = this.$refs.historyDropDown;
|
||||
this.historyList = this.$refs.historyList;
|
||||
|
||||
this.callback = null;
|
||||
this.editor = null;
|
||||
this.history = {};
|
||||
this.historyKey = 'code_history';
|
||||
this.setupListeners();
|
||||
}
|
||||
|
||||
setupListeners() {
|
||||
this.container.addEventListener('keydown', event => {
|
||||
if (event.ctrlKey && event.key === 'Enter') {
|
||||
this.save();
|
||||
}
|
||||
});
|
||||
|
||||
onSelect(this.languageLinks, event => {
|
||||
const language = event.target.dataset.lang;
|
||||
this.languageInput.value = language;
|
||||
this.updateEditorMode(language);
|
||||
});
|
||||
|
||||
onEnterPress(this.languageInput, e => this.save());
|
||||
onSelect(this.saveButton, e => this.save());
|
||||
|
||||
onChildEvent(this.historyList, 'button', 'click', (event, elem) => {
|
||||
event.preventDefault();
|
||||
const historyTime = elem.dataset.time;
|
||||
if (this.editor) {
|
||||
this.editor.setValue(this.history[historyTime]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
save() {
|
||||
if (this.callback) {
|
||||
this.callback(this.editor.getValue(), this.languageInput.value);
|
||||
}
|
||||
this.hide();
|
||||
}
|
||||
|
||||
open(code, language, callback) {
|
||||
this.languageInput.value = language;
|
||||
this.callback = callback;
|
||||
|
||||
this.show();
|
||||
this.updateEditorMode(language);
|
||||
|
||||
Code.setContent(this.editor, code);
|
||||
}
|
||||
|
||||
show() {
|
||||
if (!this.editor) {
|
||||
this.editor = Code.popupEditor(this.editorInput, this.languageInput.value);
|
||||
}
|
||||
this.loadHistory();
|
||||
this.popup.components.popup.show(() => {
|
||||
Code.updateLayout(this.editor);
|
||||
this.editor.focus();
|
||||
}, () => {
|
||||
this.addHistory()
|
||||
});
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.popup.components.popup.hide();
|
||||
this.addHistory();
|
||||
}
|
||||
|
||||
updateEditorMode(language) {
|
||||
Code.setMode(this.editor, language, this.editor.getValue());
|
||||
}
|
||||
|
||||
loadHistory() {
|
||||
this.history = JSON.parse(window.sessionStorage.getItem(this.historyKey) || '{}');
|
||||
const historyKeys = Object.keys(this.history).reverse();
|
||||
this.historyDropDown.classList.toggle('hidden', historyKeys.length === 0);
|
||||
this.historyList.innerHTML = historyKeys.map(key => {
|
||||
const localTime = (new Date(parseInt(key))).toLocaleTimeString();
|
||||
return `<li><button type="button" data-time="${key}">${localTime}</button></li>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
addHistory() {
|
||||
if (!this.editor) return;
|
||||
const code = this.editor.getValue();
|
||||
if (!code) return;
|
||||
|
||||
// Stop if we'd be storing the same as the last item
|
||||
const lastHistoryKey = Object.keys(this.history).pop();
|
||||
if (this.history[lastHistoryKey] === code) return;
|
||||
|
||||
this.history[String(Date.now())] = code;
|
||||
const historyString = JSON.stringify(this.history);
|
||||
window.sessionStorage.setItem(this.historyKey, historyString);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default CodeEditor;
|
||||
@@ -37,7 +37,7 @@ class Collapsible {
|
||||
}
|
||||
|
||||
openIfContainsError() {
|
||||
const error = this.content.querySelector('.text-neg');
|
||||
const error = this.content.querySelector('.text-neg.text-small');
|
||||
if (error) {
|
||||
this.open();
|
||||
}
|
||||
|
||||
@@ -3,14 +3,16 @@ import {onSelect} from "../services/dom";
|
||||
/**
|
||||
* Dropdown
|
||||
* Provides some simple logic to create simple dropdown menus.
|
||||
* @extends {Component}
|
||||
*/
|
||||
class DropDown {
|
||||
|
||||
constructor(elem) {
|
||||
this.container = elem;
|
||||
this.menu = elem.querySelector('.dropdown-menu, [dropdown-menu]');
|
||||
this.moveMenu = elem.hasAttribute('dropdown-move-menu');
|
||||
this.toggle = elem.querySelector('[dropdown-toggle]');
|
||||
setup() {
|
||||
this.container = this.$el;
|
||||
this.menu = this.$refs.menu;
|
||||
this.toggle = this.$refs.toggle;
|
||||
this.moveMenu = this.$opts.moveMenu;
|
||||
|
||||
this.direction = (document.dir === 'rtl') ? 'right' : 'left';
|
||||
this.body = document.body;
|
||||
this.showing = false;
|
||||
|
||||
77
resources/js/components/dropzone.js
Normal file
77
resources/js/components/dropzone.js
Normal file
@@ -0,0 +1,77 @@
|
||||
import DropZoneLib from "dropzone";
|
||||
import {fadeOut} from "../services/animations";
|
||||
|
||||
/**
|
||||
* Dropzone
|
||||
* @extends {Component}
|
||||
*/
|
||||
class Dropzone {
|
||||
setup() {
|
||||
this.container = this.$el;
|
||||
this.url = this.$opts.url;
|
||||
this.successMessage = this.$opts.successMessage;
|
||||
this.removeMessage = this.$opts.removeMessage;
|
||||
this.uploadLimitMessage = this.$opts.uploadLimitMessage;
|
||||
this.timeoutMessage = this.$opts.timeoutMessage;
|
||||
|
||||
const _this = this;
|
||||
this.dz = new DropZoneLib(this.container, {
|
||||
addRemoveLinks: true,
|
||||
dictRemoveFile: this.removeMessage,
|
||||
timeout: Number(window.uploadTimeout) || 60000,
|
||||
maxFilesize: Number(window.uploadLimit) || 256,
|
||||
url: this.url,
|
||||
withCredentials: true,
|
||||
init() {
|
||||
this.dz = this;
|
||||
this.dz.on('sending', _this.onSending.bind(_this));
|
||||
this.dz.on('success', _this.onSuccess.bind(_this));
|
||||
this.dz.on('error', _this.onError.bind(_this));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onSending(file, xhr, data) {
|
||||
|
||||
const token = window.document.querySelector('meta[name=token]').getAttribute('content');
|
||||
data.append('_token', token);
|
||||
|
||||
xhr.ontimeout = (e) => {
|
||||
this.dz.emit('complete', file);
|
||||
this.dz.emit('error', file, this.timeoutMessage);
|
||||
}
|
||||
}
|
||||
|
||||
onSuccess(file, data) {
|
||||
this.$emit('success', {file, data});
|
||||
|
||||
if (this.successMessage) {
|
||||
window.$events.emit('success', this.successMessage);
|
||||
}
|
||||
|
||||
fadeOut(file.previewElement, 800, () => {
|
||||
this.dz.removeFile(file);
|
||||
});
|
||||
}
|
||||
|
||||
onError(file, errorMessage, xhr) {
|
||||
this.$emit('error', {file, errorMessage, xhr});
|
||||
|
||||
const setMessage = (message) => {
|
||||
const messsageEl = file.previewElement.querySelector('[data-dz-errormessage]');
|
||||
messsageEl.textContent = message;
|
||||
}
|
||||
|
||||
if (xhr && xhr.status === 413) {
|
||||
setMessage(this.uploadLimitMessage);
|
||||
} else if (errorMessage.file) {
|
||||
setMessage(errorMessage.file);
|
||||
}
|
||||
}
|
||||
|
||||
removeAll() {
|
||||
this.dz.removeAllFiles(true);
|
||||
}
|
||||
}
|
||||
|
||||
export default Dropzone;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user