]> BookStack Code Mirror - bookstack/commitdiff
Merged branch master into release
authorDan Brown <redacted>
Sun, 6 Mar 2016 13:26:29 +0000 (13:26 +0000)
committerDan Brown <redacted>
Sun, 6 Mar 2016 13:26:29 +0000 (13:26 +0000)
85 files changed:
.env.example
app/Activity.php
app/Entity.php
app/Exceptions/Handler.php
app/Exceptions/NotFoundException.php [new file with mode: 0644]
app/Exceptions/PermissionsException.php [new file with mode: 0644]
app/Http/Controllers/Auth/AuthController.php
app/Http/Controllers/BookController.php
app/Http/Controllers/ChapterController.php
app/Http/Controllers/Controller.php
app/Http/Controllers/HomeController.php
app/Http/Controllers/ImageController.php
app/Http/Controllers/PageController.php
app/Http/Controllers/PermissionController.php [new file with mode: 0644]
app/Http/Controllers/SettingController.php
app/Http/Controllers/UserController.php
app/Http/Middleware/Authenticate.php
app/Http/routes.php
app/Image.php
app/Ownable.php
app/Permission.php
app/Providers/CustomFacadeProvider.php
app/Repos/BookRepo.php
app/Repos/ChapterRepo.php
app/Repos/EntityRepo.php
app/Repos/PageRepo.php
app/Repos/PermissionsRepo.php [new file with mode: 0644]
app/Repos/UserRepo.php
app/Restriction.php [new file with mode: 0644]
app/Role.php
app/Services/ActivityService.php
app/Services/EmailConfirmationService.php
app/Services/ImageService.php
app/Services/RestrictionService.php [new file with mode: 0644]
app/Services/SocialAuthService.php
app/Services/ViewService.php
app/User.php
app/helpers.php
config/cache.php
database/factories/ModelFactory.php
database/migrations/2016_02_27_120329_update_permissions_and_roles.php [new file with mode: 0644]
database/migrations/2016_02_28_084200_add_entity_access_controls.php [new file with mode: 0644]
database/seeds/DummyContentSeeder.php
phpunit.xml
public/libs/jq-color-picker/tiny-color-picker.min.js [new file with mode: 0644]
readme.md
resources/assets/js/controllers.js
resources/assets/js/global.js
resources/assets/sass/_header.scss
resources/assets/sass/_lists.scss
resources/assets/sass/styles.scss
resources/lang/en/errors.php
resources/views/base.blade.php
resources/views/books/index.blade.php
resources/views/books/restrictions.blade.php [new file with mode: 0644]
resources/views/books/show.blade.php
resources/views/chapters/restrictions.blade.php [new file with mode: 0644]
resources/views/chapters/show.blade.php
resources/views/errors/404.blade.php
resources/views/form/checkbox.blade.php [new file with mode: 0644]
resources/views/form/restriction-checkbox.blade.php [new file with mode: 0644]
resources/views/form/restriction-form.blade.php [new file with mode: 0644]
resources/views/form/role-checkboxes.blade.php [new file with mode: 0644]
resources/views/home.blade.php
resources/views/pages/restrictions.blade.php [new file with mode: 0644]
resources/views/pages/show.blade.php
resources/views/partials/activity-item.blade.php
resources/views/partials/custom-styles.blade.php [new file with mode: 0644]
resources/views/public.blade.php
resources/views/settings/index.blade.php
resources/views/settings/navbar.blade.php
resources/views/settings/roles/checkbox.blade.php [new file with mode: 0644]
resources/views/settings/roles/create.blade.php [new file with mode: 0644]
resources/views/settings/roles/delete.blade.php [new file with mode: 0644]
resources/views/settings/roles/edit.blade.php [new file with mode: 0644]
resources/views/settings/roles/form.blade.php [new file with mode: 0644]
resources/views/settings/roles/index.blade.php [new file with mode: 0644]
resources/views/users/forms/ldap.blade.php
resources/views/users/forms/standard.blade.php
resources/views/users/index.blade.php
tests/Auth/AuthTest.php
tests/EntityTest.php
tests/RestrictionsTest.php [new file with mode: 0644]
tests/RolesTest.php [new file with mode: 0644]
tests/TestCase.php

index 6d706abddf5ff6c0d5632275136f697fd7593199..5661cda2209f753c3827e58a14d73a5f69445fd1 100644 (file)
@@ -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
index 34daa2760500859b73b45603fdb18d51abe7ac2c..ac7c1d749a560de97483deed10939dc07c2868c4 100644 (file)
@@ -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');
     }
 
     /**
index 42323628ac8c3f09319ac34aa681a6580ff34339..4f97c6bab255caa63e880a19c187469942666a9c 100644 (file)
@@ -1,14 +1,9 @@
-<?php
+<?php namespace BookStack;
 
-namespace BookStack;
 
-use Illuminate\Database\Eloquent\Model;
-
-abstract class Entity extends Model
+abstract class Entity extends Ownable
 {
 
-    use Ownable;
-
     /**
      * Compares this entity to another given entity.
      * Matches by comparing class and id.
@@ -53,7 +48,6 @@ abstract class Entity extends Model
 
     /**
      * Get View objects for this entity.
-     * @return mixed
      */
     public function views()
     {
@@ -61,34 +55,44 @@ abstract class Entity extends Model
     }
 
     /**
-     * Allows checking of the exact class, Used to check entity type.
-     * Cleaner method for is_a.
-     * @param $type
+     * 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 static function isA($type)
+    public function hasRestriction($role_id, $action)
     {
-        return static::getClassName() === strtolower($type);
+        return $this->restrictions->where('role_id', $role_id)->where('action', $action)->count() > 0;
     }
 
     /**
-     * Gets the class name.
-     * @return string
+     * Allows checking of the exact class, Used to check entity type.
+     * Cleaner method for is_a.
+     * @param $type
+     * @return bool
      */
-    public static function getClassName()
+    public static function isA($type)
     {
-        return strtolower(array_slice(explode('\\', static::class), -1, 1)[0]);
+        return static::getClassName() === strtolower($type);
     }
 
     /**
-     *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');
index 73a3169531c82c8080d13cb822315b1380ff4618..14d553ed0bb359b200dfc91a95bdc05ff92501ba 100644 (file)
@@ -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 (file)
index 0000000..3c027dc
--- /dev/null
@@ -0,0 +1,14 @@
+<?php namespace BookStack\Exceptions;
+
+
+class NotFoundException extends PrettyException {
+
+    /**
+     * NotFoundException constructor.
+     * @param string $message
+     */
+    public function __construct($message = 'Item not found')
+    {
+        parent::__construct($message, 404);
+    }
+}
\ No newline at end of file
diff --git a/app/Exceptions/PermissionsException.php b/app/Exceptions/PermissionsException.php
new file mode 100644 (file)
index 0000000..ae4c676
--- /dev/null
@@ -0,0 +1,6 @@
+<?php namespace BookStack\Exceptions;
+
+
+use Exception;
+
+class PermissionsException extends Exception {}
\ No newline at end of file
index fef87d5c87cff3a6759a6b835c0eb496fc29d79d..fda0ee66842547d8819ba673f30a659230b2bcf0 100644 (file)
@@ -41,9 +41,9 @@ class AuthController extends Controller
 
     /**
      * Create a new authentication controller instance.
-     * @param SocialAuthService        $socialAuthService
+     * @param SocialAuthService $socialAuthService
      * @param EmailConfirmationService $emailConfirmationService
-     * @param UserRepo                 $userRepo
+     * @param UserRepo $userRepo
      */
     public function __construct(SocialAuthService $socialAuthService, EmailConfirmationService $emailConfirmationService, UserRepo $userRepo)
     {
@@ -63,15 +63,15 @@ class AuthController extends Controller
     protected function validator(array $data)
     {
         return Validator::make($data, [
-            'name'     => '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);
index d577a85b134dcd060eebf72b2be744bc8914766e..5b2b510c9ce279ba80728c8584b0ffbf97a6907a 100644 (file)
@@ -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());
+    }
 }
index fc13e8b58b777a24584cd59f01a6020fa74eee95..6b8a2f18fc8aa9af8a1fb15f0decb239754f49ad 100644 (file)
@@ -1,13 +1,9 @@
-<?php
-
-namespace BookStack\Http\Controllers;
+<?php namespace BookStack\Http\Controllers;
 
 use Activity;
+use BookStack\Repos\UserRepo;
 use Illuminate\Http\Request;
-
-use Illuminate\Support\Facades\Auth;
 use BookStack\Http\Requests;
-use BookStack\Http\Controllers\Controller;
 use BookStack\Repos\BookRepo;
 use BookStack\Repos\ChapterRepo;
 use Views;
@@ -17,20 +13,22 @@ class ChapterController extends Controller
 
     protected $bookRepo;
     protected $chapterRepo;
+    protected $userRepo;
 
     /**
      * ChapterController constructor.
-     * @param $bookRepo
-     * @param $chapterRepo
+     * @param BookRepo $bookRepo
+     * @param ChapterRepo $chapterRepo
+     * @param UserRepo $userRepo
      */
-    public function __construct(BookRepo $bookRepo, ChapterRepo $chapterRepo)
+    public function __construct(BookRepo $bookRepo, ChapterRepo $chapterRepo, UserRepo $userRepo)
     {
         $this->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());
+    }
 }
