Merge branch 'master' into release

This commit is contained in:
Dan Brown 2016-04-09 15:47:14 +01:00
commit 09f478bd74
50 changed files with 1093 additions and 558 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; namespace BookStack\Http\Controllers\Auth;
use BookStack\Exceptions\AuthException;
use BookStack\Exceptions\PrettyException;
use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use BookStack\Exceptions\SocialSignInException; use BookStack\Exceptions\SocialSignInException;
@ -115,6 +117,7 @@ class AuthController extends Controller
* @param Request $request * @param Request $request
* @param Authenticatable $user * @param Authenticatable $user
* @return \Illuminate\Http\RedirectResponse * @return \Illuminate\Http\RedirectResponse
* @throws AuthException
*/ */
protected function authenticated(Request $request, Authenticatable $user) protected function authenticated(Request $request, Authenticatable $user)
{ {
@ -132,6 +135,13 @@ class AuthController extends Controller
} }
if (!$user->exists) { 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(); $user->save();
$this->userRepo->attachDefaultRole($user); $this->userRepo->attachDefaultRole($user);
auth()->login($user); auth()->login($user);
@ -184,14 +194,11 @@ class AuthController extends Controller
} }
if (setting('registration-confirmation') || setting('registration-restrict')) { if (setting('registration-confirmation') || setting('registration-restrict')) {
$newUser->email_confirmed = false;
$newUser->save(); $newUser->save();
$this->emailConfirmationService->sendConfirmation($newUser); $this->emailConfirmationService->sendConfirmation($newUser);
return redirect('/register/confirm'); return redirect('/register/confirm');
} }
$newUser->email_confirmed = true;
auth()->login($newUser); auth()->login($newUser);
session()->flash('success', 'Thanks for signing up! You are now registered and signed in.'); session()->flash('success', 'Thanks for signing up! You are now registered and signed in.');
return redirect($this->redirectPath()); return redirect($this->redirectPath());

View File

