Compare commits

...

72 Commits

Author SHA1 Message Date
Dan Brown
c6404d8917 Updated assets for release v0.11 2016-07-03 10:56:16 +01:00
Dan Brown
7113807f12 Merge branch 'master' into release 2016-07-03 10:52:04 +01:00
Dan Brown
10418323ef Prevented shift-tab from selecting a tag 2016-07-03 10:47:20 +01:00
Dan Brown
a11d5245ec Cut down homepage queries a little 2016-07-03 10:42:13 +01:00
Dan Brown
565033e0d4 Fixed bug which hid entities and fixed new chapter priority 2016-07-03 10:31:20 +01:00
Dan Brown
c25ef18900 Made list default messages a little nicer 2016-07-03 10:12:12 +01:00
Dan Brown
b0a63ba0cc Tightened and cleaned some list styles 2016-07-03 09:58:45 +01:00
Dan Brown
7b6c88f17c Fixed error on image deletion
Also Added tests to cover image upload and deletion.
Fixes #136.
2016-07-01 20:13:30 +01:00
Dan Brown
361ba8b244 Made stick book navigation recalc on window resize 2016-06-25 15:46:31 +01:00
Dan Brown
9baa96d41c Added chapter move actions. Closes #86 2016-06-25 15:31:38 +01:00
Dan Brown
e584b4926f Travis CI updates (#130)
* Updated travis config to use mysql

* Added cache for composer
2016-06-19 19:30:43 +01:00
Dan Brown
bcd9c2044e Added WYSIWYG editor callouts 2016-06-19 19:02:53 +01:00
Dan Brown
d582e76fed Fixed theme color elements not showing on new instance
Also cleaned notification session access
2016-06-12 13:37:15 +01:00
Dan Brown
991dd8a558 Merged branch add_page_move into master
References #86
2016-06-12 12:49:08 +01:00
Dan Brown
bc49784797 Added tests to cover page_move features 2016-06-12 12:48:06 +01:00
Dan Brown
7f99903fdb Finished off page move functionality 2016-06-12 12:14:14 +01:00
Dan Brown
97d011ac8e Started work on page move view and entity selector 2016-06-11 21:04:18 +01:00
Dan Brown
61596a8e21 Prevent page redirect on draft saving
Only an issue when using the non 'mysqlnd' extension.
Fixes #120
2016-06-10 19:45:53 +01:00
Dan Brown
44e337cef6 Merged branch rovox-master into master 2016-06-04 16:33:23 +01:00
Dan Brown
1bec3eaa1e Added checks to use MyISAM if MySQL 5.5 is found 2016-06-04 16:32:57 +01:00
Dan Brown
5942d796b5 Merge branch 'master' of git://github.com/rovox/BookStack into rovox-master 2016-06-04 15:43:08 +01:00
Dan Brown
eec9c05518 Added tag autosuggestion when no input provided
Shows the most popular tag names/values.
As requested on #121
2016-06-04 15:37:28 +01:00
Dan Brown
246d1621f5 Limited tag value autosuggestions based on tag name
As requested on #121
2016-06-04 14:54:31 +01:00
Dan Brown
6c1e06bf86 Updated License file 2016-06-03 18:52:49 +01:00
Dan Brown
3c1e165134 Add in LICENSE file 2016-06-03 18:51:47 +01:00
Dan Brown
80d1c594cc Added licence and release badges 2016-06-03 18:50:20 +01:00
Dan Brown
947db95d16 Fixed error with similar activity filtering 2016-05-28 14:02:48 +01:00
Dan Brown
5b9362ab0b Added (Ctrl+s) draft force save 2016-05-28 13:51:07 +01:00
Dan Brown
f602b088ac Added basic markdown scroll syncing 2016-05-28 13:24:07 +01:00
robert
4acf0c4ee0 Making sure MyISAM is set for the tables that need it for new installtions that are using mariadb. 2016-05-25 23:52:43 +02:00
Dan Brown
be711215e8 Updated assets for release v0.10 2016-05-22 15:12:47 +01:00
Dan Brown
7e3b404240 Merge branch 'master' into release for version v0.10 2016-05-22 15:11:50 +01:00
Dan Brown
9d3f329bc9 Fixed missing column drop on migration rollback 2016-05-22 14:56:26 +01:00
Dan Brown
bd00a03e7b Added support for custom content in HTML head 2016-05-22 11:01:21 +01:00
Dan Brown
be517de7dc Added pagination, sorting & searching to users list
As requested on #113
2016-05-22 10:44:31 +01:00
Dan Brown
23ab1f0c81 Standardised delete aesthetics regarding roles and users
Also added user delete success message.
Fixes #114
2016-05-22 09:23:41 +01:00
Dan Brown
49621e7b15 Fixed flexbox overflow scrolling issues in firefox
Fixes #111
2016-05-22 09:08:37 +01:00
Dan Brown
1ab6f017c9 Made user names clickable at the bottom of assets
Fixes #117
2016-05-22 08:48:40 +01:00
Dan Brown
0b364fd72f Merge pull request #110 from ssddanbrown/page_attributes
Attribute System. Closes #48.
2016-05-15 20:24:57 +01:00
Dan Brown
e80ae76856 Added auto-suggestions to tag names and values 2016-05-15 20:12:53 +01:00
Dan Brown
eebad3e2a0 Fixed tags not being saved on new pages 2016-05-15 14:17:25 +01:00
Dan Brown
db2af47286 Revert travis CI changes 2016-05-15 13:59:16 +01:00
Dan Brown
7ad28aeab4 Updated travis file to hopefully fix CI errors 2016-05-15 13:53:42 +01:00
Dan Brown
8d80e7311c Added tag searching to search interfaces 2016-05-15 13:41:18 +01:00
Dan Brown
7932069535 Added tags to page display and simplified editing flow
Tags now save with the page content data
2016-05-15 10:39:17 +01:00
Dan Brown
78564ec61d Cleaned up tag edit interface 2016-05-14 20:02:00 +01:00
Dan Brown
b80184cd93 Renamed attribute to tags & continued interface
Also fixed page create route broken in last commit
2016-05-13 21:20:21 +01:00
Dan Brown
1fa079b466 Started the page attributes interface 2016-05-12 23:12:05 +01:00
Dan Brown
fcfb9470c9 Added further attribute endpoints and added tests 2016-05-07 14:29:43 +01:00
Dan Brown
c99653f0f2 Fixed bad refactor in the permission service 2016-05-06 20:44:07 +01:00
Dan Brown
5080b4996e Started base work on attribute system 2016-05-06 20:33:08 +01:00
Dan Brown
1903422113 Merge branch 'v0.9' 2016-05-03 21:30:55 +01:00
Dan Brown
6f9d2939e7 Updated travisCI to help fix node error 2016-05-02 13:14:35 +01:00
Dan Brown
3234510d31 Merged ci_testing into master 2016-05-02 13:01:46 +01:00
Dan Brown
a40af08018 Changed mariadb version 2016-05-02 12:50:17 +01:00
Dan Brown
223a6b546c Added dependancy caching and travis badge 2016-05-02 12:37:58 +01:00
Dan Brown
0d5da2d9d4 Added travis CI file 2016-05-02 12:26:09 +01:00
Dan Brown
37337b8b1d Added codeship CI badge 2016-05-02 12:02:23 +01:00
Dan Brown
ffcc058927 Merge pull request #106 from ssddanbrown/ci_testing
Ci testing
2016-05-02 11:58:12 +01:00
Dan Brown
3a1cda5802 Updated ldap so extension not required in testing 2016-05-02 11:38:07 +01:00
Dan Brown
5c1015d6fc Updated social testing compatibility 2016-05-02 11:26:47 +01:00
Dan Brown
3385ec3f78 Updated database config to be codeship compatible 2016-05-02 10:35:42 +01:00
Dan Brown
1b46c19849 Merge pull request #103 from ssddanbrown/add_role_view_permissions
Overhauled permission system and stripped migrations of most app code to reduce future breakages. Closes #101.
2016-05-01 22:47:22 +01:00
Dan Brown
75a4fc905b Major permission naming refactor and database migration cleanup 2016-05-01 22:33:56 +01:00
Dan Brown
05666efda9 Added hidden public role to fit with new permissions system 2016-05-01 19:36:53 +01:00
Dan Brown
59367b3417 Improved permission regen performance by factor of 4
Worked around slower eloquent access to speed up permission generation.
2016-04-30 17:16:06 +01:00
Dan Brown
9a31b83b2a Worked around create permission quirks 2016-04-26 21:48:17 +01:00
Dan Brown
a81a56706e Rolled out new permissions system throughout application 2016-04-24 16:54:20 +01:00
Dan Brown
ada7c83e96 Continued with database work for permissions overhaul
Added to the entity_permissions table with further required fields and indexes.
Wrote the code for checking permissions.
2016-04-23 18:14:26 +01:00
Dan Brown
ea287ebf86 Started creation of intermediate permission table 2016-04-20 21:37:57 +01:00
Dan Brown
043cdeafb3 Merge branch 'master' into add_role_view_permissions 2016-04-20 20:22:30 +01:00
Dan Brown
6e03078de3 Started work towards adding role view permissions
Work halted as re-write required.
In reference to #92
2016-04-09 12:40:07 +01:00
131 changed files with 3798 additions and 907 deletions

35
.travis.yml Normal file
View File

@@ -0,0 +1,35 @@
dist: trusty
sudo: required
language: php
php:
- 7.0
cache:
directories:
- vendor
- node_modules
- $HOME/.composer/cache
addons:
apt:
packages:
- mysql-server-5.6
- mysql-client-core-5.6
- mysql-client-5.6
before_install:
- npm install -g npm@latest
before_script:
- mysql -u root -e 'create database `bookstack-test`;'
- composer config -g github-oauth.github.com $GITHUB_ACCESS_TOKEN
- phpenv config-rm xdebug.ini
- composer self-update
- composer install --prefer-dist --no-interaction
- npm install
- ./node_modules/.bin/gulp
- php artisan migrate --force -n --database=mysql_testing
- php artisan db:seed --force -n --class=DummyContentSeeder --database=mysql_testing
script:
- vendor/bin/phpunit

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2016 Dan Brown
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -2,8 +2,6 @@
namespace BookStack;
use Illuminate\Database\Eloquent\Model;
/**
* @property string key
* @property \User user
@@ -28,7 +26,7 @@ class Activity extends Model
*/
public function user()
{
return $this->belongsTo('BookStack\User');
return $this->belongsTo(User::class);
}
/**
@@ -46,7 +44,7 @@ class Activity extends Model
* @return bool
*/
public function isSimilarTo($activityB) {
return [$this->key, $this->entitiy_type, $this->entitiy_id] === [$activityB->key, $activityB->entitiy_type, $activityB->entitiy_id];
return [$this->key, $this->entity_type, $this->entity_id] === [$activityB->key, $activityB->entity_type, $activityB->entity_id];
}
}

View File

@@ -1,35 +1,55 @@
<?php
namespace BookStack;
<?php namespace BookStack;
class Book extends Entity
{
protected $fillable = ['name', 'description'];
/**
* Get the url for this book.
* @return string
*/
public function getUrl()
{
return '/books/' . $this->slug;
}
/*
* Get the edit url for this book.
* @return string
*/
public function getEditUrl()
{
return $this->getUrl() . '/edit';
}
/**
* Get all pages within this book.
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function pages()
{
return $this->hasMany('BookStack\Page');
return $this->hasMany(Page::class);
}
/**
* Get all chapters within this book.
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function chapters()
{
return $this->hasMany('BookStack\Chapter');
return $this->hasMany(Chapter::class);
}
/**
* Get an excerpt of this book's description to the specified length or less.
* @param int $length
* @return string
*/
public function getExcerpt($length = 100)
{
return strlen($this->description) > $length ? substr($this->description, 0, $length-3) . '...' : $this->description;
$description = $this->description;
return strlen($description) > $length ? substr($description, 0, $length-3) . '...' : $description;
}
}

View File

@@ -5,25 +5,43 @@ class Chapter extends Entity
{
protected $fillable = ['name', 'description', 'priority', 'book_id'];
/**
* Get the book this chapter is within.
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function book()
{
return $this->belongsTo('BookStack\Book');
return $this->belongsTo(Book::class);
}
/**
* Get the pages that this chapter contains.
* @return mixed
*/
public function pages()
{
return $this->hasMany('BookStack\Page')->orderBy('priority', 'ASC');
return $this->hasMany(Page::class)->orderBy('priority', 'ASC');
}
/**
* Get the url of this chapter.
* @return string
*/
public function getUrl()
{
$bookSlug = $this->getAttribute('bookSlug') ? $this->getAttribute('bookSlug') : $this->book->slug;
return '/books/' . $bookSlug. '/chapter/' . $this->slug;
}
/**
* Get an excerpt of this chapter's description to the specified length or less.
* @param int $length
* @return string
*/
public function getExcerpt($length = 100)
{
return strlen($this->description) > $length ? substr($this->description, 0, $length-3) . '...' : $this->description;
$description = $this->description;
return strlen($description) > $length ? substr($description, 0, $length-3) . '...' : $description;
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace BookStack\Console\Commands;
use BookStack\Services\PermissionService;
use Illuminate\Console\Command;
class RegeneratePermissions extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'permissions:regen';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Regenerate all system permissions';
/**
* The service to handle the permission system.
*
* @var PermissionService
*/
protected $permissionService;
/**
* Create a new command instance.
*
* @param PermissionService $permissionService
*/
public function __construct(PermissionService $permissionService)
{
$this->permissionService = $permissionService;
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$this->permissionService->buildJointPermissions();
}
}

View File

@@ -15,6 +15,7 @@ class Kernel extends ConsoleKernel
protected $commands = [
\BookStack\Console\Commands\Inspire::class,
\BookStack\Console\Commands\ResetViews::class,
\BookStack\Console\Commands\RegeneratePermissions::class,
];
/**

View File

@@ -1,15 +1,16 @@
<?php
namespace BookStack;
use Illuminate\Database\Eloquent\Model;
<?php namespace BookStack;
class EmailConfirmation extends Model
{
protected $fillable = ['user_id', 'token'];
/**
* Get the user that this confirmation is attached to.
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function user()
{
return $this->belongsTo('BookStack\User');
return $this->belongsTo(User::class);
}
}

View File

@@ -1,7 +1,7 @@
<?php namespace BookStack;
abstract class Entity extends Ownable
class Entity extends Ownable
{
/**
@@ -43,7 +43,7 @@ abstract class Entity extends Ownable
*/
public function activity()
{
return $this->morphMany('BookStack\Activity', 'entity')->orderBy('created_at', 'desc');
return $this->morphMany(Activity::class, 'entity')->orderBy('created_at', 'desc');
}
/**
@@ -51,15 +51,24 @@ abstract class Entity extends Ownable
*/
public function views()
{
return $this->morphMany('BookStack\View', 'viewable');
return $this->morphMany(View::class, 'viewable');
}
/**
* Get the Tag models that have been user assigned to this entity.
* @return \Illuminate\Database\Eloquent\Relations\MorphMany
*/
public function tags()
{
return $this->morphMany(Tag::class, 'entity')->orderBy('order', 'asc');
}
/**
* Get this entities restrictions.
*/
public function restrictions()
public function permissions()
{
return $this->morphMany('BookStack\Restriction', 'restrictable');
return $this->morphMany(EntityPermission::class, 'restrictable');
}
/**
@@ -70,7 +79,28 @@ abstract class Entity extends Ownable
*/
public function hasRestriction($role_id, $action)
{
return $this->restrictions->where('role_id', $role_id)->where('action', $action)->count() > 0;
return $this->permissions()->where('role_id', '=', $role_id)
->where('action', '=', $action)->count() > 0;
}
/**
* Check if this entity has live (active) restrictions in place.
* @param $role_id
* @param $action
* @return bool
*/
public function hasActiveRestriction($role_id, $action)
{
return $this->getRawAttribute('restricted') && $this->hasRestriction($role_id, $action);
}
/**
* Get the entity jointPermissions this is connected to.
* @return \Illuminate\Database\Eloquent\Relations\MorphMany
*/
public function jointPermissions()
{
return $this->morphMany(JointPermission::class, 'entity');
}
/**
@@ -81,7 +111,32 @@ abstract class Entity extends Ownable
*/
public static function isA($type)
{
return static::getClassName() === strtolower($type);
return static::getType() === strtolower($type);
}
/**
* Get entity type.
* @return mixed
*/
public static function getType()
{
return strtolower(static::getClassName());
}
/**
* Get an instance of an entity of the given type.
* @param $type
* @return Entity
*/
public static function getEntityInstance($type)
{
$types = ['Page', 'Book', 'Chapter'];
$className = str_replace([' ', '-', '_'], '', ucwords($type));
if (!in_array($className, $types)) {
return null;
}
return app('BookStack\\' . $className);
}
/**
@@ -102,54 +157,54 @@ abstract class Entity extends Ownable
* @param string[] array $wheres
* @return mixed
*/
public static function fullTextSearchQuery($fieldsToSearch, $terms, $wheres = [])
public function fullTextSearchQuery($fieldsToSearch, $terms, $wheres = [])
{
$exactTerms = [];
foreach ($terms as $key => $term) {
$term = htmlentities($term, ENT_QUOTES);
$term = preg_replace('/[+\-><\(\)~*\"@]+/', ' ', $term);
if (preg_match('/\s/', $term)) {
$exactTerms[] = '%' . $term . '%';
$term = '"' . $term . '"';
} else {
$term = '' . $term . '*';
}
if ($term !== '*') $terms[$key] = $term;
}
$termString = implode(' ', $terms);
$fields = implode(',', $fieldsToSearch);
$search = static::selectRaw('*, MATCH(name) AGAINST(? IN BOOLEAN MODE) AS title_relevance', [$termString]);
$search = $search->whereRaw('MATCH(' . $fields . ') AGAINST(? IN BOOLEAN MODE)', [$termString]);
// Ensure at least one exact term matches if in search
if (count($exactTerms) > 0) {
$search = $search->where(function ($query) use ($exactTerms, $fieldsToSearch) {
foreach ($exactTerms as $exactTerm) {
foreach ($fieldsToSearch as $field) {
$query->orWhere($field, 'like', $exactTerm);
}
if (count($terms) === 0) {
$search = $this;
$orderBy = 'updated_at';
} else {
foreach ($terms as $key => $term) {
$term = htmlentities($term, ENT_QUOTES);
$term = preg_replace('/[+\-><\(\)~*\"@]+/', ' ', $term);
if (preg_match('/\s/', $term)) {
$exactTerms[] = '%' . $term . '%';
$term = '"' . $term . '"';
} else {
$term = '' . $term . '*';
}
});
}
if ($term !== '*') $terms[$key] = $term;
}
$termString = implode(' ', $terms);
$fields = implode(',', $fieldsToSearch);
$search = static::selectRaw('*, MATCH(name) AGAINST(? IN BOOLEAN MODE) AS title_relevance', [$termString]);
$search = $search->whereRaw('MATCH(' . $fields . ') AGAINST(? IN BOOLEAN MODE)', [$termString]);
// Ensure at least one exact term matches if in search
if (count($exactTerms) > 0) {
$search = $search->where(function ($query) use ($exactTerms, $fieldsToSearch) {
foreach ($exactTerms as $exactTerm) {
foreach ($fieldsToSearch as $field) {
$query->orWhere($field, 'like', $exactTerm);
}
}
});
}
$orderBy = 'title_relevance';
};
// Add additional where terms
foreach ($wheres as $whereTerm) {
$search->where($whereTerm[0], $whereTerm[1], $whereTerm[2]);
}
// Load in relations
if (static::isA('page')) {
if ($this->isA('page')) {
$search = $search->with('book', 'chapter', 'createdBy', 'updatedBy');
} else if (static::isA('chapter')) {
} else if ($this->isA('chapter')) {
$search = $search->with('book');
}
return $search->orderBy('title_relevance', 'desc');
return $search->orderBy($orderBy, 'desc');
}
/**
* Get the url for this item.
* @return string
*/
abstract public function getUrl();
}

View File

@@ -1,10 +1,7 @@
<?php
<?php namespace BookStack;
namespace BookStack;
use Illuminate\Database\Eloquent\Model;
class Restriction extends Model
class EntityPermission extends Model
{
protected $fillable = ['role_id', 'action'];
@@ -16,6 +13,6 @@ class Restriction extends Model
*/
public function restrictable()
{
return $this->morphTo();
return $this->morphTo('restrictable');
}
}

View File

@@ -71,11 +71,7 @@ class BookController extends Controller
'name' => 'required|string|max:255',
'description' => 'string|max:1000'
]);
$book = $this->bookRepo->newFromInput($request->all());
$book->slug = $this->bookRepo->findSuitableSlug($book->name);
$book->created_by = Auth::user()->id;
$book->updated_by = Auth::user()->id;
$book->save();
$book = $this->bookRepo->createFromInput($request->all());
Activity::add($book, 'book_create', $book->id);
return redirect($book->getUrl());
}
@@ -88,6 +84,7 @@ class BookController extends Controller
public function show($slug)
{
$book = $this->bookRepo->getBySlug($slug);
$this->checkOwnablePermission('book-view', $book);
$bookChildren = $this->bookRepo->getChildren($book);
Views::add($book);
$this->setPageTitle($book->getShortName());
@@ -121,10 +118,7 @@ class BookController extends Controller
'name' => 'required|string|max:255',
'description' => 'string|max:1000'
]);
$book->fill($request->all());
$book->slug = $this->bookRepo->findSuitableSlug($book->name, $book->id);
$book->updated_by = Auth::user()->id;
$book->save();
$book = $this->bookRepo->updateFromInput($book, $request->all());
Activity::add($book, 'book_update', $book->id);
return redirect($book->getUrl());
}
@@ -209,6 +203,7 @@ class BookController extends Controller
// Add activity for books
foreach ($sortedBooks as $bookId) {
$updatedBook = $this->bookRepo->getById($bookId);
$this->bookRepo->updateBookPermissions($updatedBook);
Activity::add($updatedBook, 'book_sort', $updatedBook->id);
}
@@ -226,7 +221,7 @@ class BookController extends Controller
$this->checkOwnablePermission('book-delete', $book);
Activity::addMessage('book_delete', 0, $book->name);
Activity::removeEntity($book);
$this->bookRepo->destroyBySlug($bookSlug);
$this->bookRepo->destroy($book);
return redirect('/books');
}
@@ -257,7 +252,7 @@ class BookController extends Controller
{
$book = $this->bookRepo->getBySlug($bookSlug);
$this->checkOwnablePermission('restrictions-manage', $book);
$this->bookRepo->updateRestrictionsFromRequest($request, $book);
$this->bookRepo->updateEntityPermissionsFromRequest($request, $book);
session()->flash('success', 'Book Restrictions Updated');
return redirect($book->getUrl());
}