index ab37a44a1d7223c816c9ca9aade22b7f96ac193c..f0cb47cd9197f6cfaf3b6c3441390e4ecd57be8f 100644 (file)
@@ -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
index e20c89e06339c41ab3c472d31c24ec38300f13df..489396df64d9f5cacac81f84fbb07bb398a6e537 100644 (file)
@@ -24,7 +24,6 @@ class HomeController extends Controller
 
     /**
      * Display the homepage.
-     *
      * @return Response
      */
     public function index()
index 3fff28d3ba493cd31ef4acc06ac32f91ab78ae11..48e89ee41d7d8858f72e8585deb6096c5881c22b 100644 (file)
@@ -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);
index e78ae13e4c2972067ef9ed4232f6771426ab852d..19e4744ea5643e5412a1b24689b4ad03de545314 100644 (file)
@@ -1,12 +1,10 @@
-<?php
-
-namespace BookStack\Http\Controllers;
+<?php namespace BookStack\Http\Controllers;
 
 use Activity;
+use BookStack\Exceptions\NotFoundException;
+use BookStack\Repos\UserRepo;
 use BookStack\Services\ExportService;
 use Illuminate\Http\Request;
-
-use Illuminate\Support\Facades\Auth;
 use BookStack\Http\Requests;
 use BookStack\Repos\BookRepo;
 use BookStack\Repos\ChapterRepo;
@@ -21,26 +19,28 @@ class PageController extends Controller
     protected $bookRepo;
     protected $chapterRepo;
     protected $exportService;
+    protected $userRepo;
 
     /**
      * PageController constructor.
-     * @param PageRepo      $pageRepo
-     * @param BookRepo      $bookRepo
-     * @param ChapterRepo   $chapterRepo
+     * @param PageRepo $pageRepo
+     * @param BookRepo $bookRepo
+     * @param ChapterRepo $chapterRepo
      * @param ExportService $exportService
+     * @param UserRepo $userRepo
      */
-    public function __construct(PageRepo $pageRepo, BookRepo $bookRepo, ChapterRepo $chapterRepo, ExportService $exportService)
+    public function __construct(PageRepo $pageRepo, BookRepo $bookRepo, ChapterRepo $chapterRepo, ExportService $exportService, UserRepo $userRepo)
     {
         $this->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 (file)
index 0000000..c565bb2
--- /dev/null
@@ -0,0 +1,129 @@
+<?php namespace BookStack\Http\Controllers;
+
+use BookStack\Exceptions\PermissionsException;
+use BookStack\Repos\PermissionsRepo;
+use Illuminate\Http\Request;
+use BookStack\Http\Requests;
+
+class PermissionController extends Controller
+{
+
+    protected $permissionsRepo;
+
+    /**
+     * PermissionController constructor.
+     * @param PermissionsRepo $permissionsRepo
+     */
+    public function __construct(PermissionsRepo $permissionsRepo)
+    {
+        $this->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');
+    }
+}
index 1739e0b530cb9dac9793ff4a91fd2d3ae3c7cb05..1791ccface8876754ce9dc6127891ef4a79732ae 100644 (file)
@@ -1,42 +1,36 @@
-<?php
-
-namespace BookStack\Http\Controllers;
+<?php namespace BookStack\Http\Controllers;
 
 use Illuminate\Http\Request;
 
 use BookStack\Http\Requests;
-use BookStack\Http\Controllers\Controller;
 use Setting;
 
 class SettingController extends Controller
 {
     /**
      * Display a listing of the settings.
-     *
      * @return Response
      */
     public function index()
     {
-        $this->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);
         }
index 55ca5be19899bbdbc93927a7354548478a973ec9..9f6a4105f1656fc1d5db21c5f4883447a929c164 100644 (file)
@@ -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;
         });
 
index ad804d0d86b15e16fb6c2bb6a553d7f4b29401ab..81392fe6e94a152faf290271b95554594aa0e18a 100644 (file)
@@ -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 {
index 36cf2a19f63019031f948855911c1ff0ab7fc289..81bbb16bc0f2bfad7f4c8a402d7773b2b2d88525 100644 (file)
@@ -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');
     });
 
 });
index 3ac084d8fab08d41a637aa9ba6747c495c2fd037..ad23a077adc5ba12cb652ad55043fa3289101f4d 100644 (file)
@@ -1,14 +1,9 @@
-<?php
+<?php namespace BookStack;
 
-namespace BookStack;
-
-
-use Illuminate\Database\Eloquent\Model;
 use Images;
 
