Compare commits

...

34 Commits

Author SHA1 Message Date
Dan Brown
2cc36787f5 Updated assets for release 0.9.2 2016-04-15 19:57:02 +01:00
Dan Brown
448ac61b48 Merge branch 'master' into release 2016-04-15 19:52:59 +01:00
Dan Brown
8933179017 Prevented drafts from showing up in a book sort
Added tests to cover regresssion.
In reference to #100.
2016-04-15 19:51:27 +01:00
Dan Brown
792e786880 Added lists plugin to TinyMCE, Fixed strange nested list behaviour
Fixes #97
2016-04-14 22:34:33 +01:00
Dan Brown
753f6394f7 Merge branch 'master' into release 2016-04-12 20:09:14 +01:00
Dan Brown
0a8030306e Fixed redis config not being set if not using redis 2016-04-12 20:08:49 +01:00
Dan Brown
b1faf65934 Updated assets for release 0.9.0 2016-04-09 15:49:02 +01:00
Dan Brown
09f478bd74 Merge branch 'master' into release 2016-04-09 15:47:14 +01:00
Dan Brown
d6bad01130 Fixed draft time display, Cleaned up some code
Cleaned up some comment spacing in book controller and refactored some of the view service functions.
2016-04-09 14:26:42 +01:00
Dan Brown
a33deed26b Added user listing to role edit screen 2016-04-09 13:57:38 +01:00
Dan Brown
2e7345f4f0 Localised draft save time display
In reference to #83.
2016-04-09 13:36:32 +01:00
Dan Brown
1a7de4c2d6 Fixed and cleaned some permisison/role based components 2016-04-09 12:37:58 +01:00
Dan Brown
66ba773367 Updated composer dependancies 2016-04-09 10:56:10 +01:00
Dan Brown
afc3583be8 Made new pages in chapters have a better inital priority 2016-04-07 19:03:00 +01:00
Dan Brown
cbff2c6035 Added uploaded to book/page filters & search in image manager
Also refactored tab styles which affected the settings area.

Closes #41
2016-04-03 14:59:54 +01:00
Dan Brown
8e614ecb6e Updated tests to match recent email confirmation changes 2016-04-03 12:34:10 +01:00
Dan Brown
d099885fd1 Fixed some label casing and fixed incorrect notifications
Fixed the book & chapter permission update notification stating the 'page permissions' have been updated.
2016-04-03 12:19:44 +01:00
Dan Brown
2bb8c3d914 Made email confirmations work with LDAP auth
The email_confirmed user field now actually indicates if an email is confirmed rather than defaulting to true if not checked.
 This ensures toggleing the 'Require email confirmation' setting actually makes all currently unconfirmed users confirm thier emails.