@ -1,13 +1,9 @@
<?php <?php namespace BookStack\Http\Controllers;
namespace BookStack\Http\Controllers;
use Activity; use Activity;
use BookStack\Repos\UserRepo; use BookStack\Repos\UserRepo;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use BookStack\Http\Requests; use BookStack\Http\Requests;
use BookStack\Repos\BookRepo; use BookStack\Repos\BookRepo;
use BookStack\Repos\ChapterRepo; use BookStack\Repos\ChapterRepo;
@ -40,7 +36,6 @@ class BookController extends Controller
/** /**
* Display a listing of the book. * Display a listing of the book.
*
* @return Response * @return Response
*/ */
public function index() public function index()
@ -54,7 +49,6 @@ class BookController extends Controller
/** /**
* Show the form for creating a new book. * Show the form for creating a new book.
*
* @return Response * @return Response
*/ */
public function create() public function create()
@ -88,7 +82,6 @@ class BookController extends Controller
/** /**
* Display the specified book. * Display the specified book.
*
* @param $slug * @param $slug
* @return Response * @return Response
*/ */
@ -103,7 +96,6 @@ class BookController extends Controller
/** /**
* Show the form for editing the specified book. * Show the form for editing the specified book.
*
* @param $slug * @param $slug
* @return Response * @return Response
*/ */
@ -117,7 +109,6 @@ class BookController extends Controller
/** /**
* Update the specified book in storage. * Update the specified book in storage.
*
* @param Request $request * @param Request $request
* @param $slug * @param $slug
* @return Response * @return Response
@ -267,7 +258,7 @@ class BookController extends Controller
$book = $this->bookRepo->getBySlug($bookSlug); $book = $this->bookRepo->getBySlug($bookSlug);
$this->checkOwnablePermission('restrictions-manage', $book); $this->checkOwnablePermission('restrictions-manage', $book);
$this->bookRepo->updateRestrictionsFromRequest($request, $book); $this->bookRepo->updateRestrictionsFromRequest($request, $book);
session()->flash('success', 'Page Restrictions Updated'); session()->flash('success', 'Book Restrictions Updated');
return redirect($book->getUrl()); return redirect($book->getUrl());
} }
} }

View File

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

View File

@ -1,14 +1,9 @@
<?php <?php namespace BookStack\Http\Controllers;
namespace BookStack\Http\Controllers;
use BookStack\Exceptions\ImageUploadException; use BookStack\Exceptions\ImageUploadException;
use BookStack\Repos\ImageRepo; use BookStack\Repos\ImageRepo;
use Illuminate\Filesystem\Filesystem as File; use Illuminate\Filesystem\Filesystem as File;
use Illuminate\Http\Request; 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\Image;
use BookStack\Repos\PageRepo; use BookStack\Repos\PageRepo;
@ -44,6 +39,24 @@ class ImageController extends Controller
return response()->json($imgData); 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. * Get all images for a user.
* @param int $page * @param int $page
@ -55,6 +68,27 @@ class ImageController extends Controller
return response()->json($imgData); 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. * Handles image uploads for use on pages.
* @param string $type * @param string $type

View File

@ -4,6 +4,7 @@ use Activity;
use BookStack\Exceptions\NotFoundException; use BookStack\Exceptions\NotFoundException;
use BookStack\Repos\UserRepo; use BookStack\Repos\UserRepo;
use BookStack\Services\ExportService; use BookStack\Services\ExportService;
use Carbon\Carbon;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use BookStack\Http\Requests; use BookStack\Http\Requests;
use BookStack\Repos\BookRepo; use BookStack\Repos\BookRepo;
@ -88,7 +89,6 @@ class PageController extends Controller
$input = $request->all(); $input = $request->all();
$book = $this->bookRepo->getBySlug($bookSlug); $book = $this->bookRepo->getBySlug($bookSlug);
$input['priority'] = $this->bookRepo->getNewPriority($book);
$draftPage = $this->pageRepo->getById($pageId, true); $draftPage = $this->pageRepo->getById($pageId, true);
@ -96,6 +96,12 @@ class PageController extends Controller
$parent = $chapterId !== 0 ? $this->chapterRepo->getById($chapterId) : $book; $parent = $chapterId !== 0 ? $this->chapterRepo->getById($chapterId) : $book;
$this->checkOwnablePermission('page-create', $parent); $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); $page = $this->pageRepo->publishDraft($draftPage, $input);
Activity::add($page, 'page_create', $book->id); Activity::add($page, 'page_create', $book->id);
@ -164,6 +170,7 @@ class PageController extends Controller
$draft = $this->pageRepo->getUserPageDraft($page, $this->currentUser->id); $draft = $this->pageRepo->getUserPageDraft($page, $this->currentUser->id);
$page->name = $draft->name; $page->name = $draft->name;
$page->html = $draft->html; $page->html = $draft->html;
$page->markdown = $draft->markdown;
$page->isDraft = true; $page->isDraft = true;
$warnings [] = $this->pageRepo->getUserPageDraftMessage($draft); $warnings [] = $this->pageRepo->getUserPageDraftMessage($draft);
} }
@ -204,12 +211,18 @@ class PageController extends Controller
$page = $this->pageRepo->getById($pageId, true); $page = $this->pageRepo->getById($pageId, true);
$this->checkOwnablePermission('page-update', $page); $this->checkOwnablePermission('page-update', $page);
if ($page->draft) { if ($page->draft) {
$draft = $this->pageRepo->updateDraftPage($page, $request->only(['name', 'html'])); $draft = $this->pageRepo->updateDraftPage($page, $request->only(['name', 'html', 'markdown']));
} else { } 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

@ -11,14 +11,12 @@ class Authenticate
{ {
/** /**
* The Guard implementation. * The Guard implementation.
*
* @var Guard * @var Guard
*/ */
protected $auth; protected $auth;
/** /**
* Create a new filter instance. * Create a new filter instance.
*
* @param Guard $auth * @param Guard $auth
*/ */
public function __construct(Guard $auth) public function __construct(Guard $auth)
@ -28,14 +26,13 @@ class Authenticate
/** /**
* Handle an incoming request. * Handle an incoming request.
*
* @param \Illuminate\Http\Request $request * @param \Illuminate\Http\Request $request
* @param \Closure $next * @param \Closure $next
* @return mixed * @return mixed
*/ */
public function handle($request, Closure $next) 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'); return redirect()->guest('/register/confirm/awaiting');
} }