-class Image extends Model
+class Image extends Ownable
 {
-    use Ownable;
 
     protected $fillable = ['name'];
 
index d6505b7460440f95f1bf9e8eecd09ae6775129bd..28d55c2bb98156cb08a4b6e2e44842270f4bbe99 100644 (file)
@@ -1,7 +1,8 @@
 <?php namespace BookStack;
 
+use Illuminate\Database\Eloquent\Model;
 
-trait Ownable
+abstract class Ownable extends Model
 {
     /**
      * Relation for the user that created this entity.
@@ -20,4 +21,14 @@ trait Ownable
     {
         return $this->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
index 6859ed56ed9c26e0134b2696b88f9aaa02d91a9a..794df01ab8338e624f3f3b03c6521d6b7e3217ae 100644 (file)
@@ -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();
+    }
 }
index 1df14a076f9e667d58c1ef079626f947ee5ce7f9..9b290039ceb34abee8b551c0144824ae3a9c37e7 100644 (file)
@@ -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'),
index d8a24c099734d6a24a757130d842fc14e7445c05..2ec9a4c25b71a77ffdf3ce6301bd5cc9320ec91d 100644 (file)
@@ -1,28 +1,35 @@
 <?php namespace BookStack\Repos;
 
-use Activity;
+use BookStack\Exceptions\NotFoundException;
 use Illuminate\Support\Str;
 use BookStack\Book;
 use Views;
 
-class BookRepo
+class BookRepo extends EntityRepo
 {
-
-    protected $book;
     protected $pageRepo;
     protected $chapterRepo;
 
     /**
      * BookRepo constructor.
-     * @param Book $book
      * @param PageRepo $pageRepo
      * @param ChapterRepo $chapterRepo
      */
-    public function __construct(Book $book, PageRepo $pageRepo, ChapterRepo $chapterRepo)
+    public function __construct(PageRepo $pageRepo, ChapterRepo $chapterRepo)
     {
-        $this->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) {
index bba6cbe7a134c6ac66843376bda7f279f89b46c6..5d1d6437f19cdfa72adc27389461437c6643face 100644 (file)
@@ -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) {
index 28942d94a081fc532e62d50eb33f29b772b90b0b..ea2805855452cf95111b8632cca4a4e3c5bca715 100644 (file)
@@ -1,38 +1,60 @@
 <?php namespace BookStack\Repos;
 
-
 use BookStack\Book;
 use BookStack\Chapter;
+use BookStack\Entity;
 use BookStack\Page;
+use BookStack\Services\RestrictionService;
 
 class EntityRepo
 {
 
+    /**
+     * @var Book $book
+     */
     public $book;
+
+    /**
+     * @var Chapter
+     */
     public $chapter;
+
+    /**
+     * @var Page
+     */
     public $page;
 
+    /**
+     * @var RestrictionService
+     */
+    protected $restrictionService;
+
     /**
      * EntityService constructor.
-     * @param $book
-     * @param $chapter
-     * @param $page
      */
-    public function __construct(Book $book, Chapter $chapter, Page $page)
+    public function __construct()
     {
-        $this->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;
     }
 
 
index f028a1fccfa642890a9fc1f9aa4e8aec73cdd0c6..4784ad407605e2be2444378df55ef44d66be3953 100644 (file)
@@ -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 (file)
index 0000000..3c5efde
--- /dev/null
@@ -0,0 +1,142 @@
+<?php namespace BookStack\Repos;
+
+
+use BookStack\Exceptions\PermissionsException;
+use BookStack\Permission;
+use BookStack\Role;
+use Setting;
+
+class PermissionsRepo
+{
+
+    protected $permission;
+    protected $role;
+
+    /**
+     * PermissionsRepo constructor.
+     * @param $permission
+     * @param $role
+     */
+    public function __construct(Permission $permission, Role $role)
+    {
+        $this->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
index 48541a51aa341aeb576dab3ef91056bee8d1b39b..d5a4b1503ccf0735efbfef90f9ad87a47591522d 100644 (file)
@@ -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 (file)
index 0000000..58d1179
--- /dev/null
@@ -0,0 +1,21 @@
+<?php
+
+namespace BookStack;
+
+use Illuminate\Database\Eloquent\Model;
+
+class Restriction extends Model
+{
+
+    protected $fillable = ['role_id', 'action'];
+    public $timestamps = false;
+
+    /**
+     * Get all this restriction's attached entity.
+     * @return \Illuminate\Database\Eloquent\Relations\MorphTo
+     */
+    public function restrictable()
+    {
+        return $this->morphTo();
+    }
+}
index 3d93bf7702b6300ee784949aeab989e949981858..270e4e0b8e32ce1287be95dae0d1c138d8120e3b 100644 (file)
@@ -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.
@@ -29,21 +26,21 @@ class Role extends Model
     }
 
     /**
-     * Add a permission to this role.
-     * @param Permission $permission
+     * Check if this role has a permission.
+     * @param $permission
      */
-    public function attachPermission(Permission $permission)
+    public function hasPermission($permission)
     {
-        $this->permissions()->attach($permission->id);
+        return $this->permissions->pluck('name')->contains($permission);
     }
 
     /**
-     * Get an instance of the default role.
-     * @return Role
+     * Add a permission to this role.
+     * @param Permission $permission
      */
-    public static function getDefault()
+    public function attachPermission(Permission $permission)
     {
-        return static::getRole(static::$default);
+        $this->permissions()->attach($permission->id);
     }
 
     /**
index a065ae01f4816e42ab48a6f786dc45fbc8b6d255..d0029b6c4b450d053e6750248b37d7c4082f17cb 100644 (file)
@@ -1,6 +1,5 @@
 <?php namespace BookStack\Services;
 
-use Illuminate\Support\Facades\Auth;
 use BookStack\Activity;
 use BookStack\Entity;
 use Session;
@@ -9,14 +8,17 @@ class ActivityService
 {
     protected $activity;
     protected $user;
+    protected $restrictionService;
 
     /**
      * ActivityService constructor.
-     * @param $activity
+     * @param Activity $activity
+     * @param RestrictionService $restrictionService
      */
-    public function __construct(Activity $activity)
+    public function __construct(Activity $activity, RestrictionService $restrictionService)
     {
         $this->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);
     }
 
     /**
index ffe21eec4d4181d32bfcf20d0564bf8a7d654fe8..c3096c654fb0fb0e1853d6ec5961b8f9df78d07a 100644 (file)
@@ -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 . '.');
         });
     }
index 47c27cd0a2930f08fedba74127d9c76548d99de0..aefc8a4fb4920d977b02136fc8045555cb902677 100644 (file)
@@ -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 (file)
index 0000000..f7838bf
--- /dev/null
@@ -0,0 +1,272 @@
+<?php namespace BookStack\Services;
+
+use BookStack\Entity;
+
+class RestrictionService
+{
+
+    protected $userRoles;
+    protected $isAdmin;
+    protected $currentAction;
+
+    /**
+     * RestrictionService constructor.
+     */
+    public function __construct()
+    {
+        $user = auth()->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
index 2437a482706b20dd4db1eebcfef5f512d0e1e013..df213609a5e540d5ffb48003fc3352c6d24a5912 100644 (file)
@@ -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');
index 5b800d939c8c4468bdebe966383007d43ae462f4..75ffd21dcd68bfa6ef49502fc3ab8a6084e67dba 100644 (file)
@@ -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;
     }
index c551020788153a32594752c7e327c965f82d9c3e..e1b7c143b267d6fe24d28a1ae8b1f2324c840594 100644 (file)
@@ -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
      */
index f25a8f765d4e7694d92b276e36a2e4a0b96c356f..f60e917c55cf5ef3656ea2e75118211dc4f43c53 100644 (file)
@@ -1,10 +1,10 @@
 <?php
 
-if (! function_exists('versioned_asset')) {
+if (!function_exists('versioned_asset')) {
     /**
      * Get the path to a versioned file.
      *
-     * @param  string  $file
+     * @param  string $file
      * @return string
      *
      * @throws \InvalidArgumentException
@@ -27,4 +27,47 @@ if (! function_exists('versioned_asset')) {
 
         throw new InvalidArgumentException("File {$file} not defined in asset manifest.");
     }
-}
\ No newline at end of file
+}
+
+/**
+ * Check if the current user has a permission.
+ * If an ownable element is passed in the permissions are checked against
+ * that particular item.
+ * @param $permission
+ * @param \BookStack\Ownable $ownable
+ * @return mixed
+ */
+function userCan($permission, \BookStack\Ownable $ownable = null)
+{
+    if (!auth()->check()) return false;
+    if ($ownable === null) {
+        return auth()->user() && auth()->user()->can($permission);
+    }
+
+    // Check permission on ownable item
+    $permissionBaseName = strtolower($permission) . '-';
+    $hasPermission = false;
+    if (auth()->user()->can($permissionBaseName . 'all')) $hasPermission = true;
+    if (auth()->user()->can($permissionBaseName . 'own') && $ownable->createdBy && $ownable->createdBy->id === auth()->user()->id) $hasPermission = true;
+
+    if (!$ownable instanceof \BookStack\Entity) return $hasPermission;
+
+    // Check restrictions on the 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);
+}
index 379135b0eb6904733cc3ac4f569b4df04ad15d9e..076a0299fc1196329d51875e8c64dbe678b7edc4 100644 (file)
@@ -1,5 +1,18 @@
 <?php
 
+// MEMCACHED - Split out configuration into an array
+if (env('CACHE_DRIVER') === 'memcached') {
+    $memcachedServerKeys = ['host', 'port', 'weight'];
+    $memcachedServers = explode(',', trim(env('MEMCACHED_SERVERS', '127.0.0.1:11211:100'), ','));
+    foreach ($memcachedServers as $index => $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' => [
index e0f1550875c27a6e0ec0710a3493f23ca1e097f6..2840356e87198633213a700cec46109cdb494f1a 100644 (file)
@@ -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 (file)
index 0000000..ea3735d
--- /dev/null
@@ -0,0 +1,99 @@
+<?php
+
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+class UpdatePermissionsAndRoles extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        // Get roles with permissions we need to change
+        $adminRole = \BookStack\Role::getRole('admin');
+        $editorRole = \BookStack\Role::getRole('editor');
+
+        // Delete old permissions
+        $permissions = \BookStack\Permission::all();
+        $permissions->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 (file)
index 0000000..5df2353
--- /dev/null
@@ -0,0 +1,73 @@
+<?php
+
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+class AddEntityAccessControls extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::table('images', function (Blueprint $table) {
+            $table->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');
+    }
+}
index aa70eaa0a6139eeab12abf1a50bddcae66d3eab2..328971f260b787baac504f8687b6883bce3da7fc 100644 (file)
@@ -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);
 
 
index 762fc2da70c061dde6eed4de437cefc875780572..66196e8cf1bdda4df84d062beb08a9721ff97048 100644 (file)
@@ -21,6 +21,7 @@
     </filter>
     <php>
         <env name="APP_ENV" value="testing"/>
+        <env name="APP_DEBUG" value="false"/>
         <env name="CACHE_DRIVER" value="array"/>
         <env name="SESSION_DRIVER" value="array"/>
         <env name="QUEUE_DRIVER" value="sync"/>
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 (file)
index 0000000..8402407
--- /dev/null
@@ -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('<style type="text/css">'+(r.css||I)+(r.cssAddon||"")+"</style>"),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("<div>").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='<div class="cp-color-picker"><div class="cp-z-slider"><div class="cp-z-cursor"></div></div><div class="cp-xy-slider"><div class="cp-white"></div><div class="cp-xy-cursor"></div></div><div class="cp-alpha"><div class="cp-alpha-cursor"></div></div></div>',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
index a191e1694b6fd6196cb378647883762ecb7de788..0730e3de3f8b2ca85e4f9bf2dfc88f46e8fa6ec3 100644 (file)
--- 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)
index 76def6abd032a006a5743ad8d67178813f5e7d84..1f73888590d2fc18ffb92d3566ae5aec28af01c7 100644 (file)
@@ -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);
             };
 
index 90b03e8568ed3593583a85635cc812e653890b40..5400a8af01d7037f3c46158178b75f69f196926f 100644 (file)
@@ -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;
index 1edfc0037ea399b6f77696272ed88afb0f0b14ff..87aa20046dc6f4d9ec09e59b642f212bf70dd684 100644 (file)
@@ -87,6 +87,9 @@ header {
       padding-top: $-s;
     }
   }
+  .dropdown-container {
+    font-size: 0.9em;
+  }
 }
 
 form.search-box {
index f0bd3b1eaffdca3a4ce6b73c8c480ba2484975e0..09707ebc42196c75974872130751638e78ae628e 100644 (file)
 
 // 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;
index 4e6823fc0d35c785fd52b3f569cb1c273a030200..9c4a4dafc3c48661a3c60adaab09c4aaa0635195 100644 (file)
@@ -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;
     }
index 53785b684ac5e1658908cc64eeb09890becb4d29..b1a252bf3a52a06c057d87ba94e818c3a6642f72 100644 (file)
@@ -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
index 0df49485efd400f813f028b768b4ea432971045f..5c744d821fc123a80259aad71059a3a699a43d95 100644 (file)
@@ -1,7 +1,7 @@
 <!DOCTYPE html>
 <html>
 <head>
-    <title>{{ isset($pageTitle) ? $pageTitle . ' | ' : '' }}{{ Setting::get('app-name', 'BookStack') }}</title>
+    <title>{{ isset($pageTitle) ? $pageTitle . ' | ' : '' }}{{ setting('app-name', 'BookStack') }}</title>
 
     <!-- Meta -->
     <meta name="viewport" content="width=device-width">
@@ -17,6 +17,8 @@
     <script src="/libs/jquery/jquery.min.js?version=2.1.4"></script>
 
     @yield('head')
+
+    @include('partials/custom-styles')
 </head>
 <body class="@yield('body-class')" ng-app="bookStack">
 
             <div class="row">
                 <div class="col-lg-4 col-sm-4" ng-non-bindable>
                     <a href="/" class="logo">
-                        @if(Setting::get('app-logo', '') !== 'none')
-                            <img class="logo-image" src="{{ Setting::get('app-logo', '') === '' ? '/logo.png' : Setting::get('app-logo', '') }}" alt="Logo">
+                        @if(setting('app-logo', '') !== 'none')
+                            <img class="logo-image" src="{{ setting('app-logo', '') === '' ? '/logo.png' : setting('app-logo', '') }}" alt="Logo">
                         @endif
-                        <span class="logo-text">{{ Setting::get('app-name', 'BookStack') }}</span>
+                        <span class="logo-text">{{ setting('app-name', 'BookStack') }}</span>
                     </a>
                 </div>
                 <div class="col-lg-4 col-sm-3 text-center">
@@ -43,7 +45,7 @@
                     <div class="float right">
                         <div class="links text-center">
                             <a href="/books"><i class="zmdi zmdi-book"></i>Books</a>
-                            @if(isset($currentUser) && $currentUser->can('settings-update'))
+                            @if(isset($currentUser) && userCan('settings-manage'))
                                 <a href="/settings"><i class="zmdi zmdi-settings"></i>Settings</a>
                             @endif
                             @if(!isset($signedIn) || !$signedIn)
index 9fa48373558b259753a8855bd54069f39684654a..7b5c92b5a9dfd0d5f0f9b399c925ff507737f813 100644 (file)
@@ -8,7 +8,7 @@
                 <div class="col-xs-1"></div>
                 <div class="col-xs-11 faded">
                     <div class="action-buttons">
-                        @if($currentUser->can('book-create'))
+                        @if($currentUser->can('book-create-all'))
                             <a href="/books/create" class="text-pos text-button"><i class="zmdi zmdi-plus"></i>Add new book</a>
                         @endif
                     </div>
@@ -30,7 +30,9 @@
                     {!! $books->render() !!}
                 @else
                     <p class="text-muted">No books have been created.</p>
-                    <a href="/books/create" class="text-pos"><i class="zmdi zmdi-edit"></i>Create one now</a>
+                    @if(userCan('books-create-all'))
+                        <a href="/books/create" class="text-pos"><i class="zmdi zmdi-edit"></i>Create one now</a>
+                    @endif
                 @endif
             </div>
             <div class="col-sm-4 col-sm-offset-1">
diff --git a/resources/views/books/restrictions.blade.php b/resources/views/books/restrictions.blade.php
new file mode 100644 (file)
index 0000000..60b126a
--- /dev/null
@@ -0,0 +1,23 @@
+@extends('base')
+
+@section('content')
+
+    <div class="faded-small toolbar">
+        <div class="container">
+            <div class="row">
+                <div class="col-sm-12 faded">
+                    <div class="breadcrumbs">
+                        <a href="{{$book->getUrl()}}" class="text-book text-button"><i class="zmdi zmdi-book"></i>{{ $book->getShortName() }}</a>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+
+
+    <div class="container" ng-non-bindable>
+        <h1>Book Restrictions</h1>
+        @include('form/restriction-form', ['model' => $book])
+    </div>
+
+@stop
index db89bed9ece04eb2a6840b20f34a5402b04603a0..cd32a406b65d6173323c892fe761a5c49c973369 100644 (file)
@@ -2,23 +2,35 @@
 
 @section('content')
 
-    <div class="faded-small toolbar" ng-non-bindable>
+    <div class="faded-small toolbar">
         <div class="container">
             <div class="row">
                 <div class="col-md-12">
                     <div class="action-buttons faded">
-                        @if($currentUser->can('page-create'))
+                        @if(userCan('page-create', $book))
                             <a href="{{$book->getUrl() . '/page/create'}}" class="text-pos text-button"><i class="zmdi zmdi-plus"></i> New Page</a>
                         @endif
-                        @if($currentUser->can('chapter-create'))
+                        @if(userCan('chapter-create', $book))
                             <a href="{{$book->getUrl() . '/chapter/create'}}" class="text-pos text-button"><i class="zmdi zmdi-plus"></i> New Chapter</a>
                         @endif
-                        @if($currentUser->can('book-update'))
+                        @if(userCan('book-update', $book))
                             <a href="{{$book->getEditUrl()}}" class="text-primary text-button"><i class="zmdi zmdi-edit"></i>Edit</a>
-                            <a href="{{ $book->getUrl() }}/sort" class="text-primary text-button"><i class="zmdi zmdi-sort"></i>Sort</a>
                         @endif
-                        @if($currentUser->can('book-delete'))
-                            <a href="{{ $book->getUrl() }}/delete" class="text-neg text-button"><i class="zmdi zmdi-delete"></i>Delete</a>
+                        @if(userCan('book-update', $book) || userCan('restrictions-manage', $book) || userCan('book-delete', $book))
+                            <div dropdown class="dropdown-container">
+                                <a dropdown-toggle class="text-primary text-button"><i class="zmdi zmdi-more-vert"></i></a>
+                                <ul>
+                                    @if(userCan('book-update', $book))
+                                        <li><a href="{{ $book->getUrl() }}/sort" class="text-primary"><i class="zmdi zmdi-sort"></i>Sort</a></li>
+                                    @endif
+                                    @if(userCan('restrictions-manage', $book))
+                                        <li><a href="{{$book->getUrl()}}/restrict" class="text-primary"><i class="zmdi zmdi-lock-outline"></i>Restrict</a></li>
+                                    @endif
+                                    @if(userCan('book-delete', $book))
+                                        <li><a href="{{ $book->getUrl() }}/delete" class="text-neg"><i class="zmdi zmdi-delete"></i>Delete</a></li>
+                                    @endif
+                                </ul>
+                            </div>
                         @endif
                     </div>
                 </div>
 
             <div class="col-md-4 col-md-offset-1">
                 <div class="margin-top large"></div>
+                @if($book->restricted)
+                    <p class="text-muted">
+                        @if(userCan('restrictions-manage', $book))
+                            <a href="{{ $book->getUrl() }}/restrict"><i class="zmdi zmdi-lock-outline"></i>Book Restricted</a>
+                        @else
+                            <i class="zmdi zmdi-lock-outline"></i>Book Restricted
+                        @endif
+                    </p>
+                @endif
                 <div class="search-box">
                     <form ng-submit="searchBook($event)">
                         <input ng-model="searchTerm" ng-change="checkSearchForm()" type="text" name="term" placeholder="Search This Book">
diff --git a/resources/views/chapters/restrictions.blade.php b/resources/views/chapters/restrictions.blade.php
new file mode 100644 (file)
index 0000000..1f2f9c8
--- /dev/null
@@ -0,0 +1,24 @@
+@extends('base')
+
+@section('content')
+
+    <div class="faded-small toolbar">
+        <div class="container">
+            <div class="row">
+                <div class="col-sm-12 faded">
+                    <div class="breadcrumbs">
+                        <a href="{{$chapter->book->getUrl()}}" class="text-book text-button"><i class="zmdi zmdi-book"></i>{{ $chapter->book->getShortName() }}</a>
+                        <span class="sep">&raquo;</span>
+                        <a href="{{ $chapter->getUrl() }}" class="text-chapter text-button"><i class="zmdi zmdi-collection-bookmark"></i>{{$chapter->getShortName()}}</a>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+
+    <div class="container" ng-non-bindable>
+        <h1>Chapter Restrictions</h1>
+        @include('form/restriction-form', ['model' => $chapter])
+    </div>
+
+@stop
index 7033093e5675c863f47db6d92343ad2c48fb9d77..f053edc1c68e39d0441c2f0764802ed347ac3050 100644 (file)
                 </div>
                 <div class="col-md-8 faded">
                     <div class="action-buttons">
-                        @if($currentUser->can('chapter-create'))
+                        @if(userCan('page-create', $chapter))
                             <a href="{{$chapter->getUrl() . '/create-page'}}" class="text-pos text-button"><i class="zmdi zmdi-plus"></i>New Page</a>
                         @endif
-                        @if($currentUser->can('chapter-update'))
+                        @if(userCan('chapter-update', $chapter))
                             <a href="{{$chapter->getUrl() . '/edit'}}" class="text-primary text-button"><i class="zmdi zmdi-edit"></i>Edit</a>
                         @endif
-                        @if($currentUser->can('chapter-delete'))
+                        @if(userCan('restrictions-manage', $chapter))
+                            <a href="{{$chapter->getUrl()}}/restrict" class="text-primary text-button"><i class="zmdi zmdi-lock-outline"></i>Restrict</a>
+                        @endif
+                        @if(userCan('chapter-delete', $chapter))
                             <a href="{{$chapter->getUrl() . '/delete'}}" class="text-neg text-button"><i class="zmdi zmdi-delete"></i>Delete</a>
                         @endif
                     </div>
                 <h1>{{ $chapter->name }}</h1>
                 <p class="text-muted">{{ $chapter->description }}</p>
 
-                @if(count($chapter->pages) > 0)
+                @if(count($pages) > 0)
                     <div class="page-list">
                         <hr>
-                        @foreach($chapter->pages as $page)
+                        @foreach($pages as $page)
                             @include('pages/list-item', ['page' => $page])
                             <hr>
                         @endforeach
                 </p>
             </div>
             <div class="col-md-3 col-md-offset-1">
+                <div class="margin-top large"></div>
+                @if($book->restricted || $chapter->restricted)
+                    <div class="text-muted">
+
+                        @if($book->restricted)
+                            @if(userCan('restrictions-manage', $book))
+                                <a href="{{ $book->getUrl() }}/restrict"><i class="zmdi zmdi-lock-outline"></i>Book Restricted</a>
+                            @else
+                                <i class="zmdi zmdi-lock-outline"></i>Book Restricted
+                            @endif
+                                <br>
+                        @endif
+
+                        @if($chapter->restricted)
+                            @if(userCan('restrictions-manage', $chapter))
+                                <a href="{{ $chapter->getUrl() }}/restrict"><i class="zmdi zmdi-lock-outline"></i>Chapter Restricted</a>
+                            @else
+                                <i class="zmdi zmdi-lock-outline"></i>Chapter Restricted
+                            @endif
+                        @endif
+                    </div>
+                @endif
+
                 @include('pages/sidebar-tree-list', ['book' => $book, 'sidebarTree' => $sidebarTree])
             </div>
         </div>
index 191cf758faeabe7f18d83a728d2e389304006614..a9294c7cf4cf80c0f909e03cc011c29e257611f9 100644 (file)
@@ -4,7 +4,7 @@
 
 
 <div class="container">
-    <h1 class="text-muted">Page Not Found</h1>
+    <h1 class="text-muted">{{ $message or 'Page Not Found' }}</h1>
     <p>Sorry, The page you were looking for could not be found.</p>
     <a href="/" class="button">Return To Home</a>
 </div>
diff --git a/resources/views/form/checkbox.blade.php b/resources/views/form/checkbox.blade.php
new file mode 100644 (file)
index 0000000..2558969
--- /dev/null
@@ -0,0 +1,12 @@
+
+<label>
+    <input value="true" id="{{$name}}" type="checkbox" name="{{$name}}"
+           @if($errors->has($name)) class="neg" @endif
+           @if(old($name) || (!old() && isset($model) && $model->$name)) checked="checked" @endif
+    >
+    {{ $label }}
+</label>
+
+@if($errors->has($name))
+    <div class="text-neg text-small">{{ $errors->first($name) }}</div>
+@endif
\ No newline at end of file
diff --git a/resources/views/form/restriction-checkbox.blade.php b/resources/views/form/restriction-checkbox.blade.php
new file mode 100644 (file)
index 0000000..a4449cc
--- /dev/null
@@ -0,0 +1,7 @@
+
+<label>
+    <input value="true" id="{{$name}}[{{$role->id}}][{{$action}}]" type="checkbox" name="{{$name}}[{{$role->id}}][{{$action}}]"
+           @if(old($name .'.'.$role->id.'.'.$action) || (!old() && isset($model) && $model->hasRestriction($role->id, $action))) checked="checked" @endif
+    >
+    {{ $label }}
+</label>
\ No newline at end of file
diff --git a/resources/views/form/restriction-form.blade.php b/resources/views/form/restriction-form.blade.php
new file mode 100644 (file)
index 0000000..d2fa239
--- /dev/null
@@ -0,0 +1,29 @@
+<form action="{{ $model->getUrl() }}/restrict" method="POST">
+    {!! csrf_field() !!}
+    <input type="hidden" name="_method" value="PUT">
+
+    <div class="form-group">
+        @include('form/checkbox', ['name' => 'restricted', 'label' => 'Restrict this ' . $model->getClassName()])
+    </div>
+
+    <table class="table">
+        <tr>
+            <th>Role</th>
+            <th @if($model->isA('page')) colspan="3" @else colspan="4" @endif>Actions</th>
+        </tr>
+        @foreach($roles as $role)
+            <tr>
+                <td>{{ $role->display_name }}</td>
+                <td>@include('form/restriction-checkbox', ['name'=>'restrictions', 'label' => 'View', 'action' => 'view'])</td>
+                @if(!$model->isA('page'))
+                    <td>@include('form/restriction-checkbox', ['name'=>'restrictions', 'label' => 'Create', 'action' => 'create'])</td>
+                @endif
+                <td>@include('form/restriction-checkbox', ['name'=>'restrictions', 'label' => 'Update', 'action' => 'update'])</td>
+                <td>@include('form/restriction-checkbox', ['name'=>'restrictions', 'label' => 'Delete', 'action' => 'delete'])</td>
+            </tr>
+        @endforeach
+    </table>
+
+    <a href="{{ $model->getUrl() }}" class="button muted">Cancel</a>
+    <button type="submit" class="button pos">Save Restrictions</button>
+</form>
\ No newline at end of file
diff --git a/resources/views/form/role-checkboxes.blade.php b/resources/views/form/role-checkboxes.blade.php
new file mode 100644 (file)
index 0000000..df868ee
--- /dev/null
@@ -0,0 +1,14 @@
+
+@foreach($roles as $role)
+    <label>
+        <input value="{{ $role->id }}" id="{{$name}}-{{$role->name}}" type="checkbox" name="{{$name}}[{{$role->name}}]"
+               @if($errors->has($name)) class="neg" @endif
+               @if(old($name . '.' . $role->name) || (!old('name') && isset($model) && $model->hasRole($role->name))) checked="checked" @endif
+        >
+        {{ $role->display_name }}
+    </label>
+@endforeach
+
+@if($errors->has($name))
+    <div class="text-neg text-small">{{ $errors->first($name) }}</div>
+@endif
\ No newline at end of file
index 8aaae1e1bd4c118bf385ab23219ef69e7b31ecd7..f840be965f70070731558dcc67bb1e0d8be04f0a 100644 (file)
 
             <div class="col-sm-4">
                 <h3><a class="no-color" href="/pages/recently-created">Recently Created Pages</a></h3>
-                @include('partials/entity-list', ['entities' => $recentlyCreatedPages, 'style' => 'compact'])
+                <div id="recently-created-pages">
+                    @include('partials/entity-list', ['entities' => $recentlyCreatedPages, 'style' => 'compact'])
+                </div>
 
                 <h3><a class="no-color" href="/pages/recently-updated">Recently Updated Pages</a></h3>
-                @include('partials/entity-list', ['entities' => $recentlyCreatedPages, 'style' => 'compact'])
+                <div id="recently-updated-pages">
+                    @include('partials/entity-list', ['entities' => $recentlyUpdatedPages, 'style' => 'compact'])
+                </div>
             </div>
 
             <div class="col-sm-4" id="recent-activity">
diff --git a/resources/views/pages/restrictions.blade.php b/resources/views/pages/restrictions.blade.php
new file mode 100644 (file)
index 0000000..d094abc
--- /dev/null
@@ -0,0 +1,31 @@
+@extends('base')
+
+@section('content')
+
+    <div class="faded-small toolbar">
+        <div class="container">
+            <div class="row">
+                <div class="col-sm-12 faded">
+                    <div class="breadcrumbs">
+                        <a href="{{$page->book->getUrl()}}" class="text-book text-button"><i class="zmdi zmdi-book"></i>{{ $page->book->getShortName() }}</a>
+                        @if($page->hasChapter())
+                            <span class="sep">&raquo;</span>
+                            <a href="{{ $page->chapter->getUrl() }}" class="text-chapter text-button">
+                                <i class="zmdi zmdi-collection-bookmark"></i>
+                                {{$page->chapter->getShortName()}}
+                            </a>
+                        @endif
+                        <span class="sep">&raquo;</span>
+                        <a href="{{$page->getUrl()}}" class="text-book text-button"><i class="zmdi zmdi-file"></i>{{ $page->getShortName() }}</a>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+
+    <div class="container" ng-non-bindable>
+        <h1>Page Restrictions</h1>
+        @include('form/restriction-form', ['model' => $page])
+    </div>
+
+@stop
index 004a240d0290bdbc8e4272e67d0a6593c9ead29c..286d443874001fd7c22bb2017574f193790bb163 100644 (file)
                         <span dropdown class="dropdown-container">
                             <div dropdown-toggle class="text-button text-primary"><i class="zmdi zmdi-open-in-new"></i>Export</div>
                             <ul class="wide">
-                                <li><a href="{{$page->getUrl() . '/export/html'}}" target="_blank">Contained Web File <span class="text-muted float right">.html</span></a></li>
-                                <li><a href="{{$page->getUrl() . '/export/pdf'}}" target="_blank">PDF File <span class="text-muted float right">.pdf</span></a></li>
-                                <li><a href="{{$page->getUrl() . '/export/plaintext'}}" target="_blank">Plain Text File <span class="text-muted float right">.txt</span></a></li>
+                                <li><a href="{{$page->getUrl()}}/export/html" target="_blank">Contained Web File <span class="text-muted float right">.html</span></a></li>
+                                <li><a href="{{$page->getUrl()}}/export/pdf" target="_blank">PDF File <span class="text-muted float right">.pdf</span></a></li>
+                                <li><a href="{{$page->getUrl()}}/export/plaintext" target="_blank">Plain Text File <span class="text-muted float right">.txt</span></a></li>
                             </ul>
                         </span>
-                        @if($currentUser->can('page-update'))
-                            <a href="{{$page->getUrl() . '/revisions'}}" class="text-primary text-button"><i class="zmdi zmdi-replay"></i>Revisions</a>
-                            <a href="{{$page->getUrl() . '/edit'}}" class="text-primary text-button" ><i class="zmdi zmdi-edit"></i>Edit</a>
+                        @if(userCan('page-update', $page))
+                            <a href="{{$page->getUrl()}}/revisions" class="text-primary text-button"><i class="zmdi zmdi-replay"></i>Revisions</a>
+                            <a href="{{$page->getUrl()}}/edit" class="text-primary text-button" ><i class="zmdi zmdi-edit"></i>Edit</a>
                         @endif
-                        @if($currentUser->can('page-delete'))
-                            <a href="{{$page->getUrl() . '/delete'}}" class="text-neg text-button"><i class="zmdi zmdi-delete"></i>Delete</a>
+                        @if(userCan('restrictions-manage', $page))
+                            <a href="{{$page->getUrl()}}/restrict" class="text-primary text-button"><i class="zmdi zmdi-lock-outline"></i>Restrict</a>
+                        @endif
+                        @if(userCan('page-delete', $page))
+                            <a href="{{$page->getUrl()}}/delete" class="text-neg text-button"><i class="zmdi zmdi-delete"></i>Delete</a>
                         @endif
                     </div>
                 </div>
                 </div>
             </div>
             <div class="col-md-3 print-hidden">
+                <div class="margin-top large"></div>
+                @if($book->restricted || ($page->chapter && $page->chapter->restricted) || $page->restricted)
+                    <div class="text-muted">
+
+                        @if($book->restricted)
+                            @if(userCan('restrictions-manage', $book))
+                                <a href="{{ $book->getUrl() }}/restrict"><i class="zmdi zmdi-lock-outline"></i>Book restricted</a>
+                            @else
+                                <i class="zmdi zmdi-lock-outline"></i>Book restricted
+                            @endif
+                            <br>
+                        @endif
 
+                        @if($page->chapter && $page->chapter->restricted)
+                            @if(userCan('restrictions-manage', $page->chapter))
+                                <a href="{{ $page->chapter->getUrl() }}/restrict"><i class="zmdi zmdi-lock-outline"></i>Chapter restricted</a>
+                            @else
+                                <i class="zmdi zmdi-lock-outline"></i>Chapter restricted
+                            @endif
+                            <br>
+                        @endif
+
+                        @if($page->restricted)
+                            @if(userCan('restrictions-manage', $page))
+                                <a href="{{ $page->getUrl() }}/restrict"><i class="zmdi zmdi-lock-outline"></i>Page restricted</a>
+                            @else
+                                <i class="zmdi zmdi-lock-outline"></i>Page restricted
+                            @endif
+                            <br>
+                        @endif
+                    </div>
+                @endif
                 @include('pages/sidebar-tree-list', ['book' => $book, 'sidebarTree' => $sidebarTree])
 
             </div>
index f94fad5e179c648d5e14b7fd721633d4d2776afb..ff0d745866788bffcca5acec04f1d0da8e8fd743 100644 (file)
@@ -16,8 +16,8 @@
 
     {{ $activity->getText() }}
 
-    @if($activity->entity())
-        <a href="{{ $activity->entity()->getUrl() }}">{{ $activity->entity()->name }}</a>
+    @if($activity->entity)
+        <a href="{{ $activity->entity->getUrl() }}">{{ $activity->entity->name }}</a>
     @endif
 
     @if($activity->extra) "{{$activity->extra}}" @endif
diff --git a/resources/views/partials/custom-styles.blade.php b/resources/views/partials/custom-styles.blade.php
new file mode 100644 (file)
index 0000000..011f066
--- /dev/null
@@ -0,0 +1,22 @@
+@if(Setting::get('app-color'))
+    <style>
+        header, #back-to-top {
+            background-color: {{ Setting::get('app-color') }};
+        }
+        .faded-small {
+            background-color: {{ Setting::get('app-color-light') }};
+        }
+        .button-base, .button, input[type="button"], input[type="submit"] {
+            background-color: {{ Setting::get('app-color') }};
+        }
+        .button-base:hover, .button:hover, input[type="button"]:hover, input[type="submit"]:hover, .button:focus {
+            background-color: {{ Setting::get('app-color') }};
+        }
+        .setting-nav a.selected {
+            border-bottom-color: {{ Setting::get('app-color') }};
+        }
+        p.primary:hover, p .primary:hover, span.primary:hover, .text-primary:hover, a, a:hover, a:focus {
+            color: {{ Setting::get('app-color') }};
+        }
+    </style>
+@endif
\ No newline at end of file
index d34c490a7db0ac9d1ad657388c888fd84cbbcff0..f22fd47d4f25caf798ce1fd7e2d4935f5ceee60c 100644 (file)
@@ -15,7 +15,7 @@
 
     <!-- Scripts -->
     <script src="/libs/jquery/jquery.min.js?version=2.1.4"></script>
-
+    @include('partials/custom-styles')
 </head>
 <body class="@yield('body-class')" ng-app="bookStack">
 
index b5b5d0a4f1ecf4589df977ac953fd94496519e47..f946232561d05877151106520ccf42a2089aee15 100644 (file)
                     <p class="small">This image should be 43px in height. <br>Large images will be scaled down.</p>
                     <image-picker resize-height="43" show-remove="true" resize-width="200" current-image="{{ Setting::get('app-logo', '') }}" default-image="/logo.png" name="setting-app-logo" image-class="logo-image"></image-picker>
                 </div>
+                <div class="form-group" id="color-control">
+                    <label for="setting-app-color">Application Primary Color</label>
+                    <p class="small">This should be a hex value. <br> Leave empty to reset to the default color.</p>
+                    <input  type="text" value="{{ Setting::get('app-color', '') }}" name="setting-app-color" id="setting-app-color" placeholder="#0288D1">
+                    <input  type="hidden" value="{{ Setting::get('app-color-light', '') }}" name="setting-app-color-light" id="setting-app-color-light" placeholder="rgba(21, 101, 192, 0.15)">
+                </div>
             </div>
         </div>
 
@@ -54,7 +60,7 @@
                     <select id="setting-registration-role" name="setting-registration-role" @if($errors->has('setting-registration-role')) class="neg" @endif>
                         @foreach(\BookStack\Role::all() as $role)
                             <option value="{{$role->id}}"
-                                    @if(\Setting::get('registration-role', \BookStack\Role::getDefault()->id) == $role->id) selected @endif
+                                    @if(\Setting::get('registration-role', \BookStack\Role::first()->id) == $role->id) selected @endif
                                     >
                                 {{ $role->display_name }}
                             </option>
 @include('partials/image-manager', ['imageType' => 'system'])
 
 @stop
+
+@section('scripts')
+    <script src="/libs/jq-color-picker/tiny-color-picker.min.js?version=1.0.0"></script>
+    <script type="text/javascript">
+        $('#setting-app-color').colorPicker({
+            opacity: false,
+            renderCallback: function($elm, toggled) {
+                var hexVal = '#' + this.color.colors.HEX;
+                var rgb = this.color.colors.RND.rgb;
+                var rgbLightVal = 'rgba('+ [rgb.r, rgb.g, rgb.b, '0.15'].join(',') +')';
+                // Set textbox color to hex color code.
+                var isEmpty = $.trim($elm.val()).length === 0;
+                if (!isEmpty) $elm.val(hexVal);
+                $('#setting-app-color-light').val(isEmpty ? '' : rgbLightVal);
+                // Set page elements to provide preview
+                $('#header, .image-picker .button').css('background-color', hexVal);
+                $('.faded-small').css('background-color', rgbLightVal);
+                $('.setting-nav a.selected').css('border-bottom-color', hexVal);
+            }
+        });
+    </script>
+@stop
\ No newline at end of file
index 3afe59a8e66443b70daa4b729dadb1ec9b4a47b7..7c31868895fe2453bef164822923ec4127dbaa51 100644 (file)
@@ -5,6 +5,7 @@
             <div class="col-md-12 setting-nav">
                 <a href="/settings" @if($selected == 'settings') class="selected text-button" @endif><i class="zmdi zmdi-settings"></i>Settings</a>
                 <a href="/settings/users" @if($selected == 'users') class="selected text-button" @endif><i class="zmdi zmdi-accounts"></i>Users</a>
+                <a href="/settings/roles" @if($selected == 'roles') class="selected text-button" @endif><i class="zmdi zmdi-lock-open"></i>Roles</a>
             </div>
         </div>
     </div>
diff --git a/resources/views/settings/roles/checkbox.blade.php b/resources/views/settings/roles/checkbox.blade.php
new file mode 100644 (file)
index 0000000..35aa61e
--- /dev/null
@@ -0,0 +1,3 @@
+<input type="checkbox" name="permissions[{{ $permission }}]"
+       @if(old('permissions.'.$permission, false)|| (!old('display_name', false) && (isset($role) && $role->hasPermission($permission)))) checked="checked" @endif
+       value="true">
\ No newline at end of file
diff --git a/resources/views/settings/roles/create.blade.php b/resources/views/settings/roles/create.blade.php
new file mode 100644 (file)
index 0000000..f7d39f4
--- /dev/null
@@ -0,0 +1,15 @@
+@extends('base')
+
+@section('content')
+
+    @include('settings/navbar', ['selected' => 'roles'])
+
+    <div class="container">
+        <h1>Create New Role</h1>
+
+        <form action="/settings/roles/new" method="POST">
+            @include('settings/roles/form')
+        </form>
+    </div>
+
+@stop
diff --git a/resources/views/settings/roles/delete.blade.php b/resources/views/settings/roles/delete.blade.php
new file mode 100644 (file)
index 0000000..5d1ffe2
--- /dev/null
@@ -0,0 +1,28 @@
+@extends('base')
+
+@section('content')
+
+    @include('settings/navbar', ['selected' => 'roles'])
+
+    <div class="container small" ng-non-bindable>
+        <h1>Delete Role</h1>
+        <p>This will delete the role with the name '{{$role->display_name}}'.</p>
+
+        <form action="/settings/roles/delete/{{$role->id}}" method="POST">
+            {!! csrf_field() !!}
+            <input type="hidden" name="_method" value="DELETE">
+
+            @if($role->users->count() > 0)
+            <div class="form-group">
+                    <p>This role has {{$role->users->count()}} users assigned to it. If you would like to migrate the users from this role select a new role below.</p>
+                    @include('form/role-select', ['options' => $roles, 'name' => 'migration_role_id'])
+            </div>
+            @endif
+
+            <p class="text-neg">Are you sure you want to delete this role?</p>
+            <a href="/settings/roles/{{ $role->id }}" class="button">Cancel</a>
+            <button type="submit" class="button neg">Confirm</button>
+        </form>
+    </div>
+
+@stop
diff --git a/resources/views/settings/roles/edit.blade.php b/resources/views/settings/roles/edit.blade.php
new file mode 100644 (file)
index 0000000..f98e6e0
--- /dev/null
@@ -0,0 +1,24 @@
+@extends('base')
+
+@section('content')
+
+    @include('settings/navbar', ['selected' => 'roles'])
+
+    <div class="container">
+        <div class="row">
+            <div class="col-sm-6">
+                <h1>Edit Role <small> {{ $role->display_name }}</small></h1>
+            </div>
+            <div class="col-sm-6">
+                <p></p>
+                <a href="/settings/roles/delete/{{ $role->id }}" class="button neg float right">Delete Role</a>
+            </div>
+        </div>
+
+        <form action="/settings/roles/{{ $role->id }}" method="POST">
+            <input type="hidden" name="_method" value="PUT">
+            @include('settings/roles/form', ['model' => $role])
+        </form>
+    </div>
+
+@stop
diff --git a/resources/views/settings/roles/form.blade.php b/resources/views/settings/roles/form.blade.php
new file mode 100644 (file)
index 0000000..fafb9be
--- /dev/null
@@ -0,0 +1,117 @@
+{!! csrf_field() !!}
+
+<div class="row">
+
+    <div class="col-md-6">
+        <h3>Role Details</h3>
+        <div class="form-group">
+            <label for="name">Role Name</label>
+            @include('form/text', ['name' => 'display_name'])
+        </div>
+        <div class="form-group">
+            <label for="name">Short Role Description</label>
+            @include('form/text', ['name' => 'description'])
+        </div>
+        <h3>System Permissions</h3>
+        <div class="row">
+            <div class="col-md-6">
+                <label> @include('settings/roles/checkbox', ['permission' => 'users-manage']) Manage users</label>
+            </div>
+            <div class="col-md-6">
+                <label>@include('settings/roles/checkbox', ['permission' => 'user-roles-manage']) Manage user roles</label>
+            </div>
+        </div>
+        <hr class="even">
+        <div class="row">
+            <div class="col-md-6">
+                <label>@include('settings/roles/checkbox', ['permission' => 'restrictions-manage-all']) Manage all restrictions</label>
+            </div>
+            <div class="col-md-6">
+                <label>@include('settings/roles/checkbox', ['permission' => 'restrictions-manage-own']) Manage restrictions on own content</label>
+            </div>
+        </div>
+        <hr class="even">
+        <div class="form-group">
+            <label>@include('settings/roles/checkbox', ['permission' => 'settings-manage']) Manage app settings</label>
+        </div>
+        <hr class="even">
+
+    </div>
+
+    <div class="col-md-6">
+
+        <h3>Asset Permissions</h3>
+        <p>
+            These permissions control default access to the assets within the system. <br>
+            Restrictions on Books, Chapters and Pages will override these permissions.
+        </p>
+        <table class="table">
+            <tr>
+                <th></th>
+                <th>Create</th>
+                <th>Edit</th>
+                <th>Delete</th>
+            </tr>
+            <tr>
+                <td>Books</td>
+                <td>
+                    <label>@include('settings/roles/checkbox', ['permission' => 'book-create-all']) All</label>
+                </td>
+                <td>
+                    <label>@include('settings/roles/checkbox', ['permission' => 'book-update-own']) Own</label>
+                    <label>@include('settings/roles/checkbox', ['permission' => 'book-update-all']) All</label>
+                </td>
+                <td>
+                    <label>@include('settings/roles/checkbox', ['permission' => 'book-delete-own']) Own</label>
+                    <label>@include('settings/roles/checkbox', ['permission' => 'book-delete-all']) All</label>
+                </td>
+            </tr>
+            <tr>
+                <td>Chapters</td>
+                <td>
+                    <label>@include('settings/roles/checkbox', ['permission' => 'chapter-create-own']) Own</label>
+                    <label>@include('settings/roles/checkbox', ['permission' => 'chapter-create-all']) All</label>
+                </td>
+                <td>
+                    <label>@include('settings/roles/checkbox', ['permission' => 'chapter-update-own']) Own</label>
+                    <label>@include('settings/roles/checkbox', ['permission' => 'chapter-update-all']) All</label>
+                </td>
+                <td>
+                    <label>@include('settings/roles/checkbox', ['permission' => 'chapter-delete-own']) Own</label>
+                    <label>@include('settings/roles/checkbox', ['permission' => 'chapter-delete-all']) All</label>
+                </td>
+            </tr>
+            <tr>
+                <td>Pages</td>
+                <td>
+                    <label>@include('settings/roles/checkbox', ['permission' => 'page-create-own']) Own</label>
+                    <label>@include('settings/roles/checkbox', ['permission' => 'page-create-all']) All</label>
+                </td>
+                <td>
+                    <label>@include('settings/roles/checkbox', ['permission' => 'page-update-own']) Own</label>
+                    <label>@include('settings/roles/checkbox', ['permission' => 'page-update-all']) All</label>
+                </td>
+                <td>
+                    <label>@include('settings/roles/checkbox', ['permission' => 'page-delete-own']) Own</label>
+                    <label>@include('settings/roles/checkbox', ['permission' => 'page-delete-all']) All</label>
+                </td>
+            </tr>
+            <tr>
+                <td>Images</td>
+                <td>@include('settings/roles/checkbox', ['permission' => 'image-create-all'])</td>
+                <td>
+                    <label>@include('settings/roles/checkbox', ['permission' => 'image-update-own']) Own</label>
+                    <label>@include('settings/roles/checkbox', ['permission' => 'image-update-all']) All</label>
+                </td>
+                <td>
+                    <label>@include('settings/roles/checkbox', ['permission' => 'image-delete-own']) Own</label>
+                    <label>@include('settings/roles/checkbox', ['permission' => 'image-delete-all']) All</label>
+                </td>
+            </tr>
+        </table>
+    </div>
+
+</div>
+
+<a href="/settings/roles" class="button muted">Cancel</a>
+<button type="submit" class="button pos">Save Role</button>
\ No newline at end of file
diff --git a/resources/views/settings/roles/index.blade.php b/resources/views/settings/roles/index.blade.php
new file mode 100644 (file)
index 0000000..8f92a5e
--- /dev/null
@@ -0,0 +1,31 @@
+@extends('base')
+
+@section('content')
+
+    @include('settings/navbar', ['selected' => 'roles'])
+
+    <div class="container small">
+
+        <h1>User Roles</h1>
+
+        <p>
+            <a href="/settings/roles/new" class="text-pos"><i class="zmdi zmdi-lock-open"></i>Add new role</a>
+        </p>
+
+        <table class="table">
+            <tr>
+                <th>Role Name</th>
+                <th></th>
+                <th class="text-right">Users</th>
+            </tr>
+            @foreach($roles as $role)
+                <tr>
+                    <td><a href="/settings/roles/{{ $role->id }}">{{ $role->display_name }}</a></td>
+                    <td>{{ $role->description }}</td>
+                    <td class="text-right">{{ $role->users->count() }}</td>
+                </tr>
+            @endforeach
+        </table>
+    </div>
+
+@stop
index 4a6673dc2ebedcbd36fb75c44f16cd474b12be0f..47edb211b4ce1f2b352e018676578a2a9b4b0292 100644 (file)
@@ -3,21 +3,21 @@
     @include('form.text', ['name' => 'name'])
 </div>
 
-@if($currentUser->can('user-update'))
+@if(userCan('users-manage'))
 <div class="form-group">
     <label for="email">Email</label>
     @include('form.text', ['name' => 'email'])
 </div>
 @endif
 
-@if($currentUser->can('user-update'))
+@if(userCan('users-manage'))
     <div class="form-group">
         <label for="role">User Role</label>
-        @include('form.role-select', ['name' => 'role', 'options' => \BookStack\Role::all(), 'displayKey' => 'display_name'])
+        @include('form/role-checkboxes', ['name' => 'roles', 'roles' => \BookStack\Role::all()])
     </div>
 @endif
 
-@if($currentUser->can('user-update'))
+@if(userCan('users-manage'))
     <div class="form-group">
         <label for="external_auth_id">External Authentication ID</label>
         @include('form.text', ['name' => 'external_auth_id'])
index c20e2955d05b52b1beab19c0014ccee262dfa040..9bd70b43c4805d4671f0f626b69e30495a3e129d 100644 (file)
@@ -8,10 +8,10 @@
     @include('form.text', ['name' => 'email'])
 </div>
 
-@if($currentUser->can('user-update'))
+@if(userCan('users-manage'))
     <div class="form-group">
         <label for="role">User Role</label>
-        @include('form.role-select', ['name' => 'role', 'options' => \BookStack\Role::all(), 'displayKey' => 'display_name'])
+        @include('form/role-checkboxes', ['name' => 'roles', 'roles' => \BookStack\Role::all()])
     </div>
 @endif
 
index 6e5d10c5faa6a504bd618e48d04c6b61894d5a34..f0663071472349fc88ad5ea149ec41419ba2a325 100644 (file)
@@ -8,7 +8,7 @@
 
     <div class="container small" ng-non-bindable>
         <h1>Users</h1>
-        @if($currentUser->can('user-create'))
+        @if(userCan('users-manage'))
             <p>
                 <a href="/settings/users/create" class="text-pos"><i class="zmdi zmdi-account-add"></i>Add new user</a>
             </p>
                 <th></th>
                 <th>Name</th>
                 <th>Email</th>
-                <th>User Type</th>
+                <th>User Roles</th>
             </tr>
             @foreach($users as $user)
                 <tr>
                     <td style="line-height: 0;"><img class="avatar med" src="{{$user->getAvatar(40)}}" alt="{{$user->name}}"></td>
                     <td>
-                        @if($currentUser->can('user-update') || $currentUser->id == $user->id)
+                        @if(userCan('users-manage') || $currentUser->id == $user->id)
                             <a href="/settings/users/{{$user->id}}">
                                 @endif
                                 {{ $user->name }}
-                                @if($currentUser->can('user-update') || $currentUser->id == $user->id)
+                                @if(userCan('users-manage') || $currentUser->id == $user->id)
                             </a>
                         @endif
                     </td>
                     <td>
-                        @if($currentUser->can('user-update') || $currentUser->id == $user->id)
+                        @if(userCan('users-manage') || $currentUser->id == $user->id)
                             <a href="/settings/users/{{$user->id}}">
                                 @endif
                                 {{ $user->email }}
-                                @if($currentUser->can('user-update') || $currentUser->id == $user->id)
+                                @if(userCan('users-manage') || $currentUser->id == $user->id)
                             </a>
                         @endif
                     </td>
-                    <td>{{ $user->role->display_name }}</td>
+                    <td>
+                       <small> {{ $user->roles->implode('display_name', ', ') }}</small>
+                    </td>
                 </tr>
             @endforeach
         </table>
index 69402266684522b2cb0b882f66dfef04df77bd8d..067840841c8938aed53cba08a2c7c30356ac27f8 100644 (file)
@@ -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);
     }
 
