diff --git a/.env.example b/.env.example index 6d706abdd..5661cda22 100644 --- a/.env.example +++ b/.env.example @@ -12,8 +12,17 @@ DB_PASSWORD=database_user_password # Cache and session CACHE_DRIVER=file SESSION_DRIVER=file +# If using Memcached, comment the above and uncomment these +#CACHE_DRIVER=memcached +#SESSION_DRIVER=memcached QUEUE_DRIVER=sync +# Memcached settings +# If using a UNIX socket path for the host, set the port to 0 +# This follows the following format: HOST:PORT:WEIGHT +# For multiple servers separate with a comma +MEMCACHED_SERVERS=127.0.0.1:11211:100 + # Storage STORAGE_TYPE=local # Amazon S3 Config @@ -53,4 +62,4 @@ MAIL_HOST=localhost MAIL_PORT=1025 MAIL_USERNAME=null MAIL_PASSWORD=null -MAIL_ENCRYPTION=null +MAIL_ENCRYPTION=null \ No newline at end of file diff --git a/app/Activity.php b/app/Activity.php index 34daa2760..ac7c1d749 100644 --- a/app/Activity.php +++ b/app/Activity.php @@ -15,15 +15,11 @@ class Activity extends Model /** * Get the entity for this activity. - * @return bool */ public function entity() { - if ($this->entity_id) { - return $this->morphTo('entity')->first(); - } else { - return false; - } + if ($this->entity_type === '') $this->entity_type = null; + return $this->morphTo('entity'); } /** diff --git a/app/Entity.php b/app/Entity.php index 42323628a..4f97c6bab 100644 --- a/app/Entity.php +++ b/app/Entity.php @@ -1,14 +1,9 @@ -morphMany('BookStack\View', 'viewable'); } + /** + * Get this entities restrictions. + */ + public function restrictions() + { + return $this->morphMany('BookStack\Restriction', 'restrictable'); + } + + /** + * Check if this entity has a specific restriction set against it. + * @param $role_id + * @param $action + * @return bool + */ + public function hasRestriction($role_id, $action) + { + return $this->restrictions->where('role_id', $role_id)->where('action', $action)->count() > 0; + } + /** * Allows checking of the exact class, Used to check entity type. * Cleaner method for is_a. @@ -72,23 +85,14 @@ abstract class Entity extends Model } /** - * Gets the class name. - * @return string - */ - public static function getClassName() - { - return strtolower(array_slice(explode('\\', static::class), -1, 1)[0]); - } - - /** - *Gets a limited-length version of the entities name. + * Gets a limited-length version of the entities name. * @param int $length * @return string */ public function getShortName($length = 25) { - if(strlen($this->name) <= $length) return $this->name; - return substr($this->name, 0, $length-3) . '...'; + if (strlen($this->name) <= $length) return $this->name; + return substr($this->name, 0, $length - 3) . '...'; } /** @@ -100,22 +104,40 @@ abstract class Entity extends Model */ public static function fullTextSearchQuery($fieldsToSearch, $terms, $wheres = []) { - $termString = ''; - foreach ($terms as $term) { - $termString .= htmlentities($term) . '* '; + $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); - $termStringEscaped = \DB::connection()->getPdo()->quote($termString); - $search = static::addSelect(\DB::raw('*, MATCH(name) AGAINST('.$termStringEscaped.' IN BOOLEAN MODE) AS title_relevance')); + $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); + } + } + }); + } + // Add additional where terms foreach ($wheres as $whereTerm) { $search->where($whereTerm[0], $whereTerm[1], $whereTerm[2]); } - // Load in relations - if (static::isA('page')) { + if (static::isA('page')) { $search = $search->with('book', 'chapter', 'createdBy', 'updatedBy'); } else if (static::isA('chapter')) { $search = $search->with('book'); diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index 73a316953..14d553ed0 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -56,7 +56,8 @@ class Handler extends ExceptionHandler // Which will include the basic message to point the user roughly to the cause. if (($e instanceof PrettyException || $e->getPrevious() instanceof PrettyException) && !config('app.debug')) { $message = ($e instanceof PrettyException) ? $e->getMessage() : $e->getPrevious()->getMessage(); - return response()->view('errors/500', ['message' => $message], 500); + $code = ($e->getCode() === 0) ? 500 : $e->getCode(); + return response()->view('errors/' . $code, ['message' => $message], $code); } return parent::render($request, $e); diff --git a/app/Exceptions/NotFoundException.php b/app/Exceptions/NotFoundException.php new file mode 100644 index 000000000..3c027dc44 --- /dev/null +++ b/app/Exceptions/NotFoundException.php @@ -0,0 +1,14 @@ + 'required|max:255', - 'email' => 'required|email|max:255|unique:users', + 'name' => 'required|max:255', + 'email' => 'required|email|max:255|unique:users', 'password' => 'required|min:6', ]); } protected function checkRegistrationAllowed() { - if (!\Setting::get('registration-enabled')) { + if (!setting('registration-enabled')) { throw new UserRegistrationException('Registrations are currently disabled.', '/login'); } } @@ -112,7 +112,7 @@ class AuthController extends Controller /** * Overrides the action when a user is authenticated. * If the user authenticated but does not exist in the user table we create them. - * @param Request $request + * @param Request $request * @param Authenticatable $user * @return \Illuminate\Http\RedirectResponse */ @@ -153,8 +153,8 @@ class AuthController extends Controller // Create an array of the user data to create a new user instance $userData = [ - 'name' => $socialUser->getName(), - 'email' => $socialUser->getEmail(), + 'name' => $socialUser->getName(), + 'email' => $socialUser->getEmail(), 'password' => str_random(30) ]; return $this->registerUser($userData, $socialAccount); @@ -162,7 +162,7 @@ class AuthController extends Controller /** * The registrations flow for all users. - * @param array $userData + * @param array $userData * @param bool|false|SocialAccount $socialAccount * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector * @throws UserRegistrationException @@ -170,8 +170,8 @@ class AuthController extends Controller */ protected function registerUser(array $userData, $socialAccount = false) { - if (\Setting::get('registration-restrict')) { - $restrictedEmailDomains = explode(',', str_replace(' ', '', \Setting::get('registration-restrict'))); + if (setting('registration-restrict')) { + $restrictedEmailDomains = explode(',', str_replace(' ', '', setting('registration-restrict'))); $userEmailDomain = $domain = substr(strrchr($userData['email'], "@"), 1); if (!in_array($userEmailDomain, $restrictedEmailDomains)) { throw new UserRegistrationException('That email domain does not have access to this application', '/register'); @@ -183,7 +183,7 @@ class AuthController extends Controller $newUser->socialAccounts()->save($socialAccount); } - if (\Setting::get('registration-confirmation') || \Setting::get('registration-restrict')) { + if (setting('registration-confirmation') || setting('registration-restrict')) { $newUser->email_confirmed = false; $newUser->save(); $this->emailConfirmationService->sendConfirmation($newUser); diff --git a/app/Http/Controllers/BookController.php b/app/Http/Controllers/BookController.php index d577a85b1..5b2b510c9 100644 --- a/app/Http/Controllers/BookController.php +++ b/app/Http/Controllers/BookController.php @@ -3,6 +3,7 @@ namespace BookStack\Http\Controllers; use Activity; +use BookStack\Repos\UserRepo; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -19,18 +20,21 @@ class BookController extends Controller protected $bookRepo; protected $pageRepo; protected $chapterRepo; + protected $userRepo; /** * BookController constructor. - * @param BookRepo $bookRepo - * @param PageRepo $pageRepo + * @param BookRepo $bookRepo + * @param PageRepo $pageRepo * @param ChapterRepo $chapterRepo + * @param UserRepo $userRepo */ - public function __construct(BookRepo $bookRepo, PageRepo $pageRepo, ChapterRepo $chapterRepo) + public function __construct(BookRepo $bookRepo, PageRepo $pageRepo, ChapterRepo $chapterRepo, UserRepo $userRepo) { $this->bookRepo = $bookRepo; $this->pageRepo = $pageRepo; $this->chapterRepo = $chapterRepo; + $this->userRepo = $userRepo; parent::__construct(); } @@ -55,7 +59,7 @@ class BookController extends Controller */ public function create() { - $this->checkPermission('book-create'); + $this->checkPermission('book-create-all'); $this->setPageTitle('Create New Book'); return view('books/create'); } @@ -68,9 +72,9 @@ class BookController extends Controller */ public function store(Request $request) { - $this->checkPermission('book-create'); + $this->checkPermission('book-create-all'); $this->validate($request, [ - 'name' => 'required|string|max:255', + 'name' => 'required|string|max:255', 'description' => 'string|max:1000' ]); $book = $this->bookRepo->newFromInput($request->all()); @@ -105,8 +109,8 @@ class BookController extends Controller */ public function edit($slug) { - $this->checkPermission('book-update'); $book = $this->bookRepo->getBySlug($slug); + $this->checkOwnablePermission('book-update', $book); $this->setPageTitle('Edit Book ' . $book->getShortName()); return view('books/edit', ['book' => $book, 'current' => $book]); } @@ -120,10 +124,10 @@ class BookController extends Controller */ public function update(Request $request, $slug) { - $this->checkPermission('book-update'); $book = $this->bookRepo->getBySlug($slug); + $this->checkOwnablePermission('book-update', $book); $this->validate($request, [ - 'name' => 'required|string|max:255', + 'name' => 'required|string|max:255', 'description' => 'string|max:1000' ]); $book->fill($request->all()); @@ -141,8 +145,8 @@ class BookController extends Controller */ public function showDelete($bookSlug) { - $this->checkPermission('book-delete'); $book = $this->bookRepo->getBySlug($bookSlug); + $this->checkOwnablePermission('book-delete', $book); $this->setPageTitle('Delete Book ' . $book->getShortName()); return view('books/delete', ['book' => $book, 'current' => $book]); } @@ -154,8 +158,8 @@ class BookController extends Controller */ public function sort($bookSlug) { - $this->checkPermission('book-update'); $book = $this->bookRepo->getBySlug($bookSlug); + $this->checkOwnablePermission('book-update', $book); $bookChildren = $this->bookRepo->getChildren($book); $books = $this->bookRepo->getAll(false); $this->setPageTitle('Sort Book ' . $book->getShortName()); @@ -177,15 +181,14 @@ class BookController extends Controller /** * Saves an array of sort mapping to pages and chapters. - * * @param string $bookSlug * @param Request $request * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector */ public function saveSort($bookSlug, Request $request) { - $this->checkPermission('book-update'); $book = $this->bookRepo->getBySlug($bookSlug); + $this->checkOwnablePermission('book-update', $book); // Return if no map sent if (!$request->has('sort-tree')) { @@ -223,17 +226,48 @@ class BookController extends Controller /** * Remove the specified book from storage. - * * @param $bookSlug * @return Response */ public function destroy($bookSlug) { - $this->checkPermission('book-delete'); $book = $this->bookRepo->getBySlug($bookSlug); + $this->checkOwnablePermission('book-delete', $book); Activity::addMessage('book_delete', 0, $book->name); Activity::removeEntity($book); $this->bookRepo->destroyBySlug($bookSlug); return redirect('/books'); } + + /** + * Show the Restrictions view. + * @param $bookSlug + * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View + */ + public function showRestrict($bookSlug) + { + $book = $this->bookRepo->getBySlug($bookSlug); + $this->checkOwnablePermission('restrictions-manage', $book); + $roles = $this->userRepo->getRestrictableRoles(); + return view('books/restrictions', [ + 'book' => $book, + 'roles' => $roles + ]); + } + + /** + * Set the restrictions for this book. + * @param $bookSlug + * @param $bookSlug + * @param Request $request + * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector + */ + public function restrict($bookSlug, Request $request) + { + $book = $this->bookRepo->getBySlug($bookSlug); + $this->checkOwnablePermission('restrictions-manage', $book); + $this->bookRepo->updateRestrictionsFromRequest($request, $book); + session()->flash('success', 'Page Restrictions Updated'); + return redirect($book->getUrl()); + } } diff --git a/app/Http/Controllers/ChapterController.php b/app/Http/Controllers/ChapterController.php index fc13e8b58..6b8a2f18f 100644 --- a/app/Http/Controllers/ChapterController.php +++ b/app/Http/Controllers/ChapterController.php @@ -1,13 +1,9 @@ -bookRepo = $bookRepo; $this->chapterRepo = $chapterRepo; + $this->userRepo = $userRepo; parent::__construct(); } - /** * Show the form for creating a new chapter. * @param $bookSlug @@ -38,8 +36,8 @@ class ChapterController extends Controller */ public function create($bookSlug) { - $this->checkPermission('chapter-create'); $book = $this->bookRepo->getBySlug($bookSlug); + $this->checkOwnablePermission('chapter-create', $book); $this->setPageTitle('Create New Chapter'); return view('chapters/create', ['book' => $book, 'current' => $book]); } @@ -52,12 +50,13 @@ class ChapterController extends Controller */ public function store($bookSlug, Request $request) { - $this->checkPermission('chapter-create'); $this->validate($request, [ 'name' => 'required|string|max:255' ]); $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); @@ -81,7 +80,14 @@ class ChapterController extends Controller $sidebarTree = $this->bookRepo->getChildren($book); Views::add($chapter); $this->setPageTitle($chapter->getShortName()); - return view('chapters/show', ['book' => $book, 'chapter' => $chapter, 'current' => $chapter, 'sidebarTree' => $sidebarTree]); + $pages = $this->chapterRepo->getChildren($chapter); + return view('chapters/show', [ + 'book' => $book, + 'chapter' => $chapter, + 'current' => $chapter, + 'sidebarTree' => $sidebarTree, + 'pages' => $pages + ]); } /** @@ -92,9 +98,9 @@ class ChapterController extends Controller */ public function edit($bookSlug, $chapterSlug) { - $this->checkPermission('chapter-update'); $book = $this->bookRepo->getBySlug($bookSlug); $chapter = $this->chapterRepo->getBySlug($chapterSlug, $book->id); + $this->checkOwnablePermission('chapter-update', $chapter); $this->setPageTitle('Edit Chapter' . $chapter->getShortName()); return view('chapters/edit', ['book' => $book, 'chapter' => $chapter, 'current' => $chapter]); } @@ -108,9 +114,9 @@ class ChapterController extends Controller */ public function update(Request $request, $bookSlug, $chapterSlug) { - $this->checkPermission('chapter-update'); $book = $this->bookRepo->getBySlug($bookSlug); $chapter = $this->chapterRepo->getBySlug($chapterSlug, $book->id); + $this->checkOwnablePermission('chapter-update', $chapter); $chapter->fill($request->all()); $chapter->slug = $this->chapterRepo->findSuitableSlug($chapter->name, $book->id, $chapter->id); $chapter->updated_by = auth()->user()->id; @@ -127,9 +133,9 @@ class ChapterController extends Controller */ public function showDelete($bookSlug, $chapterSlug) { - $this->checkPermission('chapter-delete'); $book = $this->bookRepo->getBySlug($bookSlug); $chapter = $this->chapterRepo->getBySlug($chapterSlug, $book->id); + $this->checkOwnablePermission('chapter-delete', $chapter); $this->setPageTitle('Delete Chapter' . $chapter->getShortName()); return view('chapters/delete', ['book' => $book, 'chapter' => $chapter, 'current' => $chapter]); } @@ -142,11 +148,46 @@ class ChapterController extends Controller */ public function destroy($bookSlug, $chapterSlug) { - $this->checkPermission('chapter-delete'); $book = $this->bookRepo->getBySlug($bookSlug); $chapter = $this->chapterRepo->getBySlug($chapterSlug, $book->id); + $this->checkOwnablePermission('chapter-delete', $chapter); Activity::addMessage('chapter_delete', $book->id, $chapter->name); $this->chapterRepo->destroy($chapter); return redirect($book->getUrl()); } + + /** + * Show the Restrictions view. + * @param $bookSlug + * @param $chapterSlug + * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View + */ + public function showRestrict($bookSlug, $chapterSlug) + { + $book = $this->bookRepo->getBySlug($bookSlug); + $chapter = $this->chapterRepo->getBySlug($chapterSlug, $book->id); + $this->checkOwnablePermission('restrictions-manage', $chapter); + $roles = $this->userRepo->getRestrictableRoles(); + return view('chapters/restrictions', [ + 'chapter' => $chapter, + 'roles' => $roles + ]); + } + + /** + * Set the restrictions for this chapter. + * @param $bookSlug + * @param $chapterSlug + * @param Request $request + * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector + */ + public function restrict($bookSlug, $chapterSlug, Request $request) + { + $book = $this->bookRepo->getBySlug($bookSlug); + $chapter = $this->chapterRepo->getBySlug($chapterSlug, $book->id); + $this->checkOwnablePermission('restrictions-manage', $chapter); + $this->chapterRepo->updateRestrictionsFromRequest($request, $chapter); + session()->flash('success', 'Page Restrictions Updated'); + return redirect($chapter->getUrl()); + } } diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index ab37a44a1..f0cb47cd9 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -2,6 +2,7 @@ namespace BookStack\Http\Controllers; +use BookStack\Ownable; use HttpRequestException; use Illuminate\Foundation\Bus\DispatchesJobs; use Illuminate\Http\Exception\HttpResponseException; @@ -61,21 +62,19 @@ abstract class Controller extends BaseController } /** - * On a permission error redirect to home and display + * On a permission error redirect to home and display. * the error as a notification. */ protected function showPermissionError() { Session::flash('error', trans('errors.permission')); - throw new HttpResponseException( - redirect('/') - ); + $response = request()->wantsJson() ? response()->json(['error' => trans('errors.permissionJson')], 403) : redirect('/'); + throw new HttpResponseException($response); } /** * Checks for a permission. - * - * @param $permissionName + * @param string $permissionName * @return bool|\Illuminate\Http\RedirectResponse */ protected function checkPermission($permissionName) @@ -83,10 +82,21 @@ abstract class Controller extends BaseController if (!$this->currentUser || !$this->currentUser->can($permissionName)) { $this->showPermissionError(); } - return true; } + /** + * Check the current user's permissions against an ownable item. + * @param $permission + * @param Ownable $ownable + * @return bool + */ + protected function checkOwnablePermission($permission, Ownable $ownable) + { + if (userCan($permission, $ownable)) return true; + return $this->showPermissionError(); + } + /** * Check if a user has a permission or bypass if the callback is true. * @param $permissionName diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index e20c89e06..489396df6 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -24,7 +24,6 @@ class HomeController extends Controller /** * Display the homepage. - * * @return Response */ public function index() diff --git a/app/Http/Controllers/ImageController.php b/app/Http/Controllers/ImageController.php index 3fff28d3b..48e89ee41 100644 --- a/app/Http/Controllers/ImageController.php +++ b/app/Http/Controllers/ImageController.php @@ -64,7 +64,7 @@ class ImageController extends Controller */ public function uploadByType($type, Request $request) { - $this->checkPermission('image-create'); + $this->checkPermission('image-create-all'); $this->validate($request, [ 'file' => 'image|mimes:jpeg,gif,png' ]); @@ -90,7 +90,7 @@ class ImageController extends Controller */ public function getThumbnail($id, $width, $height, $crop) { - $this->checkPermission('image-create'); + $this->checkPermission('image-create-all'); $image = $this->imageRepo->getById($id); $thumbnailUrl = $this->imageRepo->getThumbnail($image, $width, $height, $crop == 'false'); return response()->json(['url' => $thumbnailUrl]); @@ -104,11 +104,11 @@ class ImageController extends Controller */ public function update($imageId, Request $request) { - $this->checkPermission('image-update'); $this->validate($request, [ 'name' => 'required|min:2|string' ]); $image = $this->imageRepo->getById($imageId); + $this->checkOwnablePermission('image-update', $image); $image = $this->imageRepo->updateImageDetails($image, $request->all()); return response()->json($image); } @@ -123,8 +123,8 @@ class ImageController extends Controller */ public function destroy(PageRepo $pageRepo, Request $request, $id) { - $this->checkPermission('image-delete'); $image = $this->imageRepo->getById($id); + $this->checkOwnablePermission('image-delete', $image); // Check if this image is used on any pages $isForced = ($request->has('force') && ($request->get('force') === 'true') || $request->get('force') === true); diff --git a/app/Http/Controllers/PageController.php b/app/Http/Controllers/PageController.php index e78ae13e4..19e4744ea 100644 --- a/app/Http/Controllers/PageController.php +++ b/app/Http/Controllers/PageController.php @@ -1,12 +1,10 @@ -pageRepo = $pageRepo; $this->bookRepo = $bookRepo; $this->chapterRepo = $chapterRepo; $this->exportService = $exportService; + $this->userRepo = $userRepo; parent::__construct(); } /** * Show the form for creating a new page. - * * @param $bookSlug * @param bool $chapterSlug * @return Response @@ -48,23 +48,22 @@ class PageController extends Controller */ public function create($bookSlug, $chapterSlug = false) { - $this->checkPermission('page-create'); $book = $this->bookRepo->getBySlug($bookSlug); $chapter = $chapterSlug ? $this->chapterRepo->getBySlug($chapterSlug, $book->id) : false; + $parent = $chapter ? $chapter : $book; + $this->checkOwnablePermission('page-create', $parent); $this->setPageTitle('Create New Page'); return view('pages/create', ['book' => $book, 'chapter' => $chapter]); } /** * Store a newly created page in storage. - * * @param Request $request * @param $bookSlug * @return Response */ public function store(Request $request, $bookSlug) { - $this->checkPermission('page-create'); $this->validate($request, [ 'name' => 'required|string|max:255' ]); @@ -72,6 +71,8 @@ class PageController extends Controller $input = $request->all(); $book = $this->bookRepo->getBySlug($bookSlug); $chapterId = ($request->has('chapter') && $this->chapterRepo->idExists($request->get('chapter'))) ? $request->get('chapter') : null; + $parent = $chapterId !== null ? $this->chapterRepo->getById($chapterId) : $book; + $this->checkOwnablePermission('page-create', $parent); $input['priority'] = $this->bookRepo->getNewPriority($book); $page = $this->pageRepo->saveNew($input, $book, $chapterId); @@ -84,7 +85,6 @@ class PageController extends Controller * Display the specified page. * If the page is not found via the slug the * revisions are searched for a match. - * * @param $bookSlug * @param $pageSlug * @return Response @@ -95,7 +95,7 @@ class PageController extends Controller try { $page = $this->pageRepo->getBySlug($pageSlug, $book->id); - } catch (NotFoundHttpException $e) { + } catch (NotFoundException $e) { $page = $this->pageRepo->findPageUsingOldSlug($pageSlug, $bookSlug); if ($page === null) abort(404); return redirect($page->getUrl()); @@ -109,23 +109,21 @@ class PageController extends Controller /** * Show the form for editing the specified page. - * * @param $bookSlug * @param $pageSlug * @return Response */ public function edit($bookSlug, $pageSlug) { - $this->checkPermission('page-update'); $book = $this->bookRepo->getBySlug($bookSlug); $page = $this->pageRepo->getBySlug($pageSlug, $book->id); + $this->checkOwnablePermission('page-update', $page); $this->setPageTitle('Editing Page ' . $page->getShortName()); return view('pages/edit', ['page' => $page, 'book' => $book, 'current' => $page]); } /** * Update the specified page in storage. - * * @param Request $request * @param $bookSlug * @param $pageSlug @@ -133,12 +131,12 @@ class PageController extends Controller */ public function update(Request $request, $bookSlug, $pageSlug) { - $this->checkPermission('page-update'); $this->validate($request, [ 'name' => 'required|string|max:255' ]); $book = $this->bookRepo->getBySlug($bookSlug); $page = $this->pageRepo->getBySlug($pageSlug, $book->id); + $this->checkOwnablePermission('page-update', $page); $this->pageRepo->updatePage($page, $book->id, $request->all()); Activity::add($page, 'page_update', $book->id); return redirect($page->getUrl()); @@ -164,9 +162,9 @@ class PageController extends Controller */ public function showDelete($bookSlug, $pageSlug) { - $this->checkPermission('page-delete'); $book = $this->bookRepo->getBySlug($bookSlug); $page = $this->pageRepo->getBySlug($pageSlug, $book->id); + $this->checkOwnablePermission('page-delete', $page); $this->setPageTitle('Delete Page ' . $page->getShortName()); return view('pages/delete', ['book' => $book, 'page' => $page, 'current' => $page]); } @@ -181,9 +179,9 @@ class PageController extends Controller */ public function destroy($bookSlug, $pageSlug) { - $this->checkPermission('page-delete'); $book = $this->bookRepo->getBySlug($bookSlug); $page = $this->pageRepo->getBySlug($pageSlug, $book->id); + $this->checkOwnablePermission('page-delete', $page); Activity::addMessage('page_delete', $book->id, $page->name); $this->pageRepo->destroy($page); return redirect($book->getUrl()); @@ -229,9 +227,9 @@ class PageController extends Controller */ public function restoreRevision($bookSlug, $pageSlug, $revisionId) { - $this->checkPermission('page-update'); $book = $this->bookRepo->getBySlug($bookSlug); $page = $this->pageRepo->getBySlug($pageSlug, $book->id); + $this->checkOwnablePermission('page-update', $page); $page = $this->pageRepo->restoreRevision($page, $book, $revisionId); Activity::add($page, 'page_restore', $book->id); return redirect($page->getUrl()); @@ -315,4 +313,39 @@ class PageController extends Controller ]); } + /** + * Show the Restrictions view. + * @param $bookSlug + * @param $pageSlug + * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View + */ + public function showRestrict($bookSlug, $pageSlug) + { + $book = $this->bookRepo->getBySlug($bookSlug); + $page = $this->pageRepo->getBySlug($pageSlug, $book->id); + $this->checkOwnablePermission('restrictions-manage', $page); + $roles = $this->userRepo->getRestrictableRoles(); + return view('pages/restrictions', [ + 'page' => $page, + 'roles' => $roles + ]); + } + + /** + * Set the restrictions for this page. + * @param $bookSlug + * @param $pageSlug + * @param Request $request + * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector + */ + public function restrict($bookSlug, $pageSlug, Request $request) + { + $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'); + return redirect($page->getUrl()); + } + } diff --git a/app/Http/Controllers/PermissionController.php b/app/Http/Controllers/PermissionController.php new file mode 100644 index 000000000..c565bb20a --- /dev/null +++ b/app/Http/Controllers/PermissionController.php @@ -0,0 +1,129 @@ +permissionsRepo = $permissionsRepo; + parent::__construct(); + } + + /** + * Show a listing of the roles in the system. + */ + public function listRoles() + { + $this->checkPermission('user-roles-manage'); + $roles = $this->permissionsRepo->getAllRoles(); + return view('settings/roles/index', ['roles' => $roles]); + } + + /** + * Show the form to create a new role + * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View + */ + public function createRole() + { + $this->checkPermission('user-roles-manage'); + return view('settings/roles/create'); + } + + /** + * Store a new role in the system. + * @param Request $request + * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector + */ + public function storeRole(Request $request) + { + $this->checkPermission('user-roles-manage'); + $this->validate($request, [ + 'display_name' => 'required|min:3|max:200', + 'description' => 'max:250' + ]); + + $this->permissionsRepo->saveNewRole($request->all()); + session()->flash('success', 'Role successfully created'); + return redirect('/settings/roles'); + } + + /** + * Show the form for editing a user role. + * @param $id + * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View + */ + public function editRole($id) + { + $this->checkPermission('user-roles-manage'); + $role = $this->permissionsRepo->getRoleById($id); + return view('settings/roles/edit', ['role' => $role]); + } + + /** + * Updates a user role. + * @param $id + * @param Request $request + * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector + */ + public function updateRole($id, Request $request) + { + $this->checkPermission('user-roles-manage'); + $this->validate($request, [ + 'display_name' => 'required|min:3|max:200', + 'description' => 'max:250' + ]); + + $this->permissionsRepo->updateRole($id, $request->all()); + session()->flash('success', 'Role successfully updated'); + return redirect('/settings/roles'); + } + + /** + * Show the view to delete a role. + * Offers the chance to migrate users. + * @param $id + * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View + */ + public function showDeleteRole($id) + { + $this->checkPermission('user-roles-manage'); + $role = $this->permissionsRepo->getRoleById($id); + $roles = $this->permissionsRepo->getAllRolesExcept($role); + $blankRole = $role->newInstance(['display_name' => 'Don\'t migrate users']); + $roles->prepend($blankRole); + return view('settings/roles/delete', ['role' => $role, 'roles' => $roles]); + } + + /** + * Delete a role from the system, + * Migrate from a previous role if set. + * @param $id + * @param Request $request + * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector + */ + public function deleteRole($id, Request $request) + { + $this->checkPermission('user-roles-manage'); + + try { + $this->permissionsRepo->deleteRole($id, $request->get('migrate_role_id')); + } catch (PermissionsException $e) { + session()->flash('error', $e->getMessage()); + return redirect()->back(); + } + + session()->flash('success', 'Role successfully deleted'); + return redirect('/settings/roles'); + } +} diff --git a/app/Http/Controllers/SettingController.php b/app/Http/Controllers/SettingController.php index 1739e0b53..1791ccfac 100644 --- a/app/Http/Controllers/SettingController.php +++ b/app/Http/Controllers/SettingController.php @@ -1,42 +1,36 @@ -checkPermission('settings-update'); + $this->checkPermission('settings-manage'); $this->setPageTitle('Settings'); return view('settings/index'); } - /** * Update the specified settings in storage. - * - * @param Request $request + * @param Request $request * @return Response */ public function update(Request $request) { $this->preventAccessForDemoUsers(); - $this->checkPermission('settings-update'); + $this->checkPermission('settings-manage'); // Cycles through posted settings and update them - foreach($request->all() as $name => $value) { - if(strpos($name, 'setting-') !== 0) continue; + foreach ($request->all() as $name => $value) { + if (strpos($name, 'setting-') !== 0) continue; $key = str_replace('setting-', '', trim($name)); Setting::put($key, $value); } diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 55ca5be19..9f6a4105f 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -35,7 +35,8 @@ class UserController extends Controller */ public function index() { - $users = $this->user->all(); + $this->checkPermission('users-manage'); + $users = $this->userRepo->getAllUsers(); $this->setPageTitle('Users'); return view('users/index', ['users' => $users]); } @@ -46,7 +47,7 @@ class UserController extends Controller */ public function create() { - $this->checkPermission('user-create'); + $this->checkPermission('users-manage'); $authMethod = config('auth.method'); return view('users/create', ['authMethod' => $authMethod]); } @@ -58,11 +59,10 @@ class UserController extends Controller */ public function store(Request $request) { - $this->checkPermission('user-create'); + $this->checkPermission('users-manage'); $validationRules = [ 'name' => 'required', - 'email' => 'required|email|unique:users,email', - 'role' => 'required|exists:roles,id' + 'email' => 'required|email|unique:users,email' ]; $authMethod = config('auth.method'); @@ -84,7 +84,11 @@ class UserController extends Controller } $user->save(); - $user->attachRoleId($request->get('role')); + + if ($request->has('roles')) { + $roles = $request->get('roles'); + $user->roles()->sync($roles); + } // Get avatar from gravatar and save if (!config('services.disable_services')) { @@ -104,7 +108,7 @@ class UserController extends Controller */ public function edit($id, SocialAuthService $socialAuthService) { - $this->checkPermissionOr('user-update', function () use ($id) { + $this->checkPermissionOr('users-manage', function () use ($id) { return $this->currentUser->id == $id; }); @@ -125,7 +129,7 @@ class UserController extends Controller public function update(Request $request, $id) { $this->preventAccessForDemoUsers(); - $this->checkPermissionOr('user-update', function () use ($id) { + $this->checkPermissionOr('users-manage', function () use ($id) { return $this->currentUser->id == $id; }); @@ -133,8 +137,7 @@ class UserController extends Controller 'name' => 'min:2', 'email' => 'min:2|email|unique:users,email,' . $id, 'password' => 'min:5|required_with:password_confirm', - 'password-confirm' => 'same:password|required_with:password', - 'role' => 'exists:roles,id' + 'password-confirm' => 'same:password|required_with:password' ], [ 'password-confirm.required_with' => 'Password confirmation required' ]); @@ -143,8 +146,9 @@ class UserController extends Controller $user->fill($request->all()); // Role updates - if ($this->currentUser->can('user-update') && $request->has('role')) { - $user->attachRoleId($request->get('role')); + if (userCan('users-manage') && $request->has('roles')) { + $roles = $request->get('roles'); + $user->roles()->sync($roles); } // Password updates @@ -154,11 +158,12 @@ class UserController extends Controller } // External auth id updates - if ($this->currentUser->can('user-update') && $request->has('external_auth_id')) { + if ($this->currentUser->can('users-manage') && $request->has('external_auth_id')) { $user->external_auth_id = $request->get('external_auth_id'); } $user->save(); + session()->flash('success', 'User successfully updated'); return redirect('/settings/users'); } @@ -169,7 +174,7 @@ class UserController extends Controller */ public function delete($id) { - $this->checkPermissionOr('user-delete', function () use ($id) { + $this->checkPermissionOr('users-manage', function () use ($id) { return $this->currentUser->id == $id; }); @@ -186,7 +191,7 @@ class UserController extends Controller public function destroy($id) { $this->preventAccessForDemoUsers(); - $this->checkPermissionOr('user-delete', function () use ($id) { + $this->checkPermissionOr('users-manage', function () use ($id) { return $this->currentUser->id == $id; }); diff --git a/app/Http/Middleware/Authenticate.php b/app/Http/Middleware/Authenticate.php index ad804d0d8..81392fe6e 100644 --- a/app/Http/Middleware/Authenticate.php +++ b/app/Http/Middleware/Authenticate.php @@ -39,7 +39,7 @@ class Authenticate return redirect()->guest('/register/confirm/awaiting'); } - if ($this->auth->guest() && !Setting::get('app-public')) { + if ($this->auth->guest() && !setting('app-public')) { if ($request->ajax()) { return response('Unauthorized.', 401); } else { diff --git a/app/Http/routes.php b/app/Http/routes.php index 36cf2a19f..81bbb16bc 100644 --- a/app/Http/routes.php +++ b/app/Http/routes.php @@ -19,6 +19,8 @@ Route::group(['middleware' => 'auth'], function () { Route::delete('/{id}', 'BookController@destroy'); Route::get('/{slug}/sort-item', 'BookController@getSortItem'); Route::get('/{slug}', 'BookController@show'); + Route::get('/{bookSlug}/restrict', 'BookController@showRestrict'); + Route::put('/{bookSlug}/restrict', 'BookController@restrict'); Route::get('/{slug}/delete', 'BookController@showDelete'); Route::get('/{bookSlug}/sort', 'BookController@sort'); Route::put('/{bookSlug}/sort', 'BookController@saveSort'); @@ -32,6 +34,8 @@ Route::group(['middleware' => 'auth'], function () { Route::get('/{bookSlug}/page/{pageSlug}/export/plaintext', 'PageController@exportPlainText'); Route::get('/{bookSlug}/page/{pageSlug}/edit', 'PageController@edit'); Route::get('/{bookSlug}/page/{pageSlug}/delete', 'PageController@showDelete'); + Route::get('/{bookSlug}/page/{pageSlug}/restrict', 'PageController@showRestrict'); + Route::put('/{bookSlug}/page/{pageSlug}/restrict', 'PageController@restrict'); Route::put('/{bookSlug}/page/{pageSlug}', 'PageController@update'); Route::delete('/{bookSlug}/page/{pageSlug}', 'PageController@destroy'); @@ -47,6 +51,8 @@ Route::group(['middleware' => 'auth'], function () { Route::get('/{bookSlug}/chapter/{chapterSlug}', 'ChapterController@show'); Route::put('/{bookSlug}/chapter/{chapterSlug}', 'ChapterController@update'); Route::get('/{bookSlug}/chapter/{chapterSlug}/edit', 'ChapterController@edit'); + Route::get('/{bookSlug}/chapter/{chapterSlug}/restrict', 'ChapterController@showRestrict'); + Route::put('/{bookSlug}/chapter/{chapterSlug}/restrict', 'ChapterController@restrict'); Route::get('/{bookSlug}/chapter/{chapterSlug}/delete', 'ChapterController@showDelete'); Route::delete('/{bookSlug}/chapter/{chapterSlug}', 'ChapterController@destroy'); @@ -87,6 +93,7 @@ Route::group(['middleware' => 'auth'], function () { Route::group(['prefix' => 'settings'], function() { Route::get('/', 'SettingController@index'); Route::post('/', 'SettingController@update'); + // Users Route::get('/users', 'UserController@index'); Route::get('/users/create', 'UserController@create'); @@ -95,6 +102,15 @@ Route::group(['middleware' => 'auth'], function () { Route::get('/users/{id}', 'UserController@edit'); Route::put('/users/{id}', 'UserController@update'); Route::delete('/users/{id}', 'UserController@destroy'); + + // Roles + Route::get('/roles', 'PermissionController@listRoles'); + Route::get('/roles/new', 'PermissionController@createRole'); + Route::post('/roles/new', 'PermissionController@storeRole'); + Route::get('/roles/delete/{id}', 'PermissionController@showDeleteRole'); + Route::delete('/roles/delete/{id}', 'PermissionController@deleteRole'); + Route::get('/roles/{id}', 'PermissionController@editRole'); + Route::put('/roles/{id}', 'PermissionController@updateRole'); }); }); diff --git a/app/Image.php b/app/Image.php index 3ac084d8f..ad23a077a 100644 --- a/app/Image.php +++ b/app/Image.php @@ -1,14 +1,9 @@ -belongsTo('BookStack\User', 'updated_by'); } + + /** + * Gets the class name. + * @return string + */ + public static function getClassName() + { + return strtolower(array_slice(explode('\\', static::class), -1, 1)[0]); + } + } \ No newline at end of file diff --git a/app/Permission.php b/app/Permission.php index 6859ed56e..794df01ab 100644 --- a/app/Permission.php +++ b/app/Permission.php @@ -13,4 +13,14 @@ class Permission extends Model { return $this->belongsToMany('BookStack\Permissions'); } + + /** + * Get the permission object by name. + * @param $roleName + * @return mixed + */ + public static function getByName($name) + { + return static::where('name', '=', $name)->first(); + } } diff --git a/app/Providers/CustomFacadeProvider.php b/app/Providers/CustomFacadeProvider.php index 1df14a076..9b290039c 100644 --- a/app/Providers/CustomFacadeProvider.php +++ b/app/Providers/CustomFacadeProvider.php @@ -28,11 +28,17 @@ class CustomFacadeProvider extends ServiceProvider public function register() { $this->app->bind('activity', function() { - return new ActivityService($this->app->make('BookStack\Activity')); + return new ActivityService( + $this->app->make('BookStack\Activity'), + $this->app->make('BookStack\Services\RestrictionService') + ); }); $this->app->bind('views', function() { - return new ViewService($this->app->make('BookStack\View')); + return new ViewService( + $this->app->make('BookStack\View'), + $this->app->make('BookStack\Services\RestrictionService') + ); }); $this->app->bind('setting', function() { @@ -41,6 +47,7 @@ class CustomFacadeProvider extends ServiceProvider $this->app->make('Illuminate\Contracts\Cache\Repository') ); }); + $this->app->bind('images', function() { return new ImageService( $this->app->make('Intervention\Image\ImageManager'), diff --git a/app/Repos/BookRepo.php b/app/Repos/BookRepo.php index d8a24c099..2ec9a4c25 100644 --- a/app/Repos/BookRepo.php +++ b/app/Repos/BookRepo.php @@ -1,28 +1,35 @@ book = $book; $this->pageRepo = $pageRepo; $this->chapterRepo = $chapterRepo; + parent::__construct(); + } + + /** + * Base query for getting books. + * Takes into account any restrictions. + * @return mixed + */ + private function bookQuery() + { + return $this->restrictionService->enforceBookRestrictions($this->book, 'view'); } /** @@ -32,7 +39,7 @@ class BookRepo */ public function getById($id) { - return $this->book->findOrFail($id); + return $this->bookQuery()->findOrFail($id); } /** @@ -42,7 +49,7 @@ class BookRepo */ public function getAll($count = 10) { - $bookQuery = $this->book->orderBy('name', 'asc'); + $bookQuery = $this->bookQuery()->orderBy('name', 'asc'); if (!$count) return $bookQuery->get(); return $bookQuery->take($count)->get(); } @@ -54,7 +61,8 @@ class BookRepo */ public function getAllPaginated($count = 10) { - return $this->book->orderBy('name', 'asc')->paginate($count); + return $this->bookQuery() + ->orderBy('name', 'asc')->paginate($count); } @@ -65,7 +73,7 @@ class BookRepo */ public function getLatest($count = 10) { - return $this->book->orderBy('created_at', 'desc')->take($count)->get(); + return $this->bookQuery()->orderBy('created_at', 'desc')->take($count)->get(); } /** @@ -94,11 +102,12 @@ class BookRepo * Get a book by slug * @param $slug * @return mixed + * @throws NotFoundException */ public function getBySlug($slug) { - $book = $this->book->where('slug', '=', $slug)->first(); - if ($book === null) abort(404); + $book = $this->bookQuery()->where('slug', '=', $slug)->first(); + if ($book === null) throw new NotFoundException('Book not found'); return $book; } @@ -109,7 +118,7 @@ class BookRepo */ public function exists($id) { - return $this->book->where('id', '=', $id)->exists(); + return $this->bookQuery()->where('id', '=', $id)->exists(); } /** @@ -119,17 +128,7 @@ class BookRepo */ public function newFromInput($input) { - return $this->book->fill($input); - } - - /** - * Count the amount of books that have a specific slug. - * @param $slug - * @return mixed - */ - public function countBySlug($slug) - { - return $this->book->where('slug', '=', $slug)->count(); + return $this->book->newInstance($input); } /** @@ -146,6 +145,7 @@ class BookRepo $this->chapterRepo->destroy($chapter); } $book->views()->delete(); + $book->restrictions()->delete(); $book->delete(); } @@ -202,8 +202,15 @@ class BookRepo */ public function getChildren(Book $book) { - $pages = $book->pages()->where('chapter_id', '=', 0)->get(); - $chapters = $book->chapters()->with('pages')->get(); + $pageQuery = $book->pages()->where('chapter_id', '=', 0); + $pageQuery = $this->restrictionService->enforcePageRestrictions($pageQuery, 'view'); + $pages = $pageQuery->get(); + + $chapterQuery = $book->chapters()->with(['pages' => function($query) { + $this->restrictionService->enforcePageRestrictions($query, 'view'); + }]); + $chapterQuery = $this->restrictionService->enforceChapterRestrictions($chapterQuery, 'view'); + $chapters = $chapterQuery->get(); $children = $pages->merge($chapters); $bookSlug = $book->slug; $children->each(function ($child) use ($bookSlug) { @@ -226,8 +233,8 @@ class BookRepo */ public function getBySearch($term, $count = 20, $paginationAppends = []) { - $terms = explode(' ', $term); - $books = $this->book->fullTextSearchQuery(['name', 'description'], $terms) + $terms = $this->prepareSearchTerms($term); + $books = $this->restrictionService->enforceBookRestrictions($this->book->fullTextSearchQuery(['name', 'description'], $terms)) ->paginate($count)->appends($paginationAppends); $words = join('|', explode(' ', preg_quote(trim($term), '/'))); foreach ($books as $book) { diff --git a/app/Repos/ChapterRepo.php b/app/Repos/ChapterRepo.php index bba6cbe7a..5d1d6437f 100644 --- a/app/Repos/ChapterRepo.php +++ b/app/Repos/ChapterRepo.php @@ -2,21 +2,19 @@ use Activity; +use BookStack\Exceptions\NotFoundException; use Illuminate\Support\Str; use BookStack\Chapter; -class ChapterRepo +class ChapterRepo extends EntityRepo { - - protected $chapter; - /** - * ChapterRepo constructor. - * @param $chapter + * Base query for getting chapters, Takes restrictions into account. + * @return mixed */ - public function __construct(Chapter $chapter) + private function chapterQuery() { - $this->chapter = $chapter; + return $this->restrictionService->enforceChapterRestrictions($this->chapter, 'view'); } /** @@ -26,7 +24,7 @@ class ChapterRepo */ public function idExists($id) { - return $this->chapter->where('id', '=', $id)->count() > 0; + return $this->chapterQuery()->where('id', '=', $id)->count() > 0; } /** @@ -36,7 +34,7 @@ class ChapterRepo */ public function getById($id) { - return $this->chapter->findOrFail($id); + return $this->chapterQuery()->findOrFail($id); } /** @@ -45,7 +43,7 @@ class ChapterRepo */ public function getAll() { - return $this->chapter->all(); + return $this->chapterQuery()->all(); } /** @@ -53,14 +51,24 @@ class ChapterRepo * @param $slug * @param $bookId * @return mixed + * @throws NotFoundException */ public function getBySlug($slug, $bookId) { - $chapter = $this->chapter->where('slug', '=', $slug)->where('book_id', '=', $bookId)->first(); - if ($chapter === null) abort(404); + $chapter = $this->chapterQuery()->where('slug', '=', $slug)->where('book_id', '=', $bookId)->first(); + if ($chapter === null) throw new NotFoundException('Chapter not found'); return $chapter; } + /** + * Get the child items for a chapter + * @param Chapter $chapter + */ + public function getChildren(Chapter $chapter) + { + return $this->restrictionService->enforcePageRestrictions($chapter->pages())->get(); + } + /** * Create a new chapter from request input. * @param $input @@ -85,6 +93,7 @@ class ChapterRepo } Activity::removeEntity($chapter); $chapter->views()->delete(); + $chapter->restrictions()->delete(); $chapter->delete(); } @@ -123,7 +132,7 @@ class ChapterRepo /** * Get chapters by the given search term. - * @param $term + * @param string $term * @param array $whereTerms * @param int $count * @param array $paginationAppends @@ -131,8 +140,8 @@ class ChapterRepo */ public function getBySearch($term, $whereTerms = [], $count = 20, $paginationAppends = []) { - $terms = explode(' ', $term); - $chapters = $this->chapter->fullTextSearchQuery(['name', 'description'], $terms, $whereTerms) + $terms = $this->prepareSearchTerms($term); + $chapters = $this->restrictionService->enforceChapterRestrictions($this->chapter->fullTextSearchQuery(['name', 'description'], $terms, $whereTerms)) ->paginate($count)->appends($paginationAppends); $words = join('|', explode(' ', preg_quote(trim($term), '/'))); foreach ($chapters as $chapter) { diff --git a/app/Repos/EntityRepo.php b/app/Repos/EntityRepo.php index 28942d94a..ea2805855 100644 --- a/app/Repos/EntityRepo.php +++ b/app/Repos/EntityRepo.php @@ -1,38 +1,60 @@ book = $book; - $this->chapter = $chapter; - $this->page = $page; + $this->book = app(Book::class); + $this->chapter = app(Chapter::class); + $this->page = app(Page::class); + $this->restrictionService = app(RestrictionService::class); } /** * Get the latest books added to the system. - * @param $count - * @param $page + * @param int $count + * @param int $page + * @param bool $additionalQuery + * @return */ - public function getRecentlyCreatedBooks($count = 20, $page = 0) + public function getRecentlyCreatedBooks($count = 20, $page = 0, $additionalQuery = false) { - return $this->book->orderBy('created_at', 'desc')->skip($page*$count)->take($count)->get(); + $query = $this->restrictionService->enforceBookRestrictions($this->book) + ->orderBy('created_at', 'desc'); + if ($additionalQuery !== false && is_callable($additionalQuery)) { + $additionalQuery($query); + } + return $query->skip($page * $count)->take($count)->get(); } /** @@ -43,17 +65,42 @@ class EntityRepo */ public function getRecentlyUpdatedBooks($count = 20, $page = 0) { - return $this->book->orderBy('updated_at', 'desc')->skip($page*$count)->take($count)->get(); + return $this->restrictionService->enforceBookRestrictions($this->book) + ->orderBy('updated_at', 'desc')->skip($page * $count)->take($count)->get(); } /** * Get the latest pages added to the system. - * @param $count - * @param $page + * @param int $count + * @param int $page + * @param bool $additionalQuery + * @return */ - public function getRecentlyCreatedPages($count = 20, $page = 0) + public function getRecentlyCreatedPages($count = 20, $page = 0, $additionalQuery = false) { - return $this->page->orderBy('created_at', 'desc')->skip($page*$count)->take($count)->get(); + $query = $this->restrictionService->enforcePageRestrictions($this->page) + ->orderBy('created_at', 'desc'); + if ($additionalQuery !== false && is_callable($additionalQuery)) { + $additionalQuery($query); + } + return $query->skip($page * $count)->take($count)->get(); + } + + /** + * Get the latest chapters added to the system. + * @param int $count + * @param int $page + * @param bool $additionalQuery + * @return + */ + public function getRecentlyCreatedChapters($count = 20, $page = 0, $additionalQuery = false) + { + $query = $this->restrictionService->enforceChapterRestrictions($this->chapter) + ->orderBy('created_at', 'desc'); + if ($additionalQuery !== false && is_callable($additionalQuery)) { + $additionalQuery($query); + } + return $query->skip($page * $count)->take($count)->get(); } /** @@ -64,7 +111,50 @@ class EntityRepo */ public function getRecentlyUpdatedPages($count = 20, $page = 0) { - return $this->page->orderBy('updated_at', 'desc')->skip($page*$count)->take($count)->get(); + return $this->restrictionService->enforcePageRestrictions($this->page) + ->orderBy('updated_at', 'desc')->skip($page * $count)->take($count)->get(); + } + + /** + * Updates entity restrictions from a request + * @param $request + * @param Entity $entity + */ + public function updateRestrictionsFromRequest($request, Entity $entity) + { + $entity->restricted = $request->has('restricted') && $request->get('restricted') === 'true'; + $entity->restrictions()->delete(); + if ($request->has('restrictions')) { + foreach ($request->get('restrictions') as $roleId => $restrictions) { + foreach ($restrictions as $action => $value) { + $entity->restrictions()->create([ + 'role_id' => $roleId, + 'action' => strtolower($action) + ]); + } + } + } + $entity->save(); + } + + /** + * Prepare a string of search terms by turning + * it into an array of terms. + * Keeps quoted terms together. + * @param $termString + * @return array + */ + protected function prepareSearchTerms($termString) + { + preg_match_all('/"(.*?)"/', $termString, $matches); + if (count($matches[1]) > 0) { + $terms = $matches[1]; + $termString = trim(preg_replace('/"(.*?)"/', '', $termString)); + } else { + $terms = []; + } + if (!empty($termString)) $terms = array_merge($terms, explode(' ', $termString)); + return $terms; } diff --git a/app/Repos/PageRepo.php b/app/Repos/PageRepo.php index f028a1fcc..4784ad407 100644 --- a/app/Repos/PageRepo.php +++ b/app/Repos/PageRepo.php @@ -3,39 +3,32 @@ use Activity; use BookStack\Book; -use BookStack\Chapter; -use Illuminate\Http\Request; -use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Log; +use BookStack\Exceptions\NotFoundException; use Illuminate\Support\Str; use BookStack\Page; use BookStack\PageRevision; -use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; -class PageRepo +class PageRepo extends EntityRepo { - protected $page; protected $pageRevision; /** * PageRepo constructor. - * @param Page $page * @param PageRevision $pageRevision */ - public function __construct(Page $page, PageRevision $pageRevision) + public function __construct(PageRevision $pageRevision) { - $this->page = $page; $this->pageRevision = $pageRevision; + parent::__construct(); } /** - * Check if a page id exists. - * @param $id - * @return bool + * Base query for getting pages, Takes restrictions into account. + * @return mixed */ - public function idExists($id) + private function pageQuery() { - return $this->page->where('page_id', '=', $id)->count() > 0; + return $this->restrictionService->enforcePageRestrictions($this->page, 'view'); } /** @@ -45,16 +38,7 @@ class PageRepo */ public function getById($id) { - return $this->page->findOrFail($id); - } - - /** - * Get all pages. - * @return \Illuminate\Database\Eloquent\Collection|static[] - */ - public function getAll() - { - return $this->page->all(); + return $this->pageQuery()->findOrFail($id); } /** @@ -62,11 +46,12 @@ class PageRepo * @param $slug * @param $bookId * @return mixed + * @throws NotFoundException */ public function getBySlug($slug, $bookId) { - $page = $this->page->where('slug', '=', $slug)->where('book_id', '=', $bookId)->first(); - if ($page === null) throw new NotFoundHttpException('Page not found'); + $page = $this->pageQuery()->where('slug', '=', $slug)->where('book_id', '=', $bookId)->first(); + if ($page === null) throw new NotFoundException('Page not found'); return $page; } @@ -81,6 +66,9 @@ class PageRepo public function findPageUsingOldSlug($pageSlug, $bookSlug) { $revision = $this->pageRevision->where('slug', '=', $pageSlug) + ->whereHas('page', function($query) { + $this->restrictionService->enforcePageRestrictions($query); + }) ->where('book_slug', '=', $bookSlug)->orderBy('created_at', 'desc') ->with('page')->first(); return $revision !== null ? $revision->page : null; @@ -201,8 +189,8 @@ class PageRepo */ public function getBySearch($term, $whereTerms = [], $count = 20, $paginationAppends = []) { - $terms = explode(' ', $term); - $pages = $this->page->fullTextSearchQuery(['name', 'text'], $terms, $whereTerms) + $terms = $this->prepareSearchTerms($term); + $pages = $this->restrictionService->enforcePageRestrictions($this->page->fullTextSearchQuery(['name', 'text'], $terms, $whereTerms)) ->paginate($count)->appends($paginationAppends); // Add highlights to page text. @@ -240,7 +228,7 @@ class PageRepo */ public function searchForImage($imageString) { - $pages = $this->page->where('html', 'like', '%' . $imageString . '%')->get(); + $pages = $this->pageQuery()->where('html', 'like', '%' . $imageString . '%')->get(); foreach ($pages as $page) { $page->url = $page->getUrl(); $page->html = ''; @@ -386,6 +374,7 @@ class PageRepo Activity::removeEntity($page); $page->views()->delete(); $page->revisions()->delete(); + $page->restrictions()->delete(); $page->delete(); } @@ -395,7 +384,7 @@ class PageRepo */ public function getRecentlyCreatedPaginated($count = 20) { - return $this->page->orderBy('created_at', 'desc')->paginate($count); + return $this->pageQuery()->orderBy('created_at', 'desc')->paginate($count); } /** @@ -404,7 +393,7 @@ class PageRepo */ public function getRecentlyUpdatedPaginated($count = 20) { - return $this->page->orderBy('updated_at', 'desc')->paginate($count); + return $this->pageQuery()->orderBy('updated_at', 'desc')->paginate($count); } } diff --git a/app/Repos/PermissionsRepo.php b/app/Repos/PermissionsRepo.php new file mode 100644 index 000000000..3c5efde23 --- /dev/null +++ b/app/Repos/PermissionsRepo.php @@ -0,0 +1,142 @@ +permission = $permission; + $this->role = $role; + } + + /** + * Get all the user roles from the system. + * @return \Illuminate\Database\Eloquent\Collection|static[] + */ + public function getAllRoles() + { + return $this->role->all(); + } + + /** + * Get all the roles except for the provided one. + * @param Role $role + * @return mixed + */ + public function getAllRolesExcept(Role $role) + { + return $this->role->where('id', '!=', $role->id)->get(); + } + + /** + * Get a role via its ID. + * @param $id + * @return mixed + */ + public function getRoleById($id) + { + return $this->role->findOrFail($id); + } + + /** + * Save a new role into the system. + * @param array $roleData + * @return Role + */ + public function saveNewRole($roleData) + { + $role = $this->role->newInstance($roleData); + $role->name = str_replace(' ', '-', strtolower($roleData['display_name'])); + // Prevent duplicate names + while ($this->role->where('name', '=', $role->name)->count() > 0) { + $role->name .= strtolower(str_random(2)); + } + $role->save(); + + $permissions = isset($roleData['permissions']) ? array_keys($roleData['permissions']) : []; + $this->assignRolePermissions($role, $permissions); + return $role; + } + + /** + * Updates an existing role. + * Ensure Admin role always has all permissions. + * @param $roleId + * @param $roleData + */ + public function updateRole($roleId, $roleData) + { + $role = $this->role->findOrFail($roleId); + $permissions = isset($roleData['permissions']) ? array_keys($roleData['permissions']) : []; + $this->assignRolePermissions($role, $permissions); + + if ($role->name === 'admin') { + $permissions = $this->permission->all()->pluck('id')->toArray(); + $role->permissions()->sync($permissions); + } + + $role->fill($roleData); + $role->save(); + } + + /** + * Assign an list of permission names to an role. + * @param Role $role + * @param array $permissionNameArray + */ + public function assignRolePermissions(Role $role, $permissionNameArray = []) + { + $permissions = []; + $permissionNameArray = array_values($permissionNameArray); + if ($permissionNameArray && count($permissionNameArray) > 0) { + $permissions = $this->permission->whereIn('name', $permissionNameArray)->pluck('id')->toArray(); + } + $role->permissions()->sync($permissions); + } + + /** + * Delete a role from the system. + * Check it's not an admin role or set as default before deleting. + * If an migration Role ID is specified the users assign to the current role + * will be added to the role of the specified id. + * @param $roleId + * @param $migrateRoleId + * @throws PermissionsException + */ + public function deleteRole($roleId, $migrateRoleId) + { + $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'); + } else if ($role->id == setting('registration-role')) { + throw new PermissionsException('This role cannot be deleted while set as the default registration role.'); + } + + if ($migrateRoleId) { + $newRole = $this->role->find($migrateRoleId); + if ($newRole) { + $users = $role->users->pluck('id')->toArray(); + $newRole->users()->sync($users); + } + } + + $role->delete(); + } + +} \ No newline at end of file diff --git a/app/Repos/UserRepo.php b/app/Repos/UserRepo.php index 48541a51a..d5a4b1503 100644 --- a/app/Repos/UserRepo.php +++ b/app/Repos/UserRepo.php @@ -42,6 +42,15 @@ class UserRepo return $this->user->findOrFail($id); } + /** + * Get all the users with their permissions. + * @return \Illuminate\Database\Eloquent\Builder|static + */ + public function getAllUsers() + { + return $this->user->with('roles', 'avatar')->orderBy('name', 'asc')->get(); + } + /** * Creates a new user and attaches a role to them. * @param array $data @@ -68,8 +77,8 @@ class UserRepo */ public function attachDefaultRole($user) { - $roleId = Setting::get('registration-role'); - if ($roleId === false) $roleId = $this->role->getDefault()->id; + $roleId = setting('registration-role'); + if ($roleId === false) $roleId = $this->role->first()->id; $user->attachRoleId($roleId); } @@ -80,15 +89,10 @@ class UserRepo */ public function isOnlyAdmin(User $user) { - if ($user->role->name != 'admin') { - return false; - } - - $adminRole = $this->role->where('name', '=', 'admin')->first(); - if (count($adminRole->users) > 1) { - return false; - } + if (!$user->roles->pluck('name')->contains('admin')) return false; + $adminRole = $this->role->getRole('admin'); + if ($adminRole->users->count() > 1) return false; return true; } @@ -137,12 +141,15 @@ class UserRepo public function getRecentlyCreated(User $user, $count = 20) { return [ - 'pages' => $this->entityRepo->page->where('created_by', '=', $user->id)->orderBy('created_at', 'desc') - ->take($count)->get(), - 'chapters' => $this->entityRepo->chapter->where('created_by', '=', $user->id)->orderBy('created_at', 'desc') - ->take($count)->get(), - 'books' => $this->entityRepo->book->where('created_by', '=', $user->id)->orderBy('created_at', 'desc') - ->take($count)->get() + 'pages' => $this->entityRepo->getRecentlyCreatedPages($count, 0, function ($query) use ($user) { + $query->where('created_by', '=', $user->id); + }), + 'chapters' => $this->entityRepo->getRecentlyCreatedChapters($count, 0, function ($query) use ($user) { + $query->where('created_by', '=', $user->id); + }), + 'books' => $this->entityRepo->getRecentlyCreatedBooks($count, 0, function ($query) use ($user) { + $query->where('created_by', '=', $user->id); + }) ]; } @@ -154,10 +161,20 @@ class UserRepo public function getAssetCounts(User $user) { return [ - 'pages' => $this->entityRepo->page->where('created_by', '=', $user->id)->count(), + 'pages' => $this->entityRepo->page->where('created_by', '=', $user->id)->count(), 'chapters' => $this->entityRepo->chapter->where('created_by', '=', $user->id)->count(), - 'books' => $this->entityRepo->book->where('created_by', '=', $user->id)->count(), + 'books' => $this->entityRepo->book->where('created_by', '=', $user->id)->count(), ]; } + /** + * Get all the roles which can be given restricted access to + * other entities in the system. + * @return mixed + */ + public function getRestrictableRoles() + { + return $this->role->where('name', '!=', 'admin')->get(); + } + } \ No newline at end of file diff --git a/app/Restriction.php b/app/Restriction.php new file mode 100644 index 000000000..58d117997 --- /dev/null +++ b/app/Restriction.php @@ -0,0 +1,21 @@ +morphTo(); + } +} diff --git a/app/Role.php b/app/Role.php index 3d93bf770..270e4e0b8 100644 --- a/app/Role.php +++ b/app/Role.php @@ -6,11 +6,8 @@ use Illuminate\Database\Eloquent\Model; class Role extends Model { - /** - * Sets the default role name for newly registered users. - * @var string - */ - protected static $default = 'viewer'; + + protected $fillable = ['display_name', 'description']; /** * The roles that belong to the role. @@ -28,6 +25,15 @@ class Role extends Model return $this->belongsToMany('BookStack\Permission'); } + /** + * Check if this role has a permission. + * @param $permission + */ + public function hasPermission($permission) + { + return $this->permissions->pluck('name')->contains($permission); + } + /** * Add a permission to this role. * @param Permission $permission @@ -37,15 +43,6 @@ class Role extends Model $this->permissions()->attach($permission->id); } - /** - * Get an instance of the default role. - * @return Role - */ - public static function getDefault() - { - return static::getRole(static::$default); - } - /** * Get the role object for the specified role. * @param $roleName diff --git a/app/Services/ActivityService.php b/app/Services/ActivityService.php index a065ae01f..d0029b6c4 100644 --- a/app/Services/ActivityService.php +++ b/app/Services/ActivityService.php @@ -1,6 +1,5 @@ activity = $activity; + $this->restrictionService = $restrictionService; $this->user = auth()->user(); } @@ -24,8 +26,8 @@ class ActivityService * Add activity data to database. * @param Entity $entity * @param $activityKey - * @param int $bookId - * @param bool $extra + * @param int $bookId + * @param bool $extra */ public function add(Entity $entity, $activityKey, $bookId = 0, $extra = false) { @@ -43,7 +45,7 @@ class ActivityService /** * Adds a activity history with a message & without binding to a entity. * @param $activityKey - * @param int $bookId + * @param int $bookId * @param bool|false $extra */ public function addMessage($activityKey, $bookId = 0, $extra = false) @@ -86,8 +88,10 @@ class ActivityService */ public function latest($count = 20, $page = 0) { - $activityList = $this->activity->orderBy('created_at', 'desc') - ->skip($count * $page)->take($count)->get(); + $activityList = $this->restrictionService + ->filterRestrictedEntityRelations($this->activity, 'activities', 'entity_id', 'entity_type') + ->orderBy('created_at', 'desc')->skip($count * $page)->take($count)->get(); + return $this->filterSimilar($activityList); } @@ -95,8 +99,8 @@ class ActivityService * Gets the latest activity for an entity, Filtering out similar * items to prevent a message activity list. * @param Entity $entity - * @param int $count - * @param int $page + * @param int $count + * @param int $page * @return array */ public function entityActivity($entity, $count = 20, $page = 0) @@ -117,9 +121,10 @@ class ActivityService */ public function userActivity($user, $count = 20, $page = 0) { - $activity = $this->activity->where('user_id', '=', $user->id) - ->orderBy('created_at', 'desc')->skip($count * $page)->take($count)->get(); - return $this->filterSimilar($activity); + $activityList = $this->restrictionService + ->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/EmailConfirmationService.php b/app/Services/EmailConfirmationService.php index ffe21eec4..c3096c654 100644 --- a/app/Services/EmailConfirmationService.php +++ b/app/Services/EmailConfirmationService.php @@ -45,7 +45,7 @@ class EmailConfirmationService 'token' => $token, ]); $this->mailer->send('emails/email-confirmation', ['token' => $token], function (Message $message) use ($user) { - $appName = \Setting::get('app-name', 'BookStack'); + $appName = setting('app-name', 'BookStack'); $message->to($user->email, $user->name)->subject('Confirm your email on ' . $appName . '.'); }); } diff --git a/app/Services/ImageService.php b/app/Services/ImageService.php index 47c27cd0a..aefc8a4fb 100644 --- a/app/Services/ImageService.php +++ b/app/Services/ImageService.php @@ -79,7 +79,7 @@ class ImageService private function saveNew($imageName, $imageData, $type) { $storage = $this->getStorage(); - $secureUploads = Setting::get('app-secure-images'); + $secureUploads = setting('app-secure-images'); $imageName = str_replace(' ', '-', $imageName); if ($secureUploads) $imageName = str_random(16) . '-' . $imageName; diff --git a/app/Services/RestrictionService.php b/app/Services/RestrictionService.php new file mode 100644 index 000000000..f7838bf88 --- /dev/null +++ b/app/Services/RestrictionService.php @@ -0,0 +1,272 @@ +user(); + $this->userRoles = $user ? auth()->user()->roles->pluck('id') : []; + $this->isAdmin = $user ? auth()->user()->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; + } + + /** + * Add restrictions for a page query + * @param $query + * @param string $action + * @return mixed + */ + public function enforcePageRestrictions($query, $action = 'view') + { + 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); + }); + }); + }); + }); + } + + /** + * 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 2437a4827..df213609a 100644 --- a/app/Services/SocialAuthService.php +++ b/app/Services/SocialAuthService.php @@ -135,7 +135,7 @@ class SocialAuthService // Otherwise let the user know this social account is not used by anyone. $message = 'This ' . $socialDriver . ' account is not linked to any users. Please attach it in your profile settings'; - if (\Setting::get('registration-enabled')) { + if (setting('registration-enabled')) { $message .= ' or, If you do not yet have an account, You can register an account using the ' . $socialDriver . ' option'; } throw new SocialSignInException($message . '.', '/login'); diff --git a/app/Services/ViewService.php b/app/Services/ViewService.php index 5b800d939..75ffd21dc 100644 --- a/app/Services/ViewService.php +++ b/app/Services/ViewService.php @@ -9,15 +9,18 @@ class ViewService protected $view; protected $user; + protected $restrictionService; /** * ViewService constructor. - * @param $view + * @param View $view + * @param RestrictionService $restrictionService */ - public function __construct(View $view) + public function __construct(View $view, RestrictionService $restrictionService) { $this->view = $view; $this->user = auth()->user(); + $this->restrictionService = $restrictionService; } /** @@ -27,7 +30,7 @@ class ViewService */ public function add(Entity $entity) { - if($this->user === null) return 0; + if ($this->user === null) return 0; $view = $entity->views()->where('user_id', '=', $this->user->id)->first(); // Add view if model exists if ($view) { @@ -47,18 +50,19 @@ class ViewService /** * Get the entities with the most views. - * @param int $count - * @param int $page + * @param int $count + * @param int $page * @param bool|false $filterModel */ public function getPopular($count = 10, $page = 0, $filterModel = false) { $skipCount = $count * $page; - $query = $this->view->select('id', 'viewable_id', 'viewable_type', \DB::raw('SUM(views) as view_count')) + $query = $this->restrictionService->filterRestrictedEntityRelations($this->view, 'views', 'viewable_id', 'viewable_type') + ->select('id', 'viewable_id', 'viewable_type', \DB::raw('SUM(views) as view_count')) ->groupBy('viewable_id', 'viewable_type') ->orderBy('view_count', 'desc'); - if($filterModel) $query->where('viewable_type', '=', get_class($filterModel)); + if ($filterModel) $query->where('viewable_type', '=', get_class($filterModel)); $views = $query->with('viewable')->skip($skipCount)->take($count)->get(); $viewedEntities = $views->map(function ($item) { @@ -69,22 +73,24 @@ class ViewService /** * Get all recently viewed entities for the current user. - * @param int $count - * @param int $page + * @param int $count + * @param int $page * @param Entity|bool $filterModel * @return mixed */ public function getUserRecentlyViewed($count = 10, $page = 0, $filterModel = false) { - if($this->user === null) return collect(); + if ($this->user === null) return collect(); $skipCount = $count * $page; - $query = $this->view->where('user_id', '=', auth()->user()->id); + $query = $this->restrictionService + ->filterRestrictedEntityRelations($this->view, 'views', 'viewable_id', 'viewable_type'); - if ($filterModel) $query->where('viewable_type', '=', get_class($filterModel)); + if ($filterModel) $query = $query->where('viewable_type', '=', get_class($filterModel)); + $query = $query->where('user_id', '=', auth()->user()->id); $views = $query->with('viewable')->orderBy('updated_at', 'desc')->skip($skipCount)->take($count)->get(); $viewedEntities = $views->map(function ($item) { - return $item->viewable()->getResults(); + return $item->viewable; }); return $viewedEntities; } diff --git a/app/User.php b/app/User.php index c55102078..e1b7c143b 100644 --- a/app/User.php +++ b/app/User.php @@ -14,21 +14,18 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon /** * The database table used by the model. - * * @var string */ protected $table = 'users'; /** * The attributes that are mass assignable. - * * @var array */ protected $fillable = ['name', 'email', 'image_id']; /** * The attributes excluded from the model's JSON form. - * * @var array */ protected $hidden = ['password', 'remember_token']; @@ -50,10 +47,6 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon ]); } - /** - * Permissions and roles - */ - /** * The roles that belong to the user. */ @@ -62,21 +55,30 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon return $this->belongsToMany('BookStack\Role'); } - public function getRoleAttribute() + /** + * Check if the user has a role. + * @param $role + * @return mixed + */ + public function hasRole($role) { - return $this->roles()->with('permissions')->first(); + return $this->roles->pluck('name')->contains($role); } /** - * Loads the user's permissions from their role. + * Get all permissions belonging to a the current user. + * @param bool $cache + * @return \Illuminate\Database\Eloquent\Relations\HasManyThrough */ - private function loadPermissions() + public function permissions($cache = true) { - if (isset($this->permissions)) return; + if(isset($this->permissions) && $cache) return $this->permissions; $this->load('roles.permissions'); - $permissions = $this->roles[0]->permissions; - $permissionsArray = $permissions->pluck('name')->all(); - $this->permissions = $permissionsArray; + $permissions = $this->roles->map(function($role) { + return $role->permissions; + })->flatten()->unique(); + $this->permissions = $permissions; + return $permissions; } /** @@ -86,11 +88,8 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon */ public function can($permissionName) { - if ($this->email == 'guest') { - return false; - } - $this->loadPermissions(); - return array_search($permissionName, $this->permissions) !== false; + if ($this->email === 'guest') return false; + return $this->permissions()->pluck('name')->contains($permissionName); } /** @@ -108,12 +107,11 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon */ public function attachRoleId($id) { - $this->roles()->sync([$id]); + $this->roles()->attach($id); } /** * Get the social account associated with this user. - * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function socialAccounts() @@ -138,8 +136,6 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon /** * Returns the user's avatar, - * Uses Gravatar as the avatar service. - * * @param int $size * @return string */ diff --git a/app/helpers.php b/app/helpers.php index f25a8f765..f60e917c5 100644 --- a/app/helpers.php +++ b/app/helpers.php @@ -1,10 +1,10 @@ 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 entitiy + $restrictionService = app('BookStack\Services\RestrictionService'); + $explodedPermission = explode('-', $permission); + $action = end($explodedPermission); + $hasAccess = $restrictionService->checkIfEntityRestricted($ownable, $action); + return $hasAccess && $hasPermission; +} + +/** + * Helper to access system settings. + * @param $key + * @param bool $default + * @return mixed + */ +function setting($key, $default = false) +{ + $settingService = app('BookStack\Services\SettingService'); + return $settingService->get($key, $default); +} diff --git a/config/cache.php b/config/cache.php index 379135b0e..076a0299f 100644 --- a/config/cache.php +++ b/config/cache.php @@ -1,5 +1,18 @@ $memcachedServer) { + $memcachedServerDetails = explode(':', $memcachedServer); + $components = count($memcachedServerDetails); + if ($components < 2) $memcachedServerDetails[] = '11211'; + if ($components < 3) $memcachedServerDetails[] = '100'; + $memcachedServers[$index] = array_combine($memcachedServerKeys, $memcachedServerDetails); + } +} + return [ /* @@ -49,11 +62,7 @@ return [ 'memcached' => [ 'driver' => 'memcached', - 'servers' => [ - [ - 'host' => '127.0.0.1', 'port' => 11211, 'weight' => 100, - ], - ], + 'servers' => env('CACHE_DRIVER') === 'memcached' ? $memcachedServers : [], ], 'redis' => [ diff --git a/database/factories/ModelFactory.php b/database/factories/ModelFactory.php index e0f155087..2840356e8 100644 --- a/database/factories/ModelFactory.php +++ b/database/factories/ModelFactory.php @@ -17,6 +17,7 @@ $factory->define(BookStack\User::class, function ($faker) { 'email' => $faker->email, 'password' => str_random(10), 'remember_token' => str_random(10), + 'email_confirmed' => 1 ]; }); @@ -45,3 +46,10 @@ $factory->define(BookStack\Page::class, function ($faker) { 'text' => strip_tags($html) ]; }); + +$factory->define(BookStack\Role::class, function ($faker) { + return [ + 'display_name' => $faker->sentence(3), + 'description' => $faker->sentence(10) + ]; +}); \ No newline at end of file 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 new file mode 100644 index 000000000..ea3735d9e --- /dev/null +++ b/database/migrations/2016_02_27_120329_update_permissions_and_roles.php @@ -0,0 +1,99 @@ +each(function ($permission) { + $permission->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' + ]; + foreach ($permissionsToCreate as $name => $displayName) { + $newPermission = new \BookStack\Permission(); + $newPermission->name = $name; + $newPermission->display_name = $displayName; + $newPermission->save(); + $adminRole->attachPermission($newPermission); + } + + // Create & attach new entity permissions + $entities = ['Book', 'Page', 'Chapter', 'Image']; + $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); + } + } + + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + // Get roles with permissions we need to change + $adminRole = \BookStack\Role::getRole('admin'); + + // Delete old permissions + $permissions = \BookStack\Permission::all(); + $permissions->each(function ($permission) { + $permission->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); + } + } + + // Create admin permissions + $entities = ['Settings', 'User']; + $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); + } + } + } +} diff --git a/database/migrations/2016_02_28_084200_add_entity_access_controls.php b/database/migrations/2016_02_28_084200_add_entity_access_controls.php new file mode 100644 index 000000000..5df2353a2 --- /dev/null +++ b/database/migrations/2016_02_28_084200_add_entity_access_controls.php @@ -0,0 +1,73 @@ +integer('uploaded_to')->default(0); + $table->index('uploaded_to'); + }); + + Schema::table('books', function (Blueprint $table) { + $table->boolean('restricted')->default(false); + $table->index('restricted'); + }); + + Schema::table('chapters', function (Blueprint $table) { + $table->boolean('restricted')->default(false); + $table->index('restricted'); + }); + + Schema::table('pages', function (Blueprint $table) { + $table->boolean('restricted')->default(false); + $table->index('restricted'); + }); + + Schema::create('restrictions', function(Blueprint $table) { + $table->increments('id'); + $table->integer('restrictable_id'); + $table->string('restrictable_type'); + $table->integer('role_id'); + $table->string('action'); + $table->index('role_id'); + $table->index('action'); + $table->index(['restrictable_id', 'restrictable_type']); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('images', function (Blueprint $table) { + $table->dropColumn('uploaded_to'); + }); + + Schema::table('books', function (Blueprint $table) { + $table->dropColumn('restricted'); + }); + + Schema::table('chapters', function (Blueprint $table) { + $table->dropColumn('restricted'); + }); + + + Schema::table('pages', function (Blueprint $table) { + $table->dropColumn('restricted'); + }); + + Schema::drop('restrictions'); + } +} diff --git a/database/seeds/DummyContentSeeder.php b/database/seeds/DummyContentSeeder.php index aa70eaa0a..328971f26 100644 --- a/database/seeds/DummyContentSeeder.php +++ b/database/seeds/DummyContentSeeder.php @@ -12,7 +12,7 @@ class DummyContentSeeder extends Seeder public function run() { $user = factory(BookStack\User::class, 1)->create(); - $role = \BookStack\Role::getDefault(); + $role = \BookStack\Role::getRole('editor'); $user->attachRole($role); diff --git a/phpunit.xml b/phpunit.xml index 762fc2da7..66196e8cf 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -21,6 +21,7 @@ + diff --git a/public/libs/jq-color-picker/tiny-color-picker.min.js b/public/libs/jq-color-picker/tiny-color-picker.min.js new file mode 100644 index 000000000..84024073f --- /dev/null +++ b/public/libs/jq-color-picker/tiny-color-picker.min.js @@ -0,0 +1,4 @@ +/*! tinyColorPicker - v1.0.0 2016-02-28 */ +// https://github.com/PitPik/tinyColorPicker +// http://www.dematte.at/tinyColorPicker/index.html?type=small#demo +!function(a,b){"object"==typeof exports?module.exports=b(a):"function"==typeof define&&define.amd?define([],function(){return b(a)}):a.Colors=b(a)}(this,function(a,b){"use strict";function c(a,c,d,f,g){if("string"==typeof c){var c=t.txt2color(c);d=c.type,n[d]=c[d],g=g!==b?g:c.alpha}else if(c)for(var h in c)a[d][h]=k(c[h]/l[d][h][1],0,1);return g!==b&&(a.alpha=k(+g,0,1)),e(d,f?a:b)}function d(a,b,c){var d=m.options.grey,e={};return e.RGB={r:a.r,g:a.g,b:a.b},e.rgb={r:b.r,g:b.g,b:b.b},e.alpha=c,e.equivalentGrey=Math.round(d.r*a.r+d.g*a.g+d.b*a.b),e.rgbaMixBlack=i(b,{r:0,g:0,b:0},c,1),e.rgbaMixWhite=i(b,{r:1,g:1,b:1},c,1),e.rgbaMixBlack.luminance=h(e.rgbaMixBlack,!0),e.rgbaMixWhite.luminance=h(e.rgbaMixWhite,!0),m.options.customBG&&(e.rgbaMixCustom=i(b,m.options.customBG,c,1),e.rgbaMixCustom.luminance=h(e.rgbaMixCustom,!0),m.options.customBG.luminance=h(m.options.customBG,!0)),e}function e(a,b){var c,e,k,o=b||n,p=t,q=m.options,r=l,s=o.RND,u="",v="",w={hsl:"hsv",rgb:a},x=s.rgb;if("alpha"!==a){for(var y in r)if(!r[y][y]){a!==y&&(v=w[y]||"rgb",o[y]=p[v+"2"+y](o[v])),s[y]||(s[y]={}),c=o[y];for(u in c)s[y][u]=Math.round(c[u]*r[y][u][1])}x=s.rgb,o.HEX=p.RGB2HEX(x),o.equivalentGrey=q.grey.r*o.rgb.r+q.grey.g*o.rgb.g+q.grey.b*o.rgb.b,o.webSave=e=f(x,51),o.webSmart=k=f(x,17),o.saveColor=x.r===e.r&&x.g===e.g&&x.b===e.b?"web save":x.r===k.r&&x.g===k.g&&x.b===k.b?"web smart":"",o.hueRGB=t.hue2RGB(o.hsv.h),b&&(o.background=d(x,o.rgb,o.alpha))}var z,A,B,C=o.rgb,D=o.alpha,E="luminance",F=o.background;return z=i(C,{r:0,g:0,b:0},D,1),z[E]=h(z,!0),o.rgbaMixBlack=z,A=i(C,{r:1,g:1,b:1},D,1),A[E]=h(A,!0),o.rgbaMixWhite=A,q.customBG&&(B=i(C,F.rgbaMixCustom,D,1),B[E]=h(B,!0),B.WCAG2Ratio=j(B[E],F.rgbaMixCustom[E]),o.rgbaMixBGMixCustom=B,B.luminanceDelta=Math.abs(B[E]-F.rgbaMixCustom[E]),B.hueDelta=g(F.rgbaMixCustom,B,!0)),o.RGBLuminance=h(x),o.HUELuminance=h(o.hueRGB),q.convertCallback&&q.convertCallback(o,a),o}function f(a,b){var c={},d=0,e=b/2;for(var f in a)d=a[f]%b,c[f]=a[f]+(d>e?b-d:-d);return c}function g(a,b,c){return(Math.max(a.r-b.r,b.r-a.r)+Math.max(a.g-b.g,b.g-a.g)+Math.max(a.b-b.b,b.b-a.b))*(c?255:1)/765}function h(a,b){for(var c=b?1:255,d=[a.r/c,a.g/c,a.b/c],e=m.options.luminance,f=d.length;f--;)d[f]=d[f]<=.03928?d[f]/12.92:Math.pow((d[f]+.055)/1.055,2.4);return e.r*d[0]+e.g*d[1]+e.b*d[2]}function i(a,c,d,e){var f={},g=d!==b?d:1,h=e!==b?e:1,i=g+h*(1-g);for(var j in a)f[j]=(a[j]*g+c[j]*h*(1-g))/i;return f.a=i,f}function j(a,b){var c=1;return c=a>=b?(a+.05)/(b+.05):(b+.05)/(a+.05),Math.round(100*c)/100}function k(a,b,c){return a>c?c:b>a?b:a}var l={rgb:{r:[0,255],g:[0,255],b:[0,255]},hsv:{h:[0,360],s:[0,100],v:[0,100]},hsl:{h:[0,360],s:[0,100],l:[0,100]},alpha:{alpha:[0,1]},HEX:{HEX:[0,16777215]}},m={},n={},o={r:.298954,g:.586434,b:.114612},p={r:.2126,g:.7152,b:.0722},q=function(a){this.colors={RND:{}},this.options={color:"rgba(204, 82, 37, 0.8)",grey:o,luminance:p,valueRanges:l},r(this,a||{})},r=function(a,d){var e,f=a.options;s(a);for(var g in d)d[g]!==b&&(f[g]=d[g]);e=f.customBG,f.customBG="string"==typeof e?t.txt2color(e).rgb:e,n=c(a.colors,f.color,b,!0)},s=function(a){m!==a&&(m=a,n=a.colors)};q.prototype.setColor=function(a,d,f){return s(this),a?c(this.colors,a,d,b,f):(f!==b&&(this.colors.alpha=k(f,0,1)),e(d))},q.prototype.setCustomBackground=function(a){return s(this),this.options.customBG="string"==typeof a?t.txt2color(a).rgb:a,c(this.colors,b,"rgb")},q.prototype.saveAsBackground=function(){return s(this),c(this.colors,b,"rgb",!0)};var t={txt2color:function(a){var b={},c=a.replace(/(?:#|\)|%)/g,"").split("("),d=(c[1]||"").split(/,\s*/),e=c[1]?c[0].substr(0,3):"rgb",f="";if(b.type=e,b[e]={},c[1])for(var g=3;g--;)f=e[g]||e.charAt(g),b[e][f]=+d[g]/l[e][f][1];else b.rgb=t.HEX2rgb(c[0]);return b.alpha=d[3]?+d[3]:1,b},RGB2HEX:function(a){return((a.r<16?"0":"")+a.r.toString(16)+(a.g<16?"0":"")+a.g.toString(16)+(a.b<16?"0":"")+a.b.toString(16)).toUpperCase()},HEX2rgb:function(a){return a=a.split(""),{r:parseInt(a[0]+a[a[3]?1:0],16)/255,g:parseInt(a[a[3]?2:1]+(a[3]||a[1]),16)/255,b:parseInt((a[4]||a[2])+(a[5]||a[2]),16)/255}},hue2RGB:function(a){var b=6*a,c=~~b%6,d=6===b?0:b-c;return{r:Math.round(255*[1,1-d,0,0,d,1][c]),g:Math.round(255*[d,1,1,1-d,0,0][c]),b:Math.round(255*[0,0,d,1,1,1-d][c])}},rgb2hsv:function(a){var b,c,d,e=a.r,f=a.g,g=a.b,h=0;return g>f&&(f=g+(g=f,0),h=-1),c=g,f>e&&(e=f+(f=e,0),h=-2/6-h,c=Math.min(f,g)),b=e-c,d=e?b/e:0,{h:1e-15>d?n&&n.hsl&&n.hsl.h||0:b?Math.abs(h+(f-g)/(6*b)):0,s:e?b/e:n&&n.hsv&&n.hsv.s||0,v:e}},hsv2rgb:function(a){var b=6*a.h,c=a.s,d=a.v,e=~~b,f=b-e,g=d*(1-c),h=d*(1-f*c),i=d*(1-(1-f)*c),j=e%6;return{r:[d,h,g,g,i,d][j],g:[i,d,d,h,g,g][j],b:[g,g,i,d,d,h][j]}},hsv2hsl:function(a){var b=(2-a.s)*a.v,c=a.s*a.v;return c=a.s?1>b?b?c/b:0:c/(2-b):0,{h:a.h,s:a.v||c?c:n&&n.hsl&&n.hsl.s||0,l:b/2}},rgb2hsl:function(a,b){var c=t.rgb2hsv(a);return t.hsv2hsl(b?c:n.hsv=c)},hsl2rgb:function(a){var b=6*a.h,c=a.s,d=a.l,e=.5>d?d*(1+c):d+c-c*d,f=d+d-e,g=e?(e-f)/e:0,h=~~b,i=b-h,j=e*g*i,k=f+j,l=e-j,m=h%6;return{r:[e,l,f,f,k,e][m],g:[k,e,e,l,f,f][m],b:[f,f,k,e,e,l][m]}}};return q}),function(a,b){"object"==typeof exports?module.exports=b(a,require("jquery"),require("colors")):"function"==typeof define&&define.amd?define(["jquery","colors"],function(c,d){return b(a,c,d)}):b(a,a.jQuery,a.Colors)}(this,function(a,b,c,d){"use strict";function e(a){return a.value||a.getAttribute("value")||b(a).css("background-color")||"#fff"}function f(a){return a=a.originalEvent&&a.originalEvent.touches?a.originalEvent.touches[0]:a,a.originalEvent?a.originalEvent:a}function g(a){return b(a.find(r.doRender)[0]||a[0])}function h(c){var d=b(this),f=d.offset(),h=b(a),j=r.gap;c?(s=g(d),s._colorMode=s.data("colorMode"),p.$trigger=d,(t||i()).css({left:(t[0]._left=f.left)-((t[0]._left=t[0]._left+t[0]._width-(h.scrollLeft()+h.width()))+j>0?t[0]._left+j:0),top:(t[0]._top=f.top+d.outerHeight())-((t[0]._top=t[0]._top+t[0]._height-(h.scrollTop()+h.height()))+j>0?t[0]._top+j:0)}).show(r.animationSpeed,function(){c!==!0&&(x._width=x.width(),u._width=u.width(),u._height=u.height(),q.setColor(e(s[0])),n(!0))})):b(t).hide(r.animationSpeed,function(){n(!1),p.$trigger=null})}function i(){return b("head").append('"),p.$UI=t=b(H).css({margin:r.margin}).appendTo("body").show(0,function(){var a=b(this);E=r.GPU&&a.css("perspective")!==d,u=b(".cp-xy-slider",this),v=b(".cp-xy-cursor",this),w=b(".cp-z-cursor",this),x=b(".cp-alpha",this).toggle(!!r.opacity),y=b(".cp-alpha-cursor",this),r.buildCallback.call(p,a),a.prepend("
").children().eq(0).css("width",a.children().eq(0).width()),this._width=this.offsetWidth,this._height=this.offsetHeight}).hide().on(C,".cp-xy-slider,.cp-z-slider,.cp-alpha",j)}function j(a){var c=this.className.replace(/cp-(.*?)(?:\s*|$)/,"$1").replace("-","_");(a.button||a.which)>1||(a.preventDefault&&a.preventDefault(),a.returnValue=!1,s._offset=b(this).offset(),(c="xy_slider"===c?k:"z_slider"===c?l:m)(a),n(),z.on(D,function(){z.off(".a")}).on(B,function(a){c(a),n()}))}function k(a){var b=f(a),c=b.pageX-s._offset.left,d=b.pageY-s._offset.top;q.setColor({s:c/u._width*100,v:100-d/u._height*100},"hsv")}function l(a){var b=f(a).pageY-s._offset.top;q.setColor({h:360-b/u._height*360},"hsv")}function m(a){var b=f(a).pageX-s._offset.left,c=b/x._width;q.setColor({},"rgb",c)}function n(a){var b=q.colors,c=b.hueRGB,e=b.RND.rgb,f=b.RND.hsl,g="#222",h="#ddd",i=s._colorMode,j=1!==b.alpha,k=F(100*b.alpha)/100,l=e.r+", "+e.g+", "+e.b,m="HEX"!==i||j?"rgb"===i||"HEX"===i&&j?j?"rgba("+l+", "+k+")":"rgb("+l+")":"hsl"+(j?"a(":"(")+f.h+", "+f.s+"%, "+f.l+"%"+(j?", "+k:"")+")":"#"+b.HEX,n=b.HUELuminance>.22?g:h,p=b.rgbaMixBlack.luminance>.22?g:h,r=(1-b.hsv.h)*u._height,t=b.hsv.s*u._width,z=(1-b.hsv.v)*u._height,A=k*x._width,B=E?"translate3d":"",C=s[0].value,D=s[0].hasAttribute("value")&&""===C&&a!==d;u._css={backgroundColor:"rgb("+c.r+","+c.g+","+c.b+")"},v._css={transform:B+"("+t+"px, "+z+"px, 0)",left:E?"":t,top:E?"":z,borderColor:b.RGBLuminance>.22?g:h},w._css={transform:B+"(0, "+r+"px, 0)",top:E?"":r,borderColor:"transparent "+n},x._css={backgroundColor:"rgb("+l+")"},y._css={transform:B+"("+A+"px, 0, 0)",left:E?"":A,borderColor:p+" transparent"},s._css={backgroundColor:D?"":m,color:D?"":b.rgbaMixBGMixCustom.luminance>.22?g:h},s.text=D?"":C!==m?m:"",a!==d?o(a):G(o)}function o(a){u.css(u._css),v.css(v._css),w.css(w._css),x.css(x._css),y.css(y._css),r.doRender&&s.css(s._css),s.text&&s.val(s.text),r.renderCallback.call(p,s,"boolean"==typeof a?a:d)}var p,q,r,s,t,u,v,w,x,y,z=b(document),A=b(),B="touchmove.a mousemove.a pointermove.a",C="touchstart.a mousedown.a pointerdown.a",D="touchend.a mouseup.a pointerup.a",E=!1,F=Math.round,G=a.requestAnimationFrame||a.webkitRequestAnimationFrame||function(a){a()},H='
',I=".cp-color-picker{position:absolute;overflow:hidden;padding:6px 6px 0;background-color:#444;color:#bbb;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:400;cursor:default;border-radius:5px}.cp-color-picker>div{position:relative;overflow:hidden}.cp-xy-slider{float:left;height:128px;width:128px;margin-bottom:6px;background:linear-gradient(to right,#FFF,rgba(255,255,255,0))}.cp-white{height:100%;width:100%;background:linear-gradient(rgba(0,0,0,0),#000)}.cp-xy-cursor{position:absolute;top:0;width:10px;height:10px;margin:-5px;border:1px solid #fff;border-radius:100%;box-sizing:border-box}.cp-z-slider{float:right;margin-left:6px;height:128px;width:20px;background:linear-gradient(red 0,#f0f 17%,#00f 33%,#0ff 50%,#0f0 67%,#ff0 83%,red 100%)}.cp-z-cursor{position:absolute;margin-top:-4px;width:100%;border:4px solid #fff;border-color:transparent #fff;box-sizing:border-box}.cp-alpha{clear:both;width:100%;height:16px;margin:6px 0;background:linear-gradient(to right,#444,rgba(0,0,0,0))}.cp-alpha-cursor{position:absolute;margin-left:-4px;height:100%;border:4px solid #fff;border-color:#fff transparent;box-sizing:border-box}",J=function(a){q=this.color=new c(a),r=q.options,p=this};return J.prototype={render:n,toggle:h},b.fn.colorPicker=function(c){var d=function(){};return c=b.extend({animationSpeed:150,GPU:!0,doRender:!0,customBG:"#FFF",opacity:!0,renderCallback:d,buildCallback:d,body:document.body,scrollResize:!0,gap:4},c),!p&&c.scrollResize&&b(a).on("resize.a scroll.a",function(){p.$trigger&&p.toggle.call(p.$trigger[0],!0)}),A=A.add(this),this.colorPicker=A.colorPicker=p||new J(c),b(c.body).off(".a").on(C,function(a){!A.add(t).find(a.target).add(A.filter(a.target))[0]&&h()}),this.on("focusin.a click.a",h).on("change.a",function(){q.setColor(this.value||"#FFF"),A.colorPicker.render(!0)}).each(function(){var a=e(this),d=a.split("("),f=g(b(this));f.data("colorMode",d[1]?d[0].substr(0,3):"HEX").attr("readonly",r.preventFocus),c.doRender&&f.css({"background-color":a,color:function(){return q.setColor(a).rgbaMixBGMixCustom.luminance>.22?"#222":"#ddd"}})})},b.fn.colorPicker.destroy=function(){A.add(r.body).off(".a"),p.toggle(!1),A=b()},b}); \ No newline at end of file diff --git a/readme.md b/readme.md index a191e1694..0730e3de3 100644 --- a/readme.md +++ b/readme.md @@ -175,3 +175,4 @@ These are the great projects used to help build BookStack: * [Material Design Iconic Font](http://zavoloklom.github.io/material-design-iconic-font/icons.html) * [Dropzone.js](http://www.dropzonejs.com/) * [ZeroClipboard](http://zeroclipboard.org/) +* [TinyColorPicker](http://www.dematte.at/tinyColorPicker/index.html) diff --git a/resources/assets/js/controllers.js b/resources/assets/js/controllers.js index 76def6abd..1f7388859 100644 --- a/resources/assets/js/controllers.js +++ b/resources/assets/js/controllers.js @@ -118,6 +118,7 @@ module.exports = function (ngApp, events) { page++; }); } + $scope.fetchData = fetchData; /** @@ -130,12 +131,16 @@ module.exports = function (ngApp, events) { $http.put(url, this.selectedImage).then((response) => { events.emit('success', 'Image details updated'); }, (response) => { - var errors = response.data; - var message = ''; - Object.keys(errors).forEach((key) => { - message += errors[key].join('\n'); - }); - events.emit('error', message); + if (response.status === 422) { + var errors = response.data; + var message = ''; + Object.keys(errors).forEach((key) => { + message += errors[key].join('\n'); + }); + events.emit('error', message); + } else if (response.status === 403) { + events.emit('error', response.data.error); + } }); }; @@ -158,6 +163,8 @@ module.exports = function (ngApp, events) { // Pages failure if (response.status === 400) { $scope.dependantPages = response.data; + } else if (response.status === 403) { + events.emit('error', response.data.error); } }); }; @@ -167,7 +174,7 @@ module.exports = function (ngApp, events) { * @param stringDate * @returns {Date} */ - $scope.getDate = function(stringDate) { + $scope.getDate = function (stringDate) { return new Date(stringDate); }; diff --git a/resources/assets/js/global.js b/resources/assets/js/global.js index 90b03e856..5400a8af0 100644 --- a/resources/assets/js/global.js +++ b/resources/assets/js/global.js @@ -95,7 +95,7 @@ $(function () { scrollTop.style.display = 'block'; scrollTopShowing = true; setTimeout(() => { - scrollTop.style.opacity = 1; + scrollTop.style.opacity = 0.4; }, 1); } else if (scrollTopShowing && document.body.scrollTop < scrollTopBreakpoint) { scrollTop.style.opacity = 0; diff --git a/resources/assets/sass/_header.scss b/resources/assets/sass/_header.scss index 1edfc0037..87aa20046 100644 --- a/resources/assets/sass/_header.scss +++ b/resources/assets/sass/_header.scss @@ -87,6 +87,9 @@ header { padding-top: $-s; } } + .dropdown-container { + font-size: 0.9em; + } } form.search-box { diff --git a/resources/assets/sass/_lists.scss b/resources/assets/sass/_lists.scss index f0bd3b1ea..09707ebc4 100644 --- a/resources/assets/sass/_lists.scss +++ b/resources/assets/sass/_lists.scss @@ -95,13 +95,14 @@ // Sidebar list .book-tree { - padding: $-xl 0 0 0; + padding: $-l 0 0 0; position: relative; right: 0; top: 0; transition: ease-in-out 240ms; transition-property: right, border; border-left: 0px solid #FFF; + background-color: #FFF; &.fixed { position: fixed; top: 0; diff --git a/resources/assets/sass/styles.scss b/resources/assets/sass/styles.scss index 4e6823fc0..9c4a4dafc 100644 --- a/resources/assets/sass/styles.scss +++ b/resources/assets/sass/styles.scss @@ -138,7 +138,7 @@ $loadingSize: 10px; // Back to top link $btt-size: 40px; #back-to-top { - background-color: rgba($primary, 0.4); + background-color: $primary; position: fixed; bottom: $-m; right: $-l; @@ -154,7 +154,7 @@ $btt-size: 40px; overflow: hidden; &:hover { width: $btt-size*3.4; - background-color: rgba($primary, 1); + opacity: 1 !important; span { display: inline-block; } diff --git a/resources/lang/en/errors.php b/resources/lang/en/errors.php index 53785b684..b1a252bf3 100644 --- a/resources/lang/en/errors.php +++ b/resources/lang/en/errors.php @@ -8,4 +8,5 @@ return [ // Pages 'permission' => 'You do not have permission to access the requested page.', + 'permissionJson' => 'You do not have permission to perform the requested action.' ]; \ No newline at end of file diff --git a/resources/views/base.blade.php b/resources/views/base.blade.php index 0df49485e..5c744d821 100644 --- a/resources/views/base.blade.php +++ b/resources/views/base.blade.php @@ -1,7 +1,7 @@ - {{ isset($pageTitle) ? $pageTitle . ' | ' : '' }}{{ Setting::get('app-name', 'BookStack') }} + {{ isset($pageTitle) ? $pageTitle . ' | ' : '' }}{{ setting('app-name', 'BookStack') }} @@ -17,6 +17,8 @@ @yield('head') + + @include('partials/custom-styles') @@ -27,10 +29,10 @@
@@ -43,7 +45,7 @@
-@if($currentUser->can('user-update')) +@if(userCan('users-manage'))
@include('form.text', ['name' => 'email'])
@endif -@if($currentUser->can('user-update')) +@if(userCan('users-manage'))
- @include('form.role-select', ['name' => 'role', 'options' => \BookStack\Role::all(), 'displayKey' => 'display_name']) + @include('form/role-checkboxes', ['name' => 'roles', 'roles' => \BookStack\Role::all()])
@endif -@if($currentUser->can('user-update')) +@if(userCan('users-manage'))
@include('form.text', ['name' => 'external_auth_id']) diff --git a/resources/views/users/forms/standard.blade.php b/resources/views/users/forms/standard.blade.php index c20e2955d..9bd70b43c 100644 --- a/resources/views/users/forms/standard.blade.php +++ b/resources/views/users/forms/standard.blade.php @@ -8,10 +8,10 @@ @include('form.text', ['name' => 'email'])
-@if($currentUser->can('user-update')) +@if(userCan('users-manage'))
- @include('form.role-select', ['name' => 'role', 'options' => \BookStack\Role::all(), 'displayKey' => 'display_name']) + @include('form/role-checkboxes', ['name' => 'roles', 'roles' => \BookStack\Role::all()])
@endif diff --git a/resources/views/users/index.blade.php b/resources/views/users/index.blade.php index 6e5d10c5f..f06630714 100644 --- a/resources/views/users/index.blade.php +++ b/resources/views/users/index.blade.php @@ -8,7 +8,7 @@

