diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 000000000..ff387bd3d --- /dev/null +++ b/.travis.yml @@ -0,0 +1,27 @@ +language: php +php: + - 7.0 + +cache: + directories: + - vendor + +addons: + mariadb: '10.0' + +before_install: + - npm install -g npm@latest + +before_script: + - mysql -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 \ No newline at end of file diff --git a/app/Activity.php b/app/Activity.php index ac7c1d749..d43419c17 100644 --- a/app/Activity.php +++ b/app/Activity.php @@ -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); } /** diff --git a/app/Book.php b/app/Book.php index de1841459..919af80a5 100644 --- a/app/Book.php +++ b/app/Book.php @@ -1,35 +1,55 @@ -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; } } diff --git a/app/Chapter.php b/app/Chapter.php index b6c8684a0..08faef68e 100644 --- a/app/Chapter.php +++ b/app/Chapter.php @@ -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; } } diff --git a/app/Console/Commands/RegeneratePermissions.php b/app/Console/Commands/RegeneratePermissions.php new file mode 100644 index 000000000..60d5f4e45 --- /dev/null +++ b/app/Console/Commands/RegeneratePermissions.php @@ -0,0 +1,51 @@ +permissionService = $permissionService; + parent::__construct(); + } + + /** + * Execute the console command. + * + * @return mixed + */ + public function handle() + { + $this->permissionService->buildJointPermissions(); + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index e3a71bd14..b725c9e21 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -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, ]; /** diff --git a/app/EmailConfirmation.php b/app/EmailConfirmation.php index 46912e733..e77b754bb 100644 --- a/app/EmailConfirmation.php +++ b/app/EmailConfirmation.php @@ -1,15 +1,16 @@ -belongsTo('BookStack\User'); + return $this->belongsTo(User::class); } + } diff --git a/app/Entity.php b/app/Entity.php index 4f97c6bab..1342c2997 100644 --- a/app/Entity.php +++ b/app/Entity.php @@ -1,7 +1,7 @@ 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(); - + } diff --git a/app/Restriction.php b/app/EntityPermission.php similarity index 66% rename from app/Restriction.php rename to app/EntityPermission.php index 58d117997..eaf0a8951 100644 --- a/app/Restriction.php +++ b/app/EntityPermission.php @@ -1,10 +1,7 @@ -morphTo(); + return $this->morphTo('restrictable'); } } diff --git a/app/Http/Controllers/BookController.php b/app/Http/Controllers/BookController.php index 91c965145..5b97fbdaf 100644 --- a/app/Http/Controllers/BookController.php +++ b/app/Http/Controllers/BookController.php @@ -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()); } diff --git a/app/Http/Controllers/ChapterController.php b/app/Http/Controllers/ChapterController.php index 4641ddbdb..69e9488b9 100644 --- a/app/Http/Controllers/ChapterController.php +++ b/app/Http/Controllers/ChapterController.php @@ -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($request->all(), $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()); @@ -186,7 +184,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()); } diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index f0cb47cd9..26eeb3002 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -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); + } + } diff --git a/app/Http/Controllers/PageController.php b/app/Http/Controllers/PageController.php index a645ede02..da9273743 100644 --- a/app/Http/Controllers/PageController.php +++ b/app/Http/Controllers/PageController.php @@ -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]); } /** @@ -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()); @@ -449,7 +451,7 @@ class PageController extends Controller } /** - * Set the restrictions for this page. + * Set the permissions for this page. * @param $bookSlug * @param $pageSlug * @param Request $request @@ -460,8 +462,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()); } diff --git a/app/Http/Controllers/PermissionController.php b/app/Http/Controllers/PermissionController.php index c565bb20a..ed430c0b7 100644 --- a/app/Http/Controllers/PermissionController.php +++ b/app/Http/Controllers/PermissionController.php @@ -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]); } diff --git a/app/Http/Controllers/TagController.php b/app/Http/Controllers/TagController.php new file mode 100644 index 000000000..1823b0dc8 --- /dev/null +++ b/app/Http/Controllers/TagController.php @@ -0,0 +1,74 @@ +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->get('search'); + $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->get('search'); + $suggestions = $this->tagRepo->getValueSuggestions($searchTerm); + return response()->json($suggestions); + } + +} diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index d59931640..053d9ebd5 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -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'); } diff --git a/app/Http/routes.php b/app/Http/routes.php index 9565b7576..9f226efd7 100644 --- a/app/Http/routes.php +++ b/app/Http/routes.php @@ -28,7 +28,7 @@ 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'); @@ -80,11 +80,19 @@ 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'); + }); + // Links Route::get('/link/{id}', 'PageController@redirectFromLink'); diff --git a/app/JointPermission.php b/app/JointPermission.php new file mode 100644 index 000000000..6d0b0212e --- /dev/null +++ b/app/JointPermission.php @@ -0,0 +1,24 @@ +belongsTo(Role::class); + } + + /** + * Get the entity this points to. + * @return \Illuminate\Database\Eloquent\Relations\MorphOne + */ + public function entity() + { + return $this->morphOne(Entity::class, 'entity'); + } +} diff --git a/app/Model.php b/app/Model.php new file mode 100644 index 000000000..9ec2b7362 --- /dev/null +++ b/app/Model.php @@ -0,0 +1,19 @@ +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'); } /** diff --git a/app/Page.php b/app/Page.php index d2a303f61..c6978d34b 100644 --- a/app/Page.php +++ b/app/Page.php @@ -1,8 +1,5 @@ -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; diff --git a/app/PageRevision.php b/app/PageRevision.php index c258913ff..dae74cd0f 100644 --- a/app/PageRevision.php +++ b/app/PageRevision.php @@ -1,6 +1,5 @@ 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); } /** diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index c027578a7..509b86182 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -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]); }); } } diff --git a/app/Providers/CustomFacadeProvider.php b/app/Providers/CustomFacadeProvider.php index 9b290039c..b2c7acf5e 100644 --- a/app/Providers/CustomFacadeProvider.php +++ b/app/Providers/CustomFacadeProvider.php @@ -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) ); }); } diff --git a/app/Repos/BookRepo.php b/app/Repos/BookRepo.php index 1a56843ae..b0530b4f5 100644 --- a/app/Repos/BookRepo.php +++ b/app/Repos/BookRepo.php @@ -1,5 +1,6 @@ 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,10 +246,10 @@ 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); $bookSlug = $book->slug; @@ -253,8 +286,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 diff --git a/app/Repos/ChapterRepo.php b/app/Repos/ChapterRepo.php index 530f550b1..048e0a63b 100644 --- a/app/Repos/ChapterRepo.php +++ b/app/Repos/ChapterRepo.php @@ -2,6 +2,7 @@ use Activity; +use BookStack\Book; use BookStack\Exceptions\NotFoundException; use Illuminate\Support\Str; use BookStack\Chapter; @@ -9,12 +10,12 @@ use BookStack\Chapter; class ChapterRepo extends EntityRepo { /** - * Base query for getting chapters, Takes restrictions into account. + * 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 +67,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 +79,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 +107,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 +168,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 diff --git a/app/Repos/EntityRepo.php b/app/Repos/EntityRepo.php index cb3dd6674..012a64967 100644 --- a/app/Repos/EntityRepo.php +++ b/app/Repos/EntityRepo.php @@ -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; + } + +} + + + + + + + + + + + -} \ No newline at end of file diff --git a/app/Repos/ImageRepo.php b/app/Repos/ImageRepo.php index 8dd4d346d..916ebd3e1 100644 --- a/app/Repos/ImageRepo.php +++ b/app/Repos/ImageRepo.php @@ -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; } diff --git a/app/Repos/PageRepo.php b/app/Repos/PageRepo.php index ef470c01d..504c3fa3b 100644 --- a/app/Repos/PageRepo.php +++ b/app/Repos/PageRepo.php @@ -14,14 +14,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 +35,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 +79,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 +145,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 +176,7 @@ class PageRepo extends EntityRepo if ($chapter) $page->chapter_id = $chapter->id; $book->pages()->save($page); + $this->permissionService->buildJointPermissionsForEntity($page); return $page; } @@ -241,8 +250,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 +317,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); @@ -577,12 +592,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(); } diff --git a/app/Repos/PermissionsRepo.php b/app/Repos/PermissionsRepo.php index 3c5efde23..e026d83e8 100644 --- a/app/Repos/PermissionsRepo.php +++ b/app/Repos/PermissionsRepo.php @@ -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(); } diff --git a/app/Repos/TagRepo.php b/app/Repos/TagRepo.php new file mode 100644 index 000000000..7d51d87f7 --- /dev/null +++ b/app/Repos/TagRepo.php @@ -0,0 +1,116 @@ +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. + * @param $searchTerm + * @return array + */ + public function getNameSuggestions($searchTerm) + { + if ($searchTerm === '') return []; + $query = $this->tag->where('name', 'LIKE', $searchTerm . '%')->groupBy('name')->orderBy('name', 'desc'); + $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. + * @param $searchTerm + * @return array + */ + public function getValueSuggestions($searchTerm) + { + if ($searchTerm === '') return []; + $query = $this->tag->where('value', 'LIKE', $searchTerm . '%')->groupBy('value')->orderBy('value', 'desc'); + $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); + } + +} \ No newline at end of file diff --git a/app/Repos/UserRepo.php b/app/Repos/UserRepo.php index 9b5c8d7e7..0926f6304 100644 --- a/app/Repos/UserRepo.php +++ b/app/Repos/UserRepo.php @@ -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(); } } \ No newline at end of file diff --git a/app/Role.php b/app/Role.php index 4e14db181..8d0a79e75 100644 --- a/app/Role.php +++ b/app/Role.php @@ -1,8 +1,5 @@ -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(); + } + } diff --git a/app/Permission.php b/app/RolePermission.php similarity index 63% rename from app/Permission.php rename to app/RolePermission.php index a146dcf63..ded6f6394 100644 --- a/app/Permission.php +++ b/app/RolePermission.php @@ -1,22 +1,19 @@ -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) diff --git a/app/Services/ActivityService.php b/app/Services/ActivityService.php index d0029b6c4..90a3a6d82 100644 --- a/app/Services/ActivityService.php +++ b/app/Services/ActivityService.php @@ -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,7 +88,7 @@ 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(); @@ -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); diff --git a/app/Services/Ldap.php b/app/Services/Ldap.php index cfefbb4b6..196e46a2f 100644 --- a/app/Services/Ldap.php +++ b/app/Services/Ldap.php @@ -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 diff --git a/app/Services/LdapService.php b/app/Services/LdapService.php index 3d89e1e44..b7f101ad2 100644 --- a/app/Services/LdapService.php +++ b/app/Services/LdapService.php @@ -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; diff --git a/app/Services/PermissionService.php b/app/Services/PermissionService.php new file mode 100644 index 000000000..218cb30a5 --- /dev/null +++ b/app/Services/PermissionService.php @@ -0,0 +1,505 @@ +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 Entity $entity + * @param $permission + * @return bool + */ + public function checkEntityUserAccess(Entity $entity, $permission) + { + if ($this->isAdmin) return true; + $explodedPermission = explode('-', $permission); + + $baseQuery = $entity->where('id', '=', $entity->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 === $entity->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); + }); + } + +} \ No newline at end of file diff --git a/app/Services/RestrictionService.php b/app/Services/RestrictionService.php deleted file mode 100644 index 50cbe4a51..000000000 --- a/app/Services/RestrictionService.php +++ /dev/null @@ -1,326 +0,0 @@ -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); - } - - -} \ No newline at end of file diff --git a/app/Services/SocialAuthService.php b/app/Services/SocialAuthService.php index df213609a..ba3479349 100644 --- a/app/Services/SocialAuthService.php +++ b/app/Services/SocialAuthService.php @@ -1,14 +1,11 @@ view = $view; $this->user = auth()->user(); - $this->restrictionService = $restrictionService; + $this->permissionService = $permissionService; } /** @@ -55,7 +55,7 @@ class ViewService 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'); @@ -76,7 +76,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)); diff --git a/app/Setting.php b/app/Setting.php index 05bd2c226..0af3652db 100644 --- a/app/Setting.php +++ b/app/Setting.php @@ -1,8 +1,4 @@ -belongsTo('BookStack\User'); + return $this->belongsTo(User::class); } } diff --git a/app/Tag.php b/app/Tag.php new file mode 100644 index 000000000..2eeb0b1f9 --- /dev/null +++ b/app/Tag.php @@ -0,0 +1,19 @@ +morphTo('entity'); + } +} \ No newline at end of file diff --git a/app/User.php b/app/User.php index a16eab972..74aec7e3a 100644 --- a/app/User.php +++ b/app/User.php @@ -1,9 +1,6 @@ -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'); } /** diff --git a/app/View.php b/app/View.php index 50dd06012..c02550c7c 100644 --- a/app/View.php +++ b/app/View.php @@ -1,8 +1,4 @@ -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'); + return $permissionService->checkEntityUserAccess($ownable, $permission); } /** @@ -72,3 +59,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); +} \ No newline at end of file diff --git a/config/database.php b/config/database.php index e4d6880c7..832852dc2 100644 --- a/config/database.php +++ b/config/database.php @@ -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' => '', diff --git a/database/factories/ModelFactory.php b/database/factories/ModelFactory.php index 2840356e8..3820d5b59 100644 --- a/database/factories/ModelFactory.php +++ b/database/factories/ModelFactory.php @@ -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) + ]; }); \ No newline at end of file diff --git a/database/migrations/2014_10_12_000000_create_users_table.php b/database/migrations/2014_10_12_000000_create_users_table.php index 5e060006e..17e71de5f 100644 --- a/database/migrations/2014_10_12_000000_create_users_table.php +++ b/database/migrations/2014_10_12_000000_create_users_table.php @@ -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() ]); } diff --git a/database/migrations/2015_08_29_105422_add_roles_and_permissions.php b/database/migrations/2015_08_29_105422_add_roles_and_permissions.php index 4389dc32e..763a33fec 100644 --- a/database/migrations/2015_08_29_105422_add_roles_and_permissions.php +++ b/database/migrations/2015_08_29_105422_add_roles_and_permissions.php @@ -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 + ]); } } diff --git a/database/migrations/2016_02_27_120329_update_permissions_and_roles.php b/database/migrations/2016_02_27_120329_update_permissions_and_roles.php index ea3735d9e..af6bb1232 100644 --- a/database/migrations/2016_02_27_120329_update_permissions_and_roles.php +++ b/database/migrations/2016_02_27_120329_update_permissions_and_roles.php @@ -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 + ]); } } } diff --git a/database/migrations/2016_04_09_100730_add_view_permissions_to_roles.php b/database/migrations/2016_04_09_100730_add_view_permissions_to_roles.php new file mode 100644 index 000000000..9bdf4397f --- /dev/null +++ b/database/migrations/2016_04_09_100730_add_view_permissions_to_roles.php @@ -0,0 +1,58 @@ +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(); + } + } + } +} diff --git a/database/migrations/2016_04_20_192649_create_joint_permissions_table.php b/database/migrations/2016_04_20_192649_create_joint_permissions_table.php new file mode 100644 index 000000000..4c1b43c4e --- /dev/null +++ b/database/migrations/2016_04_20_192649_create_joint_permissions_table.php @@ -0,0 +1,103 @@ +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'); + }); + } +} diff --git a/database/migrations/2016_05_06_185215_create_tags_table.php b/database/migrations/2016_05_06_185215_create_tags_table.php new file mode 100644 index 000000000..55eed6060 --- /dev/null +++ b/database/migrations/2016_05_06_185215_create_tags_table.php @@ -0,0 +1,40 @@ +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'); + } +} diff --git a/database/seeds/DummyContentSeeder.php b/database/seeds/DummyContentSeeder.php index 328971f26..c64ca2a8c 100644 --- a/database/seeds/DummyContentSeeder.php +++ b/database/seeds/DummyContentSeeder.php @@ -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(); } } diff --git a/package.json b/package.json index 866109c2a..c387d1317 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/phpunit.xml b/phpunit.xml index 66196e8cf..2150a5aa3 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -34,5 +34,6 @@ + diff --git a/public/libs/jquery/jquery-ui.min.js b/public/libs/jquery/jquery-ui.min.js new file mode 100644 index 000000000..6ec36cad5 --- /dev/null +++ b/public/libs/jquery/jquery-ui.min.js @@ -0,0 +1,7 @@ +/*! jQuery UI - v1.11.4 - 2016-05-14 +* http://jqueryui.com +* Includes: core.js, widget.js, mouse.js, sortable.js +* Copyright jQuery Foundation and other contributors; Licensed MIT */ + +(function(t){"function"==typeof define&&define.amd?define(["jquery"],t):t(jQuery)})(function(t){function e(e,s){var n,a,o,r=e.nodeName.toLowerCase();return"area"===r?(n=e.parentNode,a=n.name,e.href&&a&&"map"===n.nodeName.toLowerCase()?(o=t("img[usemap='#"+a+"']")[0],!!o&&i(o)):!1):(/^(input|select|textarea|button|object)$/.test(r)?!e.disabled:"a"===r?e.href||s:s)&&i(e)}function i(e){return t.expr.filters.visible(e)&&!t(e).parents().addBack().filter(function(){return"hidden"===t.css(this,"visibility")}).length}t.ui=t.ui||{},t.extend(t.ui,{version:"1.11.4",keyCode:{BACKSPACE:8,COMMA:188,DELETE:46,DOWN:40,END:35,ENTER:13,ESCAPE:27,HOME:36,LEFT:37,PAGE_DOWN:34,PAGE_UP:33,PERIOD:190,RIGHT:39,SPACE:32,TAB:9,UP:38}}),t.fn.extend({scrollParent:function(e){var i=this.css("position"),s="absolute"===i,n=e?/(auto|scroll|hidden)/:/(auto|scroll)/,a=this.parents().filter(function(){var e=t(this);return s&&"static"===e.css("position")?!1:n.test(e.css("overflow")+e.css("overflow-y")+e.css("overflow-x"))}).eq(0);return"fixed"!==i&&a.length?a:t(this[0].ownerDocument||document)},uniqueId:function(){var t=0;return function(){return this.each(function(){this.id||(this.id="ui-id-"+ ++t)})}}(),removeUniqueId:function(){return this.each(function(){/^ui-id-\d+$/.test(this.id)&&t(this).removeAttr("id")})}}),t.extend(t.expr[":"],{data:t.expr.createPseudo?t.expr.createPseudo(function(e){return function(i){return!!t.data(i,e)}}):function(e,i,s){return!!t.data(e,s[3])},focusable:function(i){return e(i,!isNaN(t.attr(i,"tabindex")))},tabbable:function(i){var s=t.attr(i,"tabindex"),n=isNaN(s);return(n||s>=0)&&e(i,!n)}}),t("").outerWidth(1).jquery||t.each(["Width","Height"],function(e,i){function s(e,i,s,a){return t.each(n,function(){i-=parseFloat(t.css(e,"padding"+this))||0,s&&(i-=parseFloat(t.css(e,"border"+this+"Width"))||0),a&&(i-=parseFloat(t.css(e,"margin"+this))||0)}),i}var n="Width"===i?["Left","Right"]:["Top","Bottom"],a=i.toLowerCase(),o={innerWidth:t.fn.innerWidth,innerHeight:t.fn.innerHeight,outerWidth:t.fn.outerWidth,outerHeight:t.fn.outerHeight};t.fn["inner"+i]=function(e){return void 0===e?o["inner"+i].call(this):this.each(function(){t(this).css(a,s(this,e)+"px")})},t.fn["outer"+i]=function(e,n){return"number"!=typeof e?o["outer"+i].call(this,e):this.each(function(){t(this).css(a,s(this,e,!0,n)+"px")})}}),t.fn.addBack||(t.fn.addBack=function(t){return this.add(null==t?this.prevObject:this.prevObject.filter(t))}),t("").data("a-b","a").removeData("a-b").data("a-b")&&(t.fn.removeData=function(e){return function(i){return arguments.length?e.call(this,t.camelCase(i)):e.call(this)}}(t.fn.removeData)),t.ui.ie=!!/msie [\w.]+/.exec(navigator.userAgent.toLowerCase()),t.fn.extend({focus:function(e){return function(i,s){return"number"==typeof i?this.each(function(){var e=this;setTimeout(function(){t(e).focus(),s&&s.call(e)},i)}):e.apply(this,arguments)}}(t.fn.focus),disableSelection:function(){var t="onselectstart"in document.createElement("div")?"selectstart":"mousedown";return function(){return this.bind(t+".ui-disableSelection",function(t){t.preventDefault()})}}(),enableSelection:function(){return this.unbind(".ui-disableSelection")},zIndex:function(e){if(void 0!==e)return this.css("zIndex",e);if(this.length)for(var i,s,n=t(this[0]);n.length&&n[0]!==document;){if(i=n.css("position"),("absolute"===i||"relative"===i||"fixed"===i)&&(s=parseInt(n.css("zIndex"),10),!isNaN(s)&&0!==s))return s;n=n.parent()}return 0}}),t.ui.plugin={add:function(e,i,s){var n,a=t.ui[e].prototype;for(n in s)a.plugins[n]=a.plugins[n]||[],a.plugins[n].push([i,s[n]])},call:function(t,e,i,s){var n,a=t.plugins[e];if(a&&(s||t.element[0].parentNode&&11!==t.element[0].parentNode.nodeType))for(n=0;a.length>n;n++)t.options[a[n][0]]&&a[n][1].apply(t.element,i)}};var s=0,n=Array.prototype.slice;t.cleanData=function(e){return function(i){var s,n,a;for(a=0;null!=(n=i[a]);a++)try{s=t._data(n,"events"),s&&s.remove&&t(n).triggerHandler("remove")}catch(o){}e(i)}}(t.cleanData),t.widget=function(e,i,s){var n,a,o,r,h={},l=e.split(".")[0];return e=e.split(".")[1],n=l+"-"+e,s||(s=i,i=t.Widget),t.expr[":"][n.toLowerCase()]=function(e){return!!t.data(e,n)},t[l]=t[l]||{},a=t[l][e],o=t[l][e]=function(t,e){return this._createWidget?(arguments.length&&this._createWidget(t,e),void 0):new o(t,e)},t.extend(o,a,{version:s.version,_proto:t.extend({},s),_childConstructors:[]}),r=new i,r.options=t.widget.extend({},r.options),t.each(s,function(e,s){return t.isFunction(s)?(h[e]=function(){var t=function(){return i.prototype[e].apply(this,arguments)},n=function(t){return i.prototype[e].apply(this,t)};return function(){var e,i=this._super,a=this._superApply;return this._super=t,this._superApply=n,e=s.apply(this,arguments),this._super=i,this._superApply=a,e}}(),void 0):(h[e]=s,void 0)}),o.prototype=t.widget.extend(r,{widgetEventPrefix:a?r.widgetEventPrefix||e:e},h,{constructor:o,namespace:l,widgetName:e,widgetFullName:n}),a?(t.each(a._childConstructors,function(e,i){var s=i.prototype;t.widget(s.namespace+"."+s.widgetName,o,i._proto)}),delete a._childConstructors):i._childConstructors.push(o),t.widget.bridge(e,o),o},t.widget.extend=function(e){for(var i,s,a=n.call(arguments,1),o=0,r=a.length;r>o;o++)for(i in a[o])s=a[o][i],a[o].hasOwnProperty(i)&&void 0!==s&&(e[i]=t.isPlainObject(s)?t.isPlainObject(e[i])?t.widget.extend({},e[i],s):t.widget.extend({},s):s);return e},t.widget.bridge=function(e,i){var s=i.prototype.widgetFullName||e;t.fn[e]=function(a){var o="string"==typeof a,r=n.call(arguments,1),h=this;return o?this.each(function(){var i,n=t.data(this,s);return"instance"===a?(h=n,!1):n?t.isFunction(n[a])&&"_"!==a.charAt(0)?(i=n[a].apply(n,r),i!==n&&void 0!==i?(h=i&&i.jquery?h.pushStack(i.get()):i,!1):void 0):t.error("no such method '"+a+"' for "+e+" widget instance"):t.error("cannot call methods on "+e+" prior to initialization; "+"attempted to call method '"+a+"'")}):(r.length&&(a=t.widget.extend.apply(null,[a].concat(r))),this.each(function(){var e=t.data(this,s);e?(e.option(a||{}),e._init&&e._init()):t.data(this,s,new i(a,this))})),h}},t.Widget=function(){},t.Widget._childConstructors=[],t.Widget.prototype={widgetName:"widget",widgetEventPrefix:"",defaultElement:"
",options:{disabled:!1,create:null},_createWidget:function(e,i){i=t(i||this.defaultElement||this)[0],this.element=t(i),this.uuid=s++,this.eventNamespace="."+this.widgetName+this.uuid,this.bindings=t(),this.hoverable=t(),this.focusable=t(),i!==this&&(t.data(i,this.widgetFullName,this),this._on(!0,this.element,{remove:function(t){t.target===i&&this.destroy()}}),this.document=t(i.style?i.ownerDocument:i.document||i),this.window=t(this.document[0].defaultView||this.document[0].parentWindow)),this.options=t.widget.extend({},this.options,this._getCreateOptions(),e),this._create(),this._trigger("create",null,this._getCreateEventData()),this._init()},_getCreateOptions:t.noop,_getCreateEventData:t.noop,_create:t.noop,_init:t.noop,destroy:function(){this._destroy(),this.element.unbind(this.eventNamespace).removeData(this.widgetFullName).removeData(t.camelCase(this.widgetFullName)),this.widget().unbind(this.eventNamespace).removeAttr("aria-disabled").removeClass(this.widgetFullName+"-disabled "+"ui-state-disabled"),this.bindings.unbind(this.eventNamespace),this.hoverable.removeClass("ui-state-hover"),this.focusable.removeClass("ui-state-focus")},_destroy:t.noop,widget:function(){return this.element},option:function(e,i){var s,n,a,o=e;if(0===arguments.length)return t.widget.extend({},this.options);if("string"==typeof e)if(o={},s=e.split("."),e=s.shift(),s.length){for(n=o[e]=t.widget.extend({},this.options[e]),a=0;s.length-1>a;a++)n[s[a]]=n[s[a]]||{},n=n[s[a]];if(e=s.pop(),1===arguments.length)return void 0===n[e]?null:n[e];n[e]=i}else{if(1===arguments.length)return void 0===this.options[e]?null:this.options[e];o[e]=i}return this._setOptions(o),this},_setOptions:function(t){var e;for(e in t)this._setOption(e,t[e]);return this},_setOption:function(t,e){return this.options[t]=e,"disabled"===t&&(this.widget().toggleClass(this.widgetFullName+"-disabled",!!e),e&&(this.hoverable.removeClass("ui-state-hover"),this.focusable.removeClass("ui-state-focus"))),this},enable:function(){return this._setOptions({disabled:!1})},disable:function(){return this._setOptions({disabled:!0})},_on:function(e,i,s){var n,a=this;"boolean"!=typeof e&&(s=i,i=e,e=!1),s?(i=n=t(i),this.bindings=this.bindings.add(i)):(s=i,i=this.element,n=this.widget()),t.each(s,function(s,o){function r(){return e||a.options.disabled!==!0&&!t(this).hasClass("ui-state-disabled")?("string"==typeof o?a[o]:o).apply(a,arguments):void 0}"string"!=typeof o&&(r.guid=o.guid=o.guid||r.guid||t.guid++);var h=s.match(/^([\w:-]*)\s*(.*)$/),l=h[1]+a.eventNamespace,u=h[2];u?n.delegate(u,l,r):i.bind(l,r)})},_off:function(e,i){i=(i||"").split(" ").join(this.eventNamespace+" ")+this.eventNamespace,e.unbind(i).undelegate(i),this.bindings=t(this.bindings.not(e).get()),this.focusable=t(this.focusable.not(e).get()),this.hoverable=t(this.hoverable.not(e).get())},_delay:function(t,e){function i(){return("string"==typeof t?s[t]:t).apply(s,arguments)}var s=this;return setTimeout(i,e||0)},_hoverable:function(e){this.hoverable=this.hoverable.add(e),this._on(e,{mouseenter:function(e){t(e.currentTarget).addClass("ui-state-hover")},mouseleave:function(e){t(e.currentTarget).removeClass("ui-state-hover")}})},_focusable:function(e){this.focusable=this.focusable.add(e),this._on(e,{focusin:function(e){t(e.currentTarget).addClass("ui-state-focus")},focusout:function(e){t(e.currentTarget).removeClass("ui-state-focus")}})},_trigger:function(e,i,s){var n,a,o=this.options[e];if(s=s||{},i=t.Event(i),i.type=(e===this.widgetEventPrefix?e:this.widgetEventPrefix+e).toLowerCase(),i.target=this.element[0],a=i.originalEvent)for(n in a)n in i||(i[n]=a[n]);return this.element.trigger(i,s),!(t.isFunction(o)&&o.apply(this.element[0],[i].concat(s))===!1||i.isDefaultPrevented())}},t.each({show:"fadeIn",hide:"fadeOut"},function(e,i){t.Widget.prototype["_"+e]=function(s,n,a){"string"==typeof n&&(n={effect:n});var o,r=n?n===!0||"number"==typeof n?i:n.effect||i:e;n=n||{},"number"==typeof n&&(n={duration:n}),o=!t.isEmptyObject(n),n.complete=a,n.delay&&s.delay(n.delay),o&&t.effects&&t.effects.effect[r]?s[e](n):r!==e&&s[r]?s[r](n.duration,n.easing,a):s.queue(function(i){t(this)[e](),a&&a.call(s[0]),i()})}}),t.widget;var a=!1;t(document).mouseup(function(){a=!1}),t.widget("ui.mouse",{version:"1.11.4",options:{cancel:"input,textarea,button,select,option",distance:1,delay:0},_mouseInit:function(){var e=this;this.element.bind("mousedown."+this.widgetName,function(t){return e._mouseDown(t)}).bind("click."+this.widgetName,function(i){return!0===t.data(i.target,e.widgetName+".preventClickEvent")?(t.removeData(i.target,e.widgetName+".preventClickEvent"),i.stopImmediatePropagation(),!1):void 0}),this.started=!1},_mouseDestroy:function(){this.element.unbind("."+this.widgetName),this._mouseMoveDelegate&&this.document.unbind("mousemove."+this.widgetName,this._mouseMoveDelegate).unbind("mouseup."+this.widgetName,this._mouseUpDelegate)},_mouseDown:function(e){if(!a){this._mouseMoved=!1,this._mouseStarted&&this._mouseUp(e),this._mouseDownEvent=e;var i=this,s=1===e.which,n="string"==typeof this.options.cancel&&e.target.nodeName?t(e.target).closest(this.options.cancel).length:!1;return s&&!n&&this._mouseCapture(e)?(this.mouseDelayMet=!this.options.delay,this.mouseDelayMet||(this._mouseDelayTimer=setTimeout(function(){i.mouseDelayMet=!0},this.options.delay)),this._mouseDistanceMet(e)&&this._mouseDelayMet(e)&&(this._mouseStarted=this._mouseStart(e)!==!1,!this._mouseStarted)?(e.preventDefault(),!0):(!0===t.data(e.target,this.widgetName+".preventClickEvent")&&t.removeData(e.target,this.widgetName+".preventClickEvent"),this._mouseMoveDelegate=function(t){return i._mouseMove(t)},this._mouseUpDelegate=function(t){return i._mouseUp(t)},this.document.bind("mousemove."+this.widgetName,this._mouseMoveDelegate).bind("mouseup."+this.widgetName,this._mouseUpDelegate),e.preventDefault(),a=!0,!0)):!0}},_mouseMove:function(e){if(this._mouseMoved){if(t.ui.ie&&(!document.documentMode||9>document.documentMode)&&!e.button)return this._mouseUp(e);if(!e.which)return this._mouseUp(e)}return(e.which||e.button)&&(this._mouseMoved=!0),this._mouseStarted?(this._mouseDrag(e),e.preventDefault()):(this._mouseDistanceMet(e)&&this._mouseDelayMet(e)&&(this._mouseStarted=this._mouseStart(this._mouseDownEvent,e)!==!1,this._mouseStarted?this._mouseDrag(e):this._mouseUp(e)),!this._mouseStarted)},_mouseUp:function(e){return this.document.unbind("mousemove."+this.widgetName,this._mouseMoveDelegate).unbind("mouseup."+this.widgetName,this._mouseUpDelegate),this._mouseStarted&&(this._mouseStarted=!1,e.target===this._mouseDownEvent.target&&t.data(e.target,this.widgetName+".preventClickEvent",!0),this._mouseStop(e)),a=!1,!1},_mouseDistanceMet:function(t){return Math.max(Math.abs(this._mouseDownEvent.pageX-t.pageX),Math.abs(this._mouseDownEvent.pageY-t.pageY))>=this.options.distance},_mouseDelayMet:function(){return this.mouseDelayMet},_mouseStart:function(){},_mouseDrag:function(){},_mouseStop:function(){},_mouseCapture:function(){return!0}}),t.widget("ui.sortable",t.ui.mouse,{version:"1.11.4",widgetEventPrefix:"sort",ready:!1,options:{appendTo:"parent",axis:!1,connectWith:!1,containment:!1,cursor:"auto",cursorAt:!1,dropOnEmpty:!0,forcePlaceholderSize:!1,forceHelperSize:!1,grid:!1,handle:!1,helper:"original",items:"> *",opacity:!1,placeholder:!1,revert:!1,scroll:!0,scrollSensitivity:20,scrollSpeed:20,scope:"default",tolerance:"intersect",zIndex:1e3,activate:null,beforeStop:null,change:null,deactivate:null,out:null,over:null,receive:null,remove:null,sort:null,start:null,stop:null,update:null},_isOverAxis:function(t,e,i){return t>=e&&e+i>t},_isFloating:function(t){return/left|right/.test(t.css("float"))||/inline|table-cell/.test(t.css("display"))},_create:function(){this.containerCache={},this.element.addClass("ui-sortable"),this.refresh(),this.offset=this.element.offset(),this._mouseInit(),this._setHandleClassName(),this.ready=!0},_setOption:function(t,e){this._super(t,e),"handle"===t&&this._setHandleClassName()},_setHandleClassName:function(){this.element.find(".ui-sortable-handle").removeClass("ui-sortable-handle"),t.each(this.items,function(){(this.instance.options.handle?this.item.find(this.instance.options.handle):this.item).addClass("ui-sortable-handle")})},_destroy:function(){this.element.removeClass("ui-sortable ui-sortable-disabled").find(".ui-sortable-handle").removeClass("ui-sortable-handle"),this._mouseDestroy();for(var t=this.items.length-1;t>=0;t--)this.items[t].item.removeData(this.widgetName+"-item");return this},_mouseCapture:function(e,i){var s=null,n=!1,a=this;return this.reverting?!1:this.options.disabled||"static"===this.options.type?!1:(this._refreshItems(e),t(e.target).parents().each(function(){return t.data(this,a.widgetName+"-item")===a?(s=t(this),!1):void 0}),t.data(e.target,a.widgetName+"-item")===a&&(s=t(e.target)),s?!this.options.handle||i||(t(this.options.handle,s).find("*").addBack().each(function(){this===e.target&&(n=!0)}),n)?(this.currentItem=s,this._removeCurrentsFromItems(),!0):!1:!1)},_mouseStart:function(e,i,s){var n,a,o=this.options;if(this.currentContainer=this,this.refreshPositions(),this.helper=this._createHelper(e),this._cacheHelperProportions(),this._cacheMargins(),this.scrollParent=this.helper.scrollParent(),this.offset=this.currentItem.offset(),this.offset={top:this.offset.top-this.margins.top,left:this.offset.left-this.margins.left},t.extend(this.offset,{click:{left:e.pageX-this.offset.left,top:e.pageY-this.offset.top},parent:this._getParentOffset(),relative:this._getRelativeOffset()}),this.helper.css("position","absolute"),this.cssPosition=this.helper.css("position"),this.originalPosition=this._generatePosition(e),this.originalPageX=e.pageX,this.originalPageY=e.pageY,o.cursorAt&&this._adjustOffsetFromHelper(o.cursorAt),this.domPosition={prev:this.currentItem.prev()[0],parent:this.currentItem.parent()[0]},this.helper[0]!==this.currentItem[0]&&this.currentItem.hide(),this._createPlaceholder(),o.containment&&this._setContainment(),o.cursor&&"auto"!==o.cursor&&(a=this.document.find("body"),this.storedCursor=a.css("cursor"),a.css("cursor",o.cursor),this.storedStylesheet=t("").appendTo(a)),o.opacity&&(this.helper.css("opacity")&&(this._storedOpacity=this.helper.css("opacity")),this.helper.css("opacity",o.opacity)),o.zIndex&&(this.helper.css("zIndex")&&(this._storedZIndex=this.helper.css("zIndex")),this.helper.css("zIndex",o.zIndex)),this.scrollParent[0]!==this.document[0]&&"HTML"!==this.scrollParent[0].tagName&&(this.overflowOffset=this.scrollParent.offset()),this._trigger("start",e,this._uiHash()),this._preserveHelperProportions||this._cacheHelperProportions(),!s)for(n=this.containers.length-1;n>=0;n--)this.containers[n]._trigger("activate",e,this._uiHash(this));return t.ui.ddmanager&&(t.ui.ddmanager.current=this),t.ui.ddmanager&&!o.dropBehaviour&&t.ui.ddmanager.prepareOffsets(this,e),this.dragging=!0,this.helper.addClass("ui-sortable-helper"),this._mouseDrag(e),!0},_mouseDrag:function(e){var i,s,n,a,o=this.options,r=!1;for(this.position=this._generatePosition(e),this.positionAbs=this._convertPositionTo("absolute"),this.lastPositionAbs||(this.lastPositionAbs=this.positionAbs),this.options.scroll&&(this.scrollParent[0]!==this.document[0]&&"HTML"!==this.scrollParent[0].tagName?(this.overflowOffset.top+this.scrollParent[0].offsetHeight-e.pageY=0;i--)if(s=this.items[i],n=s.item[0],a=this._intersectsWithPointer(s),a&&s.instance===this.currentContainer&&n!==this.currentItem[0]&&this.placeholder[1===a?"next":"prev"]()[0]!==n&&!t.contains(this.placeholder[0],n)&&("semi-dynamic"===this.options.type?!t.contains(this.element[0],n):!0)){if(this.direction=1===a?"down":"up","pointer"!==this.options.tolerance&&!this._intersectsWithSides(s))break;this._rearrange(e,s),this._trigger("change",e,this._uiHash());break}return this._contactContainers(e),t.ui.ddmanager&&t.ui.ddmanager.drag(this,e),this._trigger("sort",e,this._uiHash()),this.lastPositionAbs=this.positionAbs,!1},_mouseStop:function(e,i){if(e){if(t.ui.ddmanager&&!this.options.dropBehaviour&&t.ui.ddmanager.drop(this,e),this.options.revert){var s=this,n=this.placeholder.offset(),a=this.options.axis,o={};a&&"x"!==a||(o.left=n.left-this.offset.parent.left-this.margins.left+(this.offsetParent[0]===this.document[0].body?0:this.offsetParent[0].scrollLeft)),a&&"y"!==a||(o.top=n.top-this.offset.parent.top-this.margins.top+(this.offsetParent[0]===this.document[0].body?0:this.offsetParent[0].scrollTop)),this.reverting=!0,t(this.helper).animate(o,parseInt(this.options.revert,10)||500,function(){s._clear(e)})}else this._clear(e,i);return!1}},cancel:function(){if(this.dragging){this._mouseUp({target:null}),"original"===this.options.helper?this.currentItem.css(this._storedCSS).removeClass("ui-sortable-helper"):this.currentItem.show();for(var e=this.containers.length-1;e>=0;e--)this.containers[e]._trigger("deactivate",null,this._uiHash(this)),this.containers[e].containerCache.over&&(this.containers[e]._trigger("out",null,this._uiHash(this)),this.containers[e].containerCache.over=0)}return this.placeholder&&(this.placeholder[0].parentNode&&this.placeholder[0].parentNode.removeChild(this.placeholder[0]),"original"!==this.options.helper&&this.helper&&this.helper[0].parentNode&&this.helper.remove(),t.extend(this,{helper:null,dragging:!1,reverting:!1,_noFinalSort:null}),this.domPosition.prev?t(this.domPosition.prev).after(this.currentItem):t(this.domPosition.parent).prepend(this.currentItem)),this},serialize:function(e){var i=this._getItemsAsjQuery(e&&e.connected),s=[];return e=e||{},t(i).each(function(){var i=(t(e.item||this).attr(e.attribute||"id")||"").match(e.expression||/(.+)[\-=_](.+)/);i&&s.push((e.key||i[1]+"[]")+"="+(e.key&&e.expression?i[1]:i[2]))}),!s.length&&e.key&&s.push(e.key+"="),s.join("&")},toArray:function(e){var i=this._getItemsAsjQuery(e&&e.connected),s=[];return e=e||{},i.each(function(){s.push(t(e.item||this).attr(e.attribute||"id")||"")}),s},_intersectsWith:function(t){var e=this.positionAbs.left,i=e+this.helperProportions.width,s=this.positionAbs.top,n=s+this.helperProportions.height,a=t.left,o=a+t.width,r=t.top,h=r+t.height,l=this.offset.click.top,u=this.offset.click.left,c="x"===this.options.axis||s+l>r&&h>s+l,d="y"===this.options.axis||e+u>a&&o>e+u,p=c&&d;return"pointer"===this.options.tolerance||this.options.forcePointerForContainers||"pointer"!==this.options.tolerance&&this.helperProportions[this.floating?"width":"height"]>t[this.floating?"width":"height"]?p:e+this.helperProportions.width/2>a&&o>i-this.helperProportions.width/2&&s+this.helperProportions.height/2>r&&h>n-this.helperProportions.height/2},_intersectsWithPointer:function(t){var e="x"===this.options.axis||this._isOverAxis(this.positionAbs.top+this.offset.click.top,t.top,t.height),i="y"===this.options.axis||this._isOverAxis(this.positionAbs.left+this.offset.click.left,t.left,t.width),s=e&&i,n=this._getDragVerticalDirection(),a=this._getDragHorizontalDirection();return s?this.floating?a&&"right"===a||"down"===n?2:1:n&&("down"===n?2:1):!1},_intersectsWithSides:function(t){var e=this._isOverAxis(this.positionAbs.top+this.offset.click.top,t.top+t.height/2,t.height),i=this._isOverAxis(this.positionAbs.left+this.offset.click.left,t.left+t.width/2,t.width),s=this._getDragVerticalDirection(),n=this._getDragHorizontalDirection();return this.floating&&n?"right"===n&&i||"left"===n&&!i:s&&("down"===s&&e||"up"===s&&!e)},_getDragVerticalDirection:function(){var t=this.positionAbs.top-this.lastPositionAbs.top;return 0!==t&&(t>0?"down":"up")},_getDragHorizontalDirection:function(){var t=this.positionAbs.left-this.lastPositionAbs.left;return 0!==t&&(t>0?"right":"left")},refresh:function(t){return this._refreshItems(t),this._setHandleClassName(),this.refreshPositions(),this},_connectWith:function(){var t=this.options;return t.connectWith.constructor===String?[t.connectWith]:t.connectWith},_getItemsAsjQuery:function(e){function i(){r.push(this)}var s,n,a,o,r=[],h=[],l=this._connectWith();if(l&&e)for(s=l.length-1;s>=0;s--)for(a=t(l[s],this.document[0]),n=a.length-1;n>=0;n--)o=t.data(a[n],this.widgetFullName),o&&o!==this&&!o.options.disabled&&h.push([t.isFunction(o.options.items)?o.options.items.call(o.element):t(o.options.items,o.element).not(".ui-sortable-helper").not(".ui-sortable-placeholder"),o]);for(h.push([t.isFunction(this.options.items)?this.options.items.call(this.element,null,{options:this.options,item:this.currentItem}):t(this.options.items,this.element).not(".ui-sortable-helper").not(".ui-sortable-placeholder"),this]),s=h.length-1;s>=0;s--)h[s][0].each(i);return t(r)},_removeCurrentsFromItems:function(){var e=this.currentItem.find(":data("+this.widgetName+"-item)");this.items=t.grep(this.items,function(t){for(var i=0;e.length>i;i++)if(e[i]===t.item[0])return!1;return!0})},_refreshItems:function(e){this.items=[],this.containers=[this];var i,s,n,a,o,r,h,l,u=this.items,c=[[t.isFunction(this.options.items)?this.options.items.call(this.element[0],e,{item:this.currentItem}):t(this.options.items,this.element),this]],d=this._connectWith();if(d&&this.ready)for(i=d.length-1;i>=0;i--)for(n=t(d[i],this.document[0]),s=n.length-1;s>=0;s--)a=t.data(n[s],this.widgetFullName),a&&a!==this&&!a.options.disabled&&(c.push([t.isFunction(a.options.items)?a.options.items.call(a.element[0],e,{item:this.currentItem}):t(a.options.items,a.element),a]),this.containers.push(a));for(i=c.length-1;i>=0;i--)for(o=c[i][1],r=c[i][0],s=0,l=r.length;l>s;s++)h=t(r[s]),h.data(this.widgetName+"-item",o),u.push({item:h,instance:o,width:0,height:0,left:0,top:0})},refreshPositions:function(e){this.floating=this.items.length?"x"===this.options.axis||this._isFloating(this.items[0].item):!1,this.offsetParent&&this.helper&&(this.offset.parent=this._getParentOffset());var i,s,n,a;for(i=this.items.length-1;i>=0;i--)s=this.items[i],s.instance!==this.currentContainer&&this.currentContainer&&s.item[0]!==this.currentItem[0]||(n=this.options.toleranceElement?t(this.options.toleranceElement,s.item):s.item,e||(s.width=n.outerWidth(),s.height=n.outerHeight()),a=n.offset(),s.left=a.left,s.top=a.top);if(this.options.custom&&this.options.custom.refreshContainers)this.options.custom.refreshContainers.call(this);else for(i=this.containers.length-1;i>=0;i--)a=this.containers[i].element.offset(),this.containers[i].containerCache.left=a.left,this.containers[i].containerCache.top=a.top,this.containers[i].containerCache.width=this.containers[i].element.outerWidth(),this.containers[i].containerCache.height=this.containers[i].element.outerHeight();return this},_createPlaceholder:function(e){e=e||this;var i,s=e.options;s.placeholder&&s.placeholder.constructor!==String||(i=s.placeholder,s.placeholder={element:function(){var s=e.currentItem[0].nodeName.toLowerCase(),n=t("<"+s+">",e.document[0]).addClass(i||e.currentItem[0].className+" ui-sortable-placeholder").removeClass("ui-sortable-helper");return"tbody"===s?e._createTrPlaceholder(e.currentItem.find("tr").eq(0),t("",e.document[0]).appendTo(n)):"tr"===s?e._createTrPlaceholder(e.currentItem,n):"img"===s&&n.attr("src",e.currentItem.attr("src")),i||n.css("visibility","hidden"),n},update:function(t,n){(!i||s.forcePlaceholderSize)&&(n.height()||n.height(e.currentItem.innerHeight()-parseInt(e.currentItem.css("paddingTop")||0,10)-parseInt(e.currentItem.css("paddingBottom")||0,10)),n.width()||n.width(e.currentItem.innerWidth()-parseInt(e.currentItem.css("paddingLeft")||0,10)-parseInt(e.currentItem.css("paddingRight")||0,10)))}}),e.placeholder=t(s.placeholder.element.call(e.element,e.currentItem)),e.currentItem.after(e.placeholder),s.placeholder.update(e,e.placeholder)},_createTrPlaceholder:function(e,i){var s=this;e.children().each(function(){t(" ",s.document[0]).attr("colspan",t(this).attr("colspan")||1).appendTo(i)})},_contactContainers:function(e){var i,s,n,a,o,r,h,l,u,c,d=null,p=null;for(i=this.containers.length-1;i>=0;i--)if(!t.contains(this.currentItem[0],this.containers[i].element[0]))if(this._intersectsWith(this.containers[i].containerCache)){if(d&&t.contains(this.containers[i].element[0],d.element[0]))continue;d=this.containers[i],p=i}else this.containers[i].containerCache.over&&(this.containers[i]._trigger("out",e,this._uiHash(this)),this.containers[i].containerCache.over=0);if(d)if(1===this.containers.length)this.containers[p].containerCache.over||(this.containers[p]._trigger("over",e,this._uiHash(this)),this.containers[p].containerCache.over=1);else{for(n=1e4,a=null,u=d.floating||this._isFloating(this.currentItem),o=u?"left":"top",r=u?"width":"height",c=u?"clientX":"clientY",s=this.items.length-1;s>=0;s--)t.contains(this.containers[p].element[0],this.items[s].item[0])&&this.items[s].item[0]!==this.currentItem[0]&&(h=this.items[s].item.offset()[o],l=!1,e[c]-h>this.items[s][r]/2&&(l=!0),n>Math.abs(e[c]-h)&&(n=Math.abs(e[c]-h),a=this.items[s],this.direction=l?"up":"down"));if(!a&&!this.options.dropOnEmpty)return;if(this.currentContainer===this.containers[p])return this.currentContainer.containerCache.over||(this.containers[p]._trigger("over",e,this._uiHash()),this.currentContainer.containerCache.over=1),void 0;a?this._rearrange(e,a,null,!0):this._rearrange(e,null,this.containers[p].element,!0),this._trigger("change",e,this._uiHash()),this.containers[p]._trigger("change",e,this._uiHash(this)),this.currentContainer=this.containers[p],this.options.placeholder.update(this.currentContainer,this.placeholder),this.containers[p]._trigger("over",e,this._uiHash(this)),this.containers[p].containerCache.over=1}},_createHelper:function(e){var i=this.options,s=t.isFunction(i.helper)?t(i.helper.apply(this.element[0],[e,this.currentItem])):"clone"===i.helper?this.currentItem.clone():this.currentItem;return s.parents("body").length||t("parent"!==i.appendTo?i.appendTo:this.currentItem[0].parentNode)[0].appendChild(s[0]),s[0]===this.currentItem[0]&&(this._storedCSS={width:this.currentItem[0].style.width,height:this.currentItem[0].style.height,position:this.currentItem.css("position"),top:this.currentItem.css("top"),left:this.currentItem.css("left")}),(!s[0].style.width||i.forceHelperSize)&&s.width(this.currentItem.width()),(!s[0].style.height||i.forceHelperSize)&&s.height(this.currentItem.height()),s},_adjustOffsetFromHelper:function(e){"string"==typeof e&&(e=e.split(" ")),t.isArray(e)&&(e={left:+e[0],top:+e[1]||0}),"left"in e&&(this.offset.click.left=e.left+this.margins.left),"right"in e&&(this.offset.click.left=this.helperProportions.width-e.right+this.margins.left),"top"in e&&(this.offset.click.top=e.top+this.margins.top),"bottom"in e&&(this.offset.click.top=this.helperProportions.height-e.bottom+this.margins.top)},_getParentOffset:function(){this.offsetParent=this.helper.offsetParent();var e=this.offsetParent.offset();return"absolute"===this.cssPosition&&this.scrollParent[0]!==this.document[0]&&t.contains(this.scrollParent[0],this.offsetParent[0])&&(e.left+=this.scrollParent.scrollLeft(),e.top+=this.scrollParent.scrollTop()),(this.offsetParent[0]===this.document[0].body||this.offsetParent[0].tagName&&"html"===this.offsetParent[0].tagName.toLowerCase()&&t.ui.ie)&&(e={top:0,left:0}),{top:e.top+(parseInt(this.offsetParent.css("borderTopWidth"),10)||0),left:e.left+(parseInt(this.offsetParent.css("borderLeftWidth"),10)||0)}},_getRelativeOffset:function(){if("relative"===this.cssPosition){var t=this.currentItem.position();return{top:t.top-(parseInt(this.helper.css("top"),10)||0)+this.scrollParent.scrollTop(),left:t.left-(parseInt(this.helper.css("left"),10)||0)+this.scrollParent.scrollLeft()}}return{top:0,left:0}},_cacheMargins:function(){this.margins={left:parseInt(this.currentItem.css("marginLeft"),10)||0,top:parseInt(this.currentItem.css("marginTop"),10)||0}},_cacheHelperProportions:function(){this.helperProportions={width:this.helper.outerWidth(),height:this.helper.outerHeight()}},_setContainment:function(){var e,i,s,n=this.options;"parent"===n.containment&&(n.containment=this.helper[0].parentNode),("document"===n.containment||"window"===n.containment)&&(this.containment=[0-this.offset.relative.left-this.offset.parent.left,0-this.offset.relative.top-this.offset.parent.top,"document"===n.containment?this.document.width():this.window.width()-this.helperProportions.width-this.margins.left,("document"===n.containment?this.document.width():this.window.height()||this.document[0].body.parentNode.scrollHeight)-this.helperProportions.height-this.margins.top]),/^(document|window|parent)$/.test(n.containment)||(e=t(n.containment)[0],i=t(n.containment).offset(),s="hidden"!==t(e).css("overflow"),this.containment=[i.left+(parseInt(t(e).css("borderLeftWidth"),10)||0)+(parseInt(t(e).css("paddingLeft"),10)||0)-this.margins.left,i.top+(parseInt(t(e).css("borderTopWidth"),10)||0)+(parseInt(t(e).css("paddingTop"),10)||0)-this.margins.top,i.left+(s?Math.max(e.scrollWidth,e.offsetWidth):e.offsetWidth)-(parseInt(t(e).css("borderLeftWidth"),10)||0)-(parseInt(t(e).css("paddingRight"),10)||0)-this.helperProportions.width-this.margins.left,i.top+(s?Math.max(e.scrollHeight,e.offsetHeight):e.offsetHeight)-(parseInt(t(e).css("borderTopWidth"),10)||0)-(parseInt(t(e).css("paddingBottom"),10)||0)-this.helperProportions.height-this.margins.top]) +},_convertPositionTo:function(e,i){i||(i=this.position);var s="absolute"===e?1:-1,n="absolute"!==this.cssPosition||this.scrollParent[0]!==this.document[0]&&t.contains(this.scrollParent[0],this.offsetParent[0])?this.scrollParent:this.offsetParent,a=/(html|body)/i.test(n[0].tagName);return{top:i.top+this.offset.relative.top*s+this.offset.parent.top*s-("fixed"===this.cssPosition?-this.scrollParent.scrollTop():a?0:n.scrollTop())*s,left:i.left+this.offset.relative.left*s+this.offset.parent.left*s-("fixed"===this.cssPosition?-this.scrollParent.scrollLeft():a?0:n.scrollLeft())*s}},_generatePosition:function(e){var i,s,n=this.options,a=e.pageX,o=e.pageY,r="absolute"!==this.cssPosition||this.scrollParent[0]!==this.document[0]&&t.contains(this.scrollParent[0],this.offsetParent[0])?this.scrollParent:this.offsetParent,h=/(html|body)/i.test(r[0].tagName);return"relative"!==this.cssPosition||this.scrollParent[0]!==this.document[0]&&this.scrollParent[0]!==this.offsetParent[0]||(this.offset.relative=this._getRelativeOffset()),this.originalPosition&&(this.containment&&(e.pageX-this.offset.click.leftthis.containment[2]&&(a=this.containment[2]+this.offset.click.left),e.pageY-this.offset.click.top>this.containment[3]&&(o=this.containment[3]+this.offset.click.top)),n.grid&&(i=this.originalPageY+Math.round((o-this.originalPageY)/n.grid[1])*n.grid[1],o=this.containment?i-this.offset.click.top>=this.containment[1]&&i-this.offset.click.top<=this.containment[3]?i:i-this.offset.click.top>=this.containment[1]?i-n.grid[1]:i+n.grid[1]:i,s=this.originalPageX+Math.round((a-this.originalPageX)/n.grid[0])*n.grid[0],a=this.containment?s-this.offset.click.left>=this.containment[0]&&s-this.offset.click.left<=this.containment[2]?s:s-this.offset.click.left>=this.containment[0]?s-n.grid[0]:s+n.grid[0]:s)),{top:o-this.offset.click.top-this.offset.relative.top-this.offset.parent.top+("fixed"===this.cssPosition?-this.scrollParent.scrollTop():h?0:r.scrollTop()),left:a-this.offset.click.left-this.offset.relative.left-this.offset.parent.left+("fixed"===this.cssPosition?-this.scrollParent.scrollLeft():h?0:r.scrollLeft())}},_rearrange:function(t,e,i,s){i?i[0].appendChild(this.placeholder[0]):e.item[0].parentNode.insertBefore(this.placeholder[0],"down"===this.direction?e.item[0]:e.item[0].nextSibling),this.counter=this.counter?++this.counter:1;var n=this.counter;this._delay(function(){n===this.counter&&this.refreshPositions(!s)})},_clear:function(t,e){function i(t,e,i){return function(s){i._trigger(t,s,e._uiHash(e))}}this.reverting=!1;var s,n=[];if(!this._noFinalSort&&this.currentItem.parent().length&&this.placeholder.before(this.currentItem),this._noFinalSort=null,this.helper[0]===this.currentItem[0]){for(s in this._storedCSS)("auto"===this._storedCSS[s]||"static"===this._storedCSS[s])&&(this._storedCSS[s]="");this.currentItem.css(this._storedCSS).removeClass("ui-sortable-helper")}else this.currentItem.show();for(this.fromOutside&&!e&&n.push(function(t){this._trigger("receive",t,this._uiHash(this.fromOutside))}),!this.fromOutside&&this.domPosition.prev===this.currentItem.prev().not(".ui-sortable-helper")[0]&&this.domPosition.parent===this.currentItem.parent()[0]||e||n.push(function(t){this._trigger("update",t,this._uiHash())}),this!==this.currentContainer&&(e||(n.push(function(t){this._trigger("remove",t,this._uiHash())}),n.push(function(t){return function(e){t._trigger("receive",e,this._uiHash(this))}}.call(this,this.currentContainer)),n.push(function(t){return function(e){t._trigger("update",e,this._uiHash(this))}}.call(this,this.currentContainer)))),s=this.containers.length-1;s>=0;s--)e||n.push(i("deactivate",this,this.containers[s])),this.containers[s].containerCache.over&&(n.push(i("out",this,this.containers[s])),this.containers[s].containerCache.over=0);if(this.storedCursor&&(this.document.find("body").css("cursor",this.storedCursor),this.storedStylesheet.remove()),this._storedOpacity&&this.helper.css("opacity",this._storedOpacity),this._storedZIndex&&this.helper.css("zIndex","auto"===this._storedZIndex?"":this._storedZIndex),this.dragging=!1,e||this._trigger("beforeStop",t,this._uiHash()),this.placeholder[0].parentNode.removeChild(this.placeholder[0]),this.cancelHelperRemoval||(this.helper[0]!==this.currentItem[0]&&this.helper.remove(),this.helper=null),!e){for(s=0;n.length>s;s++)n[s].call(this,t);this._trigger("stop",t,this._uiHash())}return this.fromOutside=!1,!this.cancelHelperRemoval},_trigger:function(){t.Widget.prototype._trigger.apply(this,arguments)===!1&&this.cancel()},_uiHash:function(e){var i=e||this;return{helper:i.helper,placeholder:i.placeholder||t([]),position:i.position,originalPosition:i.originalPosition,offset:i.positionAbs,item:i.currentItem,sender:e?e.element:null}}})}); \ No newline at end of file diff --git a/readme.md b/readme.md index 067983e84..8a20d52d9 100644 --- a/readme.md +++ b/readme.md @@ -1,5 +1,7 @@ # BookStack +[![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) diff --git a/resources/assets/js/controllers.js b/resources/assets/js/controllers.js index 8b3d952be..8f434bf7e 100644 --- a/resources/assets/js/controllers.js +++ b/resources/assets/js/controllers.js @@ -400,4 +400,116 @@ module.exports = function (ngApp, events) { }]); -}; \ No newline at end of file + 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); + }; + + }]); + +}; + + + + + + + + + + + + + + + + + diff --git a/resources/assets/js/directives.js b/resources/assets/js/directives.js index 97d8a89e2..62557f976 100644 --- a/resources/assets/js/directives.js +++ b/resources/assets/js/directives.js @@ -301,6 +301,219 @@ module.exports = function (ngApp, events) { } } - }]) + }]); + + 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); + }); + } + } + }]); + + ngApp.directive('autosuggestions', ['$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', '[autosuggest]', function(event) { + let $input = $(this); + let val = $input.val(); + let url = $input.attr('autosuggest'); + // No suggestions until at least 3 chars + if (val.length < 3) { + if (isShowing) { + $suggestionBox.hide(); + isShowing = false; + } + return; + }; + + let suggestionPromise = getSuggestions(val.slice(0, 3), url); + suggestionPromise.then((suggestions) => { + if (val.length > 2) { + 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. + elem.on('blur', '[autosuggest]', function(event) { + setTimeout(() => { + $suggestionBox.hide(); + isShowing = false; + }, 200) + }); + + 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 key + else if (event.keyCode === 13) { + let text = suggestionElems[active].textContent; + currentInput[0].value = text; + currentInput.focus(); + $suggestionBox.hide(); + isShowing = false; + 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 searchUrl = url + '?search=' + encodeURIComponent(input); + + // Get from local cache if exists + if (localCache[searchUrl]) { + return new Promise((resolve, reject) => { + resolve(localCache[input]); + }); + } + + return $http.get(searchUrl).then((response) => { + localCache[input] = response.data; + return response.data; + }); + } + + } + } + }]); +}; + + + + + + + + + + + + + -}; \ No newline at end of file diff --git a/resources/assets/js/global.js b/resources/assets/js/global.js index 9e2b3b8ea..d4fe7020b 100644 --- a/resources/assets/js/global.js +++ b/resources/assets/js/global.js @@ -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 = { diff --git a/resources/assets/sass/_buttons.scss b/resources/assets/sass/_buttons.scss index 9b5a498f6..5bdb0cf28 100644 --- a/resources/assets/sass/_buttons.scss +++ b/resources/assets/sass/_buttons.scss @@ -65,6 +65,9 @@ $button-border-radius: 2px; &:focus, &:active { outline: 0; } + &:hover { + text-decoration: none; + } &.neg { color: $negative; } diff --git a/resources/assets/sass/_forms.scss b/resources/assets/sass/_forms.scss index 482cf54bd..da015ec7c 100644 --- a/resources/assets/sass/_forms.scss +++ b/resources/assets/sass/_forms.scss @@ -154,6 +154,11 @@ input:checked + .toggle-switch { .form-group { margin-bottom: $-s; + textarea { + display: block; + width: 100%; + min-height: 64px; + } } .form-group { @@ -239,6 +244,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; } diff --git a/resources/assets/sass/_grid.scss b/resources/assets/sass/_grid.scss index ddba1ee03..2fe1ad113 100644 --- a/resources/assets/sass/_grid.scss +++ b/resources/assets/sass/_grid.scss @@ -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; } } diff --git a/resources/assets/sass/_lists.scss b/resources/assets/sass/_lists.scss index 679443313..388d5753d 100644 --- a/resources/assets/sass/_lists.scss +++ b/resources/assets/sass/_lists.scss @@ -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; diff --git a/resources/assets/sass/_pages.scss b/resources/assets/sass/_pages.scss index e1feccb64..e61e0c823 100644 --- a/resources/assets/sass/_pages.scss +++ b/resources/assets/sass/_pages.scss @@ -49,7 +49,7 @@ height:auto; } h1, h2, h3, h4, h5, h6 { - clear: both; + 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; + } + } +} \ No newline at end of file diff --git a/resources/assets/sass/_tables.scss b/resources/assets/sass/_tables.scss index e6ec76b38..b23db436a 100644 --- a/resources/assets/sass/_tables.scss +++ b/resources/assets/sass/_tables.scss @@ -26,6 +26,13 @@ table { } } +table.no-style { + td { + border: 0; + padding: 0; + } +} + table.list-table { margin: 0 -$-xs; td { diff --git a/resources/assets/sass/_text.scss b/resources/assets/sass/_text.scss index 1a55cf868..0095b91cb 100644 --- a/resources/assets/sass/_text.scss +++ b/resources/assets/sass/_text.scss @@ -297,6 +297,12 @@ span.sep { display: block; } +.action-header { + h1 { + margin-top: $-m; + } +} + /** * Icons */ diff --git a/resources/assets/sass/styles.scss b/resources/assets/sass/styles.scss index d8453b9ed..0a7da179b 100644 --- a/resources/assets/sass/styles.scss +++ b/resources/assets/sass/styles.scss @@ -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,4 @@ $btt-size: 40px; background-color: $negative; color: #EEE; } -} \ No newline at end of file +} diff --git a/resources/views/base.blade.php b/resources/views/base.blade.php index 96bc20936..d09912c37 100644 --- a/resources/views/base.blade.php +++ b/resources/views/base.blade.php @@ -15,10 +15,16 @@ + @yield('head') @include('partials/custom-styles') + + + @if(setting('app-custom-head', false)) + {!! setting('app-custom-head') !!} + @endif diff --git a/resources/views/books/show.blade.php b/resources/views/books/show.blade.php index 5f8067bfb..12c36ba41 100644 --- a/resources/views/books/show.blade.php +++ b/resources/views/books/show.blade.php @@ -68,9 +68,9 @@
@endif

- Created {{$book->created_at->diffForHumans()}} @if($book->createdBy) by {{$book->createdBy->name}} @endif + Created {{$book->created_at->diffForHumans()}} @if($book->createdBy) by {{$book->createdBy->name}} @endif
- Last Updated {{$book->updated_at->diffForHumans()}} @if($book->updatedBy) by {{$book->updatedBy->name}} @endif + Last Updated {{$book->updated_at->diffForHumans()}} @if($book->updatedBy) by {{$book->updatedBy->name}} @endif

diff --git a/resources/views/chapters/show.blade.php b/resources/views/chapters/show.blade.php index b6b2d5c97..269358471 100644 --- a/resources/views/chapters/show.blade.php +++ b/resources/views/chapters/show.blade.php @@ -49,17 +49,23 @@

No pages are currently in this chapter.

- Create a new page -   -or-    - Sort the current book + @if(userCan('page-create', $chapter)) + Create a new page + @endif + @if(userCan('page-create', $chapter) && userCan('book-update', $book)) +   -or-    + @endif + @if(userCan('book-update', $book)) + Sort the current book + @endif


@endif

- Created {{$chapter->created_at->diffForHumans()}} @if($chapter->createdBy) by {{$chapter->createdBy->name}} @endif + Created {{$chapter->created_at->diffForHumans()}} @if($chapter->createdBy) by {{ $chapter->createdBy->name}} @endif
- Last Updated {{$chapter->updated_at->diffForHumans()}} @if($chapter->updatedBy) by {{$chapter->updatedBy->name}} @endif + Last Updated {{$chapter->updated_at->diffForHumans()}} @if($chapter->updatedBy) by {{ $chapter->updatedBy->name}} @endif

diff --git a/resources/views/pages/create.blade.php b/resources/views/pages/create.blade.php deleted file mode 100644 index 2c6403e48..000000000 --- a/resources/views/pages/create.blade.php +++ /dev/null @@ -1,17 +0,0 @@ -@extends('base') - -@section('head') - -@stop - -@section('body-class', 'flexbox') - -@section('content') - -
-
- @include('pages/form', ['model' => $draft]) -
-
- @include('partials/image-manager', ['imageType' => 'gallery', 'uploaded_to' => $draft->id]) -@stop \ No newline at end of file diff --git a/resources/views/pages/edit.blade.php b/resources/views/pages/edit.blade.php index 0ad06fc53..de6051118 100644 --- a/resources/views/pages/edit.blade.php +++ b/resources/views/pages/edit.blade.php @@ -9,10 +9,15 @@ @section('content')
-
- + + @if(!isset($isDraft)) + + @endif @include('pages/form', ['model' => $page]) + @include('pages/form-toolbox')
+ +
@include('partials/image-manager', ['imageType' => 'gallery', 'uploaded_to' => $page->id]) diff --git a/resources/views/pages/form-toolbox.blade.php b/resources/views/pages/form-toolbox.blade.php new file mode 100644 index 000000000..ae17045d1 --- /dev/null +++ b/resources/views/pages/form-toolbox.blade.php @@ -0,0 +1,37 @@ + +
+ +
+ + +
+ +
+

Page Tags

+
+

Add some tags to better categorise your content.
You can assign a value to a tag for more in-depth organisation.

+ + + + + + + + + +
+ + + + + + + + +
+ +
+
+
+ +
\ No newline at end of file diff --git a/resources/views/pages/form.blade.php b/resources/views/pages/form.blade.php index 7ce9dbfe5..aa05a9014 100644 --- a/resources/views/pages/form.blade.php +++ b/resources/views/pages/form.blade.php @@ -41,6 +41,7 @@ @include('form/text', ['name' => 'name', 'placeholder' => 'Page Title'])
+
@if(setting('app-editor') === 'wysiwyg') +

Registration Settings

+
@@ -66,8 +70,8 @@
hasPermission($permission)))) checked="checked" @endif + @if(old('permissions'.$permission, false)|| (!old('display_name', false) && (isset($role) && $role->hasPermission($permission)))) checked="checked" @endif value="true"> \ No newline at end of file diff --git a/resources/views/settings/roles/form.blade.php b/resources/views/settings/roles/form.blade.php index 9b0b35d3e..6181acaea 100644 --- a/resources/views/settings/roles/form.blade.php +++ b/resources/views/settings/roles/form.blade.php @@ -18,7 +18,7 @@ - +
@@ -31,16 +31,21 @@

