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..6bf29ca0f 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 @@ +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 47f59efbb..dfda2b6cf 100644 --- a/app/Http/Controllers/SettingController.php +++ b/app/Http/Controllers/SettingController.php @@ -17,7 +17,7 @@ class SettingController extends Controller */ public function index() { - $this->checkPermission('settings-update'); + $this->checkPermission('settings-manage'); $this->setPageTitle('Settings'); return view('settings/index'); } @@ -32,7 +32,7 @@ class SettingController extends Controller 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) { 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/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..9c5184e2f 100644 --- a/app/Repos/EntityRepo.php +++ b/app/Repos/EntityRepo.php @@ -1,28 +1,43 @@ 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); } /** @@ -32,7 +47,8 @@ class EntityRepo */ public function getRecentlyCreatedBooks($count = 20, $page = 0) { - return $this->book->orderBy('created_at', 'desc')->skip($page*$count)->take($count)->get(); + return $this->restrictionService->enforceBookRestrictions($this->book) + ->orderBy('created_at', 'desc')->skip($page * $count)->take($count)->get(); } /** @@ -43,7 +59,8 @@ 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(); } /** @@ -53,7 +70,8 @@ class EntityRepo */ public function getRecentlyCreatedPages($count = 20, $page = 0) { - return $this->page->orderBy('created_at', 'desc')->skip($page*$count)->take($count)->get(); + return $this->restrictionService->enforcePageRestrictions($this->page) + ->orderBy('created_at', 'desc')->skip($page * $count)->take($count)->get(); } /** @@ -64,7 +82,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..2d497b76a --- /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::get('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..01cf80d29 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 @@ -69,7 +78,7 @@ class UserRepo public function attachDefaultRole($user) { $roleId = Setting::get('registration-role'); - if ($roleId === false) $roleId = $this->role->getDefault()->id; + 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; } @@ -160,4 +164,14 @@ class UserRepo ]; } + /** + * 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..118bd6d9c 100644 --- a/app/Services/ActivityService.php +++ b/app/Services/ActivityService.php @@ -1,6 +1,5 @@ activity = $activity; + $this->restrictionService = $restrictionService; $this->user = auth()->user(); } @@ -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); } 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/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..ead6b3008 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; } \ No newline at end of file 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/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/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/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 830976657..59bbe6fbb 100644 --- a/resources/views/base.blade.php +++ b/resources/views/base.blade.php @@ -62,7 +62,7 @@