2016-04-03 12:16:54 +01:00
Dan Brown
4caa61fe96 Added a friendlier error for LDAP new user mismatches 2016-04-03 11:16:49 +01:00
Dan Brown
c5960f9b6a Added Redis cache/session support 2016-04-03 11:00:14 +01:00
Dan Brown
412eed19c3 Removed old input checks on entity permission checkboxes
Old input check potentialy causing issues (#89) and is not needed on the pages which it shows.
2016-04-03 10:23:16 +01:00
Dan Brown
e9b596d3bc Merge bugfixes from branch 'v0.8' 2016-03-30 21:49:25 +01:00
Dan Brown
a0497feddd Updated assets for release 0.8.2 2016-03-30 21:44:30 +01:00
Dan Brown
789693bde9 Merge branch 'v0.8' into release 2016-03-30 21:32:46 +01:00
Dan Brown
8b109bac13 Trimmed long names in header
Fixes #87
2016-03-30 21:28:38 +01:00
Dan Brown
097d9c9f3c Updated entity restrictions to allow permissions, Not just restrict
Also changed wording from 'Restrictions' to 'Permissions' to keep things more familiar and to better reflect what they do.

Referenced in issue #89.
2016-03-30 20:15:44 +01:00
Dan Brown
e7d8a041a8 Merge pull request #84 from ssddanbrown/markdown_editor
Initial implementation of a markdown editor. Closes #57.
2016-03-29 20:18:11 +01:00
Dan Brown
dc2978824e Added basic system tests for markdown editor, Added extra test helpers
Added test helpers for checking if an element exists / does not exist on a page.
Also fixed markdown editor bugs found while creating tests.
2016-03-29 20:13:23 +01:00
Dan Brown
e1994ef2cf Added editor control in admin settings & Fixed some markdown editor bugs
Also updated the setting system with a more sane approach to handling default values. (Now done via the setting-defaults config file)
2016-03-29 19:26:13 +01:00
Dan Brown
efb49019d4 Integrated the markdown editor with the image manager 2016-03-29 18:25:54 +01:00
Dan Brown
ef874712bb Cut readme down and added useful links
Remove a lot of the instructions/config info since much of it is now on the BookStack docs site.
2016-03-25 15:17:04 +00:00
Dan Brown
26965fa08f Added a markdown editor 2016-03-25 14:41:15 +00:00
Dan Brown
1fe933e4ea Merge branch 'master' into release 2016-03-13 15:38:06 +00:00
Dan Brown
491f73e0cd Fixed bug causing permission error on save and fixed non-gallery image save 2016-03-13 15:37:46 +00:00
72 changed files with 1364 additions and 638 deletions

View File

@@ -0,0 +1,4 @@
<?php namespace BookStack\Exceptions;
class AuthException extends PrettyException {}

View File

@@ -2,6 +2,8 @@
namespace BookStack\Http\Controllers\Auth;
use BookStack\Exceptions\AuthException;
use BookStack\Exceptions\PrettyException;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Http\Request;
use BookStack\Exceptions\SocialSignInException;
@@ -115,6 +117,7 @@ class AuthController extends Controller
* @param Request $request
* @param Authenticatable $user
* @return \Illuminate\Http\RedirectResponse
* @throws AuthException
*/
protected function authenticated(Request $request, Authenticatable $user)
{
@@ -132,6 +135,13 @@ class AuthController extends Controller
}
if (!$user->exists) {
// Check for users with same email already
$alreadyUser = $user->newQuery()->where('email', '=', $user->email)->count() > 0;
if ($alreadyUser) {
throw new AuthException('A user with the email ' . $user->email . ' already exists but with different credentials.');
}
$user->save();
$this->userRepo->attachDefaultRole($user);
auth()->login($user);
@@ -184,14 +194,11 @@ class AuthController extends Controller
}
if (setting('registration-confirmation') || setting('registration-restrict')) {
$newUser->email_confirmed = false;
$newUser->save();
$this->emailConfirmationService->sendConfirmation($newUser);
return redirect('/register/confirm');
}
$newUser->email_confirmed = true;
auth()->login($newUser);
session()->flash('success', 'Thanks for signing up! You are now registered and signed in.');
return redirect($this->redirectPath());

View 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 Illuminate\Support\Str;
use BookStack\Http\Requests;
use BookStack\Repos\BookRepo;
use BookStack\Repos\ChapterRepo;
@@ -40,7 +36,6 @@ class BookController extends Controller
/**
* Display a listing of the book.
*
* @return Response
*/
public function index()
@@ -54,7 +49,6 @@ class BookController extends Controller
/**
* Show the form for creating a new book.
*
* @return Response
*/
public function create()
@@ -88,7 +82,6 @@ class BookController extends Controller
/**
* Display the specified book.
*
* @param $slug
* @return Response
*/
@@ -103,7 +96,6 @@ class BookController extends Controller
/**
* Show the form for editing the specified book.
*
* @param $slug
* @return Response
*/
@@ -117,7 +109,6 @@ class BookController extends Controller
/**
* Update the specified book in storage.
*
* @param Request $request
* @param $slug
* @return Response
@@ -160,7 +151,7 @@ class BookController extends Controller
{
$book = $this->bookRepo->getBySlug($bookSlug);
$this->checkOwnablePermission('book-update', $book);
$bookChildren = $this->bookRepo->getChildren($book);
$bookChildren = $this->bookRepo->getChildren($book, true);
$books = $this->bookRepo->getAll(false);
$this->setPageTitle('Sort Book ' . $book->getShortName());
return view('books/sort', ['book' => $book, 'current' => $book, 'books' => $books, 'bookChildren' => $bookChildren]);
@@ -267,7 +258,7 @@ class BookController extends Controller
$book = $this->bookRepo->getBySlug($bookSlug);
$this->checkOwnablePermission('restrictions-manage', $book);
$this->bookRepo->updateRestrictionsFromRequest($request, $book);
session()->flash('success', 'Page Restrictions Updated');
session()->flash('success', 'Book Restrictions Updated');
return redirect($book->getUrl());
}
}

View File

@@ -187,7 +187,7 @@ class ChapterController extends Controller
$chapter = $this->chapterRepo->getBySlug($chapterSlug, $book->id);
$this->checkOwnablePermission('restrictions-manage', $chapter);
$this->chapterRepo->updateRestrictionsFromRequest($request, $chapter);
session()->flash('success', 'Page Restrictions Updated');
session()->flash('success', 'Chapter Restrictions Updated');
return redirect($chapter->getUrl());
}
}

View File

@@ -1,14 +1,9 @@
<?php
namespace BookStack\Http\Controllers;
<?php namespace BookStack\Http\Controllers;
use BookStack\Exceptions\ImageUploadException;
use BookStack\Repos\ImageRepo;
use Illuminate\Filesystem\Filesystem as File;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Intervention\Image\Facades\Image as ImageTool;
use Illuminate\Support\Facades\DB;
use BookStack\Image;
use BookStack\Repos\PageRepo;
@@ -44,6 +39,24 @@ class ImageController extends Controller
return response()->json($imgData);
}
/**
* Search through images within a particular type.
* @param $type
* @param int $page
* @param Request $request
* @return mixed
*/
public function searchByType($type, $page = 0, Request $request)
{
$this->validate($request, [
'term' => 'required|string'
]);
$searchTerm = $request->get('term');
$imgData = $this->imageRepo->searchPaginatedByType($type, $page,24, $searchTerm);
return response()->json($imgData);
}
/**
* Get all images for a user.
* @param int $page
@@ -55,6 +68,27 @@ class ImageController extends Controller
return response()->json($imgData);
}
/**
* Get gallery images with a specific filter such as book or page
* @param $filter
* @param int $page
* @param Request $request
*/
public function getGalleryFiltered($filter, $page = 0, Request $request)
{
$this->validate($request, [
'page_id' => 'required|integer'
]);
$validFilters = collect(['page', 'book']);
if (!$validFilters->contains($filter)) return response('Invalid filter', 500);
$pageId = $request->get('page_id');
$imgData = $this->imageRepo->getGalleryFiltered($page, 24, strtolower($filter), $pageId);
return response()->json($imgData);
}
/**
* Handles image uploads for use on pages.
* @param string $type
@@ -65,8 +99,7 @@ class ImageController extends Controller
{
$this->checkPermission('image-create-all');
$this->validate($request, [
'file' => 'image|mimes:jpeg,gif,png',
'uploaded_to' => 'integer|exists:pages,id'
'file' => 'image|mimes:jpeg,gif,png'
]);
$imageUpload = $request->file('file');

View File

@@ -4,6 +4,7 @@ use Activity;
use BookStack\Exceptions\NotFoundException;
use BookStack\Repos\UserRepo;
use BookStack\Services\ExportService;
use Carbon\Carbon;
use Illuminate\Http\Request;
use BookStack\Http\Requests;
use BookStack\Repos\BookRepo;
@@ -88,7 +89,6 @@ class PageController extends Controller
$input = $request->all();
$book = $this->bookRepo->getBySlug($bookSlug);
$input['priority'] = $this->bookRepo->getNewPriority($book);
$draftPage = $this->pageRepo->getById($pageId, true);
@@ -96,6 +96,12 @@ class PageController extends Controller
$parent = $chapterId !== 0 ? $this->chapterRepo->getById($chapterId) : $book;
$this->checkOwnablePermission('page-create', $parent);
if ($parent->isA('chapter')) {
$input['priority'] = $this->chapterRepo->getNewPriority($parent);
} else {
$input['priority'] = $this->bookRepo->getNewPriority($parent);
}
$page = $this->pageRepo->publishDraft($draftPage, $input);
Activity::add($page, 'page_create', $book->id);
@@ -164,6 +170,7 @@ class PageController extends Controller
$draft = $this->pageRepo->getUserPageDraft($page, $this->currentUser->id);
$page->name = $draft->name;
$page->html = $draft->html;
$page->markdown = $draft->markdown;
$page->isDraft = true;
$warnings [] = $this->pageRepo->getUserPageDraftMessage($draft);
}
@@ -204,12 +211,18 @@ class PageController extends Controller
$page = $this->pageRepo->getById($pageId, true);
$this->checkOwnablePermission('page-update', $page);
if ($page->draft) {
$draft = $this->pageRepo->updateDraftPage($page, $request->only(['name', 'html']));
$draft = $this->pageRepo->updateDraftPage($page, $request->only(['name', 'html', 'markdown']));
} else {
$draft = $this->pageRepo->saveUpdateDraft($page, $request->only(['name', 'html']));
$draft = $this->pageRepo->saveUpdateDraft($page, $request->only(['name', 'html', 'markdown']));
}
$updateTime = $draft->updated_at->format('H:i');
return response()->json(['status' => 'success', 'message' => 'Draft saved at ' . $updateTime]);
$updateTime = $draft->updated_at->timestamp;
$utcUpdateTimestamp = $updateTime + Carbon::createFromTimestamp(0)->offset;
return response()->json([
'status' => 'success',
'message' => 'Draft saved at ',
'timestamp' => $utcUpdateTimestamp
]);
}
/**

View File

@@ -164,7 +164,9 @@ class UserController extends Controller
$user->save();
session()->flash('success', 'User successfully updated');
return redirect('/settings/users');
$redirectUrl = userCan('users-manage') ? '/settings/users' : '/settings/users/' . $user->id;
return redirect($redirectUrl);
}
/**

View File

@@ -11,14 +11,12 @@ class Authenticate
{
/**
* The Guard implementation.
*
* @var Guard
*/
protected $auth;
/**
* Create a new filter instance.
*
* @param Guard $auth
*/
public function __construct(Guard $auth)
@@ -28,14 +26,13 @@ class Authenticate
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle($request, Closure $next)
{
if(auth()->check() && auth()->user()->email_confirmed == false) {
if ($this->auth->check() && setting('registration-confirmation') && !$this->auth->user()->email_confirmed) {
return redirect()->guest('/register/confirm/awaiting');
}

View File

@@ -19,8 +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('/{bookSlug}/permissions', 'BookController@showRestrict');
Route::put('/{bookSlug}/permissions', 'BookController@restrict');
Route::get('/{slug}/delete', 'BookController@showDelete');
Route::get('/{bookSlug}/sort', 'BookController@sort');
Route::put('/{bookSlug}/sort', 'BookController@saveSort');
@@ -36,8 +36,8 @@ Route::group(['middleware' => 'auth'], function () {
Route::get('/{bookSlug}/page/{pageSlug}/edit', 'PageController@edit');
Route::get('/{bookSlug}/page/{pageSlug}/delete', 'PageController@showDelete');
Route::get('/{bookSlug}/draft/{pageId}/delete', 'PageController@showDeleteDraft');
Route::get('/{bookSlug}/page/{pageSlug}/restrict', 'PageController@showRestrict');
Route::put('/{bookSlug}/page/{pageSlug}/restrict', 'PageController@restrict');
Route::get('/{bookSlug}/page/{pageSlug}/permissions', 'PageController@showRestrict');
Route::put('/{bookSlug}/page/{pageSlug}/permissions', 'PageController@restrict');
Route::put('/{bookSlug}/page/{pageSlug}', 'PageController@update');
Route::delete('/{bookSlug}/page/{pageSlug}', 'PageController@destroy');
Route::delete('/{bookSlug}/draft/{pageId}', 'PageController@destroyDraft');
@@ -54,8 +54,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}/permissions', 'ChapterController@showRestrict');
Route::put('/{bookSlug}/chapter/{chapterSlug}/permissions', 'ChapterController@restrict');
Route::get('/{bookSlug}/chapter/{chapterSlug}/delete', 'ChapterController@showDelete');
Route::delete('/{bookSlug}/chapter/{chapterSlug}', 'ChapterController@destroy');
@@ -75,6 +75,8 @@ Route::group(['middleware' => 'auth'], function () {
Route::post('/{type}/upload', 'ImageController@uploadByType');
Route::get('/{type}/all', 'ImageController@getAllByType');
Route::get('/{type}/all/{page}', 'ImageController@getAllByType');
Route::get('/{type}/search/{page}', 'ImageController@searchByType');
Route::get('/gallery/{filter}/{page}', 'ImageController@getGalleryFiltered');
Route::delete('/{imageId}', 'ImageController@destroy');
});

View File

@@ -6,7 +6,7 @@ use Illuminate\Database\Eloquent\Model;
class Page extends Entity
{
protected $fillable = ['name', 'html', 'priority'];
protected $fillable = ['name', 'html', 'priority', 'markdown'];
protected $simpleAttributes = ['name', 'id', 'slug'];

View File

@@ -4,7 +4,7 @@ use Illuminate\Database\Eloquent\Model;
class PageRevision extends Model
{
protected $fillable = ['name', 'html', 'text'];
protected $fillable = ['name', 'html', 'text', 'markdown'];
/**
* Get the user that created the page revision

View File

@@ -11,7 +11,7 @@ class Permission extends Model
*/
public function roles()
{
return $this->belongsToMany('BookStack\Permissions');
return $this->belongsToMany('BookStack\Role');
}
/**

View File

@@ -115,7 +115,7 @@ class LdapUserProvider implements UserProvider
$model->name = $userDetails['name'];
$model->external_auth_id = $userDetails['uid'];
$model->email = $userDetails['email'];
$model->email_confirmed = true;
$model->email_confirmed = false;
return $model;
}

View File

@@ -198,16 +198,23 @@ class BookRepo extends EntityRepo
* Returns a sorted collection of Pages and Chapters.
* Loads the bookslug onto child elements to prevent access database access for getting the slug.
* @param Book $book
* @param bool $filterDrafts
* @return mixed
*/
public function getChildren(Book $book)
public function getChildren(Book $book, $filterDrafts = false)
{
$pageQuery = $book->pages()->where('chapter_id', '=', 0);
$pageQuery = $this->restrictionService->enforcePageRestrictions($pageQuery, 'view');
if ($filterDrafts) {
$pageQuery = $pageQuery->where('draft', '=', false);
}
$pages = $pageQuery->get();
$chapterQuery = $book->chapters()->with(['pages' => function($query) {
$chapterQuery = $book->chapters()->with(['pages' => function($query) use ($filterDrafts) {
$this->restrictionService->enforcePageRestrictions($query, 'view');
if ($filterDrafts) $query->where('draft', '=', false);
}]);
$chapterQuery = $this->restrictionService->enforceChapterRestrictions($chapterQuery, 'view');
$chapters = $chapterQuery->get();

View File

@@ -136,6 +136,18 @@ class ChapterRepo extends EntityRepo
return $slug;
}
/**
* Get a new priority value for a new page to be added
* to the given chapter.
* @param Chapter $chapter
* @return int
*/
public function getNewPriority(Chapter $chapter)
{
$lastPage = $chapter->pages->last();
return $lastPage !== null ? $lastPage->priority + 1 : 0;
}
/**
* Get chapters by the given search term.
* @param string $term

View File

@@ -84,7 +84,7 @@ class EntityRepo
if ($additionalQuery !== false && is_callable($additionalQuery)) {
$additionalQuery($query);
}
return $query->skip($page * $count)->take($count)->get();
return $query->with('book')->skip($page * $count)->take($count)->get();
}
/**
@@ -114,7 +114,7 @@ class EntityRepo
{
return $this->restrictionService->enforcePageRestrictions($this->page)
->where('draft', '=', false)
->orderBy('updated_at', 'desc')->skip($page * $count)->take($count)->get();
->orderBy('updated_at', 'desc')->with('book')->skip($page * $count)->take($count)->get();
}
/**

View File

@@ -2,6 +2,7 @@
use BookStack\Image;
use BookStack\Page;
use BookStack\Services\ImageService;
use BookStack\Services\RestrictionService;
use Setting;
@@ -13,18 +14,21 @@ class ImageRepo
protected $image;
protected $imageService;
protected $restictionService;
protected $page;
/**
* ImageRepo constructor.
* @param Image $image
* @param ImageService $imageService
* @param RestrictionService $restrictionService
* @param Page $page
*/
public function __construct(Image $image, ImageService $imageService, RestrictionService $restrictionService)
public function __construct(Image $image, ImageService $imageService, RestrictionService $restrictionService, Page $page)
{
$this->image = $image;
$this->imageService = $imageService;
$this->restictionService = $restrictionService;
$this->page = $page;
}
@@ -38,6 +42,31 @@ class ImageRepo
return $this->image->findOrFail($id);
}
/**
* Execute a paginated query, returning in a standard format.
* Also runs the query through the restriction system.
* @param $query
* @param int $page
* @param int $pageSize
* @return array
*/
private function returnPaginated($query, $page = 0, $pageSize = 24)
{
$images = $this->restictionService->filterRelatedPages($query, 'images', 'uploaded_to');
$images = $images->orderBy('created_at', 'desc')->skip($pageSize * $page)->take($pageSize + 1)->get();
$hasMore = count($images) > $pageSize;
$returnImages = $images->take(24);
$returnImages->each(function ($image) {
$this->loadThumbs($image);
});
return [
'images' => $returnImages,
'hasMore' => $hasMore
];
}
/**
* Gets a load images paginated, filtered by image type.
* @param string $type
@@ -54,19 +83,46 @@ class ImageRepo
$images = $images->where('created_by', '=', $userFilter);
}
$images = $this->restictionService->filterRelatedPages($images, 'images', 'uploaded_to');
$images = $images->orderBy('created_at', 'desc')->skip($pageSize * $page)->take($pageSize + 1)->get();
$hasMore = count($images) > $pageSize;
return $this->returnPaginated($images, $page, $pageSize);
}
$returnImages = $images->take(24);
$returnImages->each(function ($image) {
$this->loadThumbs($image);
});
/**
* Search for images by query, of a particular type.
* @param string $type
* @param int $page
* @param int $pageSize
* @param string $searchTerm
* @return array
*/
public function searchPaginatedByType($type, $page = 0, $pageSize = 24, $searchTerm)
{
$images = $this->image->where('type', '=', strtolower($type))->where('name', 'LIKE', '%' . $searchTerm . '%');
return $this->returnPaginated($images, $page, $pageSize);
}
return [
'images' => $returnImages,
'hasMore' => $hasMore
];
/**
* Get gallery images with a particular filter criteria such as
* being within the current book or page.
* @param int $pagination
* @param int $pageSize
* @param $filter
* @param $pageId
* @return array
*/
public function getGalleryFiltered($pagination = 0, $pageSize = 24, $filter, $pageId)
{
$images = $this->image->where('type', '=', 'gallery');
$page = $this->page->findOrFail($pageId);
if ($filter === 'page') {
$images = $images->where('uploaded_to', '=', $page->id);
} elseif ($filter === 'book') {
$validPageIds = $page->book->pages->pluck('id')->toArray();
$images = $images->whereIn('uploaded_to', $validPageIds);
}
return $this->returnPaginated($images, $pagination, $pageSize);
}
/**

View File

@@ -154,10 +154,10 @@ class PageRepo extends EntityRepo
/**
* Get a new draft page instance.
* @param Book $book
* @param Chapter|null $chapter
* @param Chapter|bool $chapter
* @return static
*/
public function getDraftPage(Book $book, $chapter)
public function getDraftPage(Book $book, $chapter = false)
{
$page = $this->page->newInstance();
$page->name = 'New Page';
@@ -312,6 +312,7 @@ class PageRepo extends EntityRepo
$page->fill($input);
$page->html = $this->formatHtml($input['html']);
$page->text = strip_tags($page->html);
if (setting('app-editor') !== 'markdown') $page->markdown = '';
$page->updated_by = $userId;
$page->save();
@@ -348,6 +349,7 @@ class PageRepo extends EntityRepo
public function saveRevision(Page $page)
{
$revision = $this->pageRevision->fill($page->toArray());
if (setting('app-editor') !== 'markdown') $revision->markdown = '';
$revision->page_id = $page->id;
$revision->slug = $page->slug;
$revision->book_slug = $page->book->slug;
@@ -386,6 +388,8 @@ class PageRepo extends EntityRepo
}
$draft->fill($data);
if (setting('app-editor') !== 'markdown') $draft->markdown = '';
$draft->save();
return $draft;
}

View File

@@ -106,7 +106,8 @@ class UserRepo
return $this->user->forceCreate([
'name' => $data['name'],
'email' => $data['email'],
'password' => bcrypt($data['password'])
'password' => bcrypt($data['password']),
'email_confirmed' => false
]);
}

View File

@@ -43,6 +43,15 @@ class Role extends Model
$this->permissions()->attach($permission->id);
}
/**
* Detach a single permission from this role.
* @param Permission $permission
*/
public function detachPermission(Permission $permission)
{
$this->permissions()->detach($permission->id);
}
/**
* Get the role object for the specified role.
* @param $roleName

View File

@@ -41,6 +41,25 @@ class RestrictionService
return false;
}
/**
* Check if an entity has restrictions set on itself or its
* parent tree.
* @param Entity $entity
* @param $action
* @return bool|mixed
*/
public function checkIfRestrictionsSet(Entity $entity, $action)
{
$this->currentAction = $action;
if ($entity->isA('page')) {
return $entity->restricted || ($entity->chapter && $entity->chapter->restricted) || $entity->book->restricted;
} elseif ($entity->isA('chapter')) {
return $entity->restricted || $entity->book->restricted;
} elseif ($entity->isA('book')) {
return $entity->restricted;
}
}
/**
* Add restrictions for a page query
* @param $query

View File

@@ -44,28 +44,39 @@ class SettingService
/**
* Gets a setting value from the cache or database.
* Looks at the system defaults if not cached or in database.
* @param $key
* @param $default
* @return mixed
*/
protected function getValueFromStore($key, $default)
{
// Check for an overriding value
$overrideValue = $this->getOverrideValue($key);
if ($overrideValue !== null) return $overrideValue;
// Check the cache
$cacheKey = $this->cachePrefix . $key;
if ($this->cache->has($cacheKey)) {
return $this->cache->get($cacheKey);
}
// Check the database
$settingObject = $this->getSettingObjectByKey($key);
if ($settingObject !== null) {
$value = $settingObject->value;
$this->cache->forever($cacheKey, $value);
return $value;
}
// Check the defaults set in the app config.
$configPrefix = 'setting-defaults.' . $key;
if (config()->has($configPrefix)) {
$value = config($configPrefix);
$this->cache->forever($cacheKey, $value);
return $value;
}
return $default;
}

View File

@@ -1,6 +1,5 @@
<?php namespace BookStack\Services;
use BookStack\Entity;
use BookStack\View;
@@ -47,7 +46,6 @@ class ViewService
return 1;
}
/**
* Get the entities with the most views.
* @param int $count
@@ -58,17 +56,13 @@ class ViewService
{
$skipCount = $count * $page;
$query = $this->restrictionService->filterRestrictedEntityRelations($this->view, 'views', 'viewable_id', 'viewable_type')
->select('id', 'viewable_id', 'viewable_type', \DB::raw('SUM(views) as view_count'))
->select('*', '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));
$views = $query->with('viewable')->skip($skipCount)->take($count)->get();
$viewedEntities = $views->map(function ($item) {
return $item->viewable()->getResults();
});
return $viewedEntities;
return $query->with('viewable')->skip($skipCount)->take($count)->get()->pluck('viewable');
}
/**
@@ -81,21 +75,18 @@ class ViewService
public function getUserRecentlyViewed($count = 10, $page = 0, $filterModel = false)
{
if ($this->user === null) return collect();
$skipCount = $count * $page;
$query = $this->restrictionService
->filterRestrictedEntityRelations($this->view, 'views', 'viewable_id', 'viewable_type');
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;
});
return $viewedEntities;
$viewables = $query->with('viewable')->orderBy('updated_at', 'desc')
->skip($count * $page)->take($count)->get()->pluck('viewable');
return $viewables;
}
/**
* Reset all view counts by deleting all views.
*/
@@ -104,5 +95,4 @@ class ViewService
$this->view->truncate();
}
}

View File

@@ -162,4 +162,19 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
{
return '/settings/users/' . $this->id;
}
/**
* Get a shortened version of the user's name.
* @param int $chars
* @return string
*/
public function getShortName($chars = 8)
{
if (strlen($this->name) <= $chars) return $this->name;
$splitName = explode(' ', $this->name);
if (strlen($splitName[0]) <= $chars) return $splitName[0];
return '';
}
}

View File

@@ -52,12 +52,13 @@ function userCan($permission, \BookStack\Ownable $ownable = null)
if (!$ownable instanceof \BookStack\Entity) return $hasPermission;
// Check restrictions on the entitiy
// Check restrictions on the entity
$restrictionService = app('BookStack\Services\RestrictionService');
$explodedPermission = explode('-', $permission);
$action = end($explodedPermission);
$hasAccess = $restrictionService->checkIfEntityRestricted($ownable, $action);
return $hasAccess && $hasPermission;
$restrictionsSet = $restrictionService->checkIfRestrictionsSet($ownable, $action);
return ($hasAccess && $restrictionsSet) || (!$restrictionsSet && $hasPermission);
}
/**

View File

@@ -12,7 +12,8 @@
"barryvdh/laravel-ide-helper": "^2.1",
"barryvdh/laravel-debugbar": "^2.0",
"league/flysystem-aws-s3-v3": "^1.0",
"barryvdh/laravel-dompdf": "0.6.*"
"barryvdh/laravel-dompdf": "0.6.*",
"predis/predis": "^1.0"
},
"require-dev": {
"fzaninotto/faker": "~1.4",

426
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,8 @@ return [
'env' => env('APP_ENV', 'production'),
'editor' => env('APP_EDITOR', 'html'),
/*
|--------------------------------------------------------------------------
| Application Debug Mode

View File

@@ -6,9 +6,8 @@ if (env('CACHE_DRIVER') === 'memcached') {
$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';
if (count($memcachedServerDetails) < 2) $memcachedServerDetails[] = '11211';
if (count($memcachedServerDetails) < 3) $memcachedServerDetails[] = '100';
$memcachedServers[$index] = array_combine($memcachedServerKeys, $memcachedServerDetails);
}
}
@@ -83,6 +82,6 @@ return [
|
*/
'prefix' => 'laravel',
'prefix' => env('CACHE_PREFIX', 'bookstack'),
];