Users

- @if($currentUser->can('user-create')) + @if(userCan('users-manage'))

Add new user

@@ -18,30 +18,32 @@ Name Email - User Type + User Roles @foreach($users as $user) {{$user->name}} - @if($currentUser->can('user-update') || $currentUser->id == $user->id) + @if(userCan('users-manage') || $currentUser->id == $user->id) @endif {{ $user->name }} - @if($currentUser->can('user-update') || $currentUser->id == $user->id) + @if(userCan('users-manage') || $currentUser->id == $user->id) @endif - @if($currentUser->can('user-update') || $currentUser->id == $user->id) + @if(userCan('users-manage') || $currentUser->id == $user->id) @endif {{ $user->email }} - @if($currentUser->can('user-update') || $currentUser->id == $user->id) + @if(userCan('users-manage') || $currentUser->id == $user->id) @endif - {{ $user->role->display_name }} + + {{ $user->roles->implode('display_name', ', ') }} + @endforeach diff --git a/tests/Auth/AuthTest.php b/tests/Auth/AuthTest.php index 694022666..067840841 100644 --- a/tests/Auth/AuthTest.php +++ b/tests/Auth/AuthTest.php @@ -133,12 +133,12 @@ class AuthTest extends TestCase ->click('Add new user') ->type($user->name, '#name') ->type($user->email, '#email') - ->select(2, '#role') + ->check('roles[admin]') ->type($user->password, '#password') ->type($user->password, '#password-confirm') ->press('Save') - ->seeInDatabase('users', $user->toArray()) ->seePageIs('/settings/users') + ->seeInDatabase('users', $user->toArray()) ->see($user->name); } diff --git a/tests/EntityTest.php b/tests/EntityTest.php index 2936fc047..30858f8d9 100644 --- a/tests/EntityTest.php +++ b/tests/EntityTest.php @@ -225,4 +225,22 @@ class EntityTest extends TestCase ->seePageIs($newPageUrl); } + public function test_recently_updated_pages_on_home() + { + $page = \BookStack\Page::orderBy('updated_at', 'asc')->first(); + $this->asAdmin()->visit('/') + ->dontSeeInElement('#recently-updated-pages', $page->name); + $this->visit($page->getUrl() . '/edit') + ->press('Save Page') + ->visit('/') + ->seeInElement('#recently-updated-pages', $page->name); + } + + public function test_recently_created_pages_on_home() + { + $entityChain = $this->createEntityChainBelongingToUser($this->getNewUser()); + $this->asAdmin()->visit('/') + ->seeInElement('#recently-created-pages', $entityChain['page']->name); + } + } diff --git a/tests/RestrictionsTest.php b/tests/RestrictionsTest.php new file mode 100644 index 000000000..40b5a7647 --- /dev/null +++ b/tests/RestrictionsTest.php @@ -0,0 +1,407 @@ +user = $this->getNewUser(); + } + + /** + * Manually set some restrictions on an entity. + * @param \BookStack\Entity $entity + * @param $actions + */ + protected function setEntityRestrictions(\BookStack\Entity $entity, $actions) + { + $entity->restricted = true; + $entity->restrictions()->delete(); + $role = $this->user->roles->first(); + foreach ($actions as $action) { + $entity->restrictions()->create([ + 'role_id' => $role->id, + 'action' => strtolower($action) + ]); + } + $entity->save(); + $entity->load('restrictions'); + } + + public function test_book_view_restriction() + { + $book = \BookStack\Book::first(); + $bookPage = $book->pages->first(); + $bookChapter = $book->chapters->first(); + + $bookUrl = $book->getUrl(); + $this->actingAs($this->user) + ->visit($bookUrl) + ->seePageIs($bookUrl); + + $this->setEntityRestrictions($book, []); + + $this->forceVisit($bookUrl) + ->see('Book not found'); + $this->forceVisit($bookPage->getUrl()) + ->see('Book not found'); + $this->forceVisit($bookChapter->getUrl()) + ->see('Book not found'); + + $this->setEntityRestrictions($book, ['view']); + + $this->visit($bookUrl) + ->see($book->name); + $this->visit($bookPage->getUrl()) + ->see($bookPage->name); + $this->visit($bookChapter->getUrl()) + ->see($bookChapter->name); + } + + public function test_book_create_restriction() + { + $book = \BookStack\Book::first(); + + $bookUrl = $book->getUrl(); + $this->actingAs($this->user) + ->visit($bookUrl) + ->seeInElement('.action-buttons', 'New Page') + ->seeInElement('.action-buttons', 'New Chapter'); + + $this->setEntityRestrictions($book, ['view', 'delete', 'update']); + + $this->forceVisit($bookUrl . '/chapter/create') + ->see('You do not have permission')->seePageIs('/'); + $this->forceVisit($bookUrl . '/page/create') + ->see('You do not have permission')->seePageIs('/'); + $this->visit($bookUrl)->dontSeeInElement('.action-buttons', 'New Page') + ->dontSeeInElement('.action-buttons', 'New Chapter'); + + $this->setEntityRestrictions($book, ['view', 'create']); + + $this->visit($bookUrl . '/chapter/create') + ->type('test chapter', 'name') + ->type('test description for chapter', 'description') + ->press('Save Chapter') + ->seePageIs($bookUrl . '/chapter/test-chapter'); + $this->visit($bookUrl . '/page/create') + ->type('test page', 'name') + ->type('test content', 'html') + ->press('Save Page') + ->seePageIs($bookUrl . '/page/test-page'); + $this->visit($bookUrl)->seeInElement('.action-buttons', 'New Page') + ->seeInElement('.action-buttons', 'New Chapter'); + } + + public function test_book_update_restriction() + { + $book = \BookStack\Book::first(); + $bookPage = $book->pages->first(); + $bookChapter = $book->chapters->first(); + + $bookUrl = $book->getUrl(); + $this->actingAs($this->user) + ->visit($bookUrl . '/edit') + ->see('Edit Book'); + + $this->setEntityRestrictions($book, ['view', 'delete']); + + $this->forceVisit($bookUrl . '/edit') + ->see('You do not have permission')->seePageIs('/'); + $this->forceVisit($bookPage->getUrl() . '/edit') + ->see('You do not have permission')->seePageIs('/'); + $this->forceVisit($bookChapter->getUrl() . '/edit') + ->see('You do not have permission')->seePageIs('/'); + + $this->setEntityRestrictions($book, ['view', 'update']); + + $this->visit($bookUrl . '/edit') + ->seePageIs($bookUrl . '/edit'); + $this->visit($bookPage->getUrl() . '/edit') + ->seePageIs($bookPage->getUrl() . '/edit'); + $this->visit($bookChapter->getUrl() . '/edit') + ->see('Edit Chapter'); + } + + public function test_book_delete_restriction() + { + $book = \BookStack\Book::first(); + $bookPage = $book->pages->first(); + $bookChapter = $book->chapters->first(); + + $bookUrl = $book->getUrl(); + $this->actingAs($this->user) + ->visit($bookUrl . '/delete') + ->see('Delete Book'); + + $this->setEntityRestrictions($book, ['view', 'update']); + + $this->forceVisit($bookUrl . '/delete') + ->see('You do not have permission')->seePageIs('/'); + $this->forceVisit($bookPage->getUrl() . '/delete') + ->see('You do not have permission')->seePageIs('/'); + $this->forceVisit($bookChapter->getUrl() . '/delete') + ->see('You do not have permission')->seePageIs('/'); + + $this->setEntityRestrictions($book, ['view', 'delete']); + + $this->visit($bookUrl . '/delete') + ->seePageIs($bookUrl . '/delete')->see('Delete Book'); + $this->visit($bookPage->getUrl() . '/delete') + ->seePageIs($bookPage->getUrl() . '/delete')->see('Delete Page'); + $this->visit($bookChapter->getUrl() . '/delete') + ->see('Delete Chapter'); + } + + public function test_chapter_view_restriction() + { + $chapter = \BookStack\Chapter::first(); + $chapterPage = $chapter->pages->first(); + + $chapterUrl = $chapter->getUrl(); + $this->actingAs($this->user) + ->visit($chapterUrl) + ->seePageIs($chapterUrl); + + $this->setEntityRestrictions($chapter, []); + + $this->forceVisit($chapterUrl) + ->see('Chapter not found'); + $this->forceVisit($chapterPage->getUrl()) + ->see('Page not found'); + + $this->setEntityRestrictions($chapter, ['view']); + + $this->visit($chapterUrl) + ->see($chapter->name); + $this->visit($chapterPage->getUrl()) + ->see($chapterPage->name); + } + + public function test_chapter_create_restriction() + { + $chapter = \BookStack\Chapter::first(); + + $chapterUrl = $chapter->getUrl(); + $this->actingAs($this->user) + ->visit($chapterUrl) + ->seeInElement('.action-buttons', 'New Page'); + + $this->setEntityRestrictions($chapter, ['view', 'delete', 'update']); + + $this->forceVisit($chapterUrl . '/create-page') + ->see('You do not have permission')->seePageIs('/'); + $this->visit($chapterUrl)->dontSeeInElement('.action-buttons', 'New Page'); + + $this->setEntityRestrictions($chapter, ['view', 'create']); + + + $this->visit($chapterUrl . '/create-page') + ->type('test page', 'name') + ->type('test content', 'html') + ->press('Save Page') + ->seePageIs($chapter->book->getUrl() . '/page/test-page'); + $this->visit($chapterUrl)->seeInElement('.action-buttons', 'New Page'); + } + + public function test_chapter_update_restriction() + { + $chapter = \BookStack\Chapter::first(); + $chapterPage = $chapter->pages->first(); + + $chapterUrl = $chapter->getUrl(); + $this->actingAs($this->user) + ->visit($chapterUrl . '/edit') + ->see('Edit Chapter'); + + $this->setEntityRestrictions($chapter, ['view', 'delete']); + + $this->forceVisit($chapterUrl . '/edit') + ->see('You do not have permission')->seePageIs('/'); + $this->forceVisit($chapterPage->getUrl() . '/edit') + ->see('You do not have permission')->seePageIs('/'); + + $this->setEntityRestrictions($chapter, ['view', 'update']); + + $this->visit($chapterUrl . '/edit') + ->seePageIs($chapterUrl . '/edit')->see('Edit Chapter'); + $this->visit($chapterPage->getUrl() . '/edit') + ->seePageIs($chapterPage->getUrl() . '/edit'); + } + + public function test_chapter_delete_restriction() + { + $chapter = \BookStack\Chapter::first(); + $chapterPage = $chapter->pages->first(); + + $chapterUrl = $chapter->getUrl(); + $this->actingAs($this->user) + ->visit($chapterUrl . '/delete') + ->see('Delete Chapter'); + + $this->setEntityRestrictions($chapter, ['view', 'update']); + + $this->forceVisit($chapterUrl . '/delete') + ->see('You do not have permission')->seePageIs('/'); + $this->forceVisit($chapterPage->getUrl() . '/delete') + ->see('You do not have permission')->seePageIs('/'); + + $this->setEntityRestrictions($chapter, ['view', 'delete']); + + $this->visit($chapterUrl . '/delete') + ->seePageIs($chapterUrl . '/delete')->see('Delete Chapter'); + $this->visit($chapterPage->getUrl() . '/delete') + ->seePageIs($chapterPage->getUrl() . '/delete')->see('Delete Page'); + } + + public function test_page_view_restriction() + { + $page = \BookStack\Page::first(); + + $pageUrl = $page->getUrl(); + $this->actingAs($this->user) + ->visit($pageUrl) + ->seePageIs($pageUrl); + + $this->setEntityRestrictions($page, ['update', 'delete']); + + $this->forceVisit($pageUrl) + ->see('Page not found'); + + $this->setEntityRestrictions($page, ['view']); + + $this->visit($pageUrl) + ->see($page->name); + } + + public function test_page_update_restriction() + { + $page = \BookStack\Chapter::first(); + + $pageUrl = $page->getUrl(); + $this->actingAs($this->user) + ->visit($pageUrl . '/edit') + ->seeInField('name', $page->name); + + $this->setEntityRestrictions($page, ['view', 'delete']); + + $this->forceVisit($pageUrl . '/edit') + ->see('You do not have permission')->seePageIs('/'); + + $this->setEntityRestrictions($page, ['view', 'update']); + + $this->visit($pageUrl . '/edit') + ->seePageIs($pageUrl . '/edit')->seeInField('name', $page->name); + } + + public function test_page_delete_restriction() + { + $page = \BookStack\Page::first(); + + $pageUrl = $page->getUrl(); + $this->actingAs($this->user) + ->visit($pageUrl . '/delete') + ->see('Delete Page'); + + $this->setEntityRestrictions($page, ['view', 'update']); + + $this->forceVisit($pageUrl . '/delete') + ->see('You do not have permission')->seePageIs('/'); + + $this->setEntityRestrictions($page, ['view', 'delete']); + + $this->visit($pageUrl . '/delete') + ->seePageIs($pageUrl . '/delete')->see('Delete Page'); + } + + public function test_book_restriction_form() + { + $book = \BookStack\Book::first(); + $this->asAdmin()->visit($book->getUrl() . '/restrict') + ->see('Book Restrictions') + ->check('restricted') + ->check('restrictions[2][view]') + ->press('Save Restrictions') + ->seeInDatabase('books', ['id' => $book->id, 'restricted' => true]) + ->seeInDatabase('restrictions', [ + 'restrictable_id' => $book->id, + 'restrictable_type' => 'BookStack\Book', + 'role_id' => '2', + 'action' => 'view' + ]); + } + + public function test_chapter_restriction_form() + { + $chapter = \BookStack\Chapter::first(); + $this->asAdmin()->visit($chapter->getUrl() . '/restrict') + ->see('Chapter Restrictions') + ->check('restricted') + ->check('restrictions[2][update]') + ->press('Save Restrictions') + ->seeInDatabase('chapters', ['id' => $chapter->id, 'restricted' => true]) + ->seeInDatabase('restrictions', [ + 'restrictable_id' => $chapter->id, + 'restrictable_type' => 'BookStack\Chapter', + 'role_id' => '2', + 'action' => 'update' + ]); + } + + public function test_page_restriction_form() + { + $page = \BookStack\Page::first(); + $this->asAdmin()->visit($page->getUrl() . '/restrict') + ->see('Page Restrictions') + ->check('restricted') + ->check('restrictions[2][delete]') + ->press('Save Restrictions') + ->seeInDatabase('pages', ['id' => $page->id, 'restricted' => true]) + ->seeInDatabase('restrictions', [ + 'restrictable_id' => $page->id, + 'restrictable_type' => 'BookStack\Page', + 'role_id' => '2', + 'action' => 'delete' + ]); + } + + public function test_restricted_pages_not_visible_in_book_navigation_on_pages() + { + $chapter = \BookStack\Chapter::first(); + $page = $chapter->pages->first(); + $page2 = $chapter->pages[2]; + + $this->setEntityRestrictions($page, []); + + $this->actingAs($this->user) + ->visit($page2->getUrl()) + ->dontSeeInElement('.sidebar-page-list', $page->name); + } + + public function test_restricted_pages_not_visible_in_book_navigation_on_chapters() + { + $chapter = \BookStack\Chapter::first(); + $page = $chapter->pages->first(); + + $this->setEntityRestrictions($page, []); + + $this->actingAs($this->user) + ->visit($chapter->getUrl()) + ->dontSeeInElement('.sidebar-page-list', $page->name); + } + + public function test_restricted_pages_not_visible_on_chapter_pages() + { + $chapter = \BookStack\Chapter::first(); + $page = $chapter->pages->first(); + + $this->setEntityRestrictions($page, []); + + $this->actingAs($this->user) + ->visit($chapter->getUrl()) + ->dontSee($page->name); + } + +} diff --git a/tests/RolesTest.php b/tests/RolesTest.php new file mode 100644 index 000000000..baba208f1 --- /dev/null +++ b/tests/RolesTest.php @@ -0,0 +1,510 @@ +user = $this->getNewBlankUser(); + } + + /** + * Give the given user some permissions. + * @param \BookStack\User $user + * @param array $permissions + */ + protected function giveUserPermissions(\BookStack\User $user, $permissions = []) + { + $newRole = $this->createNewRole($permissions); + $user->attachRole($newRole); + $user->load('roles'); + $user->permissions(false); + } + + /** + * Create a new basic role for testing purposes. + * @param array $permissions + * @return static + */ + protected function createNewRole($permissions = []) + { + $permissionRepo = app('BookStack\Repos\PermissionsRepo'); + $roleData = factory(\BookStack\Role::class)->make()->toArray(); + $roleData['permissions'] = array_flip($permissions); + return $permissionRepo->saveNewRole($roleData); + } + + public function test_admin_can_see_settings() + { + $this->asAdmin()->visit('/settings')->see('Settings'); + } + + public function test_cannot_delete_admin_role() + { + $adminRole = \BookStack\Role::getRole('admin'); + $deletePageUrl = '/settings/roles/delete/' . $adminRole->id; + $this->asAdmin()->visit($deletePageUrl) + ->press('Confirm') + ->seePageIs($deletePageUrl) + ->see('cannot be deleted'); + } + + public function test_role_cannot_be_deleted_if_default() + { + $newRole = $this->createNewRole(); + $this->setSettings(['registration-role' => $newRole->id]); + + $deletePageUrl = '/settings/roles/delete/' . $newRole->id; + $this->asAdmin()->visit($deletePageUrl) + ->press('Confirm') + ->seePageIs($deletePageUrl) + ->see('cannot be deleted'); + } + + public function test_role_create_update_delete_flow() + { + $testRoleName = 'Test Role'; + $testRoleDesc = 'a little test description'; + $testRoleUpdateName = 'An Super Updated role'; + + // Creation + $this->asAdmin()->visit('/settings') + ->click('Roles') + ->seePageIs('/settings/roles') + ->click('Add new role') + ->type('Test Role', 'display_name') + ->type('A little test description', 'description') + ->press('Save Role') + ->seeInDatabase('roles', ['display_name' => $testRoleName, 'name' => 'test-role', 'description' => $testRoleDesc]) + ->seePageIs('/settings/roles'); + // Updating + $this->asAdmin()->visit('/settings/roles') + ->see($testRoleDesc) + ->click($testRoleName) + ->type($testRoleUpdateName, '#display_name') + ->press('Save Role') + ->seeInDatabase('roles', ['display_name' => $testRoleUpdateName, 'name' => 'test-role', 'description' => $testRoleDesc]) + ->seePageIs('/settings/roles'); + // Deleting + $this->asAdmin()->visit('/settings/roles') + ->click($testRoleUpdateName) + ->click('Delete Role') + ->see($testRoleUpdateName) + ->press('Confirm') + ->seePageIs('/settings/roles') + ->dontSee($testRoleUpdateName); + } + + public function test_manage_user_permission() + { + $this->actingAs($this->user)->visit('/')->visit('/settings/users') + ->seePageIs('/'); + $this->giveUserPermissions($this->user, ['users-manage']); + $this->actingAs($this->user)->visit('/')->visit('/settings/users') + ->seePageIs('/settings/users'); + } + + public function test_user_roles_manage_permission() + { + $this->actingAs($this->user)->visit('/')->visit('/settings/roles') + ->seePageIs('/')->visit('/settings/roles/1')->seePageIs('/'); + $this->giveUserPermissions($this->user, ['user-roles-manage']); + $this->actingAs($this->user)->visit('/settings/roles') + ->seePageIs('/settings/roles')->click('Admin') + ->see('Edit Role'); + } + + public function test_settings_manage_permission() + { + $this->actingAs($this->user)->visit('/')->visit('/settings') + ->seePageIs('/'); + $this->giveUserPermissions($this->user, ['settings-manage']); + $this->actingAs($this->user)->visit('/')->visit('/settings') + ->seePageIs('/settings')->press('Save Settings')->see('Settings Saved'); + } + + public function test_restrictions_manage_all_permission() + { + $page = \BookStack\Page::take(1)->get()->first(); + $this->actingAs($this->user)->visit($page->getUrl()) + ->dontSee('Restrict') + ->visit($page->getUrl() . '/restrict') + ->seePageIs('/'); + $this->giveUserPermissions($this->user, ['restrictions-manage-all']); + $this->actingAs($this->user)->visit($page->getUrl()) + ->see('Restrict') + ->click('Restrict') + ->see('Page Restrictions')->seePageIs($page->getUrl() . '/restrict'); + } + + public function test_restrictions_manage_own_permission() + { + $otherUsersPage = \BookStack\Page::take(1)->get()->first(); + $content = $this->createEntityChainBelongingToUser($this->user); + // Check can't restrict other's content + $this->actingAs($this->user)->visit($otherUsersPage->getUrl()) + ->dontSee('Restrict') + ->visit($otherUsersPage->getUrl() . '/restrict') + ->seePageIs('/'); + // Check can't restrict own content + $this->actingAs($this->user)->visit($content['page']->getUrl()) + ->dontSee('Restrict') + ->visit($content['page']->getUrl() . '/restrict') + ->seePageIs('/'); + + $this->giveUserPermissions($this->user, ['restrictions-manage-own']); + + // Check can't restrict other's content + $this->actingAs($this->user)->visit($otherUsersPage->getUrl()) + ->dontSee('Restrict') + ->visit($otherUsersPage->getUrl() . '/restrict') + ->seePageIs('/'); + // Check can restrict own content + $this->actingAs($this->user)->visit($content['page']->getUrl()) + ->see('Restrict') + ->click('Restrict') + ->seePageIs($content['page']->getUrl() . '/restrict'); + } + + /** + * Check a standard entity access permission + * @param string $permission + * @param array $accessUrls Urls that are only accessible after having the permission + * @param array $visibles Check this text, In the buttons toolbar, is only visible with the permission + * @param null $callback + */ + private function checkAccessPermission($permission, $accessUrls = [], $visibles = []) + { + foreach ($accessUrls as $url) { + $this->actingAs($this->user)->visit('/')->visit($url) + ->seePageIs('/'); + } + foreach ($visibles as $url => $text) { + $this->actingAs($this->user)->visit('/')->visit($url) + ->dontSeeInElement('.action-buttons',$text); + } + + $this->giveUserPermissions($this->user, [$permission]); + + foreach ($accessUrls as $url) { + $this->actingAs($this->user)->visit('/')->visit($url) + ->seePageIs($url); + } + foreach ($visibles as $url => $text) { + $this->actingAs($this->user)->visit('/')->visit($url) + ->see($text); + } + } + + public function test_books_create_all_permissions() + { + $this->checkAccessPermission('book-create-all', [ + '/books/create' + ], [ + '/books' => 'Add new book' + ]); + + $this->visit('/books/create') + ->type('test book', 'name') + ->type('book desc', 'description') + ->press('Save Book') + ->seePageIs('/books/test-book'); + } + + public function test_books_edit_own_permission() + { + $otherBook = \BookStack\Book::take(1)->get()->first(); + $ownBook = $this->createEntityChainBelongingToUser($this->user)['book']; + $this->checkAccessPermission('book-update-own', [ + $ownBook->getUrl() . '/edit' + ], [ + $ownBook->getUrl() => 'Edit' + ]); + + $this->visit($otherBook->getUrl()) + ->dontSeeInElement('.action-buttons', 'Edit') + ->visit($otherBook->getUrl() . '/edit') + ->seePageIs('/'); + } + + public function test_books_edit_all_permission() + { + $otherBook = \BookStack\Book::take(1)->get()->first(); + $this->checkAccessPermission('book-update-all', [ + $otherBook->getUrl() . '/edit' + ], [ + $otherBook->getUrl() => 'Edit' + ]); + } + + public function test_books_delete_own_permission() + { + $this->giveUserPermissions($this->user, ['book-update-all']); + $otherBook = \BookStack\Book::take(1)->get()->first(); + $ownBook = $this->createEntityChainBelongingToUser($this->user)['book']; + $this->checkAccessPermission('book-delete-own', [ + $ownBook->getUrl() . '/delete' + ], [ + $ownBook->getUrl() => 'Delete' + ]); + + $this->visit($otherBook->getUrl()) + ->dontSeeInElement('.action-buttons', 'Delete') + ->visit($otherBook->getUrl() . '/delete') + ->seePageIs('/'); + $this->visit($ownBook->getUrl())->visit($ownBook->getUrl() . '/delete') + ->press('Confirm') + ->seePageIs('/books') + ->dontSee($ownBook->name); + } + + public function test_books_delete_all_permission() + { + $this->giveUserPermissions($this->user, ['book-update-all']); + $otherBook = \BookStack\Book::take(1)->get()->first(); + $this->checkAccessPermission('book-delete-all', [ + $otherBook->getUrl() . '/delete' + ], [ + $otherBook->getUrl() => 'Delete' + ]); + + $this->visit($otherBook->getUrl())->visit($otherBook->getUrl() . '/delete') + ->press('Confirm') + ->seePageIs('/books') + ->dontSee($otherBook->name); + } + + public function test_chapter_create_own_permissions() + { + $book = \BookStack\Book::take(1)->get()->first(); + $ownBook = $this->createEntityChainBelongingToUser($this->user)['book']; + $baseUrl = $ownBook->getUrl() . '/chapter'; + $this->checkAccessPermission('chapter-create-own', [ + $baseUrl . '/create' + ], [ + $ownBook->getUrl() => 'New Chapter' + ]); + + $this->visit($baseUrl . '/create') + ->type('test chapter', 'name') + ->type('chapter desc', 'description') + ->press('Save Chapter') + ->seePageIs($baseUrl . '/test-chapter'); + + $this->visit($book->getUrl()) + ->dontSeeInElement('.action-buttons', 'New Chapter') + ->visit($book->getUrl() . '/chapter/create') + ->seePageIs('/'); + } + + public function test_chapter_create_all_permissions() + { + $book = \BookStack\Book::take(1)->get()->first(); + $baseUrl = $book->getUrl() . '/chapter'; + $this->checkAccessPermission('chapter-create-all', [ + $baseUrl . '/create' + ], [ + $book->getUrl() => 'New Chapter' + ]); + + $this->visit($baseUrl . '/create') + ->type('test chapter', 'name') + ->type('chapter desc', 'description') + ->press('Save Chapter') + ->seePageIs($baseUrl . '/test-chapter'); + } + + public function test_chapter_edit_own_permission() + { + $otherChapter = \BookStack\Chapter::take(1)->get()->first(); + $ownChapter = $this->createEntityChainBelongingToUser($this->user)['chapter']; + $this->checkAccessPermission('chapter-update-own', [ + $ownChapter->getUrl() . '/edit' + ], [ + $ownChapter->getUrl() => 'Edit' + ]); + + $this->visit($otherChapter->getUrl()) + ->dontSeeInElement('.action-buttons', 'Edit') + ->visit($otherChapter->getUrl() . '/edit') + ->seePageIs('/'); + } + + public function test_chapter_edit_all_permission() + { + $otherChapter = \BookStack\Chapter::take(1)->get()->first(); + $this->checkAccessPermission('chapter-update-all', [ + $otherChapter->getUrl() . '/edit' + ], [ + $otherChapter->getUrl() => 'Edit' + ]); + } + + public function test_chapter_delete_own_permission() + { + $this->giveUserPermissions($this->user, ['chapter-update-all']); + $otherChapter = \BookStack\Chapter::take(1)->get()->first(); + $ownChapter = $this->createEntityChainBelongingToUser($this->user)['chapter']; + $this->checkAccessPermission('chapter-delete-own', [ + $ownChapter->getUrl() . '/delete' + ], [ + $ownChapter->getUrl() => 'Delete' + ]); + + $bookUrl = $ownChapter->book->getUrl(); + $this->visit($otherChapter->getUrl()) + ->dontSeeInElement('.action-buttons', 'Delete') + ->visit($otherChapter->getUrl() . '/delete') + ->seePageIs('/'); + $this->visit($ownChapter->getUrl())->visit($ownChapter->getUrl() . '/delete') + ->press('Confirm') + ->seePageIs($bookUrl) + ->dontSeeInElement('.book-content', $ownChapter->name); + } + + public function test_chapter_delete_all_permission() + { + $this->giveUserPermissions($this->user, ['chapter-update-all']); + $otherChapter = \BookStack\Chapter::take(1)->get()->first(); + $this->checkAccessPermission('chapter-delete-all', [ + $otherChapter->getUrl() . '/delete' + ], [ + $otherChapter->getUrl() => 'Delete' + ]); + + $bookUrl = $otherChapter->book->getUrl(); + $this->visit($otherChapter->getUrl())->visit($otherChapter->getUrl() . '/delete') + ->press('Confirm') + ->seePageIs($bookUrl) + ->dontSeeInElement('.book-content', $otherChapter->name); + } + + public function test_page_create_own_permissions() + { + $book = \BookStack\Book::take(1)->get()->first(); + $chapter = \BookStack\Chapter::take(1)->get()->first(); + + $entities = $this->createEntityChainBelongingToUser($this->user); + $ownBook = $entities['book']; + $ownChapter = $entities['chapter']; + + $baseUrl = $ownBook->getUrl() . '/page'; + + $this->checkAccessPermission('page-create-own', [ + $baseUrl . '/create', + $ownChapter->getUrl() . '/create-page' + ], [ + $ownBook->getUrl() => 'New Page', + $ownChapter->getUrl() => 'New Page' + ]); + + $this->visit($baseUrl . '/create') + ->type('test page', 'name') + ->type('page desc', 'html') + ->press('Save Page') + ->seePageIs($baseUrl . '/test-page'); + + $this->visit($book->getUrl()) + ->dontSeeInElement('.action-buttons', 'New Page') + ->visit($book->getUrl() . '/page/create') + ->seePageIs('/'); + $this->visit($chapter->getUrl()) + ->dontSeeInElement('.action-buttons', 'New Page') + ->visit($chapter->getUrl() . '/create-page') + ->seePageIs('/'); + } + + public function test_page_create_all_permissions() + { + $book = \BookStack\Book::take(1)->get()->first(); + $chapter = \BookStack\Chapter::take(1)->get()->first(); + $baseUrl = $book->getUrl() . '/page'; + $this->checkAccessPermission('page-create-all', [ + $baseUrl . '/create', + $chapter->getUrl() . '/create-page' + ], [ + $book->getUrl() => 'New Page', + $chapter->getUrl() => 'New Page' + ]); + + $this->visit($baseUrl . '/create') + ->type('test page', 'name') + ->type('page desc', 'html') + ->press('Save Page') + ->seePageIs($baseUrl . '/test-page'); + + $this->visit($chapter->getUrl() . '/create-page') + ->type('new test page', 'name') + ->type('page desc', 'html') + ->press('Save Page') + ->seePageIs($baseUrl . '/new-test-page'); + } + + public function test_page_edit_own_permission() + { + $otherPage = \BookStack\Page::take(1)->get()->first(); + $ownPage = $this->createEntityChainBelongingToUser($this->user)['page']; + $this->checkAccessPermission('page-update-own', [ + $ownPage->getUrl() . '/edit' + ], [ + $ownPage->getUrl() => 'Edit' + ]); + + $this->visit($otherPage->getUrl()) + ->dontSeeInElement('.action-buttons', 'Edit') + ->visit($otherPage->getUrl() . '/edit') + ->seePageIs('/'); + } + + public function test_page_edit_all_permission() + { + $otherPage = \BookStack\Page::take(1)->get()->first(); + $this->checkAccessPermission('page-update-all', [ + $otherPage->getUrl() . '/edit' + ], [ + $otherPage->getUrl() => 'Edit' + ]); + } + + public function test_page_delete_own_permission() + { + $this->giveUserPermissions($this->user, ['page-update-all']); + $otherPage = \BookStack\Page::take(1)->get()->first(); + $ownPage = $this->createEntityChainBelongingToUser($this->user)['page']; + $this->checkAccessPermission('page-delete-own', [ + $ownPage->getUrl() . '/delete' + ], [ + $ownPage->getUrl() => 'Delete' + ]); + + $bookUrl = $ownPage->book->getUrl(); + $this->visit($otherPage->getUrl()) + ->dontSeeInElement('.action-buttons', 'Delete') + ->visit($otherPage->getUrl() . '/delete') + ->seePageIs('/'); + $this->visit($ownPage->getUrl())->visit($ownPage->getUrl() . '/delete') + ->press('Confirm') + ->seePageIs($bookUrl) + ->dontSeeInElement('.book-content', $ownPage->name); + } + + public function test_page_delete_all_permission() + { + $this->giveUserPermissions($this->user, ['page-update-all']); + $otherPage = \BookStack\Page::take(1)->get()->first(); + $this->checkAccessPermission('page-delete-all', [ + $otherPage->getUrl() . '/delete' + ], [ + $otherPage->getUrl() => 'Delete' + ]); + + $bookUrl = $otherPage->book->getUrl(); + $this->visit($otherPage->getUrl())->visit($otherPage->getUrl() . '/delete') + ->press('Confirm') + ->seePageIs($bookUrl) + ->dontSeeInElement('.book-content', $otherPage->name); + } + +} diff --git a/tests/TestCase.php b/tests/TestCase.php index 4b8578a43..567dc93ec 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -1,6 +1,7 @@ admin === null) { - $this->admin = \BookStack\User::find(1); + $adminRole = \BookStack\Role::getRole('admin'); + $this->admin = $adminRole->users->first(); } return $this->actingAs($this->admin); } @@ -78,8 +80,19 @@ class TestCase extends Illuminate\Foundation\Testing\TestCase protected function getNewUser($attributes = []) { $user = factory(\BookStack\User::class)->create($attributes); - $userRepo = app('BookStack\Repos\UserRepo'); - $userRepo->attachDefaultRole($user); + $role = \BookStack\Role::getRole('editor'); + $user->attachRole($role);; + return $user; + } + + /** + * Quick way to create a new user without any permissions + * @param array $attributes + * @return mixed + */ + protected function getNewBlankUser($attributes = []) + { + $user = factory(\BookStack\User::class)->create($attributes); return $user; } @@ -110,6 +123,40 @@ class TestCase extends Illuminate\Foundation\Testing\TestCase return $this; } + /** + * Assert that the current page matches a given URI. + * + * @param string $uri + * @return $this + */ + protected function seePageUrlIs($uri) + { + $this->assertEquals( + $uri, $this->currentUri, "Did not land on expected page [{$uri}].\n" + ); + + return $this; + } + + /** + * Do a forced visit that does not error out on exception. + * @param string $uri + * @param array $parameters + * @param array $cookies + * @param array $files + * @return $this + */ + protected function forceVisit($uri, $parameters = [], $cookies = [], $files = []) + { + $method = 'GET'; + $uri = $this->prepareUrlForRequest($uri); + $this->call($method, $uri, $parameters, $cookies, $files); + $this->clearInputs()->followRedirects(); + $this->currentUri = $this->app->make('request')->fullUrl(); + $this->crawler = new Crawler($this->response->getContent(), $uri); + return $this; + } + /** * Click the text within the selected element. * @param $parentElement