View File

@ -75,6 +75,8 @@ Route::group(['middleware' => 'auth'], function () {
Route::post('/{type}/upload', 'ImageController@uploadByType'); Route::post('/{type}/upload', 'ImageController@uploadByType');
Route::get('/{type}/all', 'ImageController@getAllByType'); Route::get('/{type}/all', 'ImageController@getAllByType');
Route::get('/{type}/all/{page}', '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'); Route::delete('/{imageId}', 'ImageController@destroy');
}); });

View File

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

View File

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

View File

@ -11,7 +11,7 @@ class Permission extends Model
*/ */
public function roles() 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->name = $userDetails['name'];
$model->external_auth_id = $userDetails['uid']; $model->external_auth_id = $userDetails['uid'];
$model->email = $userDetails['email']; $model->email = $userDetails['email'];
$model->email_confirmed = true; $model->email_confirmed = false;
return $model; return $model;
} }

View File

@ -136,6 +136,18 @@ class ChapterRepo extends EntityRepo
return $slug; 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. * Get chapters by the given search term.
* @param string $term * @param string $term

View File

@ -84,7 +84,7 @@ class EntityRepo
if ($additionalQuery !== false && is_callable($additionalQuery)) { if ($additionalQuery !== false && is_callable($additionalQuery)) {
$additionalQuery($query); $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) return $this->restrictionService->enforcePageRestrictions($this->page)
->where('draft', '=', false) ->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\Image;
use BookStack\Page;
use BookStack\Services\ImageService; use BookStack\Services\ImageService;
use BookStack\Services\RestrictionService; use BookStack\Services\RestrictionService;
use Setting; use Setting;
@ -13,18 +14,21 @@ class ImageRepo
protected $image; protected $image;
protected $imageService; protected $imageService;
protected $restictionService; protected $restictionService;
protected $page;
/** /**
* ImageRepo constructor. * ImageRepo constructor.
* @param Image $image * @param Image $image
* @param ImageService $imageService * @param ImageService $imageService
* @param RestrictionService $restrictionService * @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->image = $image;
$this->imageService = $imageService; $this->imageService = $imageService;
$this->restictionService = $restrictionService; $this->restictionService = $restrictionService;
$this->page = $page;
} }
@ -38,6 +42,31 @@ class ImageRepo
return $this->image->findOrFail($id); 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. * Gets a load images paginated, filtered by image type.
* @param string $type * @param string $type
@ -54,19 +83,46 @@ class ImageRepo
$images = $images->where('created_by', '=', $userFilter); $images = $images->where('created_by', '=', $userFilter);
} }
$images = $this->restictionService->filterRelatedPages($images, 'images', 'uploaded_to'); return $this->returnPaginated($images, $page, $pageSize);
$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) { * Search for images by query, of a particular type.
$this->loadThumbs($image); * @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, * Get gallery images with a particular filter criteria such as
'hasMore' => $hasMore * 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

@ -312,6 +312,7 @@ class PageRepo extends EntityRepo
$page->fill($input); $page->fill($input);
$page->html = $this->formatHtml($input['html']); $page->html = $this->formatHtml($input['html']);
$page->text = strip_tags($page->html); $page->text = strip_tags($page->html);
if (setting('app-editor') !== 'markdown') $page->markdown = '';
$page->updated_by = $userId; $page->updated_by = $userId;
$page->save(); $page->save();
@ -348,6 +349,7 @@ class PageRepo extends EntityRepo
public function saveRevision(Page $page) public function saveRevision(Page $page)
{ {
$revision = $this->pageRevision->fill($page->toArray()); $revision = $this->pageRevision->fill($page->toArray());
if (setting('app-editor') !== 'markdown') $revision->markdown = '';
$revision->page_id = $page->id; $revision->page_id = $page->id;
$revision->slug = $page->slug; $revision->slug = $page->slug;
$revision->book_slug = $page->book->slug; $revision->book_slug = $page->book->slug;
@ -386,6 +388,8 @@ class PageRepo extends EntityRepo
} }
$draft->fill($data); $draft->fill($data);
if (setting('app-editor') !== 'markdown') $draft->markdown = '';
$draft->save(); $draft->save();
return $draft; return $draft;
} }

View File

@ -106,7 +106,8 @@ class UserRepo
return $this->user->forceCreate([ return $this->user->forceCreate([
'name' => $data['name'], 'name' => $data['name'],
'email' => $data['email'], '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); $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. * Get the role object for the specified role.
* @param $roleName * @param $roleName

View File

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

View File

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

View File

@ -12,7 +12,8 @@
"barryvdh/laravel-ide-helper": "^2.1", "barryvdh/laravel-ide-helper": "^2.1",
"barryvdh/laravel-debugbar": "^2.0", "barryvdh/laravel-debugbar": "^2.0",
"league/flysystem-aws-s3-v3": "^1.0", "league/flysystem-aws-s3-v3": "^1.0",
"barryvdh/laravel-dompdf": "0.6.*" "barryvdh/laravel-dompdf": "0.6.*",
"predis/predis": "^1.0"
}, },
"require-dev": { "require-dev": {
"fzaninotto/faker": "~1.4", "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'), 'env' => env('APP_ENV', 'production'),
'editor' => env('APP_EDITOR', 'html'),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Application Debug Mode | 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'), ',')); $memcachedServers = explode(',', trim(env('MEMCACHED_SERVERS', '127.0.0.1:11211:100'), ','));
foreach ($memcachedServers as $index => $memcachedServer) { foreach ($memcachedServers as $index => $memcachedServer) {
$memcachedServerDetails = explode(':', $memcachedServer); $memcachedServerDetails = explode(':', $memcachedServer);
$components = count($memcachedServerDetails); if (count($memcachedServerDetails) < 2) $memcachedServerDetails[] = '11211';
if ($components < 2) $memcachedServerDetails[] = '11211'; if (count($memcachedServerDetails) < 3) $memcachedServerDetails[] = '100';
if ($components < 3) $memcachedServerDetails[] = '100';
$memcachedServers[$index] = array_combine($memcachedServerKeys, $memcachedServerDetails); $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 <?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 [ return [
/* /*
@ -123,16 +139,6 @@ return [
| |
*/ */
'redis' => [ 'redis' => $redisConfig,
'cluster' => false,
'default' => [
'host' => '127.0.0.1',
'port' => 6379,
'database' => 0,
],
],
]; ];

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", "bootstrap-sass": "^3.0.0",
"dropzone": "^4.0.1", "dropzone": "^4.0.1",
"laravel-elixir": "^3.4.0", "laravel-elixir": "^3.4.0",
"marked": "^0.3.5",
"moment": "^2.12.0",
"zeroclipboard": "^2.2.0" "zeroclipboard": "^2.2.0"
} }
} }