View File

@@ -1,5 +1,21 @@
<?php
// REDIS - Split out configuration into an array
if (env('REDIS_SERVERS', false)) {
$redisServerKeys = ['host', 'port', 'database'];
$redisServers = explode(',', trim(env('REDIS_SERVERS', '127.0.0.1:6379:0'), ','));
$redisConfig = [
'cluster' => env('REDIS_CLUSTER', false)
];
foreach ($redisServers as $index => $redisServer) {
$redisServerName = ($index === 0) ? 'default' : 'redis-server-' . $index;
$redisServerDetails = explode(':', $redisServer);
if (count($redisServerDetails) < 2) $redisServerDetails[] = '6379';
if (count($redisServerDetails) < 3) $redisServerDetails[] = '0';
$redisConfig[$redisServerName] = array_combine($redisServerKeys, $redisServerDetails);
}
}
return [
/*
@@ -123,16 +139,6 @@ return [
|
*/
'redis' => [
'cluster' => false,
'default' => [
'host' => '127.0.0.1',
'port' => 6379,
'database' => 0,
],
],
'redis' => env('REDIS_SERVERS', false) ? $redisConfig : [],
];

View File

@@ -0,0 +1,10 @@
<?php
/**
* The defaults for the system settings that are saved in the database.
*/
return [
'app-editor' => 'wysiwyg'
];