index 2936fc0475abe698fdbe420a6f1ece8718dadae0..30858f8d986f6fcb45c5203da666c788beb13485 100644 (file)
@@ -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 (file)
index 0000000..40b5a76
--- /dev/null
@@ -0,0 +1,407 @@
+<?php
+
+class RestrictionsTest extends TestCase
+{
+    protected $user;
+
+    public function setUp()
+    {
+        parent::setUp();
+        $this->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 (file)
index 0000000..baba208
--- /dev/null
@@ -0,0 +1,510 @@
+<?php
+
+class RolesTest extends TestCase
+{
+    protected $user;
+
+    public function setUp()
+    {
+        parent::setUp();
+        $this->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);
+    }
+
+}
index 4b8578a43280aae82c51e57bdafeee670c157eef..567dc93eca876bc7426bfee781e80f35c1526ca5 100644 (file)
@@ -1,6 +1,7 @@
 <?php
 
 use Illuminate\Foundation\Testing\DatabaseTransactions;
+use Symfony\Component\DomCrawler\Crawler;
 
 class TestCase extends Illuminate\Foundation\Testing\TestCase
 {
@@ -32,7 +33,8 @@ class TestCase extends Illuminate\Foundation\Testing\TestCase
     public function asAdmin()
     {
         if($this->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