Binary file not shown.

Binary file not shown.

146
readme.md
View File

@ -1,149 +1,18 @@
# BookStack # 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) * [Installation Instructions](https://www.bookstackapp.com/docs/admin/installation)
2. [Installation](#installation) * [Documentation](https://www.bookstackapp.com/docs)
- [Server Rewrite Rules](#url-rewrite-rules) * [Demo Instance](https://demo.bookstackapp.com) *(Login username: `admin@example.com`. Password: `password`)*
3. [Updating](#updating-bookstack) * [BookStack Blog](https://www.bookstackapp.com/blog)
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.
## Development & Testing ## 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: 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** * [Node.js](https://nodejs.org/en/)
* [Gulp](http://gulpjs.com/) **Development Only** * [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. 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/) * [Dropzone.js](http://www.dropzonejs.com/)
* [ZeroClipboard](http://zeroclipboard.org/) * [ZeroClipboard](http://zeroclipboard.org/)
* [TinyColorPicker](http://www.dematte.at/tinyColorPicker/index.html) * [TinyColorPicker](http://www.dematte.at/tinyColorPicker/index.html)
* [Marked](https://github.com/chjj/marked)

View File

@ -1,5 +1,7 @@
"use strict"; "use strict";
var moment = require('moment');
module.exports = function (ngApp, events) { module.exports = function (ngApp, events) {
ngApp.controller('ImageManagerController', ['$scope', '$attrs', '$http', '$timeout', 'imageManagerService', ngApp.controller('ImageManagerController', ['$scope', '$attrs', '$http', '$timeout', 'imageManagerService',
@ -14,20 +16,40 @@ module.exports = function (ngApp, events) {
$scope.imageUpdateSuccess = false; $scope.imageUpdateSuccess = false;
$scope.imageDeleteSuccess = false; $scope.imageDeleteSuccess = false;
$scope.uploadedTo = $attrs.uploadedTo; $scope.uploadedTo = $attrs.uploadedTo;
$scope.view = 'all';
$scope.searching = false;
$scope.searchTerm = '';
var page = 0; var page = 0;
var previousClickTime = 0; var previousClickTime = 0;
var previousClickImage = 0;
var dataLoaded = false; var dataLoaded = false;
var callback = 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} * @returns {string}
*/ */
$scope.getUploadUrl = function () { $scope.getUploadUrl = function () {
return '/images/' + $scope.imageType + '/upload'; 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 * Runs on image upload, Adds an image to local list of images
* and shows a success message to the user. * and shows a success message to the user.
@ -59,7 +81,7 @@ module.exports = function (ngApp, events) {
var currentTime = Date.now(); var currentTime = Date.now();
var timeDiff = currentTime - previousClickTime; var timeDiff = currentTime - previousClickTime;
if (timeDiff < dblClickTime) { if (timeDiff < dblClickTime && image.id === previousClickImage) {
// If double click // If double click
callbackAndHide(image); callbackAndHide(image);
} else { } else {
@ -68,6 +90,7 @@ module.exports = function (ngApp, events) {
$scope.dependantPages = false; $scope.dependantPages = false;
} }
previousClickTime = currentTime; previousClickTime = currentTime;
previousClickImage = image.id;
}; };
/** /**
@ -110,20 +133,69 @@ module.exports = function (ngApp, events) {
$scope.showing = false; $scope.showing = false;
}; };
var baseUrl = '/images/' + $scope.imageType + '/all/'
/** /**
* Fetch the list image data from the server. * Fetch the list image data from the server.
*/ */
function fetchData() { 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) => { $http.get(url).then((response) => {
$scope.images = $scope.images.concat(response.data.images); $scope.images = $scope.images.concat(response.data.images);
$scope.hasMore = response.data.hasMore; $scope.hasMore = response.data.hasMore;
page++; page++;
}); });
} }
$scope.fetchData = fetchData; $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. * Save the details of an image.
* @param event * @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.editorOptions = require('./pages/page-form');
$scope.editorHtml = ''; $scope.editContent = '';
$scope.draftText = ''; $scope.draftText = '';
var pageId = Number($attrs.pageId); var pageId = Number($attrs.pageId);
var isEdit = pageId !== 0; var isEdit = pageId !== 0;
var autosaveFrequency = 30; // AutoSave interval in seconds. var autosaveFrequency = 30; // AutoSave interval in seconds.
var isMarkdown = $attrs.editorType === 'markdown';
$scope.isUpdateDraft = Number($attrs.pageUpdateDraft) === 1; $scope.isUpdateDraft = Number($attrs.pageUpdateDraft) === 1;
$scope.isNewPageDraft = Number($attrs.pageNewDraft) === 1; $scope.isNewPageDraft = Number($attrs.pageNewDraft) === 1;
// Set inital header draft text
if ($scope.isUpdateDraft || $scope.isNewPageDraft) { if ($scope.isUpdateDraft || $scope.isNewPageDraft) {
$scope.draftText = 'Editing Draft' $scope.draftText = 'Editing Draft'
} else { } else {
@ -245,7 +321,18 @@ module.exports = function (ngApp, events) {
}, 1000); }, 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 * Start the AutoSave loop, Checks for content change
@ -253,17 +340,18 @@ module.exports = function (ngApp, events) {
*/ */
function startAutoSave() { function startAutoSave() {
currentContent.title = $('#name').val(); currentContent.title = $('#name').val();
currentContent.html = $scope.editorHtml; currentContent.html = $scope.editContent;
autoSave = $interval(() => { autoSave = $interval(() => {
var newTitle = $('#name').val(); var newTitle = $('#name').val();
var newHtml = $scope.editorHtml; var newHtml = $scope.editContent;
if (newTitle !== currentContent.title || newHtml !== currentContent.html) { if (newTitle !== currentContent.title || newHtml !== currentContent.html) {
currentContent.html = newHtml; currentContent.html = newHtml;
currentContent.title = newTitle; currentContent.title = newTitle;
saveDraft(newTitle, newHtml); saveDraft();
} }
}, 1000 * autosaveFrequency); }, 1000 * autosaveFrequency);
} }
@ -272,20 +360,23 @@ module.exports = function (ngApp, events) {
* @param title * @param title
* @param html * @param html
*/ */
function saveDraft(title, html) { function saveDraft() {
$http.put('/ajax/page/' + pageId + '/save-draft', { var data = {
name: title, name: $('#name').val(),
html: html html: isMarkdown ? $sce.getTrustedHtml($scope.displayContent) : $scope.editContent
}).then((responseData) => { };
$scope.draftText = responseData.data.message;
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; if (!$scope.isNewPageDraft) $scope.isUpdateDraft = true;
}); });
} }
$scope.forceDraftSave = function() { $scope.forceDraftSave = function() {
var newTitle = $('#name').val(); saveDraft();
var newHtml = $scope.editorHtml;
saveDraft(newTitle, newHtml);
}; };
/** /**
@ -298,6 +389,7 @@ module.exports = function (ngApp, events) {
$scope.draftText = 'Editing Page'; $scope.draftText = 'Editing Page';
$scope.isUpdateDraft = false; $scope.isUpdateDraft = false;
$scope.$broadcast('html-update', responseData.data.html); $scope.$broadcast('html-update', responseData.data.html);
$scope.$broadcast('markdown-update', responseData.data.markdown || responseData.data.html);
$('#name').val(responseData.data.name); $('#name').val(responseData.data.name);
$timeout(() => { $timeout(() => {
startAutoSave(); startAutoSave();

View File

@ -1,5 +1,6 @@
"use strict"; "use strict";
var DropZone = require('dropzone'); var DropZone = require('dropzone');
var markdown = require('marked');
var toggleSwitchTemplate = require('./components/toggle-switch.html'); var toggleSwitchTemplate = require('./components/toggle-switch.html');
var imagePickerTemplate = require('./components/image-picker.html'); var imagePickerTemplate = require('./components/image-picker.html');
@ -200,7 +201,82 @@ module.exports = function (ngApp, events) {
tinymce.init(scope.tinymce); 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

@ -94,3 +94,14 @@
font-weight: normal; font-weight: normal;
font-style: 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; 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 { label {
display: block; display: block;
line-height: 1.4em; line-height: 1.4em;
@ -160,6 +213,10 @@ input:checked + .toggle-switch {
width: 100%; width: 100%;
} }
div[editor-type="markdown"] .title-input.page-title input[type="text"] {
max-width: 100%;
}
.search-box { .search-box {
max-width: 100%; max-width: 100%;
position: relative; position: relative;

View File

@ -189,12 +189,13 @@ form.search-box {
} }
} }
.setting-nav { .nav-tabs {
text-align: center; text-align: center;
a { a, .tab-item {
padding: $-m; padding: $-m;
display: inline-block; display: inline-block;
color: #666; color: #666;
cursor: pointer;
&.selected { &.selected {
border-bottom: 2px solid $primary; border-bottom: 2px solid $primary;
} }

View File

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

View File

@ -25,3 +25,12 @@ table {
font-weight: 500; 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; @extend .code-base;
padding: 1px $-xs; padding: 1px $-xs;
} }
pre code {
background-color: transparent;
border: 0;
font-size: 1em;
}
/* /*
* Text colors * Text colors
*/ */

View File

@ -177,3 +177,28 @@ $btt-size: 40px;
top: -5px; 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

@ -1,7 +1,6 @@
<label> <label>
<input value="true" id="{{$name}}[{{$role->id}}][{{$action}}]" type="checkbox" name="{{$name}}[{{$role->id}}][{{$action}}]" <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 }}
</label> </label>

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() }} {{ csrf_field() }}
<div class="faded-small toolbar"> <div class="faded-small toolbar">
@ -42,10 +42,45 @@
</div> </div>
</div> </div>
<div class="edit-area flex-fill flex"> <div class="edit-area flex-fill flex">
<textarea id="html-editor" tinymce="editorOptions" mce-change="editorChange" mce-model="editorHtml" name="html" rows="5" @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')) class="neg" @endif>@if(isset($model) || old('html')){{htmlspecialchars( old('html') ? old('html') : $model->html)}}@endif</textarea>
@if($errors->has('html')) @if($errors->has('html'))
<div class="text-neg text-small">{{ $errors->first('html') }}</div> <div class="text-neg text-small">{{ $errors->first('html') }}</div>
@endif @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>
</div> </div>

View File

@ -12,7 +12,7 @@
.button-base:hover, .button:hover, input[type="button"]:hover, input[type="submit"]:hover, .button:focus { .button-base:hover, .button:hover, input[type="button"]:hover, input[type="submit"]:hover, .button:focus {
background-color: {{ Setting::get('app-color') }}; 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') }}; border-bottom-color: {{ Setting::get('app-color') }};
} }
p.primary:hover, p .primary:hover, span.primary:hover, .text-primary:hover, a, a:hover, a:focus { 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-body" ng-click="$event.stopPropagation()">
<div class="image-manager-content"> <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 class="image-manager-list">
<div ng-repeat="image in images"> <div ng-repeat="image in images">
<div class="image anim fadeIn" ng-style="{animationDelay: ($index > 26) ? '160ms' : ($index * 25) + 'ms'}" <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="col-md-6">
<div class="form-group"> <div class="form-group">
<label for="setting-app-name">Application name</label> <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>
<div class="form-group"> <div class="form-group">
<label>Allow public viewing?</label> <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>
<div class="form-group"> <div class="form-group">
<label>Enable higher security image uploads?</label> <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> <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> </div>
<div class="col-md-6"> <div class="col-md-6">
<div class="form-group" id="logo-control"> <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> <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>
<div class="form-group" id="color-control"> <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> <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="text" value="{{ setting('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="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> </div>
</div> </div>
@ -53,14 +61,14 @@
<div class="col-md-6"> <div class="col-md-6">
<div class="form-group"> <div class="form-group">
<label for="setting-registration-enabled">Allow registration?</label> <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>
<div class="form-group"> <div class="form-group">
<label for="setting-registration-role">Default user role after registration</label> <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> <select id="setting-registration-role" name="setting-registration-role" @if($errors->has('setting-registration-role')) class="neg" @endif>
@foreach(\BookStack\Role::all() as $role) @foreach(\BookStack\Role::all() as $role)
<option value="{{$role->id}}" <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 }} {{ $role->display_name }}
</option> </option>
@ -70,7 +78,7 @@
<div class="form-group"> <div class="form-group">
<label for="setting-registration-confirmation">Require email confirmation?</label> <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> <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> </div>
<div class="col-md-6"> <div class="col-md-6">
@ -78,7 +86,7 @@
<label for="setting-registration-restrict">Restrict registration to domain</label> <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. <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> <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> </div>
</div> </div>

View File

@ -2,7 +2,7 @@
<div class="faded-small toolbar"> <div class="faded-small toolbar">
<div class="container"> <div class="container">
<div class="row"> <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" @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/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> <a href="/settings/roles" @if($selected == 'roles') class="selected text-button" @endif><i class="zmdi zmdi-lock-open"></i>Roles</a>

View File

@ -2,7 +2,9 @@
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-9">
<div class="row">
<div class="col-md-5">
<h3>Role Details</h3> <h3>Role Details</h3>
<div class="form-group"> <div class="form-group">
<label for="name">Role Name</label> <label for="name">Role Name</label>
@ -13,36 +15,18 @@
@include('form/text', ['name' => 'description']) @include('form/text', ['name' => 'description'])
</div> </div>
<h3>System Permissions</h3> <h3>System Permissions</h3>
<div class="row"> <label>@include('settings/roles/checkbox', ['permission' => 'users-manage']) Manage users</label>
<div class="col-md-6"> <label>@include('settings/roles/checkbox', ['permission' => 'user-roles-manage']) Manage roles & role permissions</label>
<label> @include('settings/roles/checkbox', ['permission' => 'users-manage']) Manage users</label>
</div>
<div class="col-md-6">
<label>@include('settings/roles/checkbox', ['permission' => 'user-roles-manage']) Manage user roles</label>
</div>
</div>
<hr class="even">
<div class="row">
<div class="col-md-6">
<label>@include('settings/roles/checkbox', ['permission' => 'restrictions-manage-all']) Manage all Book, Chapter & Page permissions</label> <label>@include('settings/roles/checkbox', ['permission' => 'restrictions-manage-all']) Manage all Book, Chapter & Page permissions</label>
</div>
<div class="col-md-6">
<label>@include('settings/roles/checkbox', ['permission' => 'restrictions-manage-own']) Manage permissions on own Book, Chapter & Pages</label> <label>@include('settings/roles/checkbox', ['permission' => 'restrictions-manage-own']) Manage permissions on own Book, Chapter & Pages</label>
</div>
</div>
<hr class="even">
<div class="form-group">
<label>@include('settings/roles/checkbox', ['permission' => 'settings-manage']) Manage app settings</label> <label>@include('settings/roles/checkbox', ['permission' => 'settings-manage']) Manage app settings</label>
</div> </div>
<hr class="even">
</div>
<div class="col-md-6"> <div class="col-md-6">
<h3>Asset Permissions</h3> <h3>Asset Permissions</h3>
<p> <p>
These permissions control default access to the assets within the system. <br> These permissions control default access to the assets within the system.
Permissions on Books, Chapters and Pages will override these permissions. Permissions on Books, Chapters and Pages will override these permissions.
</p> </p>
<table class="table"> <table class="table">
@ -110,8 +94,38 @@
</tr> </tr>
</table> </table>
</div> </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>
@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> </div>
<a href="/settings/roles" class="button muted">Cancel</a>
<button type="submit" class="button pos">Save Role</button>

View File

@ -43,7 +43,7 @@ class LdapTest extends \TestCase
->press('Sign In') ->press('Sign In')
->seePageIs('/') ->seePageIs('/')
->see($this->mockUser->name) ->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() public function test_login_works_when_no_uid_provided_by_ldap_server()
@ -67,7 +67,7 @@ class LdapTest extends \TestCase
->press('Sign In') ->press('Sign In')
->seePageIs('/') ->seePageIs('/')
->see($this->mockUser->name) ->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() 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);
}
}

View File

@ -171,11 +171,27 @@ class TestCase extends Illuminate\Foundation\Testing\TestCase
return $this; return $this;
} }
protected function actingAsUsers($usersArray, $callback) /**
* Check if the page contains the given element.
* @param string $selector
* @return bool
*/
protected function pageHasElement($selector)
{ {
foreach ($usersArray as $user) { $elements = $this->crawler->filter($selector);
$this->actingAs($user); $this->assertTrue(count($elements) > 0, "The page does not contain an element matching " . $selector);
$callback($user); 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;
} }
} }