View File

@@ -57,12 +57,9 @@ class ChapterController extends Controller
$book = $this->bookRepo->getBySlug($bookSlug);
$this->checkOwnablePermission('chapter-create', $book);
$chapter = $this->chapterRepo->newFromInput($request->all());
$chapter->slug = $this->chapterRepo->findSuitableSlug($chapter->name, $book->id);
$chapter->priority = $this->bookRepo->getNewPriority($book);
$chapter->created_by = auth()->user()->id;
$chapter->updated_by = auth()->user()->id;
$book->chapters()->save($chapter);
$input = $request->all();
$input['priority'] = $this->bookRepo->getNewPriority($book);
$chapter = $this->chapterRepo->createFromInput($input, $book);
Activity::add($chapter, 'chapter_create', $book->id);
return redirect($chapter->getUrl());
}
@@ -77,6 +74,7 @@ class ChapterController extends Controller
{
$book = $this->bookRepo->getBySlug($bookSlug);
$chapter = $this->chapterRepo->getBySlug($chapterSlug, $book->id);
$this->checkOwnablePermission('chapter-view', $chapter);
$sidebarTree = $this->bookRepo->getChildren($book);
Views::add($chapter);
$this->setPageTitle($chapter->getShortName());
@@ -156,6 +154,63 @@ class ChapterController extends Controller
return redirect($book->getUrl());
}
/**
* Show the page for moving a chapter.
* @param $bookSlug
* @param $chapterSlug
* @return mixed
* @throws \BookStack\Exceptions\NotFoundException
*/
public function showMove($bookSlug, $chapterSlug) {
$book = $this->bookRepo->getBySlug($bookSlug);
$chapter = $this->chapterRepo->getBySlug($chapterSlug, $book->id);
$this->checkOwnablePermission('chapter-update', $chapter);
return view('chapters/move', [
'chapter' => $chapter,
'book' => $book
]);
}
/**
* Perform the move action for a chapter.
* @param $bookSlug
* @param $chapterSlug
* @param Request $request
* @return mixed
* @throws \BookStack\Exceptions\NotFoundException
*/
public function move($bookSlug, $chapterSlug, Request $request) {
$book = $this->bookRepo->getBySlug($bookSlug);
$chapter = $this->chapterRepo->getBySlug($chapterSlug, $book->id);
$this->checkOwnablePermission('chapter-update', $chapter);
$entitySelection = $request->get('entity_selection', null);
if ($entitySelection === null || $entitySelection === '') {
return redirect($chapter->getUrl());
}
$stringExploded = explode(':', $entitySelection);
$entityType = $stringExploded[0];
$entityId = intval($stringExploded[1]);
$parent = false;
if ($entityType == 'book') {
$parent = $this->bookRepo->getById($entityId);
}
if ($parent === false || $parent === null) {
session()->flash('The selected Book was not found');
return redirect()->back();
}
$this->chapterRepo->changeBook($parent->id, $chapter);
Activity::add($chapter, 'chapter_move', $chapter->book->id);
session()->flash('success', sprintf('Chapter moved to "%s"', $parent->name));
return redirect($chapter->getUrl());
}
/**
* Show the Restrictions view.
* @param $bookSlug
@@ -186,7 +241,7 @@ class ChapterController extends Controller
$book = $this->bookRepo->getBySlug($bookSlug);
$chapter = $this->chapterRepo->getBySlug($chapterSlug, $book->id);
$this->checkOwnablePermission('restrictions-manage', $chapter);
$this->chapterRepo->updateRestrictionsFromRequest($request, $chapter);
$this->chapterRepo->updateEntityPermissionsFromRequest($request, $chapter);
session()->flash('success', 'Chapter Restrictions Updated');
return redirect($chapter->getUrl());
}

View File

@@ -110,4 +110,15 @@ abstract class Controller extends BaseController
return true;
}
/**
* Send back a json error message.
* @param string $messageText
* @param int $statusCode
* @return mixed
*/
protected function jsonError($messageText = "", $statusCode = 500)
{
return response()->json(['message' => $messageText], $statusCode);
}
}

View File

@@ -51,9 +51,9 @@ class ImageController extends Controller
$this->validate($request, [
'term' => 'required|string'
]);
$searchTerm = $request->get('term');
$imgData = $this->imageRepo->searchPaginatedByType($type, $page,24, $searchTerm);
$imgData = $this->imageRepo->searchPaginatedByType($type, $page, 24, $searchTerm);
return response()->json($imgData);
}
@@ -99,7 +99,7 @@ class ImageController extends Controller
{
$this->checkPermission('image-create-all');
$this->validate($request, [
'file' => 'image|mimes:jpeg,gif,png'
'file' => 'is_image'
]);
$imageUpload = $request->file('file');

View File

@@ -69,10 +69,10 @@ class PageController extends Controller
{
$book = $this->bookRepo->getBySlug($bookSlug);
$draft = $this->pageRepo->getById($pageId, true);
$this->checkOwnablePermission('page-create', $draft);
$this->checkOwnablePermission('page-create', $book);
$this->setPageTitle('Edit Page Draft');
return view('pages/create', ['draft' => $draft, 'book' => $book]);
return view('pages/edit', ['page' => $draft, 'book' => $book, 'isDraft' => true]);
}
/**
@@ -92,7 +92,7 @@ class PageController extends Controller
$draftPage = $this->pageRepo->getById($pageId, true);
$chapterId = $draftPage->chapter_id;
$chapterId = intval($draftPage->chapter_id);
$parent = $chapterId !== 0 ? $this->chapterRepo->getById($chapterId) : $book;
$this->checkOwnablePermission('page-create', $parent);
@@ -128,6 +128,8 @@ class PageController extends Controller
return redirect($page->getUrl());
}
$this->checkOwnablePermission('page-view', $page);
$sidebarTree = $this->bookRepo->getChildren($book);
Views::add($page);
$this->setPageTitle($page->getShortName());
@@ -219,8 +221,8 @@ class PageController extends Controller
$updateTime = $draft->updated_at->timestamp;
$utcUpdateTimestamp = $updateTime + Carbon::createFromTimestamp(0)->offset;
return response()->json([
'status' => 'success',
'message' => 'Draft saved at ',
'status' => 'success',
'message' => 'Draft saved at ',
'timestamp' => $utcUpdateTimestamp
]);
}
@@ -449,7 +451,68 @@ class PageController extends Controller
}
/**
* Set the restrictions for this page.
* Show the view to choose a new parent to move a page into.
* @param $bookSlug
* @param $pageSlug
* @return mixed
* @throws NotFoundException
*/
public function showMove($bookSlug, $pageSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$page = $this->pageRepo->getBySlug($pageSlug, $book->id);
$this->checkOwnablePermission('page-update', $page);
return view('pages/move', [
'book' => $book,
'page' => $page
]);
}
/**
* Does the action of moving the location of a page
* @param $bookSlug
* @param $pageSlug
* @param Request $request
* @return mixed
* @throws NotFoundException
*/
public function move($bookSlug, $pageSlug, Request $request)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$page = $this->pageRepo->getBySlug($pageSlug, $book->id);
$this->checkOwnablePermission('page-update', $page);
$entitySelection = $request->get('entity_selection', null);
if ($entitySelection === null || $entitySelection === '') {
return redirect($page->getUrl());
}
$stringExploded = explode(':', $entitySelection);
$entityType = $stringExploded[0];
$entityId = intval($stringExploded[1]);
$parent = false;
if ($entityType == 'chapter') {
$parent = $this->chapterRepo->getById($entityId);
} else if ($entityType == 'book') {
$parent = $this->bookRepo->getById($entityId);
}
if ($parent === false || $parent === null) {
session()->flash('The selected Book or Chapter was not found');
return redirect()->back();
}
$this->pageRepo->changePageParent($page, $parent);
Activity::add($page, 'page_move', $page->book->id);
session()->flash('success', sprintf('Page moved to "%s"', $parent->name));
return redirect($page->getUrl());
}
/**
* Set the permissions for this page.
* @param $bookSlug
* @param $pageSlug
* @param Request $request
@@ -460,8 +523,8 @@ class PageController extends Controller
$book = $this->bookRepo->getBySlug($bookSlug);
$page = $this->pageRepo->getBySlug($pageSlug, $book->id);
$this->checkOwnablePermission('restrictions-manage', $page);
$this->pageRepo->updateRestrictionsFromRequest($request, $page);
session()->flash('success', 'Page Restrictions Updated');
$this->pageRepo->updateEntityPermissionsFromRequest($request, $page);
session()->flash('success', 'Page Permissions Updated');
return redirect($page->getUrl());
}

View File

@@ -2,6 +2,7 @@
use BookStack\Exceptions\PermissionsException;
use BookStack\Repos\PermissionsRepo;
use BookStack\Services\PermissionService;
use Illuminate\Http\Request;
use BookStack\Http\Requests;
@@ -62,11 +63,13 @@ 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)
{
$this->checkPermission('user-roles-manage');
$role = $this->permissionsRepo->getRoleById($id);
if ($role->hidden) throw new PermissionsException('This role cannot be edited');
return view('settings/roles/edit', ['role' => $role]);
}

View File

@@ -2,10 +2,10 @@
namespace BookStack\Http\Controllers;
use BookStack\Services\ViewService;
use Illuminate\Http\Request;
use BookStack\Http\Requests;
use BookStack\Http\Controllers\Controller;
use BookStack\Repos\BookRepo;
use BookStack\Repos\ChapterRepo;
use BookStack\Repos\PageRepo;
@@ -15,18 +15,21 @@ class SearchController extends Controller
protected $pageRepo;
protected $bookRepo;
protected $chapterRepo;
protected $viewService;
/**
* SearchController constructor.
* @param $pageRepo
* @param $bookRepo
* @param $chapterRepo
* @param PageRepo $pageRepo
* @param BookRepo $bookRepo
* @param ChapterRepo $chapterRepo
* @param ViewService $viewService
*/
public function __construct(PageRepo $pageRepo, BookRepo $bookRepo, ChapterRepo $chapterRepo)
public function __construct(PageRepo $pageRepo, BookRepo $bookRepo, ChapterRepo $chapterRepo, ViewService $viewService)
{
$this->pageRepo = $pageRepo;
$this->bookRepo = $bookRepo;
$this->chapterRepo = $chapterRepo;
$this->viewService = $viewService;
parent::__construct();
}
@@ -48,9 +51,9 @@ class SearchController extends Controller
$chapters = $this->chapterRepo->getBySearch($searchTerm, [], 10, $paginationAppends);
$this->setPageTitle('Search For ' . $searchTerm);
return view('search/all', [
'pages' => $pages,
'books' => $books,
'chapters' => $chapters,
'pages' => $pages,
'books' => $books,
'chapters' => $chapters,
'searchTerm' => $searchTerm
]);
}
@@ -69,8 +72,8 @@ class SearchController extends Controller
$pages = $this->pageRepo->getBySearch($searchTerm, [], 20, $paginationAppends);
$this->setPageTitle('Page Search For ' . $searchTerm);
return view('search/entity-search-list', [
'entities' => $pages,
'title' => 'Page Search Results',
'entities' => $pages,
'title' => 'Page Search Results',
'searchTerm' => $searchTerm
]);
}
@@ -89,8 +92,8 @@ class SearchController extends Controller
$chapters = $this->chapterRepo->getBySearch($searchTerm, [], 20, $paginationAppends);
$this->setPageTitle('Chapter Search For ' . $searchTerm);
return view('search/entity-search-list', [
'entities' => $chapters,
'title' => 'Chapter Search Results',
'entities' => $chapters,
'title' => 'Chapter Search Results',
'searchTerm' => $searchTerm
]);
}
@@ -109,8 +112,8 @@ class SearchController extends Controller
$books = $this->bookRepo->getBySearch($searchTerm, 20, $paginationAppends);
$this->setPageTitle('Book Search For ' . $searchTerm);
return view('search/entity-search-list', [
'entities' => $books,
'title' => 'Book Search Results',
'entities' => $books,
'title' => 'Book Search Results',
'searchTerm' => $searchTerm
]);
}
@@ -134,4 +137,35 @@ class SearchController extends Controller
return view('search/book', ['pages' => $pages, 'chapters' => $chapters, 'searchTerm' => $searchTerm]);
}
/**
* Search for a list of entities and return a partial HTML response of matching entities.
* Returns the most popular entities if no search is provided.
* @param Request $request
* @return mixed
*/
public function searchEntitiesAjax(Request $request)
{
$entities = collect();
$entityTypes = $request->has('types') ? collect(explode(',', $request->get('types'))) : collect(['page', 'chapter', 'book']);
$searchTerm = ($request->has('term') && trim($request->get('term')) !== '') ? $request->get('term') : false;
// Search for entities otherwise show most popular
if ($searchTerm !== false) {
if ($entityTypes->contains('page')) $entities = $entities->merge($this->pageRepo->getBySearch($searchTerm)->items());
if ($entityTypes->contains('chapter')) $entities = $entities->merge($this->chapterRepo->getBySearch($searchTerm)->items());
if ($entityTypes->contains('book')) $entities = $entities->merge($this->bookRepo->getBySearch($searchTerm)->items());
$entities = $entities->sortByDesc('title_relevance');
} else {
$entityNames = $entityTypes->map(function ($type) {
return 'BookStack\\' . ucfirst($type);
})->toArray();
$entities = $this->viewService->getPopular(20, 0, $entityNames);
}
return view('search/entity-ajax-list', ['entities' => $entities]);
}
}

View File

@@ -0,0 +1,75 @@
<?php namespace BookStack\Http\Controllers;
use BookStack\Repos\TagRepo;
use Illuminate\Http\Request;
use BookStack\Http\Requests;
class TagController extends Controller
{
protected $tagRepo;
/**
* TagController constructor.
* @param $tagRepo
*/
public function __construct(TagRepo $tagRepo)
{
$this->tagRepo = $tagRepo;
}
/**
* Get all the Tags for a particular entity
* @param $entityType
* @param $entityId
*/
public function getForEntity($entityType, $entityId)
{
$tags = $this->tagRepo->getForEntity($entityType, $entityId);
return response()->json($tags);
}
/**
* Update the tags for a particular entity.
* @param $entityType
* @param $entityId
* @param Request $request
* @return mixed
*/
public function updateForEntity($entityType, $entityId, Request $request)
{
$entity = $this->tagRepo->getEntity($entityType, $entityId, 'update');
if ($entity === null) return $this->jsonError("Entity not found", 404);
$inputTags = $request->input('tags');
$tags = $this->tagRepo->saveTagsToEntity($entity, $inputTags);
return response()->json([
'tags' => $tags,
'message' => 'Tags successfully updated'
]);
}
/**
* Get tag name suggestions from a given search term.
* @param Request $request
*/
public function getNameSuggestions(Request $request)
{
$searchTerm = $request->has('search') ? $request->get('search') : false;
$suggestions = $this->tagRepo->getNameSuggestions($searchTerm);
return response()->json($suggestions);
}
/**
* Get tag value suggestions from a given search term.
* @param Request $request
*/
public function getValueSuggestions(Request $request)
{
$searchTerm = $request->has('search') ? $request->get('search') : false;
$tagName = $request->has('name') ? $request->get('name') : false;
$suggestions = $this->tagRepo->getValueSuggestions($searchTerm, $tagName);
return response()->json($suggestions);
}
}

View File

@@ -31,14 +31,21 @@ class UserController extends Controller
/**
* Display a listing of the users.
* @param Request $request
* @return Response
*/
public function index()
public function index(Request $request)
{
$this->checkPermission('users-manage');
$users = $this->userRepo->getAllUsers();
$listDetails = [
'order' => $request->has('order') ? $request->get('order') : 'asc',
'search' => $request->has('search') ? $request->get('search') : '',
'sort' => $request->has('sort') ? $request->get('sort') : 'name',
];
$users = $this->userRepo->getAllUsersPaginatedAndSorted(20, $listDetails);
$this->setPageTitle('Users');
return view('users/index', ['users' => $users]);
$users->appends($listDetails);
return view('users/index', ['users' => $users, 'listDetails' => $listDetails]);
}
/**
@@ -49,7 +56,8 @@ class UserController extends Controller
{
$this->checkPermission('users-manage');
$authMethod = config('auth.method');
return view('users/create', ['authMethod' => $authMethod]);
$roles = $this->userRepo->getAssignableRoles();
return view('users/create', ['authMethod' => $authMethod, 'roles' => $roles]);
}
/**
@@ -117,7 +125,8 @@ class UserController extends Controller
$user = $this->user->findOrFail($id);
$activeSocialDrivers = $socialAuthService->getActiveDrivers();
$this->setPageTitle('User Profile');
return view('users/edit', ['user' => $user, 'activeSocialDrivers' => $activeSocialDrivers, 'authMethod' => $authMethod]);
$roles = $this->userRepo->getAssignableRoles();
return view('users/edit', ['user' => $user, 'activeSocialDrivers' => $activeSocialDrivers, 'authMethod' => $authMethod, 'roles' => $roles]);
}
/**
@@ -198,11 +207,14 @@ class UserController extends Controller
});
$user = $this->userRepo->getById($id);
if ($this->userRepo->isOnlyAdmin($user)) {
session()->flash('error', 'You cannot delete the only admin');
return redirect($user->getEditUrl());
}
$this->userRepo->destroy($user);
session()->flash('success', 'User successfully removed');
return redirect('/settings/users');
}

View File

@@ -28,12 +28,14 @@ Route::group(['middleware' => 'auth'], function () {
// Pages
Route::get('/{bookSlug}/page/create', 'PageController@create');
Route::get('/{bookSlug}/draft/{pageId}', 'PageController@editDraft');
Route::post('/{bookSlug}/page/{pageId}', 'PageController@store');
Route::post('/{bookSlug}/draft/{pageId}', 'PageController@store');
Route::get('/{bookSlug}/page/{pageSlug}', 'PageController@show');
Route::get('/{bookSlug}/page/{pageSlug}/export/pdf', 'PageController@exportPdf');
Route::get('/{bookSlug}/page/{pageSlug}/export/html', 'PageController@exportHtml');
Route::get('/{bookSlug}/page/{pageSlug}/export/plaintext', 'PageController@exportPlainText');
Route::get('/{bookSlug}/page/{pageSlug}/edit', 'PageController@edit');
Route::get('/{bookSlug}/page/{pageSlug}/move', 'PageController@showMove');
Route::put('/{bookSlug}/page/{pageSlug}/move', 'PageController@move');
Route::get('/{bookSlug}/page/{pageSlug}/delete', 'PageController@showDelete');
Route::get('/{bookSlug}/draft/{pageId}/delete', 'PageController@showDeleteDraft');
Route::get('/{bookSlug}/page/{pageSlug}/permissions', 'PageController@showRestrict');
@@ -53,6 +55,8 @@ Route::group(['middleware' => 'auth'], function () {
Route::post('/{bookSlug}/chapter/create', 'ChapterController@store');
Route::get('/{bookSlug}/chapter/{chapterSlug}', 'ChapterController@show');
Route::put('/{bookSlug}/chapter/{chapterSlug}', 'ChapterController@update');
Route::get('/{bookSlug}/chapter/{chapterSlug}/move', 'ChapterController@showMove');
Route::put('/{bookSlug}/chapter/{chapterSlug}/move', 'ChapterController@move');
Route::get('/{bookSlug}/chapter/{chapterSlug}/edit', 'ChapterController@edit');
Route::get('/{bookSlug}/chapter/{chapterSlug}/permissions', 'ChapterController@showRestrict');
Route::put('/{bookSlug}/chapter/{chapterSlug}/permissions', 'ChapterController@restrict');
@@ -80,11 +84,21 @@ Route::group(['middleware' => 'auth'], function () {
Route::delete('/{imageId}', 'ImageController@destroy');
});
// Ajax routes
// AJAX routes
Route::put('/ajax/page/{id}/save-draft', 'PageController@saveDraft');
Route::get('/ajax/page/{id}', 'PageController@getPageAjax');
Route::delete('/ajax/page/{id}', 'PageController@ajaxDestroy');
// Tag routes (AJAX)
Route::group(['prefix' => 'ajax/tags'], function() {
Route::get('/get/{entityType}/{entityId}', 'TagController@getForEntity');
Route::get('/suggest/names', 'TagController@getNameSuggestions');
Route::get('/suggest/values', 'TagController@getValueSuggestions');
Route::post('/update/{entityType}/{entityId}', 'TagController@updateForEntity');
});
Route::get('/ajax/search/entities', 'SearchController@searchEntitiesAjax');
// Links
Route::get('/link/{id}', 'PageController@redirectFromLink');

24
app/JointPermission.php Normal file
View File

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

19
app/Model.php Normal file
View File

@@ -0,0 +1,19 @@
<?php namespace BookStack;
use Illuminate\Database\Eloquent\Model as EloquentModel;
class Model extends EloquentModel
{
/**
* Provides public access to get the raw attribute value from the model.
* Used in areas where no mutations are required but performance is critical.
* @param $key
* @return mixed
*/
public function getRawAttribute($key)
{
return parent::getAttributeFromArray($key);
}
}

View File