- - - - + + + + + + + + +
CreateEditDeleteCreateViewEditDelete
Books + + + @@ -56,6 +61,10 @@ + + + @@ -71,6 +80,10 @@ + + + @@ -83,6 +96,7 @@
Images @include('settings/roles/checkbox', ['permission' => 'image-create-all'])Controlled by the asset they are uploaded to diff --git a/resources/views/settings/roles/index.blade.php b/resources/views/settings/roles/index.blade.php index 8f92a5eba..358c24945 100644 --- a/resources/views/settings/roles/index.blade.php +++ b/resources/views/settings/roles/index.blade.php @@ -6,11 +6,15 @@
-

User Roles

- -

- Add new role -

+
+
+

User Roles

+
+
+

+ Add new role +
+
diff --git a/resources/views/users/edit.blade.php b/resources/views/users/edit.blade.php index 6d18c12f0..16fc1bfa2 100644 --- a/resources/views/users/edit.blade.php +++ b/resources/views/users/edit.blade.php @@ -3,33 +3,29 @@ @section('content') -
-
-
-
-
- -
-
-
-
+ @include('settings/navbar', ['selected' => 'users'])
-
+
+
+

Edit {{ $user->id === $currentUser->id ? 'Profile' : 'User' }}

+
+
+

+ Delete User +
+
+
-

