Merge branch 'master' into draw.io

This commit is contained in:
Dan Brown 2018-01-20 14:01:56 +00:00
commit 34782fbc91
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
28 changed files with 351 additions and 69 deletions

View File

@ -0,0 +1,62 @@
<?php
namespace BookStack\Console\Commands;
use BookStack\User;
use BookStack\Repos\UserRepo;
use Illuminate\Console\Command;
class DeleteUsers extends Command{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'bookstack:delete-users';
protected $user;
protected $userRepo;
/**
* The console command description.
*
* @var string
*/
protected $description = 'Delete users that are not "admin" or system users.';
public function __construct(User $user, UserRepo $userRepo)
{
$this->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...');
}
}
}

View File

@ -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

View File

@ -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());
}

View File

@ -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());
}

View File

@ -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");
}
}

View File

@ -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',

View File

@ -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);
}

View File

@ -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;
}

View File

@ -98,6 +98,9 @@ class SettingService
{
$cacheKey = $this->cachePrefix . $key;
$this->cache->forget($cacheKey);
if (isset($this->localCache[$key])) {
unset($this->localCache[$key]);
}
}
/**

View File

@ -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);
}
/**

View File

@ -1,5 +1,10 @@
<?php
$viewPaths = [realpath(base_path('resources/views'))];
if ($theme = env('APP_THEME', false)) {
array_unshift($viewPaths, base_path('themes/' . $theme));
}
return [
/*
@ -13,9 +18,7 @@ return [
|
*/
'paths' => [
realpath(base_path('resources/views')),
],
'paths' => $viewPaths,
/*
|--------------------------------------------------------------------------

View File

@ -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

View File

@ -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;
}

View File

@ -49,6 +49,8 @@ return [
'toggle_details' => 'Toggle Details',
'toggle_thumbnails' => 'Toggle Thumbnails',
'details' => 'Details',
'grid_view' => 'Grid View',
'list_view' => 'List View',
/**
* Header

View File

@ -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.',

View File

@ -1,8 +1,21 @@
@extends('sidebar-layout')
@section('toolbar')
<div class="col-xs-1"></div>
<div class="col-xs-11 faded">
<div class="col-xs-6">
<div class="action-buttons text-left">
<form action="{{ baseUrl("/settings/users/{$currentUser->id}/switch-book-view") }}" method="POST" class="inline">
{!! csrf_field() !!}
{!! method_field('PATCH') !!}
<input type="hidden" value="{{ $booksViewType === 'list'? 'grid' : 'list' }}" name="book_view_type">
@if ($booksViewType === 'list')
<button type="submit" class="text-pos text-button"><i class="zmdi zmdi-view-module"></i>{{ trans('common.grid_view') }}</button>
@else
<button type="submit" class="text-pos text-button"><i class="zmdi zmdi-view-list"></i>{{ trans('common.list_view') }}</button>
@endif
</form>
</div>
</div>
<div class="col-xs-6 faded">
<div class="action-buttons">
@if($currentUser->can('book-create-all'))
<a href="{{ baseUrl("/books/create") }}" class="text-pos text-button"><i class="zmdi zmdi-plus"></i>{{ trans('entities.books_create') }}</a>
@ -52,7 +65,7 @@
<hr>
@endforeach
{!! $books->render() !!}
@else
@else
<div class="row auto-clear">
@foreach($books as $key => $book)
@include('books/grid-item', ['book' => $book])

View File

@ -1,8 +1,6 @@
@extends('simple-layout')
@section('content')
<div class="container">
<p>&nbsp;</p>
@ -16,7 +14,6 @@
</div>
@if (setting('app-public') || !user()->isDefault())
<div class="row">
<div class="col-md-4">
<div class="card">

View File

@ -6,7 +6,7 @@
<div class="card">
<h3 class="text-muted">{{ trans('errors.error_occurred') }}</h3>
<div class="body">
<h5>{{ $message }}</h5>
<h5>{{ $message or 'An unknown error occurred' }}</h5>
<p><a href="{{ baseUrl('/') }}" class="button outline">{{ trans('errors.return_home') }}</a></p>
</div>
</div>

View File

@ -43,13 +43,6 @@
@endforeach
</select>
</div>
<div class="form-group">
<label for="books-view-type">{{ trans('settings.users_books_view_type') }}</label>
<select name="setting[books_view_type]" id="books-view-type">
<option @if(setting()->getUser($user, 'books_view_type', 'list') === 'list') selected @endif value="list">List</option>
<option @if(setting()->getUser($user, 'books_view_type', 'list') === 'grid') selected @endif value="grid">Grid</option>
</select>
</div>
</div>
</div>
<div class="form-group text-right">

View File

@ -149,6 +149,7 @@ Route::group(['middleware' => 'auth'], function () {
Route::get('/users', 'UserController@index');
Route::get('/users/create', 'UserController@create');
Route::get('/users/{id}/delete', 'UserController@delete');
Route::patch('/users/{id}/switch-book-view', 'UserController@switchBookView');
Route::post('/users/create', 'UserController@store');
Route::get('/users/{id}', 'UserController@edit');
Route::put('/users/{id}', 'UserController@update');

View File

@ -3,7 +3,6 @@
use BookStack\Entity;
use BookStack\Role;
use BookStack\Services\PermissionService;
use BookStack\User;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Laravel\BrowserKitTesting\TestCase;

View File

@ -82,6 +82,27 @@ class EntityTest extends BrowserKitTest
->see($firstChapter->name);
}
public function test_toggle_book_view()
{
$editor = $this->getEditor();
setting()->putUser($editor, 'books_view_type', 'grid');
$this->actingAs($editor)
->visit('/books')
->pageHasElement('.featured-image-container')
->submitForm('List View')
// Check redirection.
->seePageIs('/books')
->pageNotHasElement('.featured-image-container');
$this->actingAs($editor)
->visit('/books')
->submitForm('Grid View')
->seePageIs('/books')
->pageHasElement('.featured-image-container');
}
public function pageCreation($chapter)
{
$page = factory(Page::class)->make([
@ -155,7 +176,7 @@ class EntityTest extends BrowserKitTest
->type($book->name, '#name')
->type($book->description, '#description')
->press('Save Book');
$expectedPattern = '/\/books\/my-first-book-[0-9a-zA-Z]{3}/';
$this->assertRegExp($expectedPattern, $this->currentUri, "Did not land on expected page [$expectedPattern].\n");

View File

@ -9,7 +9,7 @@ class PageContentTest extends TestCase
public function test_page_includes()
{
$page = Page::first();
$secondPage = Page::all()->get(2);
$secondPage = Page::where('id', '!=', $page->id)->first();
$secondPage->html = "<p id='section1'>Hello, This is a test</p><p id='section2'>This is a second block of content</p>";
$secondPage->save();
@ -38,7 +38,7 @@ class PageContentTest extends TestCase
public function test_saving_page_with_includes()
{
$page = Page::first();
$secondPage = Page::all()->get(2);
$secondPage = Page::where('id', '!=', $page->id)->first();
$this->asEditor();
$page->html = "<p>{{@$secondPage->id}}</p>";
@ -50,6 +50,23 @@ class PageContentTest extends TestCase
$this->assertContains("{{@$secondPage->id}}", $page->html);
}
public function test_page_includes_do_not_break_tables()
{
$page = Page::first();
$secondPage = Page::where('id', '!=', $page->id)->first();
$content = '<table id="table"><tbody><tr><td>test</td></tr></tbody></table>';
$secondPage->html = $content;
$secondPage->save();
$page->html = "{{@{$secondPage->id}#table}}";
$page->save();
$this->asEditor();
$pageResp = $this->get($page->getUrl());
$pageResp->assertSee($content);
}
public function test_page_revision_views_viewable()
{
$this->asEditor();

18
tests/ErrorTest.php Normal file
View File

@ -0,0 +1,18 @@
<?php namespace Tests;
class ErrorTest extends TestCase
{
public function test_404_page_does_not_show_login()
{
// Due to middleware being handled differently this will not fail
// if our custom, middleware-loaded handler fails but this is here
// as a reminder and as a general check in the event of other issues.
$editor = $this->getEditor();
$this->actingAs($editor);
$notFound = $this->get('/fgfdngldfnotfound');
$notFound->assertStatus(404);
$notFound->assertDontSeeText('Log in');
$notFound->assertSeeText($editor->getShortName(9));
}
}

View File

@ -3,6 +3,7 @@
use BookStack\Book;
use BookStack\Services\PermissionService;
use BookStack\User;
use BookStack\Repos\EntityRepo;
class RestrictionsTest extends BrowserKitTest
{
@ -554,4 +555,70 @@ class RestrictionsTest extends BrowserKitTest
$this->dontSee(substr($bookChapter->name, 0, 15));
}
public function test_book_sort_view_permission()
{
$firstBook = Book::first();
$secondBook = Book::find(2);
$thirdBook = Book::find(3);
$this->setEntityRestrictions($firstBook, ['view', 'update']);
$this->setEntityRestrictions($secondBook, ['view']);
$this->setEntityRestrictions($thirdBook, ['view', 'update']);
// Test sort page visibility
$this->actingAs($this->user)->visit($secondBook->getUrl() . '/sort')
->see('You do not have permission')
->seePageIs('/');
// Check sort page on first book
$this->actingAs($this->user)->visit($firstBook->getUrl() . '/sort')
->see($thirdBook->name)
->dontSee($secondBook->name);
}
public function test_book_sort_permission() {
$firstBook = Book::first();
$secondBook = Book::find(2);
$this->setEntityRestrictions($firstBook, ['view', 'update']);
$this->setEntityRestrictions($secondBook, ['view']);
$firstBookChapter = $this->app[EntityRepo::class]->createFromInput('chapter',
['name' => 'first book chapter'], $firstBook);
$secondBookChapter = $this->app[EntityRepo::class]->createFromInput('chapter',
['name' => 'second book chapter'], $secondBook);
// Create request data
$reqData = [
[
'id' => $firstBookChapter->id,
'sort' => 0,
'parentChapter' => false,
'type' => 'chapter',
'book' => $secondBook->id
]
];
// Move chapter from first book to a second book
$this->actingAs($this->user)->put($firstBook->getUrl() . '/sort', ['sort-tree' => json_encode($reqData)])
->followRedirects()
->see('You do not have permission')
->seePageIs('/');
$reqData = [
[
'id' => $secondBookChapter->id,
'sort' => 0,
'parentChapter' => false,
'type' => 'chapter',
'book' => $firstBook->id
]
];
// Move chapter from second book to first book
$this->actingAs($this->user)->put($firstBook->getUrl() . '/sort', ['sort-tree' => json_encode($reqData)])
->followRedirects()
->see('You do not have permission')
->seePageIs('/');
}
}

View File

@ -103,7 +103,7 @@ class UserProfileTest extends BrowserKitTest
$this->actingAs($editor)
->visit('/books')
->pageNotHasElement('.featured-image-container')
->pageHasElement('.entity-list-item');
->pageHasElement('.content .entity-list-item');
}
public function test_books_view_is_grid()

2
themes/.gitignore vendored Executable file
View File

@ -0,0 +1,2 @@
*
!.gitignore

View File

@ -1 +1 @@
v0.18-dev
v0.20-dev