@@ -1,6 +1,5 @@
<?php namespace BookStack;
use Illuminate\Database\Eloquent\Model;
abstract class Ownable extends Model
{
@@ -10,7 +9,7 @@ abstract class Ownable extends Model
*/
public function createdBy()
{
return $this->belongsTo('BookStack\User', 'created_by');
return $this->belongsTo(User::class, 'created_by');
}
/**
@@ -19,7 +18,7 @@ abstract class Ownable extends Model
*/
public function updatedBy()
{
return $this->belongsTo('BookStack\User', 'updated_by');
return $this->belongsTo(User::class, 'updated_by');
}
/**

View File

@@ -1,8 +1,5 @@
<?php
<?php namespace BookStack;
namespace BookStack;
use Illuminate\Database\Eloquent\Model;
class Page extends Entity
{
@@ -10,6 +7,10 @@ class Page extends Entity
protected $simpleAttributes = ['name', 'id', 'slug'];
/**
* Converts this page into a simplified array.
* @return mixed
*/
public function toSimpleArray()
{
$array = array_intersect_key($this->toArray(), array_flip($this->simpleAttributes));
@@ -17,26 +18,46 @@ class Page extends Entity
return $array;
}
/**
* Get the book this page sits in.
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function book()
{
return $this->belongsTo('BookStack\Book');
return $this->belongsTo(Book::class);
}
/**
* Get the chapter that this page is in, If applicable.
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function chapter()
{
return $this->belongsTo('BookStack\Chapter');
return $this->belongsTo(Chapter::class);
}
/**
* Check if this page has a chapter.
* @return bool
*/
public function hasChapter()
{
return $this->chapter()->count() > 0;
}
/**
* Get the associated page revisions, ordered by created date.
* @return mixed
*/
public function revisions()
{
return $this->hasMany('BookStack\PageRevision')->where('type', '=', 'version')->orderBy('created_at', 'desc');
return $this->hasMany(PageRevision::class)->where('type', '=', 'version')->orderBy('created_at', 'desc');
}
/**
* Get the url for this page.
* @return string
*/
public function getUrl()
{
$bookSlug = $this->getAttribute('bookSlug') ? $this->getAttribute('bookSlug') : $this->book->slug;
@@ -45,6 +66,11 @@ class Page extends Entity
return '/books/' . $bookSlug . $midText . $idComponent;
}
/**
* Get an excerpt of this page's content to the specified length.
* @param int $length
* @return mixed
*/
public function getExcerpt($length = 100)
{
$text = strlen($this->text) > $length ? substr($this->text, 0, $length-3) . '...' : $this->text;

View File

@@ -1,6 +1,5 @@
<?php namespace BookStack;
use Illuminate\Database\Eloquent\Model;
class PageRevision extends Model
{
@@ -12,7 +11,7 @@ class PageRevision extends Model
*/
public function createdBy()
{
return $this->belongsTo('BookStack\User', 'created_by');
return $this->belongsTo(User::class, 'created_by');
}
/**
@@ -21,7 +20,7 @@ class PageRevision extends Model
*/
public function page()
{
return $this->belongsTo('BookStack\Page');
return $this->belongsTo(Page::class);
}
/**

View File

@@ -15,7 +15,12 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot()
{
//
// Custom validation methods
\Validator::extend('is_image', function($attribute, $value, $parameters, $validator) {
$imageMimes = ['image/png', 'image/bmp', 'image/gif', 'image/jpeg', 'image/jpg', 'image/tiff', 'image/webp'];
return in_array($value->getMimeType(), $imageMimes);
});
}
/**

View File

@@ -3,6 +3,7 @@
namespace BookStack\Providers;
use Auth;
use BookStack\Services\LdapService;
use Illuminate\Support\ServiceProvider;
class AuthServiceProvider extends ServiceProvider
@@ -25,7 +26,7 @@ class AuthServiceProvider extends ServiceProvider
public function register()
{
Auth::provider('ldap', function($app, array $config) {
return new LdapUserProvider($config['model'], $app['BookStack\Services\LdapService']);
return new LdapUserProvider($config['model'], $app[LdapService::class]);
});
}
}

View File

@@ -2,11 +2,18 @@
namespace BookStack\Providers;
use BookStack\Activity;
use BookStack\Services\ImageService;
use BookStack\Services\PermissionService;
use BookStack\Services\ViewService;
use BookStack\Setting;
use BookStack\View;
use Illuminate\Contracts\Cache\Repository;
use Illuminate\Contracts\Filesystem\Factory;
use Illuminate\Support\ServiceProvider;
use BookStack\Services\ActivityService;
use BookStack\Services\SettingService;
use Intervention\Image\ImageManager;
class CustomFacadeProvider extends ServiceProvider
{
@@ -29,30 +36,30 @@ class CustomFacadeProvider extends ServiceProvider
{
$this->app->bind('activity', function() {
return new ActivityService(
$this->app->make('BookStack\Activity'),
$this->app->make('BookStack\Services\RestrictionService')
$this->app->make(Activity::class),
$this->app->make(PermissionService::class)
);
});
$this->app->bind('views', function() {
return new ViewService(
$this->app->make('BookStack\View'),
$this->app->make('BookStack\Services\RestrictionService')
$this->app->make(View::class),
$this->app->make(PermissionService::class)
);
});
$this->app->bind('setting', function() {
return new SettingService(
$this->app->make('BookStack\Setting'),
$this->app->make('Illuminate\Contracts\Cache\Repository')
$this->app->make(Setting::class),
$this->app->make(Repository::class)
);
});
$this->app->bind('images', function() {
return new ImageService(
$this->app->make('Intervention\Image\ImageManager'),
$this->app->make('Illuminate\Contracts\Filesystem\Factory'),
$this->app->make('Illuminate\Contracts\Cache\Repository')
$this->app->make(ImageManager::class),
$this->app->make(Factory::class),
$this->app->make(Repository::class)
);
});
}

View File

@@ -1,5 +1,6 @@
<?php namespace BookStack\Repos;
use Alpha\B;
use BookStack\Exceptions\NotFoundException;
use Illuminate\Support\Str;
use BookStack\Book;
@@ -29,7 +30,7 @@ class BookRepo extends EntityRepo
*/
private function bookQuery()
{
return $this->restrictionService->enforceBookRestrictions($this->book, 'view');
return $this->permissionService->enforceBookRestrictions($this->book, 'view');
}
/**
@@ -123,21 +124,43 @@ class BookRepo extends EntityRepo
/**
* Get a new book instance from request input.
* @param $input
* @param array $input
* @return Book
*/
public function newFromInput($input)
public function createFromInput($input)
{
return $this->book->newInstance($input);
$book = $this->book->newInstance($input);
$book->slug = $this->findSuitableSlug($book->name);
$book->created_by = auth()->user()->id;
$book->updated_by = auth()->user()->id;
$book->save();
$this->permissionService->buildJointPermissionsForEntity($book);
return $book;
}
/**
* Destroy a book identified by the given slug.
* @param $bookSlug
* Update the given book from user input.
* @param Book $book
* @param $input
* @return Book
*/
public function destroyBySlug($bookSlug)
public function updateFromInput(Book $book, $input)
{
$book->fill($input);
$book->slug = $this->findSuitableSlug($book->name, $book->id);
$book->updated_by = auth()->user()->id;
$book->save();
$this->permissionService->buildJointPermissionsForEntity($book);
return $book;
}
/**
* Destroy the given book.
* @param Book $book
* @throws \Exception
*/
public function destroy(Book $book)
{
$book = $this->getBySlug($bookSlug);
foreach ($book->pages as $page) {
$this->pageRepo->destroy($page);
}
@@ -145,10 +168,20 @@ class BookRepo extends EntityRepo
$this->chapterRepo->destroy($chapter);
}
$book->views()->delete();
$book->restrictions()->delete();
$book->permissions()->delete();
$this->permissionService->deleteJointPermissionsForEntity($book);
$book->delete();
}
/**
* Alias method to update the book jointPermissions in the PermissionService.
* @param Book $book
*/
public function updateBookPermissions(Book $book)
{
$this->permissionService->buildJointPermissionsForEntity($book);
}
/**
* Get the next child element priority.
* @param Book $book
@@ -204,7 +237,7 @@ class BookRepo extends EntityRepo
public function getChildren(Book $book, $filterDrafts = false)
{
$pageQuery = $book->pages()->where('chapter_id', '=', 0);
$pageQuery = $this->restrictionService->enforcePageRestrictions($pageQuery, 'view');
$pageQuery = $this->permissionService->enforcePageRestrictions($pageQuery, 'view');
if ($filterDrafts) {
$pageQuery = $pageQuery->where('draft', '=', false);
@@ -213,12 +246,15 @@ class BookRepo extends EntityRepo
$pages = $pageQuery->get();
$chapterQuery = $book->chapters()->with(['pages' => function($query) use ($filterDrafts) {
$this->restrictionService->enforcePageRestrictions($query, 'view');
$this->permissionService->enforcePageRestrictions($query, 'view');
if ($filterDrafts) $query->where('draft', '=', false);
}]);
$chapterQuery = $this->restrictionService->enforceChapterRestrictions($chapterQuery, 'view');
$chapterQuery = $this->permissionService->enforceChapterRestrictions($chapterQuery, 'view');
$chapters = $chapterQuery->get();
$children = $pages->merge($chapters);
$children = $pages->values();
foreach ($chapters as $chapter) {
$children->push($chapter);
}
$bookSlug = $book->slug;
$children->each(function ($child) use ($bookSlug) {
@@ -253,8 +289,9 @@ class BookRepo extends EntityRepo
public function getBySearch($term, $count = 20, $paginationAppends = [])
{
$terms = $this->prepareSearchTerms($term);
$books = $this->restrictionService->enforceBookRestrictions($this->book->fullTextSearchQuery(['name', 'description'], $terms))
->paginate($count)->appends($paginationAppends);
$bookQuery = $this->permissionService->enforceBookRestrictions($this->book->fullTextSearchQuery(['name', 'description'], $terms));
$bookQuery = $this->addAdvancedSearchQueries($bookQuery, $term);
$books = $bookQuery->paginate($count)->appends($paginationAppends);
$words = join('|', explode(' ', preg_quote(trim($term), '/')));
foreach ($books as $book) {
//highlight

View File

@@ -2,19 +2,32 @@
use Activity;
use BookStack\Book;
use BookStack\Exceptions\NotFoundException;
use Illuminate\Support\Str;
use BookStack\Chapter;
class ChapterRepo extends EntityRepo
{
protected $pageRepo;
/**
* Base query for getting chapters, Takes restrictions into account.
* ChapterRepo constructor.
* @param $pageRepo
*/
public function __construct(PageRepo $pageRepo)
{
$this->pageRepo = $pageRepo;
parent::__construct();
}
/**
* Base query for getting chapters, Takes permissions into account.
* @return mixed
*/
private function chapterQuery()
{
return $this->restrictionService->enforceChapterRestrictions($this->chapter, 'view');
return $this->permissionService->enforceChapterRestrictions($this->chapter, 'view');
}
/**
@@ -66,7 +79,7 @@ class ChapterRepo extends EntityRepo
*/
public function getChildren(Chapter $chapter)
{
$pages = $this->restrictionService->enforcePageRestrictions($chapter->pages())->get();
$pages = $this->permissionService->enforcePageRestrictions($chapter->pages())->get();
// Sort items with drafts first then by priority.
return $pages->sortBy(function($child, $key) {
$score = $child->priority;
@@ -78,11 +91,18 @@ class ChapterRepo extends EntityRepo
/**
* Create a new chapter from request input.
* @param $input
* @return $this
* @param Book $book
* @return Chapter
*/
public function newFromInput($input)
public function createFromInput($input, Book $book)
{
return $this->chapter->fill($input);
$chapter = $this->chapter->newInstance($input);
$chapter->slug = $this->findSuitableSlug($chapter->name, $book->id);
$chapter->created_by = auth()->user()->id;
$chapter->updated_by = auth()->user()->id;
$chapter = $book->chapters()->save($chapter);
$this->permissionService->buildJointPermissionsForEntity($chapter);
return $chapter;
}
/**
@@ -99,7 +119,8 @@ class ChapterRepo extends EntityRepo
}
Activity::removeEntity($chapter);
$chapter->views()->delete();
$chapter->restrictions()->delete();
$chapter->permissions()->delete();
$this->permissionService->deleteJointPermissionsForEntity($chapter);
$chapter->delete();
}
@@ -159,8 +180,9 @@ class ChapterRepo extends EntityRepo
public function getBySearch($term, $whereTerms = [], $count = 20, $paginationAppends = [])
{
$terms = $this->prepareSearchTerms($term);
$chapters = $this->restrictionService->enforceChapterRestrictions($this->chapter->fullTextSearchQuery(['name', 'description'], $terms, $whereTerms))
->paginate($count)->appends($paginationAppends);
$chapterQuery = $this->permissionService->enforceChapterRestrictions($this->chapter->fullTextSearchQuery(['name', 'description'], $terms, $whereTerms));
$chapterQuery = $this->addAdvancedSearchQueries($chapterQuery, $term);
$chapters = $chapterQuery->paginate($count)->appends($paginationAppends);
$words = join('|', explode(' ', preg_quote(trim($term), '/')));
foreach ($chapters as $chapter) {
//highlight
@@ -179,12 +201,21 @@ class ChapterRepo extends EntityRepo
public function changeBook($bookId, Chapter $chapter)
{
$chapter->book_id = $bookId;
// Update related activity
foreach ($chapter->activity as $activity) {
$activity->book_id = $bookId;
$activity->save();
}
$chapter->slug = $this->findSuitableSlug($chapter->name, $bookId, $chapter->id);
$chapter->save();
// Update all child pages
foreach ($chapter->pages as $page) {
$this->pageRepo->changeBook($bookId, $page);
}
// Update permissions
$chapter->load('book');
$this->permissionService->buildJointPermissionsForEntity($chapter->book);
return $chapter;
}

View File

@@ -4,8 +4,9 @@ use BookStack\Book;
use BookStack\Chapter;
use BookStack\Entity;
use BookStack\Page;
use BookStack\Services\RestrictionService;
use BookStack\Services\PermissionService;
use BookStack\User;
use Illuminate\Support\Facades\Log;
class EntityRepo
{
@@ -26,9 +27,15 @@ class EntityRepo
public $page;
/**
* @var RestrictionService
* @var PermissionService
*/
protected $restrictionService;
protected $permissionService;
/**
* Acceptable operators to be used in a query
* @var array
*/
protected $queryOperators = ['<=', '>=', '=', '<', '>', 'like', '!='];
/**
* EntityService constructor.
@@ -38,7 +45,7 @@ class EntityRepo
$this->book = app(Book::class);
$this->chapter = app(Chapter::class);
$this->page = app(Page::class);
$this->restrictionService = app(RestrictionService::class);
$this->permissionService = app(PermissionService::class);
}
/**
@@ -50,7 +57,7 @@ class EntityRepo
*/
public function getRecentlyCreatedBooks($count = 20, $page = 0, $additionalQuery = false)
{
$query = $this->restrictionService->enforceBookRestrictions($this->book)
$query = $this->permissionService->enforceBookRestrictions($this->book)
->orderBy('created_at', 'desc');
if ($additionalQuery !== false && is_callable($additionalQuery)) {
$additionalQuery($query);
@@ -66,7 +73,7 @@ class EntityRepo
*/
public function getRecentlyUpdatedBooks($count = 20, $page = 0)
{
return $this->restrictionService->enforceBookRestrictions($this->book)
return $this->permissionService->enforceBookRestrictions($this->book)
->orderBy('updated_at', 'desc')->skip($page * $count)->take($count)->get();
}
@@ -79,7 +86,7 @@ class EntityRepo
*/
public function getRecentlyCreatedPages($count = 20, $page = 0, $additionalQuery = false)
{
$query = $this->restrictionService->enforcePageRestrictions($this->page)
$query = $this->permissionService->enforcePageRestrictions($this->page)
->orderBy('created_at', 'desc')->where('draft', '=', false);
if ($additionalQuery !== false && is_callable($additionalQuery)) {
$additionalQuery($query);
@@ -96,7 +103,7 @@ class EntityRepo
*/
public function getRecentlyCreatedChapters($count = 20, $page = 0, $additionalQuery = false)
{
$query = $this->restrictionService->enforceChapterRestrictions($this->chapter)
$query = $this->permissionService->enforceChapterRestrictions($this->chapter)
->orderBy('created_at', 'desc');
if ($additionalQuery !== false && is_callable($additionalQuery)) {
$additionalQuery($query);
@@ -112,7 +119,7 @@ class EntityRepo
*/
public function getRecentlyUpdatedPages($count = 20, $page = 0)
{
return $this->restrictionService->enforcePageRestrictions($this->page)
return $this->permissionService->enforcePageRestrictions($this->page)
->where('draft', '=', false)
->orderBy('updated_at', 'desc')->with('book')->skip($page * $count)->take($count)->get();
}
@@ -136,14 +143,14 @@ class EntityRepo
* @param $request
* @param Entity $entity
*/
public function updateRestrictionsFromRequest($request, Entity $entity)
public function updateEntityPermissionsFromRequest($request, Entity $entity)
{
$entity->restricted = $request->has('restricted') && $request->get('restricted') === 'true';
$entity->restrictions()->delete();
$entity->permissions()->delete();
if ($request->has('restrictions')) {
foreach ($request->get('restrictions') as $roleId => $restrictions) {
foreach ($restrictions as $action => $value) {
$entity->restrictions()->create([
$entity->permissions()->create([
'role_id' => $roleId,
'action' => strtolower($action)
]);
@@ -151,6 +158,7 @@ class EntityRepo
}
}
$entity->save();
$this->permissionService->buildJointPermissionsForEntity($entity);
}
/**
@@ -162,6 +170,7 @@ class EntityRepo
*/
protected function prepareSearchTerms($termString)
{
$termString = $this->cleanSearchTermString($termString);
preg_match_all('/"(.*?)"/', $termString, $matches);
if (count($matches[1]) > 0) {
$terms = $matches[1];
@@ -173,5 +182,93 @@ class EntityRepo
return $terms;
}
/**
* Removes any special search notation that should not
* be used in a full-text search.
* @param $termString
* @return mixed
*/
protected function cleanSearchTermString($termString)
{
// Strip tag searches
$termString = preg_replace('/\[.*?\]/', '', $termString);
// Reduced multiple spacing into single spacing
$termString = preg_replace("/\s{2,}/", " ", $termString);
return $termString;
}
/**
* Get the available query operators as a regex escaped list.
* @return mixed
*/
protected function getRegexEscapedOperators()
{
$escapedOperators = [];
foreach ($this->queryOperators as $operator) {
$escapedOperators[] = preg_quote($operator);
}
return join('|', $escapedOperators);
}
/**
* Parses advanced search notations and adds them to the db query.
* @param $query
* @param $termString
* @return mixed
*/
protected function addAdvancedSearchQueries($query, $termString)
{
$escapedOperators = $this->getRegexEscapedOperators();
// Look for tag searches
preg_match_all("/\[(.*?)((${escapedOperators})(.*?))?\]/", $termString, $tags);
if (count($tags[0]) > 0) {
$this->applyTagSearches($query, $tags);
}
return $query;
}
/**
* Apply extracted tag search terms onto a entity query.
* @param $query
* @param $tags
* @return mixed
*/
protected function applyTagSearches($query, $tags) {
$query->where(function($query) use ($tags) {
foreach ($tags[1] as $index => $tagName) {
$query->whereHas('tags', function($query) use ($tags, $index, $tagName) {
$tagOperator = $tags[3][$index];
$tagValue = $tags[4][$index];
if (!empty($tagOperator) && !empty($tagValue) && in_array($tagOperator, $this->queryOperators)) {
if (is_numeric($tagValue) && $tagOperator !== 'like') {
// We have to do a raw sql query for this since otherwise PDO will quote the value and MySQL will
// search the value as a string which prevents being able to do number-based operations
// on the tag values. We ensure it has a numeric value and then cast it just to be sure.
$tagValue = (float) trim($query->getConnection()->getPdo()->quote($tagValue), "'");
$query->where('name', '=', $tagName)->whereRaw("value ${tagOperator} ${tagValue}");
} else {
$query->where('name', '=', $tagName)->where('value', $tagOperator, $tagValue);
}
} else {
$query->where('name', '=', $tagName);
}
});
}
});
return $query;
}
}
}

View File

@@ -4,7 +4,7 @@
use BookStack\Image;
use BookStack\Page;
use BookStack\Services\ImageService;
use BookStack\Services\RestrictionService;
use BookStack\Services\PermissionService;
use Setting;
use Symfony\Component\HttpFoundation\File\UploadedFile;
@@ -20,14 +20,14 @@ class ImageRepo
* ImageRepo constructor.
* @param Image $image
* @param ImageService $imageService
* @param RestrictionService $restrictionService
* @param PermissionService $permissionService
* @param Page $page
*/
public function __construct(Image $image, ImageService $imageService, RestrictionService $restrictionService, Page $page)
public function __construct(Image $image, ImageService $imageService, PermissionService $permissionService, Page $page)
{
$this->image = $image;
$this->imageService = $imageService;
$this->restictionService = $restrictionService;
$this->restictionService = $permissionService;
$this->page = $page;
}

View File

@@ -3,6 +3,7 @@
use Activity;
use BookStack\Book;
use BookStack\Chapter;
use BookStack\Entity;
use BookStack\Exceptions\NotFoundException;
use Carbon\Carbon;
use DOMDocument;
@@ -14,14 +15,17 @@ class PageRepo extends EntityRepo
{
protected $pageRevision;
protected $tagRepo;
/**
* PageRepo constructor.
* @param PageRevision $pageRevision
* @param TagRepo $tagRepo
*/
public function __construct(PageRevision $pageRevision)
public function __construct(PageRevision $pageRevision, TagRepo $tagRepo)
{
$this->pageRevision = $pageRevision;
$this->tagRepo = $tagRepo;
parent::__construct();
}
@@ -32,7 +36,7 @@ class PageRepo extends EntityRepo
*/
private function pageQuery($allowDrafts = false)
{
$query = $this->restrictionService->enforcePageRestrictions($this->page, 'view');
$query = $this->permissionService->enforcePageRestrictions($this->page, 'view');
if (!$allowDrafts) {
$query = $query->where('draft', '=', false);
}
@@ -76,7 +80,7 @@ class PageRepo extends EntityRepo
{
$revision = $this->pageRevision->where('slug', '=', $pageSlug)
->whereHas('page', function ($query) {
$this->restrictionService->enforcePageRestrictions($query);
$this->permissionService->enforcePageRestrictions($query);
})
->where('type', '=', 'version')
->where('book_slug', '=', $bookSlug)->orderBy('created_at', 'desc')
@@ -142,6 +146,11 @@ class PageRepo extends EntityRepo
{
$draftPage->fill($input);
// Save page tags if present
if(isset($input['tags'])) {
$this->tagRepo->saveTagsToEntity($draftPage, $input['tags']);
}
$draftPage->slug = $this->findSuitableSlug($draftPage->name, $draftPage->book->id);
$draftPage->html = $this->formatHtml($input['html']);
$draftPage->text = strip_tags($draftPage->html);
@@ -168,6 +177,7 @@ class PageRepo extends EntityRepo
if ($chapter) $page->chapter_id = $chapter->id;
$book->pages()->save($page);
$this->permissionService->buildJointPermissionsForEntity($page);
return $page;
}
@@ -241,8 +251,9 @@ class PageRepo extends EntityRepo
public function getBySearch($term, $whereTerms = [], $count = 20, $paginationAppends = [])
{
$terms = $this->prepareSearchTerms($term);
$pages = $this->restrictionService->enforcePageRestrictions($this->page->fullTextSearchQuery(['name', 'text'], $terms, $whereTerms))
->paginate($count)->appends($paginationAppends);
$pageQuery = $this->permissionService->enforcePageRestrictions($this->page->fullTextSearchQuery(['name', 'text'], $terms, $whereTerms));
$pageQuery = $this->addAdvancedSearchQueries($pageQuery, $term);
$pages = $pageQuery->paginate($count)->appends($paginationAppends);
// Add highlights to page text.
$words = join('|', explode(' ', preg_quote(trim($term), '/')));
@@ -307,6 +318,11 @@ class PageRepo extends EntityRepo
$page->slug = $this->findSuitableSlug($input['name'], $book_id, $page->id);
}
// Save page tags if present
if(isset($input['tags'])) {
$this->tagRepo->saveTagsToEntity($page, $input['tags']);
}
// Update with new details
$userId = auth()->user()->id;
$page->fill($input);
@@ -557,6 +573,22 @@ class PageRepo extends EntityRepo
return $page;
}
/**
* Change the page's parent to the given entity.
* @param Page $page
* @param Entity $parent
*/
public function changePageParent(Page $page, Entity $parent)
{
$book = $parent->isA('book') ? $parent : $parent->book;
$page->chapter_id = $parent->isA('chapter') ? $parent->id : 0;
$page->save();
$page = $this->changeBook($book->id, $page);
$page->load('book');
$this->permissionService->buildJointPermissionsForEntity($book);
}
/**
* Gets a suitable slug for the resource
* @param $name
@@ -577,12 +609,14 @@ class PageRepo extends EntityRepo
* Destroy a given page along with its dependencies.
* @param $page
*/
public function destroy($page)
public function destroy(Page $page)
{
Activity::removeEntity($page);
$page->views()->delete();
$page->tags()->delete();
$page->revisions()->delete();
$page->restrictions()->delete();
$page->permissions()->delete();
$this->permissionService->deleteJointPermissionsForEntity($page);
$page->delete();
}

View File

@@ -2,8 +2,9 @@
use BookStack\Exceptions\PermissionsException;
use BookStack\Permission;
use BookStack\RolePermission;
use BookStack\Role;
use BookStack\Services\PermissionService;
use Setting;
class PermissionsRepo
@@ -11,16 +12,21 @@ class PermissionsRepo
protected $permission;
protected $role;
protected $permissionService;
protected $systemRoles = ['admin', 'public'];
/**
* PermissionsRepo constructor.
* @param $permission
* @param $role
* @param RolePermission $permission
* @param Role $role
* @param PermissionService $permissionService
*/
public function __construct(Permission $permission, Role $role)
public function __construct(RolePermission $permission, Role $role, PermissionService $permissionService)
{
$this->permission = $permission;
$this->role = $role;
$this->permissionService = $permissionService;
}
/**
@@ -29,7 +35,7 @@ class PermissionsRepo
*/
public function getAllRoles()
{
return $this->role->all();
return $this->role->where('hidden', '=', false)->get();
}
/**
@@ -39,7 +45,7 @@ class PermissionsRepo
*/
public function getAllRolesExcept(Role $role)
{
return $this->role->where('id', '!=', $role->id)->get();
return $this->role->where('id', '!=', $role->id)->where('hidden', '=', false)->get();
}
/**
@@ -69,6 +75,7 @@ class PermissionsRepo
$permissions = isset($roleData['permissions']) ? array_keys($roleData['permissions']) : [];
$this->assignRolePermissions($role, $permissions);
$this->permissionService->buildJointPermissionForRole($role);
return $role;
}
@@ -77,10 +84,14 @@ class PermissionsRepo
* Ensure Admin role always has all permissions.
* @param $roleId
* @param $roleData
* @throws PermissionsException
*/
public function updateRole($roleId, $roleData)
{
$role = $this->role->findOrFail($roleId);
if ($role->hidden) throw new PermissionsException("Cannot update a hidden role");
$permissions = isset($roleData['permissions']) ? array_keys($roleData['permissions']) : [];
$this->assignRolePermissions($role, $permissions);
@@ -91,6 +102,7 @@ class PermissionsRepo
$role->fill($roleData);
$role->save();
$this->permissionService->buildJointPermissionForRole($role);
}
/**
@@ -122,8 +134,8 @@ class PermissionsRepo
$role = $this->role->findOrFail($roleId);
// Prevent deleting admin role or default registration role.
if ($role->name === 'admin') {
throw new PermissionsException('The admin role cannot be deleted');
if ($role->system_name && in_array($role->system_name, $this->systemRoles)) {
throw new PermissionsException('This role is a system role and cannot be deleted');
} else if ($role->id == setting('registration-role')) {
throw new PermissionsException('This role cannot be deleted while set as the default registration role.');
}
@@ -136,6 +148,7 @@ class PermissionsRepo
}
}
$this->permissionService->deleteJointPermissionsForRole($role);
$role->delete();
}

135
app/Repos/TagRepo.php Normal file
View File

@@ -0,0 +1,135 @@
<?php namespace BookStack\Repos;
use BookStack\Tag;
use BookStack\Entity;
use BookStack\Services\PermissionService;
/**
* Class TagRepo
* @package BookStack\Repos
*/
class TagRepo
{
protected $tag;
protected $entity;
protected $permissionService;
/**
* TagRepo constructor.
* @param Tag $attr
* @param Entity $ent
* @param PermissionService $ps
*/
public function __construct(Tag $attr, Entity $ent, PermissionService $ps)
{
$this->tag = $attr;
$this->entity = $ent;
$this->permissionService = $ps;
}
/**
* Get an entity instance of its particular type.
* @param $entityType
* @param $entityId
* @param string $action
*/
public function getEntity($entityType, $entityId, $action = 'view')
{
$entityInstance = $this->entity->getEntityInstance($entityType);
$searchQuery = $entityInstance->where('id', '=', $entityId)->with('tags');
$searchQuery = $this->permissionService->enforceEntityRestrictions($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)
{
$query = $this->tag->select('*', \DB::raw('count(*) as count'))->groupBy('name');
if ($searchTerm) {
$query = $query->where('name', 'LIKE', $searchTerm . '%')->orderBy('name', 'desc');
} else {
$query = $query->orderBy('count', 'desc')->take(50);
}
$query = $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type');
return $query->get(['name'])->pluck('name');
}
/**
* 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)
{
$query = $this->tag->select('*', \DB::raw('count(*) as count'))->groupBy('value');
if ($searchTerm) {
$query = $query->where('value', 'LIKE', $searchTerm . '%')->orderBy('value', 'desc');
} else {
$query = $query->orderBy('count', 'desc')->take(50);
}
if ($tagName !== false) $query = $query->where('name', '=', $tagName);
$query = $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type');
return $query->get(['value'])->pluck('value');
}
/**
* Save an array of tags to an entity
* @param Entity $entity
* @param array $tags
* @return array|\Illuminate\Database\Eloquent\Collection
*/
public function saveTagsToEntity(Entity $entity, $tags = [])
{
$entity->tags()->delete();
$newTags = [];
foreach ($tags as $tag) {
if (trim($tag['name']) === '') continue;
$newTags[] = $this->newInstanceFromInput($tag);
}
return $entity->tags()->saveMany($newTags);
}
/**
* Create a new Tag instance from user input.
* @param $input
* @return static
*/
protected function newInstanceFromInput($input)
{
$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);
}
}

View File

@@ -51,6 +51,27 @@ class UserRepo
return $this->user->with('roles', 'avatar')->orderBy('name', 'asc')->get();
}
/**
* Get all the users with their permissions in a paginated format.
* @param int $count
* @param $sortData
* @return \Illuminate\Database\Eloquent\Builder|static
*/
public function getAllUsersPaginatedAndSorted($count = 20, $sortData)
{
$query = $this->user->with('roles', 'avatar')->orderBy($sortData['sort'], $sortData['order']);
if ($sortData['search']) {
$term = '%' . $sortData['search'] . '%';
$query->where(function($query) use ($term) {
$query->where('name', 'like', $term)
->orWhere('email', 'like', $term);
});
}
return $query->paginate($count);
}
/**
* Creates a new user and attaches a role to them.
* @param array $data
@@ -168,6 +189,15 @@ class UserRepo
];
}
/**
* Get the roles in the system that are assignable to a user.
* @return mixed
*/
public function getAssignableRoles()
{
return $this->role->visible();
}
/**
* Get all the roles which can be given restricted access to
* other entities in the system.
@@ -175,7 +205,7 @@ class UserRepo
*/
public function getRestrictableRoles()
{
return $this->role->where('name', '!=', 'admin')->get();
return $this->role->where('hidden', '=', false)->where('system_name', '=', '')->get();
}
}

View File

@@ -1,8 +1,5 @@
<?php
<?php namespace BookStack;
namespace BookStack;
use Illuminate\Database\Eloquent\Model;
class Role extends Model
{
@@ -14,40 +11,54 @@ class Role extends Model
*/
public function users()
{
return $this->belongsToMany('BookStack\User');
return $this->belongsToMany(User::class);
}
/**
* The permissions that belong to the role.
* Get all related JointPermissions.
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function jointPermissions()
{
return $this->hasMany(JointPermission::class);
}
/**
* The RolePermissions that belong to the role.
*/
public function permissions()
{
return $this->belongsToMany('BookStack\Permission');
return $this->belongsToMany(RolePermission::class, 'permission_role', 'role_id', 'permission_id');
}
/**
* Check if this role has a permission.
* @param $permission
* @param $permissionName
* @return bool
*/
public function hasPermission($permission)
public function hasPermission($permissionName)
{
return $this->permissions->pluck('name')->contains($permission);
$permissions = $this->getRelationValue('permissions');
foreach ($permissions as $permission) {
if ($permission->getRawAttribute('name') === $permissionName) return true;
}
return false;
}
/**
* Add a permission to this role.
* @param Permission $permission
* @param RolePermission $permission
*/
public function attachPermission(Permission $permission)
public function attachPermission(RolePermission $permission)
{
$this->permissions()->attach($permission->id);
}
/**
* Detach a single permission from this role.
* @param Permission $permission
* @param RolePermission $permission
*/
public function detachPermission(Permission $permission)
public function detachPermission(RolePermission $permission)
{
$this->permissions()->detach($permission->id);
}
@@ -61,4 +72,24 @@ class Role extends Model
{
return static::where('name', '=', $roleName)->first();
}
/**
* Get the role object for the specified system role.
* @param $roleName
* @return mixed
*/
public static function getSystemRole($roleName)
{
return static::where('system_name', '=', $roleName)->first();
}
/**
* Get all visible roles
* @return mixed
*/
public static function visible()
{
return static::where('hidden', '=', false)->orderBy('name')->get();
}
}

View File

@@ -1,22 +1,19 @@
<?php
<?php namespace BookStack;
namespace BookStack;
use Illuminate\Database\Eloquent\Model;
class Permission extends Model
class RolePermission extends Model
{
/**
* The roles that belong to the permission.
*/
public function roles()
{
return $this->belongsToMany('BookStack\Role');
return $this->belongsToMany(Role::class, 'permission_role','permission_id', 'role_id');
}
/**
* Get the permission object by name.
* @param $roleName
* @param $name
* @return mixed
*/
public static function getByName($name)

View File

@@ -8,17 +8,17 @@ class ActivityService
{
protected $activity;
protected $user;
protected $restrictionService;
protected $permissionService;
/**
* ActivityService constructor.
* @param Activity $activity
* @param RestrictionService $restrictionService
* @param PermissionService $permissionService
*/
public function __construct(Activity $activity, RestrictionService $restrictionService)
public function __construct(Activity $activity, PermissionService $permissionService)
{
$this->activity = $activity;
$this->restrictionService = $restrictionService;
$this->permissionService = $permissionService;
$this->user = auth()->user();
}
@@ -88,9 +88,9 @@ class ActivityService
*/
public function latest($count = 20, $page = 0)
{
$activityList = $this->restrictionService
$activityList = $this->permissionService
->filterRestrictedEntityRelations($this->activity, 'activities', 'entity_id', 'entity_type')
->orderBy('created_at', 'desc')->skip($count * $page)->take($count)->get();
->orderBy('created_at', 'desc')->with('user', 'entity')->skip($count * $page)->take($count)->get();
return $this->filterSimilar($activityList);
}
@@ -105,8 +105,16 @@ class ActivityService
*/
public function entityActivity($entity, $count = 20, $page = 0)
{
$activity = $entity->hasMany('BookStack\Activity')->orderBy('created_at', 'desc')
->skip($count * $page)->take($count)->get();
if ($entity->isA('book')) {
$query = $this->activity->where('book_id', '=', $entity->id);
} else {
$query = $this->activity->where('entity_type', '=', get_class($entity))
->where('entity_id', '=', $entity->id);
}
$activity = $this->permissionService
->filterRestrictedEntityRelations($query, 'activities', 'entity_id', 'entity_type')
->orderBy('created_at', 'desc')->skip($count * $page)->take($count)->get();
return $this->filterSimilar($activity);
}
@@ -121,7 +129,7 @@ class ActivityService
*/
public function userActivity($user, $count = 20, $page = 0)
{
$activityList = $this->restrictionService
$activityList = $this->permissionService
->filterRestrictedEntityRelations($this->activity, 'activities', 'entity_id', 'entity_type')
->orderBy('created_at', 'desc')->where('user_id', '=', $user->id)->skip($count * $page)->take($count)->get();
return $this->filterSimilar($activityList);

View File

@@ -33,6 +33,17 @@ class Ldap
return ldap_set_option($ldapConnection, $option, $value);
}
/**
* Set the version number for the given ldap connection.
* @param $ldapConnection
* @param $version
* @return bool
*/
public function setVersion($ldapConnection, $version)
{
return $this->setOption($ldapConnection, LDAP_OPT_PROTOCOL_VERSION, $version);
}
/**
* Search LDAP tree using the provided filter.
* @param resource $ldapConnection

View File

@@ -122,7 +122,7 @@ class LdapService
// Set any required options
if ($this->config['version']) {
$this->ldap->setOption($ldapConnection, LDAP_OPT_PROTOCOL_VERSION, $this->config['version']);
$this->ldap->setVersion($ldapConnection, $this->config['version']);
}
$this->ldapConnection = $ldapConnection;

View File

@@ -0,0 +1,506 @@
<?php namespace BookStack\Services;
use BookStack\Book;
use BookStack\Chapter;
use BookStack\Entity;
use BookStack\JointPermission;
use BookStack\Ownable;
use BookStack\Page;
use BookStack\Role;
use BookStack\User;
use Illuminate\Database\Eloquent\Collection;
class PermissionService
{
protected $userRoles;
protected $isAdmin;
protected $currentAction;
protected $currentUser;
public $book;
public $chapter;
public $page;
protected $jointPermission;
protected $role;
/**
* PermissionService constructor.
* @param JointPermission $jointPermission
* @param Book $book
* @param Chapter $chapter
* @param Page $page
* @param Role $role
*/
public function __construct(JointPermission $jointPermission, Book $book, Chapter $chapter, Page $page, Role $role)
{
$this->currentUser = auth()->user();
$userSet = $this->currentUser !== null;
$this->userRoles = false;
$this->isAdmin = $userSet ? $this->currentUser->hasRole('admin') : false;
if (!$userSet) $this->currentUser = new User();
$this->jointPermission = $jointPermission;
$this->role = $role;
$this->book = $book;
$this->chapter = $chapter;
$this->page = $page;
}
/**
* Get the roles for the current user;
* @return array|bool
*/
protected function getRoles()
{
if ($this->userRoles !== false) return $this->userRoles;
$roles = [];
if (auth()->guest()) {
$roles[] = $this->role->getSystemRole('public')->id;
return $roles;
}
foreach ($this->currentUser->roles as $role) {
$roles[] = $role->id;
}
return $roles;
}
/**
* Re-generate all entity permission from scratch.
*/
public function buildJointPermissions()
{
$this->jointPermission->truncate();
// Get all roles (Should be the most limited dimension)
$roles = $this->role->with('permissions')->get();
// Chunk through all books
$this->book->with('permissions')->chunk(500, function ($books) use ($roles) {
$this->createManyJointPermissions($books, $roles);
});
// Chunk through all chapters
$this->chapter->with('book', 'permissions')->chunk(500, function ($chapters) use ($roles) {
$this->createManyJointPermissions($chapters, $roles);
});
// Chunk through all pages
$this->page->with('book', 'chapter', 'permissions')->chunk(500, function ($pages) use ($roles) {
$this->createManyJointPermissions($pages, $roles);
});
}
/**
* Create the entity jointPermissions for a particular entity.
* @param Entity $entity
*/
public function buildJointPermissionsForEntity(Entity $entity)
{
$roles = $this->role->with('jointPermissions')->get();
$entities = collect([$entity]);
if ($entity->isA('book')) {
$entities = $entities->merge($entity->chapters);
$entities = $entities->merge($entity->pages);
} elseif ($entity->isA('chapter')) {
$entities = $entities->merge($entity->pages);
}
$this->deleteManyJointPermissionsForEntities($entities);
$this->createManyJointPermissions($entities, $roles);
}
/**
* Build the entity jointPermissions for a particular role.
* @param Role $role
*/
public function buildJointPermissionForRole(Role $role)
{
$roles = collect([$role]);
$this->deleteManyJointPermissionsForRoles($roles);
// Chunk through all books
$this->book->with('permissions')->chunk(500, function ($books) use ($roles) {
$this->createManyJointPermissions($books, $roles);
});
// Chunk through all chapters
$this->chapter->with('book', 'permissions')->chunk(500, function ($books) use ($roles) {
$this->createManyJointPermissions($books, $roles);
});
// Chunk through all pages
$this->page->with('book', 'chapter', 'permissions')->chunk(500, function ($books) use ($roles) {
$this->createManyJointPermissions($books, $roles);
});
}
/**
* Delete the entity jointPermissions attached to a particular role.
* @param Role $role
*/
public function deleteJointPermissionsForRole(Role $role)
{
$this->deleteManyJointPermissionsForRoles([$role]);
}
/**
* Delete all of the entity jointPermissions for a list of entities.
* @param Role[] $roles
*/
protected function deleteManyJointPermissionsForRoles($roles)
{
foreach ($roles as $role) {
$role->jointPermissions()->delete();
}
}
/**
* Delete the entity jointPermissions for a particular entity.
* @param Entity $entity
*/
public function deleteJointPermissionsForEntity(Entity $entity)
{
$this->deleteManyJointPermissionsForEntities([$entity]);
}
/**
* Delete all of the entity jointPermissions for a list of entities.
* @param Entity[] $entities
*/
protected function deleteManyJointPermissionsForEntities($entities)
{
foreach ($entities as $entity) {
$entity->jointPermissions()->delete();
}
}
/**
* Create & Save entity jointPermissions for many entities and jointPermissions.
* @param Collection $entities
* @param Collection $roles
*/
protected function createManyJointPermissions($entities, $roles)
{
$jointPermissions = [];
foreach ($entities as $entity) {
foreach ($roles as $role) {
foreach ($this->getActions($entity) as $action) {
$jointPermissions[] = $this->createJointPermissionData($entity, $role, $action);
}
}
}
$this->jointPermission->insert($jointPermissions);
}
/**
* Get the actions related to an entity.
* @param $entity
* @return array
*/
protected function getActions($entity)
{
$baseActions = ['view', 'update', 'delete'];
if ($entity->isA('chapter')) {
$baseActions[] = 'page-create';
} else if ($entity->isA('book')) {
$baseActions[] = 'page-create';
$baseActions[] = 'chapter-create';
}
return $baseActions;
}
/**
* Create entity permission data for an entity and role
* for a particular action.
* @param Entity $entity
* @param Role $role
* @param $action
* @return array
*/
protected function createJointPermissionData(Entity $entity, Role $role, $action)
{
$permissionPrefix = (strpos($action, '-') === false ? ($entity->getType() . '-') : '') . $action;
$roleHasPermission = $role->hasPermission($permissionPrefix . '-all');
$roleHasPermissionOwn = $role->hasPermission($permissionPrefix . '-own');
$explodedAction = explode('-', $action);
$restrictionAction = end($explodedAction);
if ($entity->isA('book')) {
if (!$entity->restricted) {
return $this->createJointPermissionDataArray($entity, $role, $action, $roleHasPermission, $roleHasPermissionOwn);
} else {
$hasAccess = $entity->hasActiveRestriction($role->id, $restrictionAction);
return $this->createJointPermissionDataArray($entity, $role, $action, $hasAccess, $hasAccess);
}
} elseif ($entity->isA('chapter')) {
if (!$entity->restricted) {
$hasExplicitAccessToBook = $entity->book->hasActiveRestriction($role->id, $restrictionAction);
$hasPermissiveAccessToBook = !$entity->book->restricted;
return $this->createJointPermissionDataArray($entity, $role, $action,
($hasExplicitAccessToBook || ($roleHasPermission && $hasPermissiveAccessToBook)),
($hasExplicitAccessToBook || ($roleHasPermissionOwn && $hasPermissiveAccessToBook)));
} else {
$hasAccess = $entity->hasActiveRestriction($role->id, $restrictionAction);
return $this->createJointPermissionDataArray($entity, $role, $action, $hasAccess, $hasAccess);
}
} elseif ($entity->isA('page')) {
if (!$entity->restricted) {
$hasExplicitAccessToBook = $entity->book->hasActiveRestriction($role->id, $restrictionAction);
$hasPermissiveAccessToBook = !$entity->book->restricted;
$hasExplicitAccessToChapter = $entity->chapter && $entity->chapter->hasActiveRestriction($role->id, $restrictionAction);
$hasPermissiveAccessToChapter = $entity->chapter && !$entity->chapter->restricted;
$acknowledgeChapter = ($entity->chapter && $entity->chapter->restricted);
$hasExplicitAccessToParents = $acknowledgeChapter ? $hasExplicitAccessToChapter : $hasExplicitAccessToBook;
$hasPermissiveAccessToParents = $acknowledgeChapter ? $hasPermissiveAccessToChapter : $hasPermissiveAccessToBook;
return $this->createJointPermissionDataArray($entity, $role, $action,
($hasExplicitAccessToParents || ($roleHasPermission && $hasPermissiveAccessToParents)),
($hasExplicitAccessToParents || ($roleHasPermissionOwn && $hasPermissiveAccessToParents))
);
} else {
$hasAccess = $entity->hasRestriction($role->id, $action);
return $this->createJointPermissionDataArray($entity, $role, $action, $hasAccess, $hasAccess);
}
}
}
/**
* Create an array of data with the information of an entity jointPermissions.
* Used to build data for bulk insertion.
* @param Entity $entity
* @param Role $role
* @param $action
* @param $permissionAll
* @param $permissionOwn
* @return array
*/
protected function createJointPermissionDataArray(Entity $entity, Role $role, $action, $permissionAll, $permissionOwn)
{
$entityClass = get_class($entity);
return [
'role_id' => $role->getRawAttribute('id'),
'entity_id' => $entity->getRawAttribute('id'),
'entity_type' => $entityClass,
'action' => $action,
'has_permission' => $permissionAll,
'has_permission_own' => $permissionOwn,
'created_by' => $entity->getRawAttribute('created_by')
];
}
/**
* Checks if an entity has a restriction set upon it.
* @param Ownable $ownable
* @param $permission
* @return bool
*/
public function checkOwnableUserAccess(Ownable $ownable, $permission)
{
if ($this->isAdmin) return true;
$explodedPermission = explode('-', $permission);
$baseQuery = $ownable->where('id', '=', $ownable->id);
$action = end($explodedPermission);
$this->currentAction = $action;
$nonJointPermissions = ['restrictions'];
// Handle non entity specific jointPermissions
if (in_array($explodedPermission[0], $nonJointPermissions)) {
$allPermission = $this->currentUser && $this->currentUser->can($permission . '-all');
$ownPermission = $this->currentUser && $this->currentUser->can($permission . '-own');
$this->currentAction = 'view';
$isOwner = $this->currentUser && $this->currentUser->id === $ownable->created_by;
return ($allPermission || ($isOwner && $ownPermission));
}
// Handle abnormal create jointPermissions
if ($action === 'create') {
$this->currentAction = $permission;
}
return $this->entityRestrictionQuery($baseQuery)->count() > 0;
}
/**
* Check if an entity has restrictions set on itself or its
* parent tree.
* @param Entity $entity
* @param $action
* @return bool|mixed
*/
public function checkIfRestrictionsSet(Entity $entity, $action)
{
$this->currentAction = $action;
if ($entity->isA('page')) {
return $entity->restricted || ($entity->chapter && $entity->chapter->restricted) || $entity->book->restricted;
} elseif ($entity->isA('chapter')) {
return $entity->restricted || $entity->book->restricted;
} elseif ($entity->isA('book')) {
return $entity->restricted;
}
}
/**
* The general query filter to remove all entities
* that the current user does not have access to.
* @param $query
* @return mixed
*/
protected function entityRestrictionQuery($query)
{
return $query->where(function ($parentQuery) {
$parentQuery->whereHas('jointPermissions', function ($permissionQuery) {
$permissionQuery->whereIn('role_id', $this->getRoles())
->where('action', '=', $this->currentAction)
->where(function ($query) {
$query->where('has_permission', '=', true)
->orWhere(function ($query) {
$query->where('has_permission_own', '=', true)
->where('created_by', '=', $this->currentUser->id);
});
});
});
});
}
/**
* Add restrictions for a page query
* @param $query
* @param string $action
* @return mixed
*/
public function enforcePageRestrictions($query, $action = 'view')
{
// Prevent drafts being visible to others.
$query = $query->where(function ($query) {
$query->where('draft', '=', false);
if ($this->currentUser) {
$query->orWhere(function ($query) {
$query->where('draft', '=', true)->where('created_by', '=', $this->currentUser->id);
});
}
});
return $this->enforceEntityRestrictions($query, $action);
}
/**
* Add on permission restrictions to a chapter query.
* @param $query
* @param string $action
* @return mixed
*/
public function enforceChapterRestrictions($query, $action = 'view')
{
return $this->enforceEntityRestrictions($query, $action);
}
/**
* Add restrictions to a book query.
* @param $query
* @param string $action
* @return mixed
*/
public function enforceBookRestrictions($query, $action = 'view')
{
return $this->enforceEntityRestrictions($query, $action);
}
/**
* Add restrictions for a generic entity
* @param $query
* @param string $action
* @return mixed
*/
public function enforceEntityRestrictions($query, $action = 'view')
{
if ($this->isAdmin) return $query;
$this->currentAction = $action;
return $this->entityRestrictionQuery($query);
}
/**
* Filter items that have entities set a a polymorphic relation.
* @param $query
* @param string $tableName
* @param string $entityIdColumn
* @param string $entityTypeColumn
* @return mixed
*/
public function filterRestrictedEntityRelations($query, $tableName, $entityIdColumn, $entityTypeColumn)
{
if ($this->isAdmin) return $query;
$this->currentAction = 'view';
$tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn, 'entityTypeColumn' => $entityTypeColumn];
return $query->where(function ($query) use ($tableDetails) {
$query->whereExists(function ($permissionQuery) use (&$tableDetails) {
$permissionQuery->select('id')->from('joint_permissions')
->whereRaw('joint_permissions.entity_id=' . $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
->whereRaw('joint_permissions.entity_type=' . $tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'])
->where('action', '=', $this->currentAction)
->whereIn('role_id', $this->getRoles())
->where(function ($query) {
$query->where('has_permission', '=', true)->orWhere(function ($query) {
$query->where('has_permission_own', '=', true)
->where('created_by', '=', $this->currentUser->id);
});
});
});
});
}
/**
* Filters pages that are a direct relation to another item.
* @param $query
* @param $tableName
* @param $entityIdColumn
* @return mixed
*/
public function filterRelatedPages($query, $tableName, $entityIdColumn)
{
if ($this->isAdmin) return $query;
$this->currentAction = 'view';
$tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn];
return $query->where(function ($query) use ($tableDetails) {
$query->where(function ($query) use (&$tableDetails) {
$query->whereExists(function ($permissionQuery) use (&$tableDetails) {
$permissionQuery->select('id')->from('joint_permissions')
->whereRaw('joint_permissions.entity_id=' . $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
->where('entity_type', '=', 'Bookstack\\Page')
->where('action', '=', $this->currentAction)
->whereIn('role_id', $this->getRoles())
->where(function ($query) {
$query->where('has_permission', '=', true)->orWhere(function ($query) {
$query->where('has_permission_own', '=', true)
->where('created_by', '=', $this->currentUser->id);
});
});
});
})->orWhere($tableDetails['entityIdColumn'], '=', 0);
});
}
}

View File

@@ -1,326 +0,0 @@
<?php namespace BookStack\Services;
use BookStack\Entity;
class RestrictionService
{
protected $userRoles;
protected $isAdmin;
protected $currentAction;
protected $currentUser;
/**
* RestrictionService constructor.
*/
public function __construct()
{
$this->currentUser = auth()->user();
$this->userRoles = $this->currentUser ? $this->currentUser->roles->pluck('id') : [];
$this->isAdmin = $this->currentUser ? $this->currentUser->hasRole('admin') : false;
}
/**
* Checks if an entity has a restriction set upon it.
* @param Entity $entity
* @param $action
* @return bool
*/
public function checkIfEntityRestricted(Entity $entity, $action)
{
if ($this->isAdmin) return true;
$this->currentAction = $action;
$baseQuery = $entity->where('id', '=', $entity->id);
if ($entity->isA('page')) {
return $this->pageRestrictionQuery($baseQuery)->count() > 0;
} elseif ($entity->isA('chapter')) {
return $this->chapterRestrictionQuery($baseQuery)->count() > 0;
} elseif ($entity->isA('book')) {
return $this->bookRestrictionQuery($baseQuery)->count() > 0;
}
return false;
}
/**
* Check if an entity has restrictions set on itself or its
* parent tree.
* @param Entity $entity
* @param $action
* @return bool|mixed
*/
public function checkIfRestrictionsSet(Entity $entity, $action)
{
$this->currentAction = $action;
if ($entity->isA('page')) {
return $entity->restricted || ($entity->chapter && $entity->chapter->restricted) || $entity->book->restricted;
} elseif ($entity->isA('chapter')) {
return $entity->restricted || $entity->book->restricted;
} elseif ($entity->isA('book')) {
return $entity->restricted;
}
}
/**
* Add restrictions for a page query
* @param $query
* @param string $action
* @return mixed
*/
public function enforcePageRestrictions($query, $action = 'view')
{
// Prevent drafts being visible to others.
$query = $query->where(function ($query) {
$query->where('draft', '=', false);
if ($this->currentUser) {
$query->orWhere(function ($query) {
$query->where('draft', '=', true)->where('created_by', '=', $this->currentUser->id);
});
}
});
if ($this->isAdmin) return $query;
$this->currentAction = $action;
return $this->pageRestrictionQuery($query);
}
/**
* The base query for restricting pages.
* @param $query
* @return mixed
*/
private function pageRestrictionQuery($query)
{
return $query->where(function ($parentWhereQuery) {
$parentWhereQuery
// (Book & chapter & page) or (Book & page & NO CHAPTER) unrestricted
->where(function ($query) {
$query->where(function ($query) {
$query->whereExists(function ($query) {
$query->select('*')->from('chapters')
->whereRaw('chapters.id=pages.chapter_id')
->where('restricted', '=', false);
})->whereExists(function ($query) {
$query->select('*')->from('books')
->whereRaw('books.id=pages.book_id')
->where('restricted', '=', false);
})->where('restricted', '=', false);
})->orWhere(function ($query) {
$query->where('restricted', '=', false)->where('chapter_id', '=', 0)
->whereExists(function ($query) {
$query->select('*')->from('books')
->whereRaw('books.id=pages.book_id')
->where('restricted', '=', false);
});
});
})
// Page unrestricted, Has no chapter & book has accepted restrictions
->orWhere(function ($query) {
$query->where('restricted', '=', false)
->whereExists(function ($query) {
$query->select('*')->from('chapters')
->whereRaw('chapters.id=pages.chapter_id');
}, 'and', true)
->whereExists(function ($query) {
$query->select('*')->from('books')
->whereRaw('books.id=pages.book_id')
->whereExists(function ($query) {
$this->checkRestrictionsQuery($query, 'books', 'Book');
});
});
})
// Page unrestricted, Has an unrestricted chapter & book has accepted restrictions
->orWhere(function ($query) {
$query->where('restricted', '=', false)
->whereExists(function ($query) {
$query->select('*')->from('chapters')
->whereRaw('chapters.id=pages.chapter_id')->where('restricted', '=', false);
})
->whereExists(function ($query) {
$query->select('*')->from('books')
->whereRaw('books.id=pages.book_id')
->whereExists(function ($query) {
$this->checkRestrictionsQuery($query, 'books', 'Book');
});
});
})
// Page unrestricted, Has a chapter with accepted permissions
->orWhere(function ($query) {
$query->where('restricted', '=', false)
->whereExists(function ($query) {
$query->select('*')->from('chapters')
->whereRaw('chapters.id=pages.chapter_id')
->where('restricted', '=', true)
->whereExists(function ($query) {
$this->checkRestrictionsQuery($query, 'chapters', 'Chapter');
});
});
})
// Page has accepted permissions
->orWhereExists(function ($query) {
$this->checkRestrictionsQuery($query, 'pages', 'Page');
});
});
}
/**
* Add on permission restrictions to a chapter query.
* @param $query
* @param string $action
* @return mixed
*/
public function enforceChapterRestrictions($query, $action = 'view')
{
if ($this->isAdmin) return $query;
$this->currentAction = $action;
return $this->chapterRestrictionQuery($query);
}
/**
* The base query for restricting chapters.
* @param $query
* @return mixed
*/
private function chapterRestrictionQuery($query)
{
return $query->where(function ($parentWhereQuery) {
$parentWhereQuery
// Book & chapter unrestricted
->where(function ($query) {
$query->where('restricted', '=', false)->whereExists(function ($query) {
$query->select('*')->from('books')
->whereRaw('books.id=chapters.book_id')
->where('restricted', '=', false);
});
})
// Chapter unrestricted & book has accepted restrictions
->orWhere(function ($query) {
$query->where('restricted', '=', false)
->whereExists(function ($query) {
$query->select('*')->from('books')
->whereRaw('books.id=chapters.book_id')
->whereExists(function ($query) {
$this->checkRestrictionsQuery($query, 'books', 'Book');
});
});
})
// Chapter has accepted permissions
->orWhereExists(function ($query) {
$this->checkRestrictionsQuery($query, 'chapters', 'Chapter');
});
});
}
/**
* Add restrictions to a book query.
* @param $query
* @param string $action
* @return mixed
*/
public function enforceBookRestrictions($query, $action = 'view')
{
if ($this->isAdmin) return $query;
$this->currentAction = $action;
return $this->bookRestrictionQuery($query);
}
/**
* The base query for restricting books.
* @param $query
* @return mixed
*/
private function bookRestrictionQuery($query)
{
return $query->where(function ($parentWhereQuery) {
$parentWhereQuery
->where('restricted', '=', false)
->orWhere(function ($query) {
$query->where('restricted', '=', true)->whereExists(function ($query) {
$this->checkRestrictionsQuery($query, 'books', 'Book');
});
});
});
}
/**
* Filter items that have entities set a a polymorphic relation.
* @param $query
* @param string $tableName
* @param string $entityIdColumn
* @param string $entityTypeColumn
* @return mixed
*/
public function filterRestrictedEntityRelations($query, $tableName, $entityIdColumn, $entityTypeColumn)
{
if ($this->isAdmin) return $query;
$this->currentAction = 'view';
$tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn, 'entityTypeColumn' => $entityTypeColumn];
return $query->where(function ($query) use ($tableDetails) {
$query->where(function ($query) use (&$tableDetails) {
$query->where($tableDetails['entityTypeColumn'], '=', 'BookStack\Page')
->whereExists(function ($query) use (&$tableDetails) {
$query->select('*')->from('pages')->whereRaw('pages.id=' . $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
->where(function ($query) {
$this->pageRestrictionQuery($query);
});
});
})->orWhere(function ($query) use (&$tableDetails) {
$query->where($tableDetails['entityTypeColumn'], '=', 'BookStack\Book')->whereExists(function ($query) use (&$tableDetails) {
$query->select('*')->from('books')->whereRaw('books.id=' . $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
->where(function ($query) {
$this->bookRestrictionQuery($query);
});
});
})->orWhere(function ($query) use (&$tableDetails) {
$query->where($tableDetails['entityTypeColumn'], '=', 'BookStack\Chapter')->whereExists(function ($query) use (&$tableDetails) {
$query->select('*')->from('chapters')->whereRaw('chapters.id=' . $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
->where(function ($query) {
$this->chapterRestrictionQuery($query);
});
});
});
});
}
/**
* Filters pages that are a direct relation to another item.
* @param $query
* @param $tableName
* @param $entityIdColumn
* @return mixed
*/
public function filterRelatedPages($query, $tableName, $entityIdColumn)
{
if ($this->isAdmin) return $query;
$this->currentAction = 'view';
$tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn];
return $query->where(function ($query) use (&$tableDetails) {
$query->where(function ($query) use (&$tableDetails) {
$query->whereExists(function ($query) use (&$tableDetails) {
$query->select('*')->from('pages')->whereRaw('pages.id=' . $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
->where(function ($query) {
$this->pageRestrictionQuery($query);
});
})->orWhere($tableDetails['entityIdColumn'], '=', 0);
});
});
}
/**
* The query to check the restrictions on an entity.
* @param $query
* @param $tableName
* @param $modelName
*/
private function checkRestrictionsQuery($query, $tableName, $modelName)
{
$query->select('*')->from('restrictions')
->whereRaw('restrictions.restrictable_id=' . $tableName . '.id')
->where('restrictions.restrictable_type', '=', 'BookStack\\' . $modelName)
->where('restrictions.action', '=', $this->currentAction)
->whereIn('restrictions.role_id', $this->userRoles);
}
}

View File

@@ -1,14 +1,11 @@
<?php namespace BookStack\Services;
use GuzzleHttp\Exception\ClientException;
use Laravel\Socialite\Contracts\Factory as Socialite;
use BookStack\Exceptions\SocialDriverNotConfigured;
use BookStack\Exceptions\SocialSignInException;
use BookStack\Exceptions\UserRegistrationException;
use BookStack\Http\Controllers\Auth\AuthController;
use BookStack\Repos\UserRepo;
use BookStack\SocialAccount;
use BookStack\User;
class SocialAuthService
{

View File

@@ -8,18 +8,18 @@ class ViewService
protected $view;
protected $user;
protected $restrictionService;
protected $permissionService;
/**
* ViewService constructor.
* @param View $view
* @param RestrictionService $restrictionService
* @param PermissionService $permissionService
*/
public function __construct(View $view, RestrictionService $restrictionService)
public function __construct(View $view, PermissionService $permissionService)
{
$this->view = $view;
$this->user = auth()->user();
$this->restrictionService = $restrictionService;
$this->permissionService = $permissionService;
}
/**
@@ -50,17 +50,21 @@ class ViewService
* Get the entities with the most views.
* @param int $count
* @param int $page
* @param bool|false $filterModel
* @param bool|false|array $filterModel
*/
public function getPopular($count = 10, $page = 0, $filterModel = false)
{
$skipCount = $count * $page;
$query = $this->restrictionService->filterRestrictedEntityRelations($this->view, 'views', 'viewable_id', 'viewable_type')
$query = $this->permissionService->filterRestrictedEntityRelations($this->view, 'views', 'viewable_id', 'viewable_type')
->select('*', 'viewable_id', 'viewable_type', \DB::raw('SUM(views) as view_count'))
->groupBy('viewable_id', 'viewable_type')
->orderBy('view_count', 'desc');
if ($filterModel) $query->where('viewable_type', '=', get_class($filterModel));
if ($filterModel && is_array($filterModel)) {
$query->whereIn('viewable_type', $filterModel);
} else if ($filterModel) {
$query->where('viewable_type', '=', get_class($filterModel));
};
return $query->with('viewable')->skip($skipCount)->take($count)->get()->pluck('viewable');
}
@@ -76,7 +80,7 @@ class ViewService
{
if ($this->user === null) return collect();
$query = $this->restrictionService
$query = $this->permissionService
->filterRestrictedEntityRelations($this->view, 'views', 'viewable_id', 'viewable_type');
if ($filterModel) $query = $query->where('viewable_type', '=', get_class($filterModel));

View File

@@ -1,8 +1,4 @@
<?php
namespace BookStack;
use Illuminate\Database\Eloquent\Model;
<?php namespace BookStack;
class Setting extends Model
{

View File

@@ -1,8 +1,5 @@
<?php
<?php namespace BookStack;
namespace BookStack;
use Illuminate\Database\Eloquent\Model;
class SocialAccount extends Model
{
@@ -11,6 +8,6 @@ class SocialAccount extends Model
public function user()
{
return $this->belongsTo('BookStack\User');
return $this->belongsTo(User::class);
}
}

19
app/Tag.php Normal file
View File

@@ -0,0 +1,19 @@
<?php namespace BookStack;
/**
* Class Attribute
* @package BookStack
*/
class Tag extends Model
{
protected $fillable = ['name', 'value', 'order'];
/**
* Get the entity that this tag belongs to
* @return \Illuminate\Database\Eloquent\Relations\MorphTo
*/
public function entity()
{
return $this->morphTo('entity');
}
}

View File

@@ -1,9 +1,6 @@
<?php
namespace BookStack;
<?php namespace BookStack;
use Illuminate\Auth\Authenticatable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Auth\Passwords\CanResetPassword;
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;
@@ -52,7 +49,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
*/
public function roles()
{
return $this->belongsToMany('BookStack\Role');
return $this->belongsToMany(Role::class);
}
/**
@@ -116,7 +113,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
*/
public function socialAccounts()
{
return $this->hasMany('BookStack\SocialAccount');
return $this->hasMany(SocialAccount::class);
}
/**
@@ -151,7 +148,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
*/
public function avatar()
{
return $this->belongsTo('BookStack\Image', 'image_id');
return $this->belongsTo(Image::class, 'image_id');
}
/**

View File

@@ -1,8 +1,4 @@
<?php
namespace BookStack;
use Illuminate\Database\Eloquent\Model;
<?php namespace BookStack;
class View extends Model
{

View File

@@ -1,5 +1,7 @@
<?php
use BookStack\Ownable;
if (!function_exists('versioned_asset')) {
/**
* Get the path to a versioned file.
@@ -31,34 +33,21 @@ if (!function_exists('versioned_asset')) {
/**
* Check if the current user has a permission.
* If an ownable element is passed in the permissions are checked against
* If an ownable element is passed in the jointPermissions are checked against
* that particular item.
* @param $permission
* @param \BookStack\Ownable $ownable
* @param Ownable $ownable
* @return mixed
*/
function userCan($permission, \BookStack\Ownable $ownable = null)
function userCan($permission, Ownable $ownable = null)
{
if (!auth()->check()) return false;
if ($ownable === null) {
return auth()->user() && auth()->user()->can($permission);
}
// Check permission on ownable item
$permissionBaseName = strtolower($permission) . '-';
$hasPermission = false;
if (auth()->user()->can($permissionBaseName . 'all')) $hasPermission = true;
if (auth()->user()->can($permissionBaseName . 'own') && $ownable->createdBy && $ownable->createdBy->id === auth()->user()->id) $hasPermission = true;
if (!$ownable instanceof \BookStack\Entity) return $hasPermission;
// Check restrictions on the entity
$restrictionService = app('BookStack\Services\RestrictionService');
$explodedPermission = explode('-', $permission);
$action = end($explodedPermission);
$hasAccess = $restrictionService->checkIfEntityRestricted($ownable, $action);
$restrictionsSet = $restrictionService->checkIfRestrictionsSet($ownable, $action);
return ($hasAccess && $restrictionsSet) || (!$restrictionsSet && $hasPermission);
$permissionService = app(\BookStack\Services\PermissionService::class);
return $permissionService->checkOwnableUserAccess($ownable, $permission);
}
/**
@@ -72,3 +61,35 @@ function setting($key, $default = false)
$settingService = app('BookStack\Services\SettingService');
return $settingService->get($key, $default);
}
/**
* 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 $path
* @param array $data
* @param array $overrideData
* @return string
*/
function sortUrl($path, $data, $overrideData = [])
{
$queryStringSections = [];
$queryData = array_merge($data, $overrideData);
// 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 {
$queryData['order'] = 'asc';
}
foreach ($queryData as $name => $value) {
$trimmedVal = trim($value);
if ($trimmedVal === '') continue;
$queryStringSections[] = urlencode($name) . '=' . urlencode($trimmedVal);
}
if (count($queryStringSections) === 0) return $path;
return $path . '?' . implode('&', $queryStringSections);
}

View File

@@ -84,8 +84,8 @@ return [
'driver' => 'mysql',
'host' => 'localhost',
'database' => 'bookstack-test',
'username' => 'bookstack-test',
'password' => 'bookstack-test',
'username' => env('MYSQL_USER', 'bookstack-test'),
'password' => env('MYSQL_PASSWORD', 'bookstack-test'),
'charset' => 'utf8',
'collation' => 'utf8_unicode_ci',
'prefix' => '',

View File

@@ -5,6 +5,8 @@
*/
return [
'app-editor' => 'wysiwyg'
'app-editor' => 'wysiwyg',
'app-color' => '#0288D1',
'app-color-light' => 'rgba(21, 101, 192, 0.15)'
];

View File

@@ -52,4 +52,11 @@ $factory->define(BookStack\Role::class, function ($faker) {
'display_name' => $faker->sentence(3),
'description' => $faker->sentence(10)
];
});
$factory->define(BookStack\Tag::class, function ($faker) {
return [
'name' => $faker->city,
'value' => $faker->sentence(3)
];
});

View File

@@ -21,10 +21,13 @@ class CreateUsersTable extends Migration
$table->nullableTimestamps();
});
\BookStack\User::forceCreate([
// Create the initial admin user
DB::table('users')->insert([
'name' => 'Admin',
'email' => 'admin@admin.com',
'password' => bcrypt('password')
'password' => bcrypt('password'),
'created_at' => \Carbon\Carbon::now()->toDateTimeString(),
'updated_at' => \Carbon\Carbon::now()->toDateTimeString()
]);
}

View File

@@ -12,7 +12,13 @@ class CreateBooksTable extends Migration
*/
public function up()
{
Schema::create('books', function (Blueprint $table) {
$pdo = \DB::connection()->getPdo();
$mysqlVersion = $pdo->getAttribute(PDO::ATTR_SERVER_VERSION);
$requiresISAM = strpos($mysqlVersion, '5.5') === 0;
Schema::create('books', function (Blueprint $table) use ($requiresISAM) {
if($requiresISAM) $table->engine = 'MyISAM';
$table->increments('id');
$table->string('name');
$table->string('slug')->indexed();

View File

@@ -12,7 +12,13 @@ class CreatePagesTable extends Migration
*/
public function up()
{
Schema::create('pages', function (Blueprint $table) {
$pdo = \DB::connection()->getPdo();
$mysqlVersion = $pdo->getAttribute(PDO::ATTR_SERVER_VERSION);
$requiresISAM = strpos($mysqlVersion, '5.5') === 0;
Schema::create('pages', function (Blueprint $table) use ($requiresISAM) {
if($requiresISAM) $table->engine = 'MyISAM';
$table->increments('id');
$table->integer('book_id');
$table->integer('chapter_id');

View File

@@ -12,7 +12,12 @@ class CreateChaptersTable extends Migration
*/
public function up()
{
Schema::create('chapters', function (Blueprint $table) {
$pdo = \DB::connection()->getPdo();
$mysqlVersion = $pdo->getAttribute(PDO::ATTR_SERVER_VERSION);
$requiresISAM = strpos($mysqlVersion, '5.5') === 0;
Schema::create('chapters', function (Blueprint $table) use ($requiresISAM) {
if($requiresISAM) $table->engine = 'MyISAM';
$table->increments('id');
$table->integer('book_id');
$table->string('slug')->indexed();

View File

@@ -68,35 +68,44 @@ class AddRolesAndPermissions extends Migration
// Create default roles
$admin = new \BookStack\Role();
$admin->name = 'admin';
$admin->display_name = 'Admin';
$admin->description = 'Administrator of the whole application';
$admin->save();
$adminId = DB::table('roles')->insertGetId([
'name' => 'admin',
'display_name' => 'Admin',
'description' => 'Administrator of the whole application',
'created_at' => \Carbon\Carbon::now()->toDateTimeString(),
'updated_at' => \Carbon\Carbon::now()->toDateTimeString()
]);
$editorId = DB::table('roles')->insertGetId([
'name' => 'editor',
'display_name' => 'Editor',
'description' => 'User can edit Books, Chapters & Pages',
'created_at' => \Carbon\Carbon::now()->toDateTimeString(),
'updated_at' => \Carbon\Carbon::now()->toDateTimeString()
]);
$viewerId = DB::table('roles')->insertGetId([
'name' => 'viewer',
'display_name' => 'Viewer',
'description' => 'User can view books & their content behind authentication',
'created_at' => \Carbon\Carbon::now()->toDateTimeString(),
'updated_at' => \Carbon\Carbon::now()->toDateTimeString()
]);
$editor = new \BookStack\Role();
$editor->name = 'editor';
$editor->display_name = 'Editor';
$editor->description = 'User can edit Books, Chapters & Pages';
$editor->save();
$viewer = new \BookStack\Role();
$viewer->name = 'viewer';
$viewer->display_name = 'Viewer';
$viewer->description = 'User can view books & their content behind authentication';
$viewer->save();
// Create default CRUD permissions and allocate to admins and editors
$entities = ['Book', 'Page', 'Chapter', 'Image'];
$ops = ['Create', 'Update', 'Delete'];
foreach ($entities as $entity) {
foreach ($ops as $op) {
$newPermission = new \BookStack\Permission();
$newPermission->name = strtolower($entity) . '-' . strtolower($op);
$newPermission->display_name = $op . ' ' . $entity . 's';
$newPermission->save();
$admin->attachPermission($newPermission);
$editor->attachPermission($newPermission);
$newPermId = DB::table('permissions')->insertGetId([
'name' => strtolower($entity) . '-' . strtolower($op),
'display_name' => $op . ' ' . $entity . 's',
'created_at' => \Carbon\Carbon::now()->toDateTimeString(),
'updated_at' => \Carbon\Carbon::now()->toDateTimeString()
]);
DB::table('permission_role')->insert([
['permission_id' => $newPermId, 'role_id' => $adminId],
['permission_id' => $newPermId, 'role_id' => $editorId]
]);
}
}
@@ -105,19 +114,27 @@ class AddRolesAndPermissions extends Migration
$ops = ['Create', 'Update', 'Delete'];
foreach ($entities as $entity) {
foreach ($ops as $op) {
$newPermission = new \BookStack\Permission();
$newPermission->name = strtolower($entity) . '-' . strtolower($op);
$newPermission->display_name = $op . ' ' . $entity;
$newPermission->save();
$admin->attachPermission($newPermission);
$newPermId = DB::table('permissions')->insertGetId([
'name' => strtolower($entity) . '-' . strtolower($op),
'display_name' => $op . ' ' . $entity,
'created_at' => \Carbon\Carbon::now()->toDateTimeString(),
'updated_at' => \Carbon\Carbon::now()->toDateTimeString()
]);
DB::table('permission_role')->insert([
'permission_id' => $newPermId,
'role_id' => $adminId
]);
}
}
// Set all current users as admins
// (At this point only the initially create user should be an admin)
$users = \BookStack\User::all();
$users = DB::table('users')->get();
foreach ($users as $user) {
$user->attachRole($admin);
DB::table('role_user')->insert([
'role_id' => $adminId,
'user_id' => $user->id
]);
}
}

View File

@@ -13,29 +13,31 @@ class UpdatePermissionsAndRoles extends Migration
public function up()
{
// Get roles with permissions we need to change
$adminRole = \BookStack\Role::getRole('admin');
$editorRole = \BookStack\Role::getRole('editor');
$adminRoleId = DB::table('roles')->where('name', '=', 'admin')->first()->id;
$editorRole = DB::table('roles')->where('name', '=', 'editor')->first();
// Delete old permissions
$permissions = \BookStack\Permission::all();
$permissions->each(function ($permission) {
$permission->delete();
});
$permissions = DB::table('permissions')->delete();
// Create & attach new admin permissions
$permissionsToCreate = [
'settings-manage' => 'Manage Settings',
'users-manage' => 'Manage Users',
'user-roles-manage' => 'Manage Roles & Permissions',
'restrictions-manage-all' => 'Manage All Entity Restrictions',
'restrictions-manage-own' => 'Manage Entity Restrictions On Own Content'
'restrictions-manage-all' => 'Manage All Entity Permissions',
'restrictions-manage-own' => 'Manage Entity Permissions On Own Content'
];
foreach ($permissionsToCreate as $name => $displayName) {
$newPermission = new \BookStack\Permission();
$newPermission->name = $name;
$newPermission->display_name = $displayName;
$newPermission->save();
$adminRole->attachPermission($newPermission);
$permissionId = DB::table('permissions')->insertGetId([
'name' => $name,
'display_name' => $displayName,
'created_at' => \Carbon\Carbon::now()->toDateTimeString(),
'updated_at' => \Carbon\Carbon::now()->toDateTimeString()
]);
DB::table('permission_role')->insert([
'role_id' => $adminRoleId,
'permission_id' => $permissionId
]);
}
// Create & attach new entity permissions
@@ -43,12 +45,22 @@ class UpdatePermissionsAndRoles extends Migration
$ops = ['Create All', 'Create Own', 'Update All', 'Update Own', 'Delete All', 'Delete Own'];
foreach ($entities as $entity) {
foreach ($ops as $op) {
$newPermission = new \BookStack\Permission();
$newPermission->name = strtolower($entity) . '-' . strtolower(str_replace(' ', '-', $op));
$newPermission->display_name = $op . ' ' . $entity . 's';
$newPermission->save();
$adminRole->attachPermission($newPermission);
if ($editorRole !== null) $editorRole->attachPermission($newPermission);
$permissionId = DB::table('permissions')->insertGetId([
'name' => strtolower($entity) . '-' . strtolower(str_replace(' ', '-', $op)),
'display_name' => $op . ' ' . $entity . 's',
'created_at' => \Carbon\Carbon::now()->toDateTimeString(),
'updated_at' => \Carbon\Carbon::now()->toDateTimeString()
]);
DB::table('permission_role')->insert([
'role_id' => $adminRoleId,
'permission_id' => $permissionId
]);
if ($editorRole !== null) {
DB::table('permission_role')->insert([
'role_id' => $editorRole->id,
'permission_id' => $permissionId
]);
}
}
}
@@ -62,24 +74,26 @@ class UpdatePermissionsAndRoles extends Migration
public function down()
{
// Get roles with permissions we need to change
$adminRole = \BookStack\Role::getRole('admin');
$adminRoleId = DB::table('roles')->where('name', '=', 'admin')->first()->id;
// Delete old permissions
$permissions = \BookStack\Permission::all();
$permissions->each(function ($permission) {
$permission->delete();
});
$permissions = DB::table('permissions')->delete();
// Create default CRUD permissions and allocate to admins and editors
$entities = ['Book', 'Page', 'Chapter', 'Image'];
$ops = ['Create', 'Update', 'Delete'];
foreach ($entities as $entity) {
foreach ($ops as $op) {
$newPermission = new \BookStack\Permission();
$newPermission->name = strtolower($entity) . '-' . strtolower($op);
$newPermission->display_name = $op . ' ' . $entity . 's';
$newPermission->save();
$adminRole->attachPermission($newPermission);
$permissionId = DB::table('permissions')->insertGetId([
'name' => strtolower($entity) . '-' . strtolower($op),
'display_name' => $op . ' ' . $entity . 's',
'created_at' => \Carbon\Carbon::now()->toDateTimeString(),
'updated_at' => \Carbon\Carbon::now()->toDateTimeString()
]);
DB::table('permission_role')->insert([
'role_id' => $adminRoleId,
'permission_id' => $permissionId
]);
}
}
@@ -88,11 +102,16 @@ class UpdatePermissionsAndRoles extends Migration
$ops = ['Create', 'Update', 'Delete'];
foreach ($entities as $entity) {
foreach ($ops as $op) {
$newPermission = new \BookStack\Permission();
$newPermission->name = strtolower($entity) . '-' . strtolower($op);
$newPermission->display_name = $op . ' ' . $entity;
$newPermission->save();
$adminRole->attachPermission($newPermission);
$permissionId = DB::table('permissions')->insertGetId([
'name' => strtolower($entity) . '-' . strtolower($op),
'display_name' => $op . ' ' . $entity,
'created_at' => \Carbon\Carbon::now()->toDateTimeString(),
'updated_at' => \Carbon\Carbon::now()->toDateTimeString()
]);
DB::table('permission_role')->insert([
'role_id' => $adminRoleId,
'permission_id' => $permissionId
]);
}
}
}

View File

@@ -0,0 +1,58 @@
<?php
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class AddViewPermissionsToRoles extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
$currentRoles = DB::table('roles')->get();
// Create new view permission
$entities = ['Book', 'Page', 'Chapter'];
$ops = ['View All', 'View Own'];
foreach ($entities as $entity) {
foreach ($ops as $op) {
$permId = DB::table('permissions')->insertGetId([
'name' => strtolower($entity) . '-' . strtolower(str_replace(' ', '-', $op)),
'display_name' => $op . ' ' . $entity . 's',
'created_at' => \Carbon\Carbon::now()->toDateTimeString(),
'updated_at' => \Carbon\Carbon::now()->toDateTimeString()
]);
// Assign view permission to all current roles
foreach ($currentRoles as $role) {
DB::table('permission_role')->insert([
'role_id' => $role->id,
'permission_id' => $permId
]);
}
}
}
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
// Delete the new view permission
$entities = ['Book', 'Page', 'Chapter'];
$ops = ['View All', 'View Own'];
foreach ($entities as $entity) {
foreach ($ops as $op) {
$permissionName = strtolower($entity) . '-' . strtolower(str_replace(' ', '-', $op));
$permission = DB::table('permissions')->where('name', '=', $permissionName)->first();
DB::table('permission_role')->where('permission_id', '=', $permission->id)->delete();
DB::table('permissions')->where('name', '=', $permissionName)->delete();
}
}
}
}

View File

@@ -0,0 +1,103 @@
<?php
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateJointPermissionsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('joint_permissions', function (Blueprint $table) {
$table->increments('id');
$table->integer('role_id');
$table->string('entity_type');
$table->integer('entity_id');
$table->string('action');
$table->boolean('has_permission')->default(false);
$table->boolean('has_permission_own')->default(false);
$table->integer('created_by');
// Create indexes
$table->index(['entity_id', 'entity_type']);
$table->index('has_permission');
$table->index('has_permission_own');
$table->index('role_id');
$table->index('action');
$table->index('created_by');
});
Schema::table('roles', function (Blueprint $table) {
$table->string('system_name');
$table->boolean('hidden')->default(false);
$table->index('hidden');
$table->index('system_name');
});
Schema::rename('permissions', 'role_permissions');
Schema::rename('restrictions', 'entity_permissions');
// Create the new public role
$publicRoleData = [
'name' => 'public',
'display_name' => 'Public',
'description' => 'The role given to public visitors if allowed',
'system_name' => 'public',
'hidden' => true,
'created_at' => \Carbon\Carbon::now()->toDateTimeString(),
'updated_at' => \Carbon\Carbon::now()->toDateTimeString()
];
// Ensure unique name
while (DB::table('roles')->where('name', '=', $publicRoleData['display_name'])->count() > 0) {
$publicRoleData['display_name'] = $publicRoleData['display_name'] . str_random(2);
}
$publicRoleId = DB::table('roles')->insertGetId($publicRoleData);
// Add new view permissions to public role
$entities = ['Book', 'Page', 'Chapter'];
$ops = ['View All', 'View Own'];
foreach ($entities as $entity) {
foreach ($ops as $op) {
$name = strtolower($entity) . '-' . strtolower(str_replace(' ', '-', $op));
$permission = DB::table('role_permissions')->where('name', '=', $name)->first();
// Assign view permission to public
DB::table('permission_role')->insert([
'permission_id' => $permission->id,
'role_id' => $publicRoleId
]);
}
}
// Update admin role with system name
DB::table('roles')->where('name', '=', 'admin')->update(['system_name' => 'admin']);
// Generate the new entity jointPermissions
$restrictionService = app(\BookStack\Services\PermissionService::class);
$restrictionService->buildJointPermissions();
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::drop('joint_permissions');
Schema::rename('role_permissions', 'permissions');
Schema::rename('entity_permissions', 'restrictions');
// Delete the public role
DB::table('roles')->where('system_name', '=', 'public')->delete();
Schema::table('roles', function (Blueprint $table) {
$table->dropColumn('system_name');
$table->dropColumn('hidden');
});
}
}

View File

@@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateTagsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('tags', function (Blueprint $table) {
$table->increments('id');
$table->integer('entity_id');
$table->string('entity_type', 100);
$table->string('name');
$table->string('value');
$table->integer('order');
$table->timestamps();
$table->index('name');
$table->index('value');
$table->index('order');
$table->index(['entity_id', 'entity_type']);
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::drop('tags');
}
}

View File

@@ -20,12 +20,15 @@ class DummyContentSeeder extends Seeder
->each(function($book) use ($user) {
$chapters = factory(BookStack\Chapter::class, 5)->create(['created_by' => $user->id, 'updated_by' => $user->id])
->each(function($chapter) use ($user, $book){
$pages = factory(\BookStack\Page::class, 10)->make(['created_by' => $user->id, 'updated_by' => $user->id, 'book_id' => $book->id]);
$pages = factory(\BookStack\Page::class, 5)->make(['created_by' => $user->id, 'updated_by' => $user->id, 'book_id' => $book->id]);
$chapter->pages()->saveMany($pages);
});
$pages = factory(\BookStack\Page::class, 3)->make(['created_by' => $user->id, 'updated_by' => $user->id]);
$book->chapters()->saveMany($chapters);
$book->pages()->saveMany($pages);
});
$restrictionService = app(\BookStack\Services\PermissionService::class);
$restrictionService->buildJointPermissions();
}
}

View File

@@ -4,10 +4,11 @@
"gulp": "^3.9.0"
},
"dependencies": {
"angular": "^1.5.0-rc.0",
"angular-animate": "^1.5.0-rc.0",
"angular-resource": "^1.5.0-rc.0",
"angular-sanitize": "^1.5.0-rc.0",
"angular": "^1.5.5",
"angular-animate": "^1.5.5",
"angular-resource": "^1.5.5",
"angular-sanitize": "^1.5.5",
"angular-ui-sortable": "^0.14.0",
"babel-runtime": "^5.8.29",
"bootstrap-sass": "^3.0.0",
"dropzone": "^4.0.1",

View File

@@ -34,5 +34,6 @@
<env name="GITHUB_APP_SECRET" value="aaaaaaaaaaaaaa"/>
<env name="GOOGLE_APP_ID" value="aaaaaaaaaaaaaa"/>
<env name="GOOGLE_APP_SECRET" value="aaaaaaaaaaaaaa"/>
<env name="APP_URL" value="http://bookstack.dev"/>
</php>
</phpunit>

View File

@@ -1,5 +1,5 @@
{
"css/styles.css": "css/styles.css?version=83f4ada",
"css/print-styles.css": "css/print-styles.css?version=83f4ada",
"js/common.js": "js/common.js?version=83f4ada"
"css/styles.css": "css/styles.css?version=02c6599",
"css/print-styles.css": "css/print-styles.css?version=02c6599",
"js/common.js": "js/common.js?version=02c6599"
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

7
public/libs/jquery/jquery-ui.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,9 @@
# BookStack
[![GitHub release](https://img.shields.io/github/release/ssddanbrown/BookStack.svg?maxAge=2592000)](https://github.com/ssddanbrown/BookStack/releases/latest)
[![license](https://img.shields.io/github/license/ssddanbrown/BookStack.svg?maxAge=2592000)](https://github.com/ssddanbrown/BookStack/blob/master/LICENSE)
[![Build Status](https://travis-ci.org/ssddanbrown/BookStack.svg)](https://travis-ci.org/ssddanbrown/BookStack)
A platform for storing and organising information and documentation. General information and documentation for BookStack can be found at https://www.bookstackapp.com/.
* [Installation Instructions](https://www.bookstackapp.com/docs/admin/installation)

View File

@@ -379,6 +379,15 @@ module.exports = function (ngApp, events) {
saveDraft();
};
// Listen to shortcuts coming via events
$scope.$on('editor-keydown', (event, data) => {
// Save shortcut (ctrl+s)
if (data.keyCode == 83 && (navigator.platform.match("Mac") ? data.metaKey : data.ctrlKey)) {
data.preventDefault();
saveDraft();
}
});
/**
* Discard the current draft and grab the current page
* content from the system via an AJAX request.
@@ -400,4 +409,116 @@ module.exports = function (ngApp, events) {
}]);
};
ngApp.controller('PageTagController', ['$scope', '$http', '$attrs',
function ($scope, $http, $attrs) {
const pageId = Number($attrs.pageId);
$scope.tags = [];
$scope.sortOptions = {
handle: '.handle',
items: '> tr',
containment: "parent",
axis: "y"
};
/**
* Push an empty tag to the end of the scope tags.
*/
function addEmptyTag() {
$scope.tags.push({
name: '',
value: ''
});
}
$scope.addEmptyTag = addEmptyTag;
/**
* Get all tags for the current book and add into scope.
*/
function getTags() {
$http.get('/ajax/tags/get/page/' + pageId).then((responseData) => {
$scope.tags = responseData.data;
addEmptyTag();
});
}
getTags();
/**
* Set the order property on all tags.
*/
function setTagOrder() {
for (let i = 0; i < $scope.tags.length; i++) {
$scope.tags[i].order = i;
}
}
/**
* When an tag changes check if another empty editable
* field needs to be added onto the end.
* @param tag
*/
$scope.tagChange = function(tag) {
let cPos = $scope.tags.indexOf(tag);
if (cPos !== $scope.tags.length-1) return;
if (tag.name !== '' || tag.value !== '') {
addEmptyTag();
}
};
/**
* When an tag field loses focus check the tag to see if its
* empty and therefore could be removed from the list.
* @param tag
*/
$scope.tagBlur = function(tag) {
let isLast = $scope.tags.length - 1 === $scope.tags.indexOf(tag);
if (tag.name === '' && tag.value === '' && !isLast) {
let cPos = $scope.tags.indexOf(tag);
$scope.tags.splice(cPos, 1);
}
};
/**
* Save the tags to the current page.
*/
$scope.saveTags = function() {
setTagOrder();
let postData = {tags: $scope.tags};
$http.post('/ajax/tags/update/page/' + pageId, postData).then((responseData) => {
$scope.tags = responseData.data.tags;
addEmptyTag();
events.emit('success', responseData.data.message);
})
};
/**
* Remove a tag from the current list.
* @param tag
*/
$scope.removeTag = function(tag) {
let cIndex = $scope.tags.indexOf(tag);
$scope.tags.splice(cIndex, 1);
};
}]);
};

View File

@@ -149,7 +149,10 @@ module.exports = function (ngApp, events) {
};
}]);
/**
* Dropdown
* Provides some simple logic to create small dropdown menus
*/
ngApp.directive('dropdown', [function () {
return {
restrict: 'A',
@@ -166,7 +169,11 @@ module.exports = function (ngApp, events) {
};
}]);
ngApp.directive('tinymce', ['$timeout', function($timeout) {
/**
* TinyMCE
* An angular wrapper around the tinyMCE editor.
*/
ngApp.directive('tinymce', ['$timeout', function ($timeout) {
return {
restrict: 'A',
scope: {
@@ -185,6 +192,10 @@ module.exports = function (ngApp, events) {
scope.mceChange(content);
});
editor.on('keydown', (event) => {
scope.$emit('editor-keydown', event);
});
editor.on('init', (e) => {
scope.mceModel = editor.getContent();
});
@@ -200,8 +211,8 @@ module.exports = function (ngApp, events) {
scope.tinymce.extraSetups.push(tinyMceSetup);
// Custom tinyMCE plugins
tinymce.PluginManager.add('customhr', function(editor) {
editor.addCommand('InsertHorizontalRule', function() {
tinymce.PluginManager.add('customhr', function (editor) {
editor.addCommand('InsertHorizontalRule', function () {
var hrElem = document.createElement('hr');
var cNode = editor.selection.getNode();
var parentNode = cNode.parentNode;
@@ -227,7 +238,11 @@ module.exports = function (ngApp, events) {
}
}]);
ngApp.directive('markdownInput', ['$timeout', function($timeout) {
/**
* Markdown input
* Handles the logic for just the editor input field.
*/
ngApp.directive('markdownInput', ['$timeout', function ($timeout) {
return {
restrict: 'A',
scope: {
@@ -251,7 +266,7 @@ module.exports = function (ngApp, events) {
scope.$on('markdown-update', (event, value) => {
element.val(value);
scope.mdModel= value;
scope.mdModel = value;
scope.mdChange(markdown(value));
});
@@ -259,23 +274,59 @@ module.exports = function (ngApp, events) {
}
}]);
ngApp.directive('markdownEditor', ['$timeout', function($timeout) {
/**
* Markdown Editor
* Handles all functionality of the markdown editor.
*/
ngApp.directive('markdownEditor', ['$timeout', function ($timeout) {
return {
restrict: 'A',
link: function (scope, element, attrs) {
// Elements
var input = element.find('textarea[markdown-input]');
var insertImage = element.find('button[data-action="insertImage"]');
const input = element.find('textarea[markdown-input]');
const display = element.find('.markdown-display').first();
const insertImage = element.find('button[data-action="insertImage"]');
var currentCaretPos = 0;
let currentCaretPos = 0;
input.blur((event) => {
input.blur(event => {
currentCaretPos = input[0].selectionStart;
});
// Insert image shortcut
input.keydown((event) => {
// Scroll sync
let inputScrollHeight,
inputHeight,
displayScrollHeight,
displayHeight;
function setScrollHeights() {
inputScrollHeight = input[0].scrollHeight;
inputHeight = input.height();
displayScrollHeight = display[0].scrollHeight;
displayHeight = display.height();
}
setTimeout(() => {
setScrollHeights();
}, 200);
window.addEventListener('resize', setScrollHeights);
let scrollDebounceTime = 800;
let lastScroll = 0;
input.on('scroll', event => {
let now = Date.now();
if (now - lastScroll > scrollDebounceTime) {
setScrollHeights()
}
let scrollPercent = (input.scrollTop() / (inputScrollHeight - inputHeight));
let displayScrollY = (displayScrollHeight - displayHeight) * scrollPercent;
display.scrollTop(displayScrollY);
lastScroll = now;
});
// Editor key-presses
input.keydown(event => {
// Insert image shortcut
if (event.which === 73 && event.ctrlKey && event.shiftKey) {
event.preventDefault();
var caretPos = input[0].selectionStart;
@@ -285,12 +336,15 @@ module.exports = function (ngApp, events) {
input.focus();
input[0].selectionStart = caretPos + ("![](".length);
input[0].selectionEnd = caretPos + ('![](http://'.length);
return;
}
// Pass key presses to controller via event
scope.$emit('editor-keydown', event);
});
// Insert image from image manager
insertImage.click((event) => {
window.ImageManager.showExternal((image) => {
insertImage.click(event => {
window.ImageManager.showExternal(image => {
var caretPos = currentCaretPos;
var currentContent = input.val();
var mdImageText = "![" + image.name + "](" + image.url + ")";
@@ -301,6 +355,306 @@ module.exports = function (ngApp, events) {
}
}
}])
}]);
/**
* Page Editor Toolbox
* Controls all functionality for the sliding toolbox
* on the page edit view.
*/
ngApp.directive('toolbox', [function () {
return {
restrict: 'A',
link: function (scope, elem, attrs) {
// Get common elements
const $buttons = elem.find('[tab-button]');
const $content = elem.find('[tab-content]');
const $toggle = elem.find('[toolbox-toggle]');
// Handle toolbox toggle click
$toggle.click((e) => {
elem.toggleClass('open');
});
// Set an active tab/content by name
function setActive(tabName, openToolbox) {
$buttons.removeClass('active');
$content.hide();
$buttons.filter(`[tab-button="${tabName}"]`).addClass('active');
$content.filter(`[tab-content="${tabName}"]`).show();
if (openToolbox) elem.addClass('open');
}
// Set the first tab content active on load
setActive($content.first().attr('tab-content'), false);
// Handle tab button click
$buttons.click(function (e) {
let name = $(this).attr('tab-button');
setActive(name, true);
});
}
}
}]);
/**
* Tag Autosuggestions
* Listens to child inputs and provides autosuggestions depending on field type
* and input. Suggestions provided by server.
*/
ngApp.directive('tagAutosuggestions', ['$http', function ($http) {
return {
restrict: 'A',
link: function (scope, elem, attrs) {
// Local storage for quick caching.
const localCache = {};
// Create suggestion element
const suggestionBox = document.createElement('ul');
suggestionBox.className = 'suggestion-box';
suggestionBox.style.position = 'absolute';
suggestionBox.style.display = 'none';
const $suggestionBox = $(suggestionBox);
// General state tracking
let isShowing = false;
let currentInput = false;
let active = 0;
// Listen to input events on autosuggest fields
elem.on('input focus', '[autosuggest]', function (event) {
let $input = $(this);
let val = $input.val();
let url = $input.attr('autosuggest');
let type = $input.attr('autosuggest-type');
// Add name param to request if for a value
if (type.toLowerCase() === 'value') {
let $nameInput = $input.closest('tr').find('[autosuggest-type="name"]').first();
let nameVal = $nameInput.val();
if (nameVal !== '') {
url += '?name=' + encodeURIComponent(nameVal);
}
}
let suggestionPromise = getSuggestions(val.slice(0, 3), url);
suggestionPromise.then(suggestions => {
if (val.length === 0) {
displaySuggestions($input, suggestions.slice(0, 6));
} else {
suggestions = suggestions.filter(item => {
return item.toLowerCase().indexOf(val.toLowerCase()) !== -1;
}).slice(0, 4);
displaySuggestions($input, suggestions);
}
});
});
// Hide autosuggestions when input loses focus.
// Slight delay to allow clicks.
let lastFocusTime = 0;
elem.on('blur', '[autosuggest]', function (event) {
let startTime = Date.now();
setTimeout(() => {
if (lastFocusTime < startTime) {
$suggestionBox.hide();
isShowing = false;
}
}, 200)
});
elem.on('focus', '[autosuggest]', function (event) {
lastFocusTime = Date.now();
});
elem.on('keydown', '[autosuggest]', function (event) {
if (!isShowing) return;
let suggestionElems = suggestionBox.childNodes;
let suggestCount = suggestionElems.length;
// Down arrow
if (event.keyCode === 40) {
let newActive = (active === suggestCount - 1) ? 0 : active + 1;
changeActiveTo(newActive, suggestionElems);
}
// Up arrow
else if (event.keyCode === 38) {
let newActive = (active === 0) ? suggestCount - 1 : active - 1;
changeActiveTo(newActive, suggestionElems);
}
// Enter or tab key
else if ((event.keyCode === 13 || event.keyCode === 9) && !event.shiftKey) {
let text = suggestionElems[active].textContent;
currentInput[0].value = text;
currentInput.focus();
$suggestionBox.hide();
isShowing = false;
if (event.keyCode === 13) {
event.preventDefault();
return false;
}
}
});
// Change the active suggestion to the given index
function changeActiveTo(index, suggestionElems) {
suggestionElems[active].className = '';
active = index;
suggestionElems[active].className = 'active';
}
// Display suggestions on a field
let prevSuggestions = [];
function displaySuggestions($input, suggestions) {
// Hide if no suggestions
if (suggestions.length === 0) {
$suggestionBox.hide();
isShowing = false;
prevSuggestions = suggestions;
return;
}
// Otherwise show and attach to input
if (!isShowing) {
$suggestionBox.show();
isShowing = true;
}
if ($input !== currentInput) {
$suggestionBox.detach();
$input.after($suggestionBox);
currentInput = $input;
}
// Return if no change
if (prevSuggestions.join() === suggestions.join()) {
prevSuggestions = suggestions;
return;
}
// Build suggestions
$suggestionBox[0].innerHTML = '';
for (let i = 0; i < suggestions.length; i++) {
var suggestion = document.createElement('li');
suggestion.textContent = suggestions[i];
suggestion.onclick = suggestionClick;
if (i === 0) {
suggestion.className = 'active'
active = 0;
}
;
$suggestionBox[0].appendChild(suggestion);
}
prevSuggestions = suggestions;
}
// Suggestion click event
function suggestionClick(event) {
let text = this.textContent;
currentInput[0].value = text;
currentInput.focus();
$suggestionBox.hide();
isShowing = false;
};
// Get suggestions & cache
function getSuggestions(input, url) {
let hasQuery = url.indexOf('?') !== -1;
let searchUrl = url + (hasQuery ? '&' : '?') + 'search=' + encodeURIComponent(input);
// Get from local cache if exists
if (typeof localCache[searchUrl] !== 'undefined') {
return new Promise((resolve, reject) => {
resolve(localCache[searchUrl]);
});
}
return $http.get(searchUrl).then(response => {
localCache[searchUrl] = response.data;
return response.data;
});
}
}
}
}]);
ngApp.directive('entitySelector', ['$http', '$sce', function ($http, $sce) {
return {
restrict: 'A',
scope: true,
link: function (scope, element, attrs) {
scope.loading = true;
scope.entityResults = false;
scope.search = '';
// Add input for forms
const input = element.find('[entity-selector-input]').first();
// Listen to entity item clicks
element.on('click', '.entity-list a', function(event) {
event.preventDefault();
event.stopPropagation();
let item = $(this).closest('[data-entity-type]');
itemSelect(item);
});
element.on('click', '[data-entity-type]', function(event) {
itemSelect($(this));
});
// Select entity action
function itemSelect(item) {
let entityType = item.attr('data-entity-type');
let entityId = item.attr('data-entity-id');
let isSelected = !item.hasClass('selected');
element.find('.selected').removeClass('selected').removeClass('primary-background');
if (isSelected) item.addClass('selected').addClass('primary-background');
let newVal = isSelected ? `${entityType}:${entityId}` : '';
input.val(newVal);
}
// Get search url with correct types
function getSearchUrl() {
let types = (attrs.entityTypes) ? encodeURIComponent(attrs.entityTypes) : encodeURIComponent('page,book,chapter');
return `/ajax/search/entities?types=${types}`;
}
// Get initial contents
$http.get(getSearchUrl()).then(resp => {
scope.entityResults = $sce.trustAsHtml(resp.data);
scope.loading = false;
});
// Search when typing
scope.searchEntities = function() {
scope.loading = true;
input.val('');
let url = getSearchUrl() + '&term=' + encodeURIComponent(scope.search);
$http.get(url).then(resp => {
scope.entityResults = $sce.trustAsHtml(resp.data);
scope.loading = false;
});
};
}
};
}]);
};
};

View File

@@ -5,9 +5,9 @@ var angular = require('angular');
var ngResource = require('angular-resource');
var ngAnimate = require('angular-animate');
var ngSanitize = require('angular-sanitize');
require('angular-ui-sortable');
var ngApp = angular.module('bookStack', ['ngResource', 'ngAnimate', 'ngSanitize']);
var ngApp = angular.module('bookStack', ['ngResource', 'ngAnimate', 'ngSanitize', 'ui.sortable']);
// Global Event System
var Events = {
@@ -112,16 +112,11 @@ $(function () {
// Common jQuery actions
$('[data-action="expand-entity-list-details"]').click(function() {
$('.entity-list.compact').find('p').slideToggle(240);
$('.entity-list.compact').find('p').not('.empty-text').slideToggle(240);
});
});
function elemExists(selector) {
return document.querySelector(selector) !== null;
}
// Page specific items
require('./pages/page-show');

View File

@@ -1,7 +1,8 @@
var mceOptions = module.exports = {
selector: '#html-editor',
content_css: [
'/css/styles.css'
'/css/styles.css',
'/libs/material-design-iconic-font/css/material-design-iconic-font.min.css'
],
body_class: 'page-content',
relative_urls: false,
@@ -19,11 +20,18 @@ var mceOptions = module.exports = {
{title: "Header 1", format: "h1"},
{title: "Header 2", format: "h2"},
{title: "Header 3", format: "h3"},
{title: "Paragraph", format: "p"},
{title: "Paragraph", format: "p", exact: true, classes: ''},
{title: "Blockquote", format: "blockquote"},
{title: "Code Block", icon: "code", format: "pre"},
{title: "Inline Code", icon: "code", inline: "code"}
{title: "Inline Code", icon: "code", inline: "code"},
{title: "Callouts", items: [
{title: "Success", block: 'p', exact: true, attributes : {'class' : 'callout success'}},
{title: "Info", block: 'p', exact: true, attributes : {'class' : 'callout info'}},
{title: "Warning", block: 'p', exact: true, attributes : {'class' : 'callout warning'}},
{title: "Danger", block: 'p', exact: true, attributes : {'class' : 'callout danger'}}
]}
],
style_formats_merge: false,
formats: {
alignleft: {selector: 'p,h1,h2,h3,h4,h5,h6,td,th,div,ul,ol,li,table,img', classes: 'align-left'},
aligncenter: {selector: 'p,h1,h2,h3,h4,h5,h6,td,th,div,ul,ol,li,table,img', classes: 'align-center'},

View File

@@ -74,15 +74,15 @@ window.setupPageShow = module.exports = function (pageId) {
// Make the book-tree sidebar stick in view on scroll
var $window = $(window);
var $bookTree = $(".book-tree");
var $bookTreeParent = $bookTree.parent();
// Check the page is scrollable and the content is taller than the tree
var pageScrollable = ($(document).height() > $window.height()) && ($bookTree.height() < $('.page-content').height());
// Get current tree's width and header height
var headerHeight = $("#header").height() + $(".toolbar").height();
var isFixed = $window.scrollTop() > headerHeight;
var bookTreeWidth = $bookTree.width();
// Function to fix the tree as a sidebar
function stickTree() {
$bookTree.width(bookTreeWidth + 48 + 15);
$bookTree.width($bookTreeParent.width() + 15);
$bookTree.addClass("fixed");
isFixed = true;
}
@@ -101,13 +101,27 @@ window.setupPageShow = module.exports = function (pageId) {
unstickTree();
}
}
// The event ran when the window scrolls
function windowScrollEvent() {
checkTreeStickiness(false);
}
// If the page is scrollable and the window is wide enough listen to scroll events
// and evaluate tree stickiness.
if (pageScrollable && $window.width() > 1000) {
$window.scroll(function() {
checkTreeStickiness(false);
});
$window.on('scroll', windowScrollEvent);
checkTreeStickiness(true);
}
// Handle window resizing and switch between desktop/mobile views
$window.on('resize', event => {
if (pageScrollable && $window.width() > 1000) {
$window.on('scroll', windowScrollEvent);
checkTreeStickiness(true);
} else {
$window.off('scroll', windowScrollEvent);
unstickTree();
}
});
};

View File

@@ -125,3 +125,51 @@
margin-right: $-xl;
}
}
/**
* Callouts
*/
.callout {
border-left: 3px solid #BBB;
background-color: #EEE;
padding: $-s;
&:before {
font-family: 'Material-Design-Iconic-Font';
padding-right: $-s;
display: inline-block;
}
&.success {
border-left-color: $positive;
background-color: lighten($positive, 45%);
color: darken($positive, 16%);
}
&.success:before {
content: '\f269';
}
&.danger {
border-left-color: $negative;
background-color: lighten($negative, 34%);
color: darken($negative, 20%);
}
&.danger:before {
content: '\f1f2';
}
&.info {
border-left-color: $info;
background-color: lighten($info, 50%);
color: darken($info, 16%);
}
&.info:before {
content: '\f1f8';
}
&.warning {
border-left-color: $warning;
background-color: lighten($warning, 36%);
color: darken($warning, 16%);
}
&.warning:before {
content: '\f1f1';
}
}

View File

@@ -65,6 +65,9 @@ $button-border-radius: 2px;
&:focus, &:active {
outline: 0;
}
&:hover {
text-decoration: none;
}
&.neg {
color: $negative;
}

View File

@@ -20,6 +20,9 @@
&.disabled, &[disabled] {
background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAcAAAAHCAYAAADEUlfTAAAAMUlEQVQIW2NkwAGuXbv2nxGbHEhCS0uLEUMSJgHShCKJLIEiiS4Bl8QmAZbEJQGSBAC62BuJ+tt7zgAAAABJRU5ErkJggg==);
}
&:focus {
outline: 0;
}
}
#html-editor {
@@ -154,6 +157,11 @@ input:checked + .toggle-switch {
.form-group {
margin-bottom: $-s;
textarea {
display: block;
width: 100%;
min-height: 64px;
}
}
.form-group {
@@ -239,6 +247,17 @@ div[editor-type="markdown"] .title-input.page-title input[type="text"] {
}
}
input.outline {
border: 0;
border-bottom: 2px solid #DDD;
border-radius: 0;
&:focus, &:active {
border: 0;
border-bottom: 2px solid #AAA;
outline: 0;
}
}
#login-form label[for="remember"] {
margin: 0;
}

View File

@@ -11,13 +11,16 @@ body.flexbox {
#content {
flex: 1;
display: flex;
min-height: 0px;
}
}
.flex-fill {
display: flex;
align-items: stretch;
min-height: 0px;
.flex, &.flex {
min-height: 0px;
flex: 1;
}
}

View File

@@ -1,6 +1,7 @@
.page-list {
h3 {
margin: $-l 0 $-m 0;
margin: $-l 0 $-xs 0;
font-size: 1.666em;
}
a.chapter {
color: $color-chapter;
@@ -8,7 +9,6 @@
.inset-list {
display: none;
overflow: hidden;
// padding-left: $-m;
margin-bottom: $-l;
}
h4 {
@@ -266,6 +266,7 @@ ul.pagination {
display: inline-block;
list-style: none;
margin: $-m 0;
padding-left: 1px;
li {
float: left;
}
@@ -300,6 +301,10 @@ ul.pagination {
}
}
.compact ul.pagination {
margin: 0;
}
.entity-list {
>div {
padding: $-m 0;
@@ -333,6 +338,10 @@ ul.pagination {
padding-top: $-xs;
margin: 0;
}
> p.empty-text {
display: block;
font-size: $fs-m;
}
hr {
margin: 0;
}

View File

@@ -48,8 +48,8 @@
max-width: 100%;
height:auto;
}
h1, h2, h3, h4, h5, h6 {
clear: both;
h1, h2, h3, h4, h5, h6, pre {
clear: left;
}
hr {
clear: both;
@@ -72,7 +72,7 @@
.pointer {
border: 1px solid #CCC;
display: inline-block;
padding: $-xs $-s;
padding: $-s $-s;
border-radius: 4px;
box-shadow: 0 0 8px 1px rgba(212, 209, 209, 0.35);
position: absolute;
@@ -122,9 +122,181 @@
}
}
h1, h2, h3, h4, h5, h6 {
&:hover a.link-hook {
opacity: 1;
transform: translate3d(0, 0, 0);
// Attribute form
.floating-toolbox {
background-color: #FFF;
border: 1px solid #DDD;
right: $-xl*2;
z-index: 99;
width: 48px;
overflow: hidden;
align-items: stretch;
flex-direction: row;
display: flex;
transition: width ease-in-out 180ms;
margin-top: -1px;
min-height: 0px;
&.open {
width: 480px;
}
[toolbox-toggle] i {
transition: transform ease-in-out 180ms;
}
[toolbox-toggle] {
transition: background-color ease-in-out 180ms;
}
&.open [toolbox-toggle] {
background-color: rgba(255, 0, 0, 0.29);
}
&.open [toolbox-toggle] i {
transform: rotate(180deg);
}
> div {
flex: 1;
position: relative;
}
.tabs {
display: block;
border-right: 1px solid #DDD;
width: 54px;
flex: 0;
}
.tabs i {
color: rgba(0, 0, 0, 0.5);
padding: 0;
margin: 0;
}
.tabs > span {
display: block;
cursor: pointer;
padding: $-s $-m;
font-size: 13.5px;
line-height: 1.6;
border-bottom: 1px solid rgba(255, 255, 255, 0.3);
}
&.open .tabs > span.active {
color: #444;
background-color: rgba(0, 0, 0, 0.1);
}
div[tab-content] {
padding-bottom: 45px;
display: flex;
flex: 1;
flex-direction: column;
min-height: 0px;
overflow-y: scroll;
}
div[tab-content] .padded {
flex: 1;
padding-top: 0;
}
h4 {
font-size: 24px;
margin: $-m 0 0 0;
padding: 0 $-l $-s $-l;
}
.tags input {
max-width: 100%;
width: 100%;
min-width: 50px;
}
.tags td {
padding-right: $-s;
padding-top: $-s;
position: relative;
}
button.pos {
position: absolute;
bottom: 0;
display: block;
width: 100%;
padding: $-s;
height: 45px;
border: 0;
margin: 0;
box-shadow: none;
border-radius: 0;
&:hover{
box-shadow: none;
}
}
.handle {
user-select: none;
cursor: move;
color: #999;
}
form {
display: flex;
flex: 1;
flex-direction: column;
overflow-y: scroll;
}
}
[tab-content] {
display: none;
}
.tag-display {
margin: $-xl $-xs;
border: 1px solid #DDD;
min-width: 180px;
max-width: 320px;
opacity: 0.7;
z-index: 5;
position: relative;
table {
width: 100%;
margin: 0;
padding: 0;
}
span {
color: #666;
margin-left: $-s;
}
.heading {
padding: $-xs $-s;
color: #444;
}
td {
border: 0;
border-bottom: 1px solid #DDD;
padding: $-xs $-s;
color: #444;
}
.tag-value {
color: #888;
}
td i {
color: #888;
}
tr:last-child td {
border-bottom: none;
}
.tag {
padding: $-s;
}
}
.suggestion-box {
position: absolute;
background-color: #FFF;
border: 1px solid #BBB;
box-shadow: $bs-light;
list-style: none;
z-index: 100;
padding: 0;
margin: 0;
border-radius: 3px;
li {
display: block;
padding: $-xs $-s;
border-bottom: 1px solid #DDD;
&:last-child {
border-bottom: 0;
}
&.active {
background-color: #EEE;
}
}
}

View File

@@ -26,6 +26,13 @@ table {
}
}
table.no-style {
td {
border: 0;
padding: 0;
}
}
table.list-table {
margin: 0 -$-xs;
td {

View File

@@ -3,7 +3,7 @@
*/
h1 {
font-size: 3.625em;
font-size: 3.425em;
line-height: 1.22222222em;
margin-top: 0.48888889em;
margin-bottom: 0.48888889em;
@@ -33,10 +33,10 @@ h1, h2, h3, h4 {
display: block;
color: #555;
.subheader {
display: block;
//display: block;
font-size: 0.5em;
line-height: 1em;
color: lighten($text-dark, 16%);
color: lighten($text-dark, 32%);
}
}
@@ -225,6 +225,15 @@ p.secondary, p .secondary, span.secondary, .text-secondary {
color: $color-chapter;
}
}
.faded .text-book:hover {
color: $color-book !important;
}
.faded .text-chapter:hover {
color: $color-chapter !important;
}
.faded .text-page:hover {
color: $color-page !important;
}
span.highlight {
//background-color: rgba($primary, 0.2);
@@ -297,6 +306,12 @@ span.sep {
display: block;
}
.action-header {
h1 {
margin-top: $-m;
}
}
/**
* Icons
*/

View File

@@ -38,6 +38,7 @@ $primary-dark: #0288D1;
$secondary: #e27b41;
$positive: #52A256;
$negative: #E84F4F;
$info: $primary;
$warning: $secondary;
$primary-faded: rgba(21, 101, 192, 0.15);

View File

@@ -21,6 +21,11 @@
[ng\:cloak], [ng-cloak], .ng-cloak {
display: none !important;
user-select: none;
}
[ng-click] {
cursor: pointer;
}
// Jquery Sortable Styles
@@ -201,4 +206,60 @@ $btt-size: 40px;
background-color: $negative;
color: #EEE;
}
}
}
.entity-selector {
border: 1px solid #DDD;
border-radius: 3px;
overflow: hidden;
font-size: 0.8em;
input[type="text"] {
width: 100%;
display: block;
border-radius: 0;
border: 0;
border-bottom: 1px solid #DDD;
font-size: 16px;
padding: $-s $-m;
}
.entity-list {
overflow-y: scroll;
height: 400px;
background-color: #EEEEEE;
}
.loading {
height: 400px;
padding-top: $-l;
}
.entity-list > p {
text-align: center;
padding-top: $-l;
font-size: 1.333em;
}
.entity-list > div {
padding-left: $-m;
padding-right: $-m;
background-color: #FFF;
transition: all ease-in-out 120ms;
cursor: pointer;
}
}
.entity-list-item.selected {
h3, i, p ,a, span {
color: #EEE;
}
}

View File

@@ -4,7 +4,7 @@ return [
/**
* Activity text strings.
* Is used for all the text within activity logs.
* Is used for all the text within activity logs & notifications.
*/
// Pages
@@ -16,6 +16,7 @@ return [
'page_delete_notification' => 'Page Successfully Deleted',
'page_restore' => 'restored page',
'page_restore_notification' => 'Page Successfully Restored',
'page_move' => 'moved page',
// Chapters
'chapter_create' => 'created chapter',
@@ -24,6 +25,7 @@ return [
'chapter_update_notification' => 'Chapter Successfully Updated',
'chapter_delete' => 'deleted chapter',
'chapter_delete_notification' => 'Chapter Successfully Deleted',
'chapter_move' => 'moved chapter',
// Books
'book_create' => 'created book',

View File

@@ -15,10 +15,16 @@
<!-- Scripts -->
<script src="/libs/jquery/jquery.min.js?version=2.1.4"></script>
<script src="/libs/jquery/jquery-ui.min.js?version=1.11.4"></script>
@yield('head')
@include('partials/custom-styles')
<!-- Custom user content -->
@if(setting('app-custom-head', false))
{!! setting('app-custom-head') !!}
@endif
</head>
<body class="@yield('body-class')" ng-app="bookStack">

View File

@@ -1,4 +1,4 @@
<div class="book">
<div class="book entity-list-item" data-entity-type="book" data-entity-id="{{$book->id}}">
<h3 class="text-book"><a class="text-book" href="{{$book->getUrl()}}"><i class="zmdi zmdi-book"></i>{{$book->name}}</a></h3>
@if(isset($book->searchSnippet))
<p class="text-muted">{!! $book->searchSnippet !!}</p>

View File

@@ -68,9 +68,9 @@
<hr>
@endif
<p class="text-muted small">
Created {{$book->created_at->diffForHumans()}} @if($book->createdBy) by {{$book->createdBy->name}} @endif
Created {{$book->created_at->diffForHumans()}} @if($book->createdBy) by <a href="/user/{{ $book->createdBy->id }}">{{$book->createdBy->name}}</a> @endif
<br>
Last Updated {{$book->updated_at->diffForHumans()}} @if($book->updatedBy) by {{$book->updatedBy->name}} @endif
Last Updated {{$book->updated_at->diffForHumans()}} @if($book->updatedBy) by <a href="/user/{{ $book->updatedBy->id }}">{{$book->updatedBy->name}}</a> @endif
</p>
</div>
</div>

View File

@@ -1,5 +1,11 @@
<div class="chapter">
<div class="chapter entity-list-item" data-entity-type="chapter" data-entity-id="{{$chapter->id}}">
<h3>
@if (isset($showPath) && $showPath)
<a href="{{ $chapter->book->getUrl() }}" class="text-book">
<i class="zmdi zmdi-book"></i>{{ $chapter->book->name }}
</a>
<span class="text-muted">&nbsp;&nbsp;&raquo;&nbsp;&nbsp;</span>
@endif
<a href="{{ $chapter->getUrl() }}" class="text-chapter">
<i class="zmdi zmdi-collection-bookmark"></i>{{ $chapter->name }}
</a>

View File

@@ -0,0 +1,33 @@
@extends('base')
@section('content')
<div class="faded-small toolbar">
<div class="container">
<div class="row">
<div class="col-sm-12 faded">
<div class="breadcrumbs">
<a href="{{$book->getUrl()}}" class="text-book text-button"><i class="zmdi zmdi-book"></i>{{ $book->getShortName() }}</a>
<span class="sep">&raquo;</span>
<a href="{{$chapter->getUrl()}}" class="text-chapter text-button"><i class="zmdi zmdi-collection-bookmark"></i>{{ $chapter->getShortName() }}</a>
</div>
</div>
</div>
</div>
</div>
<div class="container">
<h1>Move Chapter <small class="subheader">{{$chapter->name}}</small></h1>
<form action="{{ $chapter->getUrl() }}/move" method="POST">
{!! csrf_field() !!}
<input type="hidden" name="_method" value="PUT">
@include('partials/entity-selector', ['name' => 'entity_selection', 'selectorSize' => 'large', 'entityTypes' => 'book'])
<a href="{{ $chapter->getUrl() }}" class="button muted">Cancel</a>
<button type="submit" class="button pos">Move Chapter</button>
</form>
</div>
@stop

View File

@@ -2,15 +2,15 @@
@section('content')
<div class="faded-small toolbar" ng-non-bindable>
<div class="faded-small toolbar">
<div class="container">
<div class="row">
<div class="col-md-4 faded">
<div class="col-sm-8 faded" ng-non-bindable>
<div class="breadcrumbs">
<a href="{{$book->getUrl()}}" class="text-book text-button"><i class="zmdi zmdi-book"></i>{{ $book->getShortName() }}</a>
</div>
</div>
<div class="col-md-8 faded">
<div class="col-sm-4 faded">
<div class="action-buttons">
@if(userCan('page-create', $chapter))
<a href="{{$chapter->getUrl() . '/create-page'}}" class="text-pos text-button"><i class="zmdi zmdi-plus"></i>New Page</a>
@@ -18,11 +18,21 @@
@if(userCan('chapter-update', $chapter))
<a href="{{$chapter->getUrl() . '/edit'}}" class="text-primary text-button"><i class="zmdi zmdi-edit"></i>Edit</a>
@endif
@if(userCan('restrictions-manage', $chapter))
<a href="{{$chapter->getUrl()}}/permissions" class="text-primary text-button"><i class="zmdi zmdi-lock-outline"></i>Permissions</a>
@endif
@if(userCan('chapter-delete', $chapter))
<a href="{{$chapter->getUrl() . '/delete'}}" class="text-neg text-button"><i class="zmdi zmdi-delete"></i>Delete</a>
@if(userCan('chapter-update', $chapter) || userCan('restrictions-manage', $chapter) || userCan('chapter-delete', $chapter))
<div dropdown class="dropdown-container">
<a dropdown-toggle class="text-primary text-button"><i class="zmdi zmdi-more-vert"></i></a>
<ul>
@if(userCan('chapter-update', $chapter))
<li><a href="{{$chapter->getUrl() . '/move'}}" class="text-primary"><i class="zmdi zmdi-folder"></i>Move</a></li>
@endif
@if(userCan('restrictions-manage', $chapter))
<li><a href="{{$chapter->getUrl()}}/permissions" class="text-primary"><i class="zmdi zmdi-lock-outline"></i>Permissions</a></li>
@endif
@if(userCan('chapter-delete', $chapter))
<li><a href="{{$chapter->getUrl() . '/delete'}}" class="text-neg"><i class="zmdi zmdi-delete"></i>Delete</a></li>
@endif
</ul>
</div>
@endif
</div>
</div>
@@ -49,17 +59,23 @@
<hr>
<p class="text-muted">No pages are currently in this chapter.</p>
<p>
<a href="{{$chapter->getUrl() . '/create-page'}}" class="text-page"><i class="zmdi zmdi-file-text"></i>Create a new page</a>
&nbsp;&nbsp;<em class="text-muted">-or-</em>&nbsp;&nbsp;&nbsp;
<a href="{{$book->getUrl() . '/sort'}}" class="text-book"><i class="zmdi zmdi-book"></i>Sort the current book</a>
@if(userCan('page-create', $chapter))
<a href="{{$chapter->getUrl() . '/create-page'}}" class="text-page"><i class="zmdi zmdi-file-text"></i>Create a new page</a>
@endif
@if(userCan('page-create', $chapter) && userCan('book-update', $book))
&nbsp;&nbsp;<em class="text-muted">-or-</em>&nbsp;&nbsp;&nbsp;
@endif
@if(userCan('book-update', $book))
<a href="{{$book->getUrl() . '/sort'}}" class="text-book"><i class="zmdi zmdi-book"></i>Sort the current book</a>
@endif
</p>
<hr>
@endif
<p class="text-muted small">
Created {{$chapter->created_at->diffForHumans()}} @if($chapter->createdBy) by {{$chapter->createdBy->name}} @endif
Created {{$chapter->created_at->diffForHumans()}} @if($chapter->createdBy) by <a href="/user/{{ $chapter->createdBy->id }}">{{ $chapter->createdBy->name}}</a> @endif
<br>
Last Updated {{$chapter->updated_at->diffForHumans()}} @if($chapter->updatedBy) by {{$chapter->updatedBy->name}} @endif
Last Updated {{$chapter->updated_at->diffForHumans()}} @if($chapter->updatedBy) by <a href="/user/{{ $chapter->updatedBy->id }}">{{ $chapter->updatedBy->name}}</a> @endif
</p>
</div>
<div class="col-md-3 col-md-offset-1">

View File

@@ -34,18 +34,30 @@
@else
<h3>Recent Books</h3>
@endif
@include('partials/entity-list', ['entities' => $recents, 'style' => 'compact'])
@include('partials/entity-list', [
'entities' => $recents,
'style' => 'compact',
'emptyText' => $signedIn ? 'You have not viewed any pages' : 'No books have been created'
])
</div>
<div class="col-sm-4">
<h3><a class="no-color" href="/pages/recently-created">Recently Created Pages</a></h3>
<div id="recently-created-pages">
@include('partials/entity-list', ['entities' => $recentlyCreatedPages, 'style' => 'compact'])
@include('partials/entity-list', [
'entities' => $recentlyCreatedPages,
'style' => 'compact',
'emptyText' => 'No pages have been recently created'
])
</div>
<h3><a class="no-color" href="/pages/recently-updated">Recently Updated Pages</a></h3>
<div id="recently-updated-pages">
@include('partials/entity-list', ['entities' => $recentlyUpdatedPages, 'style' => 'compact'])
@include('partials/entity-list', [
'entities' => $recentlyUpdatedPages,
'style' => 'compact',
'emptyText' => 'No pages have been recently updated'
])
</div>
</div>

View File

@@ -1,17 +0,0 @@
@extends('base')
@section('head')
<script src="/libs/tinymce/tinymce.min.js?ver=4.3.7"></script>
@stop
@section('body-class', 'flexbox')
@section('content')
<div class="flex-fill flex">
<form action="{{$book->getUrl() . '/page/' . $draft->id}}" method="POST" class="flex flex-fill">
@include('pages/form', ['model' => $draft])
</form>
</div>
@include('partials/image-manager', ['imageType' => 'gallery', 'uploaded_to' => $draft->id])
@stop

View File

@@ -9,10 +9,15 @@
@section('content')
<div class="flex-fill flex">
<form action="{{$page->getUrl()}}" data-page-id="{{ $page->id }}" method="POST" class="flex flex-fill">
<input type="hidden" name="_method" value="PUT">
<form action="{{$page->getUrl()}}" autocomplete="off" data-page-id="{{ $page->id }}" method="POST" class="flex flex-fill">
@if(!isset($isDraft))
<input type="hidden" name="_method" value="PUT">
@endif
@include('pages/form', ['model' => $page])
@include('pages/form-toolbox')
</form>
</div>
@include('partials/image-manager', ['imageType' => 'gallery', 'uploaded_to' => $page->id])

View File

@@ -0,0 +1,37 @@
<div toolbox class="floating-toolbox">
<div class="tabs primary-background-light">
<span toolbox-toggle><i class="zmdi zmdi-caret-left-circle"></i></span>
<span tab-button="tags" title="Page Tags" class="active"><i class="zmdi zmdi-tag"></i></span>
</div>
<div tab-content="tags" ng-controller="PageTagController" page-id="{{ $page->id or 0 }}">
<h4>Page Tags</h4>
<div class="padded tags">
<p class="muted small">Add some tags to better categorise your content. <br> You can assign a value to a tag for more in-depth organisation.</p>
<table class="no-style" tag-autosuggestions style="width: 100%;">
<tbody ui-sortable="sortOptions" ng-model="tags" >
<tr ng-repeat="tag in tags track by $index">
<td width="20" ><i class="handle zmdi zmdi-menu"></i></td>
<td><input autosuggest="/ajax/tags/suggest/names" autosuggest-type="name" class="outline" ng-attr-name="tags[@{{$index}}][name]" type="text" ng-model="tag.name" ng-change="tagChange(tag)" ng-blur="tagBlur(tag)" placeholder="Tag"></td>
<td><input autosuggest="/ajax/tags/suggest/values" autosuggest-type="value" class="outline" ng-attr-name="tags[@{{$index}}][value]" type="text" ng-model="tag.value" ng-change="tagChange(tag)" ng-blur="tagBlur(tag)" placeholder="Tag Value (Optional)"></td>
<td width="10" ng-show="tags.length != 1" class="text-center text-neg" style="padding: 0;" ng-click="removeTag(tag)"><i class="zmdi zmdi-close"></i></td>
</tr>
</tbody>
</table>
<table class="no-style" style="width: 100%;">
<tbody>
<tr class="unsortable">
<td width="34"></td>
<td ng-click="addEmptyTag()">
<button type="button" class="text-button">Add another tag</button>
</td>
<td></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>

View File

@@ -41,6 +41,7 @@
@include('form/text', ['name' => 'name', 'placeholder' => 'Page Title'])
</div>
</div>
<div class="edit-area flex-fill flex">
@if(setting('app-editor') === 'wysiwyg')
<textarea id="html-editor" tinymce="editorOptions" mce-change="editorChange" mce-model="editContent" name="html" rows="5"
@@ -60,7 +61,7 @@
<button class="text-button" type="button" data-action="insertImage"><i class="zmdi zmdi-image"></i>Insert Image</button>
</div>
</div>
<textarea markdown-input md-change="editorChange" md-model="editContent" name="markdown" rows="5"
<textarea markdown-input md-change="editorChange" id="markdown-editor-input" md-model="editContent" name="markdown" rows="5"
@if($errors->has('markdown')) class="neg" @endif>@if(isset($model) || old('markdown')){{htmlspecialchars( old('markdown') ? old('markdown') : ($model->markdown === '' ? $model->html : $model->markdown))}}@endif</textarea>
</div>

View File

@@ -1,4 +1,4 @@
<div class="page {{$page->draft ? 'draft' : ''}}">
<div class="page {{$page->draft ? 'draft' : ''}} entity-list-item" data-entity-type="page" data-entity-id="{{$page->id}}">
<h3>
<a href="{{ $page->getUrl() }}" class="text-page"><i class="zmdi zmdi-file-text"></i>{{ $page->name }}</a>
</h3>
@@ -11,11 +11,11 @@
@if(isset($style) && $style === 'detailed')
<div class="row meta text-muted text-small">
<div class="col-md-4">
<div class="col-md-6">
Created {{$page->created_at->diffForHumans()}} @if($page->createdBy)by {{$page->createdBy->name}}@endif <br>
Last updated {{ $page->updated_at->diffForHumans() }} @if($page->updatedBy)by {{$page->updatedBy->name}} @endif
</div>
<div class="col-md-8">
<div class="col-md-6">
<a class="text-book" href="{{ $page->book->getUrl() }}"><i class="zmdi zmdi-book"></i>{{ $page->book->getShortName(30) }}</a>
<br>
@if($page->chapter)

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