Edit {{ $user->id === $currentUser->id ? 'Profile' : 'User' }}

{!! csrf_field() !!} @include('users.forms.' . $authMethod, ['model' => $user])
-

 

This image should be approx 256px square.

diff --git a/resources/views/users/forms/ldap.blade.php b/resources/views/users/forms/ldap.blade.php index 47edb211b..5fc8ce397 100644 --- a/resources/views/users/forms/ldap.blade.php +++ b/resources/views/users/forms/ldap.blade.php @@ -13,7 +13,7 @@ @if(userCan('users-manage'))
- @include('form/role-checkboxes', ['name' => 'roles', 'roles' => \BookStack\Role::all()]) + @include('form/role-checkboxes', ['name' => 'roles', 'roles' => $roles])
@endif diff --git a/resources/views/users/forms/standard.blade.php b/resources/views/users/forms/standard.blade.php index 9bd70b43c..52ebac976 100644 --- a/resources/views/users/forms/standard.blade.php +++ b/resources/views/users/forms/standard.blade.php @@ -11,7 +11,7 @@ @if(userCan('users-manage'))
- @include('form/role-checkboxes', ['name' => 'roles', 'roles' => \BookStack\Role::all()]) + @include('form/role-checkboxes', ['name' => 'roles', 'roles' => $roles])
@endif diff --git a/resources/views/users/index.blade.php b/resources/views/users/index.blade.php index f06630714..d539f314e 100644 --- a/resources/views/users/index.blade.php +++ b/resources/views/users/index.blade.php @@ -7,17 +7,42 @@
-