View File

@@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class AddMarkdownSupport extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('pages', function (Blueprint $table) {
$table->longText('markdown')->default('');
});
Schema::table('page_revisions', function (Blueprint $table) {
$table->longText('markdown')->default('');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('pages', function (Blueprint $table) {
$table->dropColumn('markdown');
});
Schema::table('page_revisions', function (Blueprint $table) {
$table->dropColumn('markdown');
});
}
}

View File

@@ -12,6 +12,8 @@
"bootstrap-sass": "^3.0.0",
"dropzone": "^4.0.1",
"laravel-elixir": "^3.4.0",
"marked": "^0.3.5",
"moment": "^2.12.0",
"zeroclipboard": "^2.2.0"
}
}

View File

@@ -1,5 +1,5 @@
{
"css/styles.css": "css/styles.css?version=08a6d44",
"css/print-styles.css": "css/print-styles.css?version=08a6d44",
"js/common.js": "js/common.js?version=08a6d44"
"css/styles.css": "css/styles.css?version=b4531da",
"css/print-styles.css": "css/print-styles.css?version=b4531da",
"js/common.js": "js/common.js?version=b4531da"
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

146
readme.md
View File

@@ -1,149 +1,18 @@
# BookStack
A platform to create documentation/wiki content. General information about BookStack can be found at https://www.bookstackapp.com/
A platform for storing and organising information and documentation. General information and documentation for BookStack can be found at https://www.bookstackapp.com/.
1. [Requirements](#requirements)
2. [Installation](#installation)
- [Server Rewrite Rules](#url-rewrite-rules)
3. [Updating](#updating-bookstack)
4. [Social Authentication](#social-authentication)
- [Google](#google)
- [GitHub](#github)
5. [LDAP Authentication](#ldap-authentication)
6. [Testing](#testing)
7. [License](#license)
8. [Attribution](#attribution)
## Requirements
BookStack has similar requirements to Laravel:
* PHP >= 5.5.9, Will need to be usable from the command line.
* PHP Extensions: `OpenSSL`, `PDO`, `MBstring`, `Tokenizer`, `GD`
* MySQL >= 5.6
* Git (Not strictly required but helps manage updates)
* [Composer](https://getcomposer.org/)
## Installation
Ensure the above requirements are met before installing. Currently BookStack requires its own domain/subdomain and will not work in a site subdirectory.
This project currently uses the `release` branch of this repository as a stable channel for providing updates.
The installation is currently somewhat complicated and will be made simpler in future releases. Some PHP/Laravel experience will currently benefit.
1. Clone the release branch of this repository into a folder.
```
git clone https://github.com/ssddanbrown/BookStack.git --branch release --single-branch
```
2. `cd` into the application folder and run `composer install`.
3. Copy the `.env.example` file to `.env` and fill with your own database and mail details.
4. Ensure the `storage`, `bootstrap/cache` & `public/uploads` folders are writable by the web server.
5. In the application root, Run `php artisan key:generate` to generate a unique application key.
6. If not using apache or if `.htaccess` files are disabled you will have to create some URL rewrite rules as shown below.
7. Run `php artisan migrate` to update the database.
8. Done! You can now login using the default admin details `admin@admin.com` with a password of `password`. It is recommended to change these details directly after first logging in.
#### URL Rewrite rules
**Apache**
```
Options +FollowSymLinks
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L]
```
**Nginx**
```
location / {
try_files $uri $uri/ /index.php?$query_string;
}
```
## Updating BookStack
To update BookStack you can run the following command in the root directory of the application:
```
git pull origin release && composer install && php artisan migrate
```
This command will update the repository that was created in the installation, install the PHP dependencies using `composer` then run the database migrations.
## Social Authentication
BookStack currently supports login via both Google and GitHub. Once enabled options for these services will show up in the login, registration and user profile pages. By default these services are disabled. To enable them you will have to create an application on the external services to obtain the require application id's and secrets. Here are instructions to do this for the current supported services:
### Google
1. Open the [Google Developers Console](https://console.developers.google.com/).
2. Create a new project (May have to wait a short while for it to be created).
3. Select 'Enable and manage APIs'.
4. Enable the 'Google+ API'.
5. In 'Credentials' choose the 'OAuth consent screen' tab and enter a product name ('BookStack' or your custom set name).
6. Back in the 'Credentials' tab click 'New credentials' > 'OAuth client ID'.
7. Choose an application type of 'Web application' and enter the following urls under 'Authorized redirect URIs', changing `https://example.com` to your own domain where BookStack is hosted:
- `https://example.com/login/service/google/callback`
- `https://example.com/register/service/google/callback`
8. Click 'Create' and your app_id and secret will be displayed. Replace the false value on both the `GOOGLE_APP_ID` & `GOOGLE_APP_SECRET` variables in the '.env' file in the BookStack root directory with your own app_id and secret.
9. Set the 'APP_URL' environment variable to be the same domain as you entered in step 7. So, in this example, it will be `https://example.com`.
10. All done! Users should now be able to link to their social accounts in their account profile pages and also register/login using their Google accounts.
### Github
1. While logged in, open up your [GitHub developer applications](https://github.com/settings/developers).
2. Click 'Register new application'.
3. Enter an application name ('BookStack' or your custom set name), A link to your app instance under 'Homepage URL' and an 'Authorization callback URL' of the url that your BookStack instance is hosted on then click 'Register application'.
4. A 'Client ID' and a 'Client Secret' value will be shown. Add these two values to the to the `GITHUB_APP_ID` and `GITHUB_APP_SECRET` variables, replacing the default false value, in the '.env' file found in the BookStack root folder.
5. Set the 'APP_URL' environment variable to be the same domain as you entered in step 3.
6. All done! Users should now be able to link to their social accounts in their account profile pages and also register/login using their Github account.
## LDAP Authentication
BookStack can be configured to allow LDAP based user login. While LDAP login is enabled you cannot log in with the standard user/password login and new user registration is disabled. BookStack will only use the LDAP server for getting user details and for authentication. Data on the LDAP server is not currently editable through BookStack.
When a LDAP user logs into BookStack for the first time their BookStack profile will be created and they will be given the default role set under the 'Default user role after registration' option in the application settings.
To set up LDAP-based authentication add or modify the following variables in your `.env` file:
```
# General auth
AUTH_METHOD=ldap
# The LDAP host, Adding a port is optional
LDAP_SERVER=ldap://example.com:389
# The base DN from where users will be searched within.
LDAP_BASE_DN=ou=People,dc=example,dc=com
# The full DN and password of the user used to search the server
# Can both be left as false to bind anonymously
LDAP_DN=false
LDAP_PASS=false
# A filter to use when searching for users
# The user-provided user-name used to replace any occurrences of '${user}'
LDAP_USER_FILTER=(&(uid=${user}))
# Set the LDAP version to use when connecting to the server.
LDAP_VERSION=false
```
You will also need to have the php-ldap extension installed on your system. It's recommended to change your `APP_DEBUG` variable to `true` while setting up LDAP to make any errors visible. Remember to change this back after LDAP is functioning.
A user in BookStack will be linked to a LDAP user via a 'uid'. If a LDAP user uid changes it can be updated in BookStack by an admin by changing the 'External Authentication ID' field on the user's profile.
You may find that you cannot log in with your initial Admin account after changing the `AUTH_METHOD` to `ldap`. To get around this set the `AUTH_METHOD` to `standard`, login with your admin account then change it back to `ldap`. You get then edit your profile and add your LDAP uid under the 'External Authentication ID' field. You will then be able to login in with that ID.
* [Installation Instructions](https://www.bookstackapp.com/docs/admin/installation)
* [Documentation](https://www.bookstackapp.com/docs)
* [Demo Instance](https://demo.bookstackapp.com) *(Login username: `admin@example.com`. Password: `password`)*
* [BookStack Blog](https://www.bookstackapp.com/blog)
## Development & Testing
All development on BookStack is currently done on the master branch. When it's time for a release the master branch is merged into release with built & minified CSS & JS then tagged at it's version. Here are the current development requirements:
* [Node.js](https://nodejs.org/en/) **Development Only**
* [Gulp](http://gulpjs.com/) **Development Only**
* [Node.js](https://nodejs.org/en/)
* [Gulp](http://gulpjs.com/)
SASS is used to help the CSS development and the JavaScript is run through browserify/babel to allow for writing ES6 code. Both of these are done using gulp.
@@ -176,3 +45,4 @@ These are the great projects used to help build BookStack:
* [Dropzone.js](http://www.dropzonejs.com/)
* [ZeroClipboard](http://zeroclipboard.org/)
* [TinyColorPicker](http://www.dematte.at/tinyColorPicker/index.html)
* [Marked](https://github.com/chjj/marked)

View File

@@ -1,5 +1,7 @@
"use strict";
var moment = require('moment');
module.exports = function (ngApp, events) {
ngApp.controller('ImageManagerController', ['$scope', '$attrs', '$http', '$timeout', 'imageManagerService',
@@ -14,20 +16,40 @@ module.exports = function (ngApp, events) {
$scope.imageUpdateSuccess = false;
$scope.imageDeleteSuccess = false;
$scope.uploadedTo = $attrs.uploadedTo;
$scope.view = 'all';
$scope.searching = false;
$scope.searchTerm = '';
var page = 0;
var previousClickTime = 0;
var previousClickImage = 0;
var dataLoaded = false;
var callback = false;
var preSearchImages = [];
var preSearchHasMore = false;
/**
* Simple returns the appropriate upload url depending on the image type set.
* Used by dropzone to get the endpoint to upload to.
* @returns {string}
*/
$scope.getUploadUrl = function () {
return '/images/' + $scope.imageType + '/upload';
};
/**
* Cancel the current search operation.
*/
function cancelSearch() {
$scope.searching = false;
$scope.searchTerm = '';
$scope.images = preSearchImages;
$scope.hasMore = preSearchHasMore;
}
$scope.cancelSearch = cancelSearch;
/**
* Runs on image upload, Adds an image to local list of images
* and shows a success message to the user.
@@ -59,7 +81,7 @@ module.exports = function (ngApp, events) {
var currentTime = Date.now();
var timeDiff = currentTime - previousClickTime;
if (timeDiff < dblClickTime) {
if (timeDiff < dblClickTime && image.id === previousClickImage) {
// If double click
callbackAndHide(image);
} else {
@@ -68,6 +90,7 @@ module.exports = function (ngApp, events) {
$scope.dependantPages = false;
}
previousClickTime = currentTime;
previousClickImage = image.id;
};
/**
@@ -110,20 +133,69 @@ module.exports = function (ngApp, events) {
$scope.showing = false;
};
var baseUrl = '/images/' + $scope.imageType + '/all/'
/**
* Fetch the list image data from the server.
*/
function fetchData() {
var url = '/images/' + $scope.imageType + '/all/' + page;
var url = baseUrl + page + '?';
var components = {};
if ($scope.uploadedTo) components['page_id'] = $scope.uploadedTo;
if ($scope.searching) components['term'] = $scope.searchTerm;
var urlQueryString = Object.keys(components).map((key) => {
return key + '=' + encodeURIComponent(components[key]);
}).join('&');
url += urlQueryString;
$http.get(url).then((response) => {
$scope.images = $scope.images.concat(response.data.images);
$scope.hasMore = response.data.hasMore;
page++;
});
}
$scope.fetchData = fetchData;
/**
* Start a search operation
* @param searchTerm
*/
$scope.searchImages = function() {
if ($scope.searchTerm === '') {
cancelSearch();
return;
}
if (!$scope.searching) {
preSearchImages = $scope.images;
preSearchHasMore = $scope.hasMore;
}
$scope.searching = true;
$scope.images = [];
$scope.hasMore = false;
page = 0;
baseUrl = '/images/' + $scope.imageType + '/search/';
fetchData();
};
/**
* Set the current image listing view.
* @param viewName
*/
$scope.setView = function(viewName) {
cancelSearch();
$scope.images = [];
$scope.hasMore = false;
page = 0;
$scope.view = viewName;
baseUrl = '/images/' + $scope.imageType + '/' + viewName + '/';
fetchData();
}
/**
* Save the details of an image.
* @param event
@@ -216,16 +288,20 @@ module.exports = function (ngApp, events) {
}]);
ngApp.controller('PageEditController', ['$scope', '$http', '$attrs', '$interval', '$timeout', function ($scope, $http, $attrs, $interval, $timeout) {
ngApp.controller('PageEditController', ['$scope', '$http', '$attrs', '$interval', '$timeout', '$sce',
function ($scope, $http, $attrs, $interval, $timeout, $sce) {
$scope.editorOptions = require('./pages/page-form');
$scope.editorHtml = '';
$scope.editContent = '';
$scope.draftText = '';
var pageId = Number($attrs.pageId);
var isEdit = pageId !== 0;
var autosaveFrequency = 30; // AutoSave interval in seconds.
var isMarkdown = $attrs.editorType === 'markdown';
$scope.isUpdateDraft = Number($attrs.pageUpdateDraft) === 1;
$scope.isNewPageDraft = Number($attrs.pageNewDraft) === 1;
// Set inital header draft text
if ($scope.isUpdateDraft || $scope.isNewPageDraft) {
$scope.draftText = 'Editing Draft'
} else {
@@ -245,7 +321,18 @@ module.exports = function (ngApp, events) {
}, 1000);
}
$scope.editorChange = function () {}
// Actions specifically for the markdown editor
if (isMarkdown) {
$scope.displayContent = '';
// Editor change event
$scope.editorChange = function (content) {
$scope.displayContent = $sce.trustAsHtml(content);
}
}
if (!isMarkdown) {
$scope.editorChange = function() {};
}
/**
* Start the AutoSave loop, Checks for content change
@@ -253,17 +340,18 @@ module.exports = function (ngApp, events) {
*/
function startAutoSave() {
currentContent.title = $('#name').val();
currentContent.html = $scope.editorHtml;
currentContent.html = $scope.editContent;
autoSave = $interval(() => {
var newTitle = $('#name').val();
var newHtml = $scope.editorHtml;
var newHtml = $scope.editContent;
if (newTitle !== currentContent.title || newHtml !== currentContent.html) {
currentContent.html = newHtml;
currentContent.title = newTitle;
saveDraft(newTitle, newHtml);
saveDraft();
}
}, 1000 * autosaveFrequency);
}
@@ -272,20 +360,23 @@ module.exports = function (ngApp, events) {
* @param title
* @param html
*/
function saveDraft(title, html) {
$http.put('/ajax/page/' + pageId + '/save-draft', {
name: title,
html: html
}).then((responseData) => {
$scope.draftText = responseData.data.message;
function saveDraft() {
var data = {
name: $('#name').val(),
html: isMarkdown ? $sce.getTrustedHtml($scope.displayContent) : $scope.editContent
};
if (isMarkdown) data.markdown = $scope.editContent;
$http.put('/ajax/page/' + pageId + '/save-draft', data).then((responseData) => {
var updateTime = moment.utc(moment.unix(responseData.data.timestamp)).toDate();
$scope.draftText = responseData.data.message + moment(updateTime).format('HH:mm');
if (!$scope.isNewPageDraft) $scope.isUpdateDraft = true;
});
}
$scope.forceDraftSave = function() {
var newTitle = $('#name').val();
var newHtml = $scope.editorHtml;
saveDraft(newTitle, newHtml);
saveDraft();
};
/**
@@ -298,6 +389,7 @@ module.exports = function (ngApp, events) {
$scope.draftText = 'Editing Page';
$scope.isUpdateDraft = false;
$scope.$broadcast('html-update', responseData.data.html);
$scope.$broadcast('markdown-update', responseData.data.markdown || responseData.data.html);
$('#name').val(responseData.data.name);
$timeout(() => {
startAutoSave();

View File

@@ -1,5 +1,6 @@
"use strict";
var DropZone = require('dropzone');
var markdown = require('marked');
var toggleSwitchTemplate = require('./components/toggle-switch.html');
var imagePickerTemplate = require('./components/image-picker.html');
@@ -200,7 +201,82 @@ module.exports = function (ngApp, events) {
tinymce.init(scope.tinymce);
}
}
}]);
ngApp.directive('markdownInput', ['$timeout', function($timeout) {
return {
restrict: 'A',
scope: {
mdModel: '=',
mdChange: '='
},
link: function (scope, element, attrs) {
// Set initial model content
var content = element.val();
scope.mdModel = content;
scope.mdChange(markdown(content));
element.on('change input', (e) => {
content = element.val();
$timeout(() => {
scope.mdModel = content;
scope.mdChange(markdown(content));
});
});
scope.$on('markdown-update', (event, value) => {
element.val(value);
scope.mdModel= value;
scope.mdChange(markdown(value));
});
}
}
}]);
ngApp.directive('markdownEditor', ['$timeout', function($timeout) {
return {
restrict: 'A',
link: function (scope, element, attrs) {
// Elements
var input = element.find('textarea[markdown-input]');
var insertImage = element.find('button[data-action="insertImage"]');
var currentCaretPos = 0;
input.blur((event) => {
currentCaretPos = input[0].selectionStart;
});
// Insert image shortcut
input.keydown((event) => {
if (event.which === 73 && event.ctrlKey && event.shiftKey) {
event.preventDefault();
var caretPos = input[0].selectionStart;
var currentContent = input.val();
var mdImageText = "![](http://)";
input.val(currentContent.substring(0, caretPos) + mdImageText + currentContent.substring(caretPos));
input.focus();
input[0].selectionStart = caretPos + ("![](".length);
input[0].selectionEnd = caretPos + ('![](http://'.length);
}
});
// Insert image from image manager
insertImage.click((event) => {
window.ImageManager.showExternal((image) => {
var caretPos = currentCaretPos;
var currentContent = input.val();
var mdImageText = "![" + image.name + "](" + image.url + ")";
input.val(currentContent.substring(0, caretPos) + mdImageText + currentContent.substring(caretPos));
input.change();
});
});
}
}
}])
};

View File

@@ -11,7 +11,7 @@ var mceOptions = module.exports = {
extended_valid_elements: 'pre[*]',
automatic_uploads: false,
valid_children: "-div[p|pre|h1|h2|h3|h4|h5|h6|blockquote]",
plugins: "image table textcolor paste link fullscreen imagetools code hr autosave",
plugins: "image table textcolor paste link fullscreen imagetools code hr autosave lists",
imagetools_toolbar: 'imageoptions',
toolbar: "undo redo | styleselect | bold italic underline strikethrough superscript subscript | forecolor backcolor | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | table image-insert link hr | removeformat code fullscreen",
content_style: "body {padding-left: 15px !important; padding-right: 15px !important; margin:0!important; margin-left:auto!important;margin-right:auto!important;}",

View File

@@ -93,4 +93,15 @@
url('/fonts/roboto-regular-webfont.svg#robotoregular') format('svg');
font-weight: normal;
font-style: normal;
}
/* roboto-mono-regular - latin */
// https://google-webfonts-helper.herokuapp.com
@font-face {
font-family: 'Roboto Mono';
font-style: normal;
font-weight: 400;
src: local('Roboto Mono'), local('RobotoMono-Regular'),
url('/fonts/roboto-mono-v4-latin-regular.woff2') format('woff2'), /* Chrome 26+, Opera 23+ */
url('/fonts/roboto-mono-v4-latin-regular.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
}

View File

@@ -26,6 +26,59 @@
display: none;
}
#markdown-editor {
position: relative;
z-index: 5;
textarea {
font-family: 'Roboto Mono';
font-style: normal;
font-weight: 400;
padding: $-xs $-m;
color: #444;
border-radius: 0;
max-height: 100%;
flex: 1;
border: 0;
width: 100%;
&:focus {
outline: 0;
}
}
.markdown-display, .markdown-editor-wrap {
flex: 1;
position: relative;
}
.markdown-editor-wrap {
display: flex;
flex-direction: column;
border: 1px solid #DDD;
}
.markdown-display {
padding: 0 $-m 0;
margin-left: -1px;
overflow-y: scroll;
.page-content {
margin: 0 auto;
}
}
}
.editor-toolbar {
width: 100%;
padding: $-xs $-m;
font-family: 'Roboto Mono';
font-size: 11px;
line-height: 1.6;
border-bottom: 1px solid #DDD;
background-color: #EEE;
flex: none;
&:after {
content: '';
display: block;
clear: both;
}
}
label {
display: block;
line-height: 1.4em;
@@ -160,6 +213,10 @@ input:checked + .toggle-switch {
width: 100%;
}
div[editor-type="markdown"] .title-input.page-title input[type="text"] {
max-width: 100%;
}
.search-box {
max-width: 100%;
position: relative;

View File

@@ -56,18 +56,14 @@ header {
padding-top: $-xxs;
}
> i {
padding-top: $-xs*1.2;
padding-top: 4px;
font-size: 18px;
}
@include smaller-than($screen-md) {
padding-left: $-xs;
.name {
display: none;
}
i {
font-size: 2em;
padding-left: 0;
padding-top: 0;
}
}
}
@include smaller-than($screen-md) {
@@ -193,12 +189,13 @@ form.search-box {
}
}
.setting-nav {
.nav-tabs {
text-align: center;
a {
a, .tab-item {
padding: $-m;
display: inline-block;
color: #666;
cursor: pointer;
&.selected {
border-bottom: 2px solid $primary;
}

View File

@@ -120,7 +120,6 @@
.image-manager-list {
overflow-y: scroll;
flex: 1;
border-top: 1px solid #ddd;
}
.image-manager-content {
@@ -128,6 +127,12 @@
flex-direction: column;
height: 100%;
flex: 1;
.container {
width: 100%;
}
.full-tab {
text-align: center;
}
}
// Dropzone

View File

@@ -24,4 +24,13 @@ table {
background-color: #F8F8F8;
font-weight: 500;
}
}
table.list-table {
margin: 0 -$-xs;
td {
border: 0;
vertical-align: middle;
padding: $-xs;
}
}

View File

@@ -157,6 +157,12 @@ span.code {
@extend .code-base;
padding: 1px $-xs;
}
pre code {
background-color: transparent;
border: 0;
font-size: 1em;
}
/*
* Text colors
*/

View File

@@ -176,4 +176,29 @@ $btt-size: 40px;
position: relative;
top: -5px;
}
}
.contained-search-box {
display: flex;
input, button {
border-radius: 0;
border: 1px solid #DDD;
margin-left: -1px;
}
input {
flex: 5;
&:focus, &:active {
outline: 0;
}
}
button {
width: 60px;
}
button i {
padding: 0;
}
button.cancel.active {
background-color: $negative;
color: #EEE;
}
}

View File

@@ -56,7 +56,7 @@
<div class="dropdown-container" dropdown>
<span class="user-name" dropdown-toggle>
<img class="avatar" src="{{$currentUser->getAvatar(30)}}" alt="{{ $currentUser->name }}">
<span class="name" ng-non-bindable>{{ $currentUser->name }}</span> <i class="zmdi zmdi-caret-down"></i>
<span class="name" ng-non-bindable>{{ $currentUser->getShortName(9) }}</span> <i class="zmdi zmdi-caret-down"></i>
</span>
<ul>
<li>

View File

@@ -16,7 +16,7 @@
<div class="container" ng-non-bindable>
<h1>Book Restrictions</h1>
<h1>Book Permissions</h1>
@include('form/restriction-form', ['model' => $book])
</div>

View File

@@ -24,7 +24,7 @@
<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>
<li><a href="{{$book->getUrl()}}/permissions" class="text-primary"><i class="zmdi zmdi-lock-outline"></i>Permissions</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>
@@ -90,9 +90,9 @@
@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>
<a href="{{ $book->getUrl() }}/permissions"><i class="zmdi zmdi-lock-outline"></i>Book Permissions Active</a>
@else
<i class="zmdi zmdi-lock-outline"></i>Book Restricted
<i class="zmdi zmdi-lock-outline"></i>Book Permissions Active
@endif
</p>
@endif

View File

@@ -17,7 +17,7 @@
</div>
<div class="container" ng-non-bindable>
<h1>Chapter Restrictions</h1>
<h1>Chapter Permissions</h1>
@include('form/restriction-form', ['model' => $chapter])
</div>

View File

@@ -19,7 +19,7 @@
<a href="{{$chapter->getUrl() . '/edit'}}" class="text-primary text-button"><i class="zmdi zmdi-edit"></i>Edit</a>
@endif
@if(userCan('restrictions-manage', $chapter))
<a href="{{$chapter->getUrl()}}/restrict" class="text-primary text-button"><i class="zmdi zmdi-lock-outline"></i>Restrict</a>
<a href="{{$chapter->getUrl()}}/permissions" class="text-primary text-button"><i class="zmdi zmdi-lock-outline"></i>Permissions</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>
@@ -69,18 +69,18 @@
@if($book->restricted)
@if(userCan('restrictions-manage', $book))
<a href="{{ $book->getUrl() }}/restrict"><i class="zmdi zmdi-lock-outline"></i>Book Restricted</a>
<a href="{{ $book->getUrl() }}/permissions"><i class="zmdi zmdi-lock-outline"></i>Book Permissions Active</a>
@else
<i class="zmdi zmdi-lock-outline"></i>Book Restricted
<i class="zmdi zmdi-lock-outline"></i>Book Permissions Active
@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>
<a href="{{ $chapter->getUrl() }}/permissions"><i class="zmdi zmdi-lock-outline"></i>Chapter Permissions Active</a>
@else
<i class="zmdi zmdi-lock-outline"></i>Chapter Restricted
<i class="zmdi zmdi-lock-outline"></i>Chapter Permissions Active
@endif
@endif
</div>

View File

@@ -1,7 +1,6 @@
<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
>
@if(isset($model) && $model->hasRestriction($role->id, $action)) checked="checked" @endif>
{{ $label }}
</label>

View File

@@ -1,11 +1,14 @@
<form action="{{ $model->getUrl() }}/restrict" method="POST">
<form action="{{ $model->getUrl() }}/permissions" method="POST">
{!! csrf_field() !!}
<input type="hidden" name="_method" value="PUT">
<p>Once enabled, These permissions will take priority over any set role permissions.</p>
<div class="form-group">
@include('form/checkbox', ['name' => 'restricted', 'label' => 'Restrict this ' . $model->getClassName()])
@include('form/checkbox', ['name' => 'restricted', 'label' => 'Enable custom permissions'])
</div>
<table class="table">
<tr>
<th>Role</th>
@@ -25,5 +28,5 @@
</table>
<a href="{{ $model->getUrl() }}" class="button muted">Cancel</a>
<button type="submit" class="button pos">Save Restrictions</button>
<button type="submit" class="button pos">Save Permissions</button>
</form>

View File

@@ -1,5 +1,5 @@
<div class="page-editor flex-fill flex" ng-controller="PageEditController" page-id="{{ $model->id or 0 }}" page-new-draft="{{ $model->draft or 0 }}" page-update-draft="{{ $model->isDraft or 0 }}">
<div class="page-editor flex-fill flex" ng-controller="PageEditController" editor-type="{{ setting('app-editor') }}" page-id="{{ $model->id or 0 }}" page-new-draft="{{ $model->draft or 0 }}" page-update-draft="{{ $model->isDraft or 0 }}">
{{ csrf_field() }}
<div class="faded-small toolbar">
@@ -42,10 +42,45 @@
</div>
</div>
<div class="edit-area flex-fill flex">
<textarea id="html-editor" tinymce="editorOptions" mce-change="editorChange" mce-model="editorHtml" name="html" rows="5"
@if($errors->has('html')) class="neg" @endif>@if(isset($model) || old('html')){{htmlspecialchars( old('html') ? old('html') : $model->html)}}@endif</textarea>
@if($errors->has('html'))
<div class="text-neg text-small">{{ $errors->first('html') }}</div>
@if(setting('app-editor') === 'wysiwyg')
<textarea id="html-editor" tinymce="editorOptions" mce-change="editorChange" mce-model="editContent" name="html" rows="5"
@if($errors->has('html')) class="neg" @endif>@if(isset($model) || old('html')){{htmlspecialchars( old('html') ? old('html') : $model->html)}}@endif</textarea>
@if($errors->has('html'))
<div class="text-neg text-small">{{ $errors->first('html') }}</div>
@endif
@endif
@if(setting('app-editor') === 'markdown')
<div id="markdown-editor" markdown-editor class="flex-fill flex">
<div class="markdown-editor-wrap">
<div class="editor-toolbar">
<span class="float left">Editor</span>
<div class="float right buttons">
<button class="text-button" type="button" data-action="insertImage"><i class="zmdi zmdi-image"></i>Insert Image</button>
</div>
</div>
<textarea markdown-input md-change="editorChange" md-model="editContent" name="markdown" rows="5"
@if($errors->has('markdown')) class="neg" @endif>@if(isset($model) || old('markdown')){{htmlspecialchars( old('markdown') ? old('markdown') : ($model->markdown === '' ? $model->html : $model->markdown))}}@endif</textarea>
</div>
<div class="markdown-editor-wrap">
<div class="editor-toolbar">
<div class="">Preview</div>
</div>
<div class="markdown-display">
<div class="page-content" ng-bind-html="displayContent"></div>
</div>
</div>
</div>
<input type="hidden" name="html" ng-value="displayContent">
@if($errors->has('markdown'))
<div class="text-neg text-small">{{ $errors->first('markdown') }}</div>
@endif
@endif
</div>
</div>

View File

@@ -24,7 +24,7 @@
</div>
<div class="container" ng-non-bindable>
<h1>Page Restrictions</h1>
<h1>Page Permissions</h1>
@include('form/restriction-form', ['model' => $page])
</div>

View File

@@ -32,7 +32,7 @@
<a href="{{$page->getUrl()}}/edit" class="text-primary text-button" ><i class="zmdi zmdi-edit"></i>Edit</a>
@endif
@if(userCan('restrictions-manage', $page))
<a href="{{$page->getUrl()}}/restrict" class="text-primary text-button"><i class="zmdi zmdi-lock-outline"></i>Restrict</a>
<a href="{{$page->getUrl()}}/permissions" class="text-primary text-button"><i class="zmdi zmdi-lock-outline"></i>Permissions</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>
@@ -76,27 +76,27 @@
@if($book->restricted)
@if(userCan('restrictions-manage', $book))
<a href="{{ $book->getUrl() }}/restrict"><i class="zmdi zmdi-lock-outline"></i>Book restricted</a>
<a href="{{ $book->getUrl() }}/permissions"><i class="zmdi zmdi-lock-outline"></i>Book Permissions Active</a>
@else
<i class="zmdi zmdi-lock-outline"></i>Book restricted
<i class="zmdi zmdi-lock-outline"></i>Book Permissions Active
@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>
<a href="{{ $page->chapter->getUrl() }}/permissions"><i class="zmdi zmdi-lock-outline"></i>Chapter Permissions Active</a>
@else
<i class="zmdi zmdi-lock-outline"></i>Chapter restricted
<i class="zmdi zmdi-lock-outline"></i>Chapter Permissions Active
@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>
<a href="{{ $page->getUrl() }}/permissions"><i class="zmdi zmdi-lock-outline"></i>Page Permissions Active</a>
@else
<i class="zmdi zmdi-lock-outline"></i>Page restricted
<i class="zmdi zmdi-lock-outline"></i>Page Permissions Active
@endif
<br>
@endif

View File

@@ -12,7 +12,7 @@
.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 {
.nav-tabs a.selected, .nav-tabs .tab-item.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 {

View File

@@ -3,6 +3,20 @@
<div class="image-manager-body" ng-click="$event.stopPropagation()">
<div class="image-manager-content">
<div ng-if="imageType === 'gallery'" class="container">
<div class="image-manager-header row faded-small nav-tabs">
<div class="col-xs-4 tab-item" title="View all images" ng-class="{selected: (view=='all')}" ng-click="setView('all')"><i class="zmdi zmdi-collection-image"></i> All</div>
<div class="col-xs-4 tab-item" title="View images uploaded to this book" ng-class="{selected: (view=='book')}" ng-click="setView('book')"><i class="zmdi zmdi-book text-book"></i> Book</div>
<div class="col-xs-4 tab-item" title="View images uploaded to this page" ng-class="{selected: (view=='page')}" ng-click="setView('page')"><i class="zmdi zmdi-file-text text-page"></i> Page</div>
</div>
</div>
<div ng-show="view === 'all'" >
<form ng-submit="searchImages()" class="contained-search-box">
<input type="text" placeholder="Search by image name" ng-model="searchTerm">
<button ng-class="{active: searching}" title="Clear Search" type="button" ng-click="cancelSearch()" class="text-button cancel"><i class="zmdi zmdi-close-circle-o"></i></button>
<button title="Search" class="text-button" type="submit"><i class="zmdi zmdi-search"></i></button>
</form>
</div>
<div class="image-manager-list">
<div ng-repeat="image in images">
<div class="image anim fadeIn" ng-style="{animationDelay: ($index > 26) ? '160ms' : ($index * 25) + 'ms'}"

View File

@@ -17,29 +17,37 @@
<div class="col-md-6">
<div class="form-group">
<label for="setting-app-name">Application name</label>
<input type="text" value="{{ Setting::get('app-name', 'BookStack') }}" name="setting-app-name" id="setting-app-name">
<input type="text" value="{{ setting('app-name', 'BookStack') }}" name="setting-app-name" id="setting-app-name">
</div>
<div class="form-group">
<label>Allow public viewing?</label>
<toggle-switch name="setting-app-public" value="{{ Setting::get('app-public') }}"></toggle-switch>
<toggle-switch name="setting-app-public" value="{{ setting('app-public') }}"></toggle-switch>
</div>
<div class="form-group">
<label>Enable higher security image uploads?</label>
<p class="small">For performance reasons, all images are public by default, This option adds a random, hard-to-guess characters in front of image names. Ensure directory indexes are not enabled to prevent easy access.</p>
<toggle-switch name="setting-app-secure-images" value="{{ Setting::get('app-secure-images') }}"></toggle-switch>
<toggle-switch name="setting-app-secure-images" value="{{ setting('app-secure-images') }}"></toggle-switch>
</div>
<div class="form-group">
<label for="setting-app-editor">Page editor</label>
<p class="small">Select which editor will be used by all users to edit pages.</p>
<select name="setting-app-editor" id="setting-app-editor">
<option @if(setting('app-editor') === 'wysiwyg') selected @endif value="wysiwyg">WYSIWYG</option>
<option @if(setting('app-editor') === 'markdown') selected @endif value="markdown">Markdown</option>
</select>
</div>
</div>
<div class="col-md-6">
<div class="form-group" id="logo-control">
<label for="setting-app-logo">Application Logo</label>
<label for="setting-app-logo">Application logo</label>
<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>
<image-picker resize-height="43" show-remove="true" resize-width="200" current-image="{{ setting('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>
<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)">
<input type="text" value="{{ setting('app-color', '') }}" name="setting-app-color" id="setting-app-color" placeholder="#0288D1">
<input type="hidden" value="{{ setting('app-color-light', '') }}" name="setting-app-color-light" id="setting-app-color-light" placeholder="rgba(21, 101, 192, 0.15)">
</div>
</div>
</div>
@@ -53,14 +61,14 @@
<div class="col-md-6">
<div class="form-group">
<label for="setting-registration-enabled">Allow registration?</label>
<toggle-switch name="setting-registration-enabled" value="{{ Setting::get('registration-enabled') }}"></toggle-switch>
<toggle-switch name="setting-registration-enabled" value="{{ setting('registration-enabled') }}"></toggle-switch>
</div>
<div class="form-group">
<label for="setting-registration-role">Default user role after registration</label>
<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::first()->id) == $role->id) selected @endif
@if(setting('registration-role', \BookStack\Role::first()->id) == $role->id) selected @endif
>
{{ $role->display_name }}
</option>
@@ -70,7 +78,7 @@
<div class="form-group">
<label for="setting-registration-confirmation">Require email confirmation?</label>
<p class="small">If domain restriction is used then email confirmation will be required and the below value will be ignored.</p>
<toggle-switch name="setting-registration-confirmation" value="{{ Setting::get('registration-confirmation') }}"></toggle-switch>
<toggle-switch name="setting-registration-confirmation" value="{{ setting('registration-confirmation') }}"></toggle-switch>
</div>
</div>
<div class="col-md-6">
@@ -78,7 +86,7 @@
<label for="setting-registration-restrict">Restrict registration to domain</label>
<p class="small">Enter a comma separated list of email domains you would like to restrict registration to. Users will be sent an email to confirm their address before being allowed to interact with the application.
<br> Note that users will be able to change their email addresses after successful registration.</p>
<input type="text" id="setting-registration-restrict" name="setting-registration-restrict" placeholder="No restriction set" value="{{ Setting::get('registration-restrict', '') }}">
<input type="text" id="setting-registration-restrict" name="setting-registration-restrict" placeholder="No restriction set" value="{{ setting('registration-restrict', '') }}">
</div>
</div>
</div>

View File

@@ -2,7 +2,7 @@
<div class="faded-small toolbar">
<div class="container">
<div class="row">
<div class="col-md-12 setting-nav">
<div class="col-md-12 setting-nav nav-tabs">
<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>

View File

@@ -2,116 +2,130 @@
<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="col-md-9">
<div class="row">
<div class="col-md-6">
<label> @include('settings/roles/checkbox', ['permission' => 'users-manage']) Manage users</label>
<div class="col-md-5">
<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>
<label>@include('settings/roles/checkbox', ['permission' => 'users-manage']) Manage users</label>
<label>@include('settings/roles/checkbox', ['permission' => 'user-roles-manage']) Manage roles & role permissions</label>
<label>@include('settings/roles/checkbox', ['permission' => 'restrictions-manage-all']) Manage all Book, Chapter & Page permissions</label>
<label>@include('settings/roles/checkbox', ['permission' => 'restrictions-manage-own']) Manage permissions on own Book, Chapter & Pages</label>
<label>@include('settings/roles/checkbox', ['permission' => 'settings-manage']) Manage app settings</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 class="col-md-6">
<h3>Asset Permissions</h3>
<p>
These permissions control default access to the assets within the system.
Permissions 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>
</div>
<div class="col-md-3">
<h3>Users in this role</h3>
<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>
@if(isset($role) && count($role->users) > 0)
<table class="list-table">
@foreach($role->users as $user)
<tr>
<td style="line-height: 0;"><img class="avatar small" src="{{$user->getAvatar(40)}}" alt="{{$user->name}}"></td>
<td>
@if(userCan('users-manage') || $currentUser->id == $user->id)
<a href="/settings/users/{{$user->id}}">
@endif
{{ $user->name }}
@if(userCan('users-manage') || $currentUser->id == $user->id)
</a>
@endif
</td>
</tr>
@endforeach
</table>
@else
<p class="text-muted">
No users currently in this role.
</p>
@endif
</div>
</div>
<a href="/settings/roles" class="button muted">Cancel</a>
<button type="submit" class="button pos">Save Role</button>
</div>

View File

@@ -10,7 +10,7 @@
<form action="/settings/users/{{$user->id}}" method="POST">
{!! csrf_field() !!}
<input type="hidden" name="_method" value="DELETE">
<a href="/users/{{$user->id}}" class="button muted">Cancel</a>
<a href="/settings/users/{{$user->id}}" class="button muted">Cancel</a>
<button type="submit" class="button neg">Confirm</button>
</form>
</div>

View File

@@ -43,7 +43,7 @@ class LdapTest extends \TestCase
->press('Sign In')
->seePageIs('/')
->see($this->mockUser->name)
->seeInDatabase('users', ['email' => $this->mockUser->email, 'email_confirmed' => 1, 'external_auth_id' => $this->mockUser->name]);
->seeInDatabase('users', ['email' => $this->mockUser->email, 'email_confirmed' => false, 'external_auth_id' => $this->mockUser->name]);
}
public function test_login_works_when_no_uid_provided_by_ldap_server()
@@ -67,7 +67,7 @@ class LdapTest extends \TestCase
->press('Sign In')
->seePageIs('/')
->see($this->mockUser->name)
->seeInDatabase('users', ['email' => $this->mockUser->email, 'email_confirmed' => 1, 'external_auth_id' => $ldapDn]);
->seeInDatabase('users', ['email' => $this->mockUser->email, 'email_confirmed' => false, 'external_auth_id' => $ldapDn]);
}
public function test_initial_incorrect_details()

View File

@@ -0,0 +1,51 @@
<?php
class MarkdownTest extends TestCase
{
protected $page;
public function setUp()
{
parent::setUp();
$this->page = \BookStack\Page::first();
}
protected function setMarkdownEditor()
{
$this->setSettings(['app-editor' => 'markdown']);
}
public function test_default_editor_is_wysiwyg()
{
$this->assertEquals(setting('app-editor'), 'wysiwyg');
$this->asAdmin()->visit($this->page->getUrl() . '/edit')
->pageHasElement('#html-editor');
}
public function test_markdown_setting_shows_markdown_editor()
{
$this->setMarkdownEditor();
$this->asAdmin()->visit($this->page->getUrl() . '/edit')
->pageNotHasElement('#html-editor')
->pageHasElement('#markdown-editor');
}
public function test_markdown_content_given_to_editor()
{
$this->setMarkdownEditor();
$mdContent = '# hello. This is a test';
$this->page->markdown = $mdContent;
$this->page->save();
$this->asAdmin()->visit($this->page->getUrl() . '/edit')
->seeInField('markdown', $mdContent);
}
public function test_html_content_given_to_editor_if_no_markdown()
{
$this->setMarkdownEditor();
$this->asAdmin()->visit($this->page->getUrl() . '/edit')
->seeInField('markdown', $this->page->html);
}
}

25
tests/Entity/SortTest.php Normal file
View File

@@ -0,0 +1,25 @@
<?php
class SortTest extends TestCase
{
protected $book;
public function setUp()
{
parent::setUp();
$this->book = \BookStack\Book::first();
}
public function test_drafts_do_not_show_up()
{
$this->asAdmin();
$pageRepo = app('\BookStack\Repos\PageRepo');
$draft = $pageRepo->getDraftPage($this->book);
$this->visit($this->book->getUrl())
->see($draft->name)
->visit($this->book->getUrl() . '/sort')
->dontSee($draft->name);
}
}

View File

@@ -3,11 +3,21 @@
class RestrictionsTest extends TestCase
{
protected $user;
protected $viewer;
public function setUp()
{
parent::setUp();
$this->user = $this->getNewUser();
$this->viewer = $this->getViewer();
}
protected function getViewer()
{
$role = \BookStack\Role::getRole('viewer');
$viewer = $this->getNewBlankUser();
$viewer->attachRole($role);;
return $viewer;
}
/**
@@ -20,11 +30,16 @@ class RestrictionsTest extends TestCase
$entity->restricted = true;
$entity->restrictions()->delete();
$role = $this->user->roles->first();
$viewerRole = $this->viewer->roles->first();
foreach ($actions as $action) {
$entity->restrictions()->create([
'role_id' => $role->id,
'action' => strtolower($action)
]);
$entity->restrictions()->create([
'role_id' => $viewerRole->id,
'action' => strtolower($action)
]);
}
$entity->save();
$entity->load('restrictions');
@@ -65,6 +80,10 @@ class RestrictionsTest extends TestCase
$book = \BookStack\Book::first();
$bookUrl = $book->getUrl();
$this->actingAs($this->viewer)
->visit($bookUrl)
->dontSeeInElement('.action-buttons', 'New Page')
->dontSeeInElement('.action-buttons', 'New Chapter');
$this->actingAs($this->user)
->visit($bookUrl)
->seeInElement('.action-buttons', 'New Page')
@@ -319,11 +338,11 @@ class RestrictionsTest extends TestCase
public function test_book_restriction_form()
{
$book = \BookStack\Book::first();
$this->asAdmin()->visit($book->getUrl() . '/restrict')
->see('Book Restrictions')
$this->asAdmin()->visit($book->getUrl() . '/permissions')
->see('Book Permissions')
->check('restricted')
->check('restrictions[2][view]')
->press('Save Restrictions')
->press('Save Permissions')
->seeInDatabase('books', ['id' => $book->id, 'restricted' => true])
->seeInDatabase('restrictions', [
'restrictable_id' => $book->id,
@@ -336,11 +355,11 @@ class RestrictionsTest extends TestCase
public function test_chapter_restriction_form()
{
$chapter = \BookStack\Chapter::first();
$this->asAdmin()->visit($chapter->getUrl() . '/restrict')
->see('Chapter Restrictions')
$this->asAdmin()->visit($chapter->getUrl() . '/permissions')
->see('Chapter Permissions')
->check('restricted')
->check('restrictions[2][update]')
->press('Save Restrictions')
->press('Save Permissions')
->seeInDatabase('chapters', ['id' => $chapter->id, 'restricted' => true])
->seeInDatabase('restrictions', [
'restrictable_id' => $chapter->id,
@@ -353,11 +372,11 @@ class RestrictionsTest extends TestCase
public function test_page_restriction_form()
{
$page = \BookStack\Page::first();
$this->asAdmin()->visit($page->getUrl() . '/restrict')
->see('Page Restrictions')
$this->asAdmin()->visit($page->getUrl() . '/permissions')
->see('Page Permissions')
->check('restricted')
->check('restrictions[2][delete]')
->press('Save Restrictions')
->press('Save Permissions')
->seeInDatabase('pages', ['id' => $page->id, 'restricted' => true])
->seeInDatabase('restrictions', [
'restrictable_id' => $page->id,
@@ -404,4 +423,99 @@ class RestrictionsTest extends TestCase
->dontSee($page->name);
}
public function test_book_create_restriction_override()
{
$book = \BookStack\Book::first();
$bookUrl = $book->getUrl();
$this->actingAs($this->viewer)
->visit($bookUrl)
->dontSeeInElement('.action-buttons', 'New Page')
->dontSeeInElement('.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_override()
{
$book = \BookStack\Book::first();
$bookPage = $book->pages->first();
$bookChapter = $book->chapters->first();
$bookUrl = $book->getUrl();
$this->actingAs($this->viewer)
->visit($bookUrl . '/edit')
->dontSee('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_override()
{
$book = \BookStack\Book::first();
$bookPage = $book->pages->first();
$bookChapter = $book->chapters->first();
$bookUrl = $book->getUrl();
$this->actingAs($this->viewer)
->visit($bookUrl . '/delete')
->dontSee('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');
}
}

View File

@@ -129,14 +129,14 @@ class RolesTest extends TestCase
{
$page = \BookStack\Page::take(1)->get()->first();
$this->actingAs($this->user)->visit($page->getUrl())
->dontSee('Restrict')
->visit($page->getUrl() . '/restrict')
->dontSee('Permissions')
->visit($page->getUrl() . '/permissions')
->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');
->see('Permissions')
->click('Permissions')
->see('Page Permissions')->seePageIs($page->getUrl() . '/permissions');
}
public function test_restrictions_manage_own_permission()
@@ -145,27 +145,27 @@ class RolesTest extends TestCase
$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')
->dontSee('Permissions')
->visit($otherUsersPage->getUrl() . '/permissions')
->seePageIs('/');
// Check can't restrict own content
$this->actingAs($this->user)->visit($content['page']->getUrl())
->dontSee('Restrict')
->visit($content['page']->getUrl() . '/restrict')
->dontSee('Permissions')
->visit($content['page']->getUrl() . '/permissions')
->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')
->dontSee('Permissions')
->visit($otherUsersPage->getUrl() . '/permissions')
->seePageIs('/');
// Check can restrict own content
$this->actingAs($this->user)->visit($content['page']->getUrl())
->see('Restrict')
->click('Restrict')
->seePageIs($content['page']->getUrl() . '/restrict');
->see('Permissions')
->click('Permissions')
->seePageIs($content['page']->getUrl() . '/permissions');
}
/**

View File

@@ -170,4 +170,28 @@ class TestCase extends Illuminate\Foundation\Testing\TestCase
$this->visit($link->link()->getUri());
return $this;
}
/**
* Check if the page contains the given element.
* @param string $selector
* @return bool
*/
protected function pageHasElement($selector)
{
$elements = $this->crawler->filter($selector);
$this->assertTrue(count($elements) > 0, "The page does not contain an element matching " . $selector);
return $this;
}
/**
* Check if the page contains the given element.
* @param string $selector
* @return bool
*/
protected function pageNotHasElement($selector)
{
$elements = $this->crawler->filter($selector);
$this->assertFalse(count($elements) > 0, "The page contains " . count($elements) . " elements matching " . $selector);
return $this;
}
}