diff --git a/app/Console/Commands/DeleteUsers.php b/app/Console/Commands/DeleteUsers.php new file mode 100644 index 000000000..8829d3992 --- /dev/null +++ b/app/Console/Commands/DeleteUsers.php @@ -0,0 +1,62 @@ +user = $user; + $this->userRepo = $userRepo; + parent::__construct(); + } + + public function handle() + { + $confirm = $this->ask('This will delete all users from the system that are not "admin" or system users. Are you sure you want to continue? (Type "yes" to continue)'); + $numDeleted = 0; + if (strtolower(trim($confirm)) === 'yes') + { + $totalUsers = $this->user->count(); + $users = $this->user->where('system_name', '=', null)->with('roles')->get(); + foreach ($users as $user) + { + if ($user->hasSystemRole('admin')) + { + // don't delete users with "admin" role + continue; + } + $this->userRepo->destroy($user); + ++$numDeleted; + } + $this->info("Deleted $numDeleted of $totalUsers total users."); + } + else + { + $this->info('Exiting...'); + } + } + +} diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index 12792e151..a979072e2 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -4,11 +4,14 @@ namespace BookStack\Exceptions; use Exception; use Illuminate\Auth\AuthenticationException; +use Illuminate\Http\Request; +use Illuminate\Pipeline\Pipeline; use Illuminate\Validation\ValidationException; use Illuminate\Database\Eloquent\ModelNotFoundException; use Symfony\Component\HttpKernel\Exception\HttpException; use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler; use Illuminate\Auth\Access\AuthorizationException; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class Handler extends ExceptionHandler { @@ -60,9 +63,32 @@ class Handler extends ExceptionHandler return response()->view('errors/' . $code, ['message' => $message], $code); } + // Handle 404 errors with a loaded session to enable showing user-specific information + if ($this->isExceptionType($e, NotFoundHttpException::class)) { + return $this->loadErrorMiddleware($request, function ($request) use ($e) { + $message = $e->getMessage() ?: trans('errors.404_page_not_found'); + return response()->view('errors/404', ['message' => $message], 404); + }); + } + return parent::render($request, $e); } + /** + * Load the middleware required to show state/session-enabled error pages. + * @param Request $request + * @param $callback + * @return mixed + */ + protected function loadErrorMiddleware(Request $request, $callback) + { + $middleware = (\Route::getMiddlewareGroups()['web_errors']); + return (new Pipeline($this->container)) + ->send($request) + ->through($middleware) + ->then($callback); + } + /** * Check the exception chain to compare against the original exception type. * @param Exception $e diff --git a/app/Http/Controllers/BookController.php b/app/Http/Controllers/BookController.php index e181aec89..f1645bb4b 100644 --- a/app/Http/Controllers/BookController.php +++ b/app/Http/Controllers/BookController.php @@ -46,7 +46,7 @@ class BookController extends Controller 'books' => $books, 'recents' => $recents, 'popular' => $popular, - 'new' => $new, + 'new' => $new, 'booksViewType' => $booksViewType ]); } @@ -155,7 +155,7 @@ class BookController extends Controller $book = $this->entityRepo->getBySlug('book', $bookSlug); $this->checkOwnablePermission('book-update', $book); $bookChildren = $this->entityRepo->getBookChildren($book, true); - $books = $this->entityRepo->getAll('book', false); + $books = $this->entityRepo->getAll('book', false, 'update'); $this->setPageTitle(trans('entities.books_sort_named', ['bookName'=>$book->getShortName()])); return view('books/sort', ['book' => $book, 'current' => $book, 'books' => $books, 'bookChildren' => $bookChildren]); } @@ -190,42 +190,56 @@ class BookController extends Controller } // Sort pages and chapters - $sortedBooks = []; - $updatedModels = collect(); - $sortMap = json_decode($request->get('sort-tree')); - $defaultBookId = $book->id; + $sortMap = collect(json_decode($request->get('sort-tree'))); + $bookIdsInvolved = collect([$book->id]); - // Loop through contents of provided map and update entities accordingly - foreach ($sortMap as $bookChild) { - $priority = $bookChild->sort; - $id = intval($bookChild->id); - $isPage = $bookChild->type == 'page'; - $bookId = $this->entityRepo->exists('book', $bookChild->book) ? intval($bookChild->book) : $defaultBookId; - $chapterId = ($isPage && $bookChild->parentChapter === false) ? 0 : intval($bookChild->parentChapter); - $model = $this->entityRepo->getById($isPage?'page':'chapter', $id); + // Load models into map + $sortMap->each(function($mapItem) use ($bookIdsInvolved) { + $mapItem->type = ($mapItem->type === 'page' ? 'page' : 'chapter'); + $mapItem->model = $this->entityRepo->getById($mapItem->type, $mapItem->id); + // Store source and target books + $bookIdsInvolved->push(intval($mapItem->model->book_id)); + $bookIdsInvolved->push(intval($mapItem->book)); + }); - // Update models only if there's a change in parent chain or ordering. - if ($model->priority !== $priority || $model->book_id !== $bookId || ($isPage && $model->chapter_id !== $chapterId)) { - $this->entityRepo->changeBook($isPage?'page':'chapter', $bookId, $model); - $model->priority = $priority; - if ($isPage) $model->chapter_id = $chapterId; + // Get the books involved in the sort + $bookIdsInvolved = $bookIdsInvolved->unique()->toArray(); + $booksInvolved = $this->entityRepo->book->newQuery()->whereIn('id', $bookIdsInvolved)->get(); + // Throw permission error if invalid ids or inaccessible books given. + if (count($bookIdsInvolved) !== count($booksInvolved)) { + $this->showPermissionError(); + } + // Check permissions of involved books + $booksInvolved->each(function(Book $book) { + $this->checkOwnablePermission('book-update', $book); + }); + + // Perform the sort + $sortMap->each(function($mapItem) { + $model = $mapItem->model; + + $priorityChanged = intval($model->priority) !== intval($mapItem->sort); + $bookChanged = intval($model->book_id) !== intval($mapItem->book); + $chapterChanged = ($mapItem->type === 'page') && intval($model->chapter_id) !== $mapItem->parentChapter; + + if ($bookChanged) { + $this->entityRepo->changeBook($mapItem->type, $mapItem->book, $model); + } + if ($chapterChanged) { + $model->chapter_id = intval($mapItem->parentChapter); $model->save(); - $updatedModels->push($model); } - - // Store involved books to be sorted later - if (!in_array($bookId, $sortedBooks)) { - $sortedBooks[] = $bookId; + if ($priorityChanged) { + $model->priority = intval($mapItem->sort); + $model->save(); } - } + }); - // Add activity for books - foreach ($sortedBooks as $bookId) { - /** @var Book $updatedBook */ - $updatedBook = $this->entityRepo->getById('book', $bookId); - $this->entityRepo->buildJointPermissionsForBook($updatedBook); - Activity::add($updatedBook, 'book_sort', $updatedBook->id); - } + // Rebuild permissions and add activity for involved books. + $booksInvolved->each(function(Book $book) { + $this->entityRepo->buildJointPermissionsForBook($book); + Activity::add($book, 'book_sort', $book->id); + }); return redirect($book->getUrl()); } diff --git a/app/Http/Controllers/PageController.php b/app/Http/Controllers/PageController.php index 13e928465..9dc7d6401 100644 --- a/app/Http/Controllers/PageController.php +++ b/app/Http/Controllers/PageController.php @@ -145,6 +145,7 @@ class PageController extends Controller * @param string $bookSlug * @param string $pageSlug * @return Response + * @throws NotFoundException */ public function show($bookSlug, $pageSlug) { @@ -152,7 +153,7 @@ class PageController extends Controller $page = $this->entityRepo->getBySlug('page', $pageSlug, $bookSlug); } catch (NotFoundException $e) { $page = $this->entityRepo->getPageByOldSlug($pageSlug, $bookSlug); - if ($page === null) abort(404); + if ($page === null) throw $e; return redirect($page->getUrl()); } diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index fe5c7a243..2fe22f1e1 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -249,4 +249,27 @@ class UserController extends Controller 'assetCounts' => $assetCounts ]); } + + /** + * Update the user's preferred book-list display setting. + * @param $id + * @param Request $request + * @return \Illuminate\Http\RedirectResponse + */ + public function switchBookView($id, Request $request) { + $this->checkPermissionOr('users-manage', function () use ($id) { + return $this->currentUser->id == $id; + }); + + $viewType = $request->get('book_view_type'); + if (!in_array($viewType, ['grid', 'list'])) { + $viewType = 'list'; + } + + $user = $this->user->findOrFail($id); + setting()->putUser($user, 'books_view_type', $viewType); + + return redirect()->back(302, [], "/settings/users/$id"); + } + } diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index cd894de95..9d2871bbe 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -33,6 +33,14 @@ class Kernel extends HttpKernel \Illuminate\Routing\Middleware\SubstituteBindings::class, \BookStack\Http\Middleware\Localization::class ], + 'web_errors' => [ + \BookStack\Http\Middleware\EncryptCookies::class, + \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, + \Illuminate\Session\Middleware\StartSession::class, + \Illuminate\View\Middleware\ShareErrorsFromSession::class, + \BookStack\Http\Middleware\VerifyCsrfToken::class, + \BookStack\Http\Middleware\Localization::class + ], 'api' => [ 'throttle:60,1', 'bindings', diff --git a/app/Repos/EntityRepo.php b/app/Repos/EntityRepo.php index c31ddfefe..2c92e1907 100644 --- a/app/Repos/EntityRepo.php +++ b/app/Repos/EntityRepo.php @@ -113,9 +113,9 @@ class EntityRepo * @param bool $allowDrafts * @return \Illuminate\Database\Query\Builder */ - protected function entityQuery($type, $allowDrafts = false) + protected function entityQuery($type, $allowDrafts = false, $permission = 'view') { - $q = $this->permissionService->enforceEntityRestrictions($type, $this->getEntity($type), 'view'); + $q = $this->permissionService->enforceEntityRestrictions($type, $this->getEntity($type), $permission); if (strtolower($type) === 'page' && !$allowDrafts) { $q = $q->where('draft', '=', false); } @@ -196,14 +196,15 @@ class EntityRepo } /** - * Get all entities of a type limited by count unless count if false. + * Get all entities of a type with the given permission, limited by count unless count is false. * @param string $type * @param integer|bool $count + * @param string $permission * @return Collection */ - public function getAll($type, $count = 20) + public function getAll($type, $count = 20, $permission = 'view') { - $q = $this->entityQuery($type)->orderBy('name', 'asc'); + $q = $this->entityQuery($type, false, $permission)->orderBy('name', 'asc'); if ($count !== false) $q = $q->take($count); return $q->get(); } @@ -690,6 +691,7 @@ class EntityRepo preg_match_all("/{{@\s?([0-9].*?)}}/", $content, $matches); if (count($matches[0]) === 0) return $content; + $topLevelTags = ['table', 'ul', 'ol']; foreach ($matches[1] as $index => $includeId) { $splitInclude = explode('#', $includeId, 2); $pageId = intval($splitInclude[0]); @@ -714,8 +716,13 @@ class EntityRepo continue; } $innerContent = ''; - foreach ($matchingElem->childNodes as $childNode) { - $innerContent .= $doc->saveHTML($childNode); + $isTopLevel = in_array(strtolower($matchingElem->nodeName), $topLevelTags); + if ($isTopLevel) { + $innerContent .= $doc->saveHTML($matchingElem); + } else { + foreach ($matchingElem->childNodes as $childNode) { + $innerContent .= $doc->saveHTML($childNode); + } } $content = str_replace($matches[0][$index], trim($innerContent), $content); } diff --git a/app/Repos/UserRepo.php b/app/Repos/UserRepo.php index c3546a442..52ad2e47e 100644 --- a/app/Repos/UserRepo.php +++ b/app/Repos/UserRepo.php @@ -115,9 +115,9 @@ class UserRepo */ public function isOnlyAdmin(User $user) { - if (!$user->roles->pluck('name')->contains('admin')) return false; + if (!$user->hasSystemRole('admin')) return false; - $adminRole = $this->role->getRole('admin'); + $adminRole = $this->role->getSystemRole('admin'); if ($adminRole->users->count() > 1) return false; return true; } diff --git a/app/Services/SettingService.php b/app/Services/SettingService.php index 18a7c0d1b..ce87f5a4b 100644 --- a/app/Services/SettingService.php +++ b/app/Services/SettingService.php @@ -98,6 +98,9 @@ class SettingService { $cacheKey = $this->cachePrefix . $key; $this->cache->forget($cacheKey); + if (isset($this->localCache[$key])) { + unset($this->localCache[$key]); + } } /** diff --git a/app/User.php b/app/User.php index 8033557e4..fd6879ba0 100644 --- a/app/User.php +++ b/app/User.php @@ -81,7 +81,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon */ public function hasSystemRole($role) { - return $this->roles->pluck('system_name')->contains('admin'); + return $this->roles->pluck('system_name')->contains($role); } /** diff --git a/config/view.php b/config/view.php index e193ab61d..8dc2841e7 100644 --- a/config/view.php +++ b/config/view.php @@ -1,5 +1,10 @@ [ - realpath(base_path('resources/views')), - ], + 'paths' => $viewPaths, /* |-------------------------------------------------------------------------- diff --git a/readme.md b/readme.md index 1b3db4a56..77f1e8805 100644 --- a/readme.md +++ b/readme.md @@ -72,7 +72,13 @@ Some strings have colon-prefixed variables in such as `:userName`. Leave these v Feel free to create issues to request new features or to report bugs and problems. Just please follow the template given when creating the issue. -Pull requests are very welcome. If the scope of your pull request is very large it may be best to open the pull request early or create an issue for it to discuss how it will fit in to the project and plan out the merge. +### Pull Request + +Pull requests are very welcome. If the scope of your pull request is large it may be best to open the pull request early or create an issue for it to discuss how it will fit in to the project and plan out the merge. + +Pull requests should be created from the `master` branch and should be merged back into `master` once done. Please do not build from or request a merge into the `release` branch as this is only for publishing releases. + +If you are looking to alter CSS or JavaScript content please edit the source files found in `resources/assets`. Any CSS or JS files within `public` are built from these source files and therefore should not be edited directly. ## Website, Docs & Blog diff --git a/resources/assets/sass/_components.scss b/resources/assets/sass/_components.scss index 54e109067..051d1978a 100644 --- a/resources/assets/sass/_components.scss +++ b/resources/assets/sass/_components.scss @@ -549,7 +549,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { .content { padding: $-s; font-size: 0.666em; - p, ul { + p, ul, ol { font-size: $fs-m; margin: .5em 0; } diff --git a/resources/lang/en/common.php b/resources/lang/en/common.php index 7cdd7c23e..26f096327 100644 --- a/resources/lang/en/common.php +++ b/resources/lang/en/common.php @@ -49,6 +49,8 @@ return [ 'toggle_details' => 'Toggle Details', 'toggle_thumbnails' => 'Toggle Thumbnails', 'details' => 'Details', + 'grid_view' => 'Grid View', + 'list_view' => 'List View', /** * Header diff --git a/resources/lang/en/settings.php b/resources/lang/en/settings.php index f35c486ad..f3e26fb45 100755 --- a/resources/lang/en/settings.php +++ b/resources/lang/en/settings.php @@ -96,7 +96,6 @@ return [ 'users_external_auth_id' => 'External Authentication ID', 'users_password_warning' => 'Only fill the below if you would like to change your password:', 'users_system_public' => 'This user represents any guest users that visit your instance. It cannot be used to log in but is assigned automatically.', - 'users_books_view_type' => 'Preferred layout for books viewing', 'users_delete' => 'Delete User', 'users_delete_named' => 'Delete user :userName', 'users_delete_warning' => 'This will fully delete this user with the name \':userName\' from the system.', diff --git a/resources/views/books/index.blade.php b/resources/views/books/index.blade.php index d392af045..2ab819327 100644 --- a/resources/views/books/index.blade.php +++ b/resources/views/books/index.blade.php @@ -1,8 +1,21 @@ @extends('sidebar-layout') @section('toolbar') -
-