Users

- @if(userCan('users-manage')) -

- Add new user -

- @endif +
+
+

Users

+
+
+

+ @if(userCan('users-manage')) + Add new user + @endif +
+
+ +
+
+
+ {!! $users->links() !!} +
+
+
+ + @foreach(collect($listDetails)->except('search') as $name => $val) + + @endforeach + + +
+
+
+ +
+
- - + + @foreach($users as $user) @@ -42,11 +67,17 @@ @endif @endforeach
NameEmailNameEmail User Roles
- {{ $user->roles->implode('display_name', ', ') }} + @foreach($user->roles as $index => $role) + {{$role->display_name}}@if($index !== count($user->roles) -1),@endif + @endforeach
+ +
+ {!! $users->links() !!} +
@stop diff --git a/tests/Auth/AuthTest.php b/tests/Auth/AuthTest.php index 067840841..306771ed5 100644 --- a/tests/Auth/AuthTest.php +++ b/tests/Auth/AuthTest.php @@ -181,7 +181,7 @@ class AuthTest extends TestCase public function test_user_deletion() { $userDetails = factory(\BookStack\User::class)->make(); - $user = $this->getNewUser($userDetails->toArray()); + $user = $this->getEditor($userDetails->toArray()); $this->asAdmin() ->visit('/settings/users/' . $user->id) diff --git a/tests/Auth/LdapTest.php b/tests/Auth/LdapTest.php index b52b6ffe1..76fbc662a 100644 --- a/tests/Auth/LdapTest.php +++ b/tests/Auth/LdapTest.php @@ -22,7 +22,7 @@ class LdapTest extends \TestCase public function test_login() { $this->mockLdap->shouldReceive('connect')->once()->andReturn($this->resourceId); - $this->mockLdap->shouldReceive('setOption')->once(); + $this->mockLdap->shouldReceive('setVersion')->once(); $this->mockLdap->shouldReceive('searchAndGetEntries')->times(4) ->with($this->resourceId, config('services.ldap.base_dn'), Mockery::type('string'), Mockery::type('array')) ->andReturn(['count' => 1, 0 => [ @@ -49,7 +49,7 @@ class LdapTest extends \TestCase public function test_login_works_when_no_uid_provided_by_ldap_server() { $this->mockLdap->shouldReceive('connect')->once()->andReturn($this->resourceId); - $this->mockLdap->shouldReceive('setOption')->once(); + $this->mockLdap->shouldReceive('setVersion')->once(); $ldapDn = 'cn=test-user,dc=test' . config('services.ldap.base_dn'); $this->mockLdap->shouldReceive('searchAndGetEntries')->times(2) ->with($this->resourceId, config('services.ldap.base_dn'), Mockery::type('string'), Mockery::type('array')) @@ -73,7 +73,7 @@ class LdapTest extends \TestCase public function test_initial_incorrect_details() { $this->mockLdap->shouldReceive('connect')->once()->andReturn($this->resourceId); - $this->mockLdap->shouldReceive('setOption')->once(); + $this->mockLdap->shouldReceive('setVersion')->once(); $this->mockLdap->shouldReceive('searchAndGetEntries')->times(2) ->with($this->resourceId, config('services.ldap.base_dn'), Mockery::type('string'), Mockery::type('array')) ->andReturn(['count' => 1, 0 => [ diff --git a/tests/Entity/EntityTest.php b/tests/Entity/EntityTest.php index eebb0dc36..3bf6a3f2a 100644 --- a/tests/Entity/EntityTest.php +++ b/tests/Entity/EntityTest.php @@ -161,8 +161,8 @@ class EntityTest extends TestCase public function test_entities_viewable_after_creator_deletion() { // Create required assets and revisions - $creator = $this->getNewUser(); - $updater = $this->getNewUser(); + $creator = $this->getEditor(); + $updater = $this->getEditor(); $entities = $this->createEntityChainBelongingToUser($creator, $updater); $this->actingAs($creator); app('BookStack\Repos\UserRepo')->destroy($creator); @@ -174,8 +174,8 @@ class EntityTest extends TestCase public function test_entities_viewable_after_updater_deletion() { // Create required assets and revisions - $creator = $this->getNewUser(); - $updater = $this->getNewUser(); + $creator = $this->getEditor(); + $updater = $this->getEditor(); $entities = $this->createEntityChainBelongingToUser($creator, $updater); $this->actingAs($updater); app('BookStack\Repos\UserRepo')->destroy($updater); @@ -198,7 +198,7 @@ class EntityTest extends TestCase public function test_recently_created_pages_view() { - $user = $this->getNewUser(); + $user = $this->getEditor(); $content = $this->createEntityChainBelongingToUser($user); $this->asAdmin()->visit('/pages/recently-created') @@ -207,7 +207,7 @@ class EntityTest extends TestCase public function test_recently_updated_pages_view() { - $user = $this->getNewUser(); + $user = $this->getEditor(); $content = $this->createEntityChainBelongingToUser($user); $this->asAdmin()->visit('/pages/recently-updated') @@ -241,7 +241,7 @@ class EntityTest extends TestCase public function test_recently_created_pages_on_home() { - $entityChain = $this->createEntityChainBelongingToUser($this->getNewUser()); + $entityChain = $this->createEntityChainBelongingToUser($this->getEditor()); $this->asAdmin()->visit('/') ->seeInElement('#recently-created-pages', $entityChain['page']->name); } diff --git a/tests/Entity/PageDraftTest.php b/tests/Entity/PageDraftTest.php index 2c9a28814..108b7459f 100644 --- a/tests/Entity/PageDraftTest.php +++ b/tests/Entity/PageDraftTest.php @@ -32,7 +32,7 @@ class PageDraftTest extends TestCase ->dontSeeInField('html', $addedContent); $newContent = $this->page->html . $addedContent; - $newUser = $this->getNewUser(); + $newUser = $this->getEditor(); $this->pageRepo->saveUpdateDraft($this->page, ['html' => $newContent]); $this->actingAs($newUser)->visit($this->page->getUrl() . '/edit') ->dontSeeInField('html', $newContent); @@ -54,7 +54,7 @@ class PageDraftTest extends TestCase ->dontSeeInField('html', $addedContent); $newContent = $this->page->html . $addedContent; - $newUser = $this->getNewUser(); + $newUser = $this->getEditor(); $this->pageRepo->saveUpdateDraft($this->page, ['html' => $newContent]); $this->actingAs($newUser) @@ -79,7 +79,7 @@ class PageDraftTest extends TestCase { $book = \BookStack\Book::first(); $chapter = $book->chapters->first(); - $newUser = $this->getNewUser(); + $newUser = $this->getEditor(); $this->actingAs($newUser)->visit('/') ->visit($book->getUrl() . '/page/create') diff --git a/tests/Entity/TagTests.php b/tests/Entity/TagTests.php new file mode 100644 index 000000000..0520e1a00 --- /dev/null +++ b/tests/Entity/TagTests.php @@ -0,0 +1,146 @@ +defaultTagCount)->make(); + } + + $page->tags()->saveMany($tags); + return $page; + } + + public function test_get_page_tags() + { + $page = $this->getPageWithTags(); + + // Add some other tags to check they don't interfere + factory(Tag::class, $this->defaultTagCount)->create(); + + $this->asAdmin()->get("/ajax/tags/get/page/" . $page->id) + ->shouldReturnJson(); + + $json = json_decode($this->response->getContent()); + $this->assertTrue(count($json) === $this->defaultTagCount, "Returned JSON item count is not as expected"); + } + + public function test_tag_name_suggestions() + { + // Create some tags with similar names to test with + $attrs = collect(); + $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'country'])); + $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'color'])); + $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'city'])); + $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'county'])); + $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'planet'])); + $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'plans'])); + $page = $this->getPageWithTags($attrs); + + $this->asAdmin()->get('/ajax/tags/suggest/names?search=dog')->seeJsonEquals([]); + $this->get('/ajax/tags/suggest/names?search=co')->seeJsonEquals(['color', 'country', 'county']); + $this->get('/ajax/tags/suggest/names?search=cou')->seeJsonEquals(['country', 'county']); + $this->get('/ajax/tags/suggest/names?search=pla')->seeJsonEquals(['planet', 'plans']); + } + + public function test_tag_value_suggestions() + { + // Create some tags with similar values to test with + $attrs = collect(); + $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'country', 'value' => 'cats'])); + $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'color', 'value' => 'cattery'])); + $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'city', 'value' => 'castle'])); + $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'county', 'value' => 'dog'])); + $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'planet', 'value' => 'catapult'])); + $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'plans', 'value' => 'dodgy'])); + $page = $this->getPageWithTags($attrs); + + $this->asAdmin()->get('/ajax/tags/suggest/values?search=ora')->seeJsonEquals([]); + $this->get('/ajax/tags/suggest/values?search=cat')->seeJsonEquals(['cats', 'cattery', 'catapult']); + $this->get('/ajax/tags/suggest/values?search=do')->seeJsonEquals(['dog', 'dodgy']); + $this->get('/ajax/tags/suggest/values?search=cas')->seeJsonEquals(['castle']); + } + + public function test_entity_permissions_effect_tag_suggestions() + { + $permissionService = $this->app->make(PermissionService::class); + + // Create some tags with similar names to test with and save to a page + $attrs = collect(); + $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'country'])); + $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'color'])); + $page = $this->getPageWithTags($attrs); + + $this->asAdmin()->get('/ajax/tags/suggest?search=co')->seeJsonEquals(['color', 'country']); + $this->asEditor()->get('/ajax/tags/suggest?search=co')->seeJsonEquals(['color', 'country']); + + // Set restricted permission the page + $page->restricted = true; + $page->save(); + $permissionService->buildJointPermissionsForEntity($page); + + $this->asAdmin()->get('/ajax/tags/suggest?search=co')->seeJsonEquals(['color', 'country']); + $this->asEditor()->get('/ajax/tags/suggest?search=co')->seeJsonEquals([]); + } + + public function test_entity_tag_updating() + { + $page = $this->getPageWithTags(); + + $testJsonData = [ + ['name' => 'color', 'value' => 'red'], + ['name' => 'color', 'value' => ' blue '], + ['name' => 'city', 'value' => 'London '], + ['name' => 'country', 'value' => ' England'], + ]; + $testResponseJsonData = [ + ['name' => 'color', 'value' => 'red'], + ['name' => 'color', 'value' => 'blue'], + ['name' => 'city', 'value' => 'London'], + ['name' => 'country', 'value' => 'England'], + ]; + + // Do update request + $this->asAdmin()->json("POST", "/ajax/tags/update/page/" . $page->id, ['tags' => $testJsonData]); + $updateData = json_decode($this->response->getContent()); + // Check data is correct + $testDataCorrect = true; + foreach ($updateData->tags as $data) { + $testItem = ['name' => $data->name, 'value' => $data->value]; + if (!in_array($testItem, $testResponseJsonData)) $testDataCorrect = false; + } + $testMessage = "Expected data was not found in the response.\nExpected Data: %s\nRecieved Data: %s"; + $this->assertTrue($testDataCorrect, sprintf($testMessage, json_encode($testResponseJsonData), json_encode($updateData))); + $this->assertTrue(isset($updateData->message), "No message returned in tag update response"); + + // Do get request + $this->asAdmin()->get("/ajax/tags/get/page/" . $page->id); + $getResponseData = json_decode($this->response->getContent()); + // Check counts + $this->assertTrue(count($getResponseData) === count($testJsonData), "The received tag count is incorrect"); + // Check data is correct + $testDataCorrect = true; + foreach ($getResponseData as $data) { + $testItem = ['name' => $data->name, 'value' => $data->value]; + if (!in_array($testItem, $testResponseJsonData)) $testDataCorrect = false; + } + $testMessage = "Expected data was not found in the response.\nExpected Data: %s\nRecieved Data: %s"; + $this->assertTrue($testDataCorrect, sprintf($testMessage, json_encode($testResponseJsonData), json_encode($getResponseData))); + } + +} diff --git a/tests/Permissions/RestrictionsTest.php b/tests/Permissions/RestrictionsTest.php index 4ecf5fb20..d3830cff7 100644 --- a/tests/Permissions/RestrictionsTest.php +++ b/tests/Permissions/RestrictionsTest.php @@ -4,12 +4,14 @@ class RestrictionsTest extends TestCase { protected $user; protected $viewer; + protected $restrictionService; public function setUp() { parent::setUp(); - $this->user = $this->getNewUser(); + $this->user = $this->getEditor(); $this->viewer = $this->getViewer(); + $this->restrictionService = $this->app[\BookStack\Services\PermissionService::class]; } protected function getViewer() @@ -21,28 +23,30 @@ class RestrictionsTest extends TestCase } /** - * Manually set some restrictions on an entity. + * Manually set some permissions on an entity. * @param \BookStack\Entity $entity * @param $actions */ protected function setEntityRestrictions(\BookStack\Entity $entity, $actions) { $entity->restricted = true; - $entity->restrictions()->delete(); + $entity->permissions()->delete(); $role = $this->user->roles->first(); $viewerRole = $this->viewer->roles->first(); foreach ($actions as $action) { - $entity->restrictions()->create([ + $entity->permissions()->create([ 'role_id' => $role->id, 'action' => strtolower($action) ]); - $entity->restrictions()->create([ + $entity->permissions()->create([ 'role_id' => $viewerRole->id, 'action' => strtolower($action) ]); } $entity->save(); - $entity->load('restrictions'); + $entity->load('permissions'); + $this->restrictionService->buildJointPermissionsForEntity($entity); + $entity->load('jointPermissions'); } public function test_book_view_restriction() @@ -344,7 +348,7 @@ class RestrictionsTest extends TestCase ->check('restrictions[2][view]') ->press('Save Permissions') ->seeInDatabase('books', ['id' => $book->id, 'restricted' => true]) - ->seeInDatabase('restrictions', [ + ->seeInDatabase('entity_permissions', [ 'restrictable_id' => $book->id, 'restrictable_type' => 'BookStack\Book', 'role_id' => '2', @@ -361,7 +365,7 @@ class RestrictionsTest extends TestCase ->check('restrictions[2][update]') ->press('Save Permissions') ->seeInDatabase('chapters', ['id' => $chapter->id, 'restricted' => true]) - ->seeInDatabase('restrictions', [ + ->seeInDatabase('entity_permissions', [ 'restrictable_id' => $chapter->id, 'restrictable_type' => 'BookStack\Chapter', 'role_id' => '2', @@ -378,7 +382,7 @@ class RestrictionsTest extends TestCase ->check('restrictions[2][delete]') ->press('Save Permissions') ->seeInDatabase('pages', ['id' => $page->id, 'restricted' => true]) - ->seeInDatabase('restrictions', [ + ->seeInDatabase('entity_permissions', [ 'restrictable_id' => $page->id, 'restrictable_type' => 'BookStack\Page', 'role_id' => '2', diff --git a/tests/Permissions/RolesTest.php b/tests/Permissions/RolesTest.php index 8ecdb37a3..b64f40dc6 100644 --- a/tests/Permissions/RolesTest.php +++ b/tests/Permissions/RolesTest.php @@ -7,7 +7,15 @@ class RolesTest extends TestCase public function setUp() { parent::setUp(); - $this->user = $this->getNewBlankUser(); + $this->user = $this->getViewer(); + } + + protected function getViewer() + { + $role = \BookStack\Role::getRole('viewer'); + $viewer = $this->getNewBlankUser(); + $viewer->attachRole($role);; + return $viewer; } /** @@ -141,7 +149,7 @@ class RolesTest extends TestCase public function test_restrictions_manage_own_permission() { - $otherUsersPage = \BookStack\Page::take(1)->get()->first(); + $otherUsersPage = \BookStack\Page::first(); $content = $this->createEntityChainBelongingToUser($this->user); // Check can't restrict other's content $this->actingAs($this->user)->visit($otherUsersPage->getUrl()) @@ -536,4 +544,27 @@ class RolesTest extends TestCase ->dontSeeInElement('.book-content', $otherPage->name); } + public function test_public_role_not_visible_in_user_edit_screen() + { + $user = \BookStack\User::first(); + $this->asAdmin()->visit('/settings/users/' . $user->id) + ->seeElement('#roles-admin') + ->dontSeeElement('#roles-public'); + } + + public function test_public_role_not_visible_in_role_listing() + { + $this->asAdmin()->visit('/settings/roles') + ->see('Admin') + ->dontSee('Public'); + } + + public function test_public_role_not_visible_in_default_role_setting() + { + $this->asAdmin()->visit('/settings') + ->seeElement('[data-role-name="admin"]') + ->dontSeeElement('[data-role-name="public"]'); + + } + } diff --git a/tests/TestCase.php b/tests/TestCase.php index f46d73e04..4c2893f4e 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -14,7 +14,10 @@ class TestCase extends Illuminate\Foundation\Testing\TestCase * @var string */ protected $baseUrl = 'http://localhost'; + + // Local user instances private $admin; + private $editor; /** * Creates the application. @@ -30,6 +33,10 @@ class TestCase extends Illuminate\Foundation\Testing\TestCase return $app; } + /** + * Set the current user context to be an admin. + * @return $this + */ public function asAdmin() { if($this->admin === null) { @@ -39,6 +46,18 @@ class TestCase extends Illuminate\Foundation\Testing\TestCase return $this->actingAs($this->admin); } + /** + * Set the current editor context to be an editor. + * @return $this + */ + public function asEditor() + { + if($this->editor === null) { + $this->editor = $this->getEditor(); + } + return $this->actingAs($this->editor); + } + /** * Quickly sets an array of settings. * @param $settingsArray @@ -65,6 +84,8 @@ class TestCase extends Illuminate\Foundation\Testing\TestCase $page = factory(BookStack\Page::class)->create(['created_by' => $creatorUser->id, 'updated_by' => $updaterUser->id, 'book_id' => $book->id]); $book->chapters()->saveMany([$chapter]); $chapter->pages()->saveMany([$page]); + $restrictionService = $this->app[\BookStack\Services\PermissionService::class]; + $restrictionService->buildJointPermissionsForEntity($book); return [ 'book' => $book, 'chapter' => $chapter, @@ -77,7 +98,7 @@ class TestCase extends Illuminate\Foundation\Testing\TestCase * @param array $attributes * @return mixed */ - protected function getNewUser($attributes = []) + protected function getEditor($attributes = []) { $user = factory(\BookStack\User::class)->create($attributes); $role = \BookStack\Role::getRole('editor'); diff --git a/tests/UserProfileTest.php b/tests/UserProfileTest.php index 170e7eed1..40ae004e9 100644 --- a/tests/UserProfileTest.php +++ b/tests/UserProfileTest.php @@ -33,7 +33,7 @@ class UserProfileTest extends TestCase public function test_profile_page_shows_created_content_counts() { - $newUser = $this->getNewUser(); + $newUser = $this->getEditor(); $this->asAdmin()->visit('/user/' . $newUser->id) ->see($newUser->name) @@ -52,7 +52,7 @@ class UserProfileTest extends TestCase public function test_profile_page_shows_recent_activity() { - $newUser = $this->getNewUser(); + $newUser = $this->getEditor(); $this->actingAs($newUser); $entities = $this->createEntityChainBelongingToUser($newUser, $newUser); Activity::add($entities['book'], 'book_update', $entities['book']->id); @@ -66,7 +66,7 @@ class UserProfileTest extends TestCase public function test_clicking_user_name_in_activity_leads_to_profile_page() { - $newUser = $this->getNewUser(); + $newUser = $this->getEditor(); $this->actingAs($newUser); $entities = $this->createEntityChainBelongingToUser($newUser, $newUser); Activity::add($entities['book'], 'book_update', $entities['book']->id);