Merge pull request #2 from BookStackApp/master

Getting the latest
This commit is contained in:
Abijeet Patro 2016-11-29 00:23:30 +05:30 committed by GitHub
commit d2efc2f47f
99 changed files with 2552 additions and 497 deletions

3
.gitignore vendored
View File

@ -11,4 +11,5 @@ Homestead.yaml
/storage/images
_ide_helper.php
/storage/debugbar
.phpstorm.meta.php
.phpstorm.meta.php
yarn.lock

36
app/Attachment.php Normal file
View File

@ -0,0 +1,36 @@
<?php namespace BookStack;
class Attachment extends Ownable
{
protected $fillable = ['name', 'order'];
/**
* Get the downloadable file name for this upload.
* @return mixed|string
*/
public function getFileName()
{
if (str_contains($this->name, '.')) return $this->name;
return $this->name . '.' . $this->extension;
}
/**
* Get the page this file was uploaded to.
* @return Page
*/
public function page()
{
return $this->belongsTo(Page::class, 'uploaded_to');
}
/**
* Get the url of this file.
* @return string
*/
public function getUrl()
{
return baseUrl('/attachments/' . $this->id);
}
}

View File

@ -13,9 +13,9 @@ class Book extends Entity
public function getUrl($path = false)
{
if ($path !== false) {
return baseUrl('/books/' . $this->slug . '/' . trim($path, '/'));
return baseUrl('/books/' . urlencode($this->slug) . '/' . trim($path, '/'));
}
return baseUrl('/books/' . $this->slug);
return baseUrl('/books/' . urlencode($this->slug));
}
/*

View File

@ -32,9 +32,9 @@ class Chapter extends Entity
{
$bookSlug = $this->getAttribute('bookSlug') ? $this->getAttribute('bookSlug') : $this->book->slug;
if ($path !== false) {
return baseUrl('/books/' . $bookSlug. '/chapter/' . $this->slug . '/' . trim($path, '/'));
return baseUrl('/books/' . urlencode($bookSlug) . '/chapter/' . urlencode($this->slug) . '/' . trim($path, '/'));
}
return baseUrl('/books/' . $bookSlug. '/chapter/' . $this->slug);
return baseUrl('/books/' . urlencode($bookSlug) . '/chapter/' . urlencode($this->slug));
}
/**

View File

@ -160,44 +160,50 @@ class Entity extends Ownable
public function fullTextSearchQuery($fieldsToSearch, $terms, $wheres = [])
{
$exactTerms = [];
if (count($terms) === 0) {
$search = $this;
$orderBy = 'updated_at';
} else {
foreach ($terms as $key => $term) {
$term = htmlentities($term, ENT_QUOTES);
$term = preg_replace('/[+\-><\(\)~*\"@]+/', ' ', $term);
if (preg_match('/&quot;.*?&quot;/', $term)) {
$term = str_replace('&quot;', '', $term);
$exactTerms[] = '%' . $term . '%';
$term = '"' . $term . '"';
} else {
$term = '' . $term . '*';
}
if ($term !== '*') $terms[$key] = $term;
}
$termString = implode(' ', $terms);
$fields = implode(',', $fieldsToSearch);
$search = static::selectRaw('*, MATCH(name) AGAINST(? IN BOOLEAN MODE) AS title_relevance', [$termString]);
$search = $search->whereRaw('MATCH(' . $fields . ') AGAINST(? IN BOOLEAN MODE)', [$termString]);
$fuzzyTerms = [];
$search = static::newQuery();
// Ensure at least one exact term matches if in search
if (count($exactTerms) > 0) {
$search = $search->where(function ($query) use ($exactTerms, $fieldsToSearch) {
foreach ($exactTerms as $exactTerm) {
foreach ($fieldsToSearch as $field) {
$query->orWhere($field, 'like', $exactTerm);
}
}
});
foreach ($terms as $key => $term) {
$term = htmlentities($term, ENT_QUOTES);
$term = preg_replace('/[+\-><\(\)~*\"@]+/', ' ', $term);
if (preg_match('/&quot;.*?&quot;/', $term) || is_numeric($term)) {
$term = str_replace('&quot;', '', $term);
$exactTerms[] = '%' . $term . '%';
} else {
$term = '' . $term . '*';
if ($term !== '*') $fuzzyTerms[] = $term;
}
$orderBy = 'title_relevance';
};
}
$isFuzzy = count($exactTerms) === 0 && count($fuzzyTerms) > 0;
// Perform fulltext search if relevant terms exist.
if ($isFuzzy) {
$termString = implode(' ', $fuzzyTerms);
$fields = implode(',', $fieldsToSearch);
$search = $search->selectRaw('*, MATCH(name) AGAINST(? IN BOOLEAN MODE) AS title_relevance', [$termString]);
$search = $search->whereRaw('MATCH(' . $fields . ') AGAINST(? IN BOOLEAN MODE)', [$termString]);
}
// Ensure at least one exact term matches if in search
if (count($exactTerms) > 0) {
$search = $search->where(function ($query) use ($exactTerms, $fieldsToSearch) {
foreach ($exactTerms as $exactTerm) {
foreach ($fieldsToSearch as $field) {
$query->orWhere($field, 'like', $exactTerm);
}
}
});
}
$orderBy = $isFuzzy ? 'title_relevance' : 'updated_at';
// Add additional where terms
foreach ($wheres as $whereTerm) {
$search->where($whereTerm[0], $whereTerm[1], $whereTerm[2]);
}
// Load in relations
if ($this->isA('page')) {
$search = $search->with('book', 'chapter', 'createdBy', 'updatedBy');

View File

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

View File

@ -0,0 +1,215 @@
<?php namespace BookStack\Http\Controllers;
use BookStack\Exceptions\FileUploadException;
use BookStack\Attachment;
use BookStack\Repos\PageRepo;
use BookStack\Services\AttachmentService;
use Illuminate\Http\Request;
class AttachmentController extends Controller
{
protected $attachmentService;
protected $attachment;
protected $pageRepo;
/**
* AttachmentController constructor.
* @param AttachmentService $attachmentService
* @param Attachment $attachment
* @param PageRepo $pageRepo
*/
public function __construct(AttachmentService $attachmentService, Attachment $attachment, PageRepo $pageRepo)
{
$this->attachmentService = $attachmentService;
$this->attachment = $attachment;
$this->pageRepo = $pageRepo;
parent::__construct();
}
/**
* Endpoint at which attachments are uploaded to.
* @param Request $request
* @return \Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Http\JsonResponse|\Symfony\Component\HttpFoundation\Response
*/
public function upload(Request $request)
{
$this->validate($request, [
'uploaded_to' => 'required|integer|exists:pages,id',
'file' => 'required|file'
]);
$pageId = $request->get('uploaded_to');
$page = $this->pageRepo->getById($pageId, true);
$this->checkPermission('attachment-create-all');
$this->checkOwnablePermission('page-update', $page);
$uploadedFile = $request->file('file');
try {
$attachment = $this->attachmentService->saveNewUpload($uploadedFile, $pageId);
} catch (FileUploadException $e) {
return response($e->getMessage(), 500);
}
return response()->json($attachment);
}
/**
* Update an uploaded attachment.
* @param int $attachmentId
* @param Request $request
* @return mixed
*/
public function uploadUpdate($attachmentId, Request $request)
{
$this->validate($request, [
'uploaded_to' => 'required|integer|exists:pages,id',
'file' => 'required|file'
]);
$pageId = $request->get('uploaded_to');
$page = $this->pageRepo->getById($pageId, true);
$attachment = $this->attachment->findOrFail($attachmentId);
$this->checkOwnablePermission('page-update', $page);
$this->checkOwnablePermission('attachment-create', $attachment);
if (intval($pageId) !== intval($attachment->uploaded_to)) {
return $this->jsonError('Page mismatch during attached file update');
}
$uploadedFile = $request->file('file');
try {
$attachment = $this->attachmentService->saveUpdatedUpload($uploadedFile, $attachment);
} catch (FileUploadException $e) {
return response($e->getMessage(), 500);
}
return response()->json($attachment);
}
/**
* Update the details of an existing file.
* @param $attachmentId
* @param Request $request
* @return Attachment|mixed
*/
public function update($attachmentId, Request $request)
{
$this->validate($request, [
'uploaded_to' => 'required|integer|exists:pages,id',
'name' => 'required|string|min:1|max:255',
'link' => 'url|min:1|max:255'
]);
$pageId = $request->get('uploaded_to');
$page = $this->pageRepo->getById($pageId, true);
$attachment = $this->attachment->findOrFail($attachmentId);
$this->checkOwnablePermission('page-update', $page);
$this->checkOwnablePermission('attachment-create', $attachment);
if (intval($pageId) !== intval($attachment->uploaded_to)) {
return $this->jsonError('Page mismatch during attachment update');
}
$attachment = $this->attachmentService->updateFile($attachment, $request->all());
return $attachment;
}
/**
* Attach a link to a page.
* @param Request $request
* @return mixed
*/
public function attachLink(Request $request)
{
$this->validate($request, [
'uploaded_to' => 'required|integer|exists:pages,id',
'name' => 'required|string|min:1|max:255',
'link' => 'required|url|min:1|max:255'
]);
$pageId = $request->get('uploaded_to');
$page = $this->pageRepo->getById($pageId, true);
$this->checkPermission('attachment-create-all');
$this->checkOwnablePermission('page-update', $page);
$attachmentName = $request->get('name');
$link = $request->get('link');
$attachment = $this->attachmentService->saveNewFromLink($attachmentName, $link, $pageId);
return response()->json($attachment);
}
/**
* Get the attachments for a specific page.
* @param $pageId
* @return mixed
*/
public function listForPage($pageId)
{
$page = $this->pageRepo->getById($pageId, true);
$this->checkOwnablePermission('page-view', $page);
return response()->json($page->attachments);
}
/**
* Update the attachment sorting.
* @param $pageId
* @param Request $request
* @return mixed
*/
public function sortForPage($pageId, Request $request)
{
$this->validate($request, [
'files' => 'required|array',
'files.*.id' => 'required|integer',
]);
$page = $this->pageRepo->getById($pageId);
$this->checkOwnablePermission('page-update', $page);
$attachments = $request->get('files');
$this->attachmentService->updateFileOrderWithinPage($attachments, $pageId);
return response()->json(['message' => 'Attachment order updated']);
}
/**
* Get an attachment from storage.
* @param $attachmentId
* @return \Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector|\Symfony\Component\HttpFoundation\Response
*/
public function get($attachmentId)
{
$attachment = $this->attachment->findOrFail($attachmentId);
$page = $this->pageRepo->getById($attachment->uploaded_to);
$this->checkOwnablePermission('page-view', $page);
if ($attachment->external) {
return redirect($attachment->path);
}
$attachmentContents = $this->attachmentService->getAttachmentFromStorage($attachment);
return response($attachmentContents, 200, [
'Content-Type' => 'application/octet-stream',
'Content-Disposition' => 'attachment; filename="'. $attachment->getFileName() .'"'
]);
}
/**
* Delete a specific attachment in the system.
* @param $attachmentId
* @return mixed
*/
public function delete($attachmentId)
{
$attachment = $this->attachment->findOrFail($attachmentId);
$this->checkOwnablePermission('attachment-delete', $attachment);
$this->attachmentService->deleteFile($attachment);
return response()->json(['message' => 'Attachment deleted']);
}
}

View File

@ -4,6 +4,8 @@ namespace BookStack\Http\Controllers\Auth;
use BookStack\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\SendsPasswordResetEmails;
use Illuminate\Http\Request;
use Password;
class ForgotPasswordController extends Controller
{
@ -30,4 +32,37 @@ class ForgotPasswordController extends Controller
$this->middleware('guest');
parent::__construct();
}
/**
* Send a reset link to the given user.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\RedirectResponse
*/
public function sendResetLinkEmail(Request $request)
{
$this->validate($request, ['email' => 'required|email']);
// We will send the password reset link to this user. Once we have attempted
// to send the link, we will examine the response then see the message we
// need to show to the user. Finally, we'll send out a proper response.
$response = $this->broker()->sendResetLink(
$request->only('email')
);
if ($response === Password::RESET_LINK_SENT) {
$message = 'A password reset link has been sent to ' . $request->get('email') . '.';
session()->flash('success', $message);
return back()->with('status', trans($response));
}
// If an error was returned by the password broker, we will get this message
// translated so we can notify a user of the problem. We'll redirect back
// to where the users came from so they can attempt this process again.
return back()->withErrors(
['email' => trans($response)]
);
}
}

View File

@ -2,6 +2,7 @@
namespace BookStack\Http\Controllers\Auth;
use BookStack\Exceptions\AuthException;
use BookStack\Http\Controllers\Controller;
use BookStack\Repos\UserRepo;
use BookStack\Services\SocialAuthService;

View File

@ -51,7 +51,7 @@ class RegisterController extends Controller
*/
public function __construct(SocialAuthService $socialAuthService, EmailConfirmationService $emailConfirmationService, UserRepo $userRepo)
{
$this->middleware('guest');
$this->middleware('guest')->except(['socialCallback', 'detachSocialAccount']);
$this->socialAuthService = $socialAuthService;
$this->emailConfirmationService = $emailConfirmationService;
$this->userRepo = $userRepo;
@ -297,5 +297,4 @@ class RegisterController extends Controller
return $this->registerUser($userData, $socialAccount);
}
}

View File

@ -20,6 +20,8 @@ class ResetPasswordController extends Controller
use ResetsPasswords;
protected $redirectTo = '/';
/**
* Create a new controller instance.
*
@ -30,4 +32,18 @@ class ResetPasswordController extends Controller
$this->middleware('guest');
parent::__construct();
}
/**
* Get the response for a successful password reset.
*
* @param string $response
* @return \Illuminate\Http\Response
*/
protected function sendResetResponse($response)
{
$message = 'Your password has been successfully reset.';
session()->flash('success', $message);
return redirect($this->redirectPath())
->with('status', trans($response));
}
}

View File

@ -115,9 +115,11 @@ class ChapterController extends Controller
$book = $this->bookRepo->getBySlug($bookSlug);
$chapter = $this->chapterRepo->getBySlug($chapterSlug, $book->id);
$this->checkOwnablePermission('chapter-update', $chapter);
if ($chapter->name !== $request->get('name')) {
$chapter->slug = $this->chapterRepo->findSuitableSlug($request->get('name'), $book->id, $chapter->id);
}
$chapter->fill($request->all());
$chapter->slug = $this->chapterRepo->findSuitableSlug($chapter->name, $book->id, $chapter->id);
$chapter->updated_by = auth()->user()->id;
$chapter->updated_by = user()->id;
$chapter->save();
Activity::add($chapter, 'chapter_update', $book->id);
return redirect($chapter->getUrl());

View File

@ -3,13 +3,11 @@
namespace BookStack\Http\Controllers;
use BookStack\Ownable;
use HttpRequestException;
use Illuminate\Foundation\Bus\DispatchesJobs;
use Illuminate\Http\Exception\HttpResponseException;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller as BaseController;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Session;
use BookStack\User;
abstract class Controller extends BaseController
@ -33,17 +31,16 @@ abstract class Controller extends BaseController
$this->middleware(function ($request, $next) {
// Get a user instance for the current user
$user = auth()->user();
if (!$user) $user = User::getDefault();
// Share variables with views
view()->share('signedIn', auth()->check());
view()->share('currentUser', $user);
$user = user();
// Share variables with controllers
$this->currentUser = $user;
$this->signedIn = auth()->check();
// Share variables with views
view()->share('signedIn', $this->signedIn);
view()->share('currentUser', $user);
return $next($request);
});
}
@ -72,8 +69,13 @@ abstract class Controller extends BaseController
*/
protected function showPermissionError()
{
Session::flash('error', trans('errors.permission'));
$response = request()->wantsJson() ? response()->json(['error' => trans('errors.permissionJson')], 403) : redirect('/');
if (request()->wantsJson()) {
$response = response()->json(['error' => trans('errors.permissionJson')], 403);
} else {
$response = redirect('/');
session()->flash('error', trans('errors.permission'));
}
throw new HttpResponseException($response);
}
@ -84,7 +86,7 @@ abstract class Controller extends BaseController
*/
protected function checkPermission($permissionName)
{
if (!$this->currentUser || !$this->currentUser->can($permissionName)) {
if (!user() || !user()->can($permissionName)) {
$this->showPermissionError();
}
return true;
@ -126,4 +128,22 @@ abstract class Controller extends BaseController
return response()->json(['message' => $messageText], $statusCode);
}
/**
* Create the response for when a request fails validation.
*
* @param \Illuminate\Http\Request $request
* @param array $errors
* @return \Symfony\Component\HttpFoundation\Response
*/
protected function buildFailedValidationResponse(Request $request, array $errors)
{
if ($request->expectsJson()) {
return response()->json(['validation' => $errors], 422);
}
return redirect()->to($this->getRedirectUrl())
->withInput($request->input())
->withErrors($errors, $this->errorBag());
}
}

View File

@ -12,6 +12,7 @@ use BookStack\Repos\ChapterRepo;
use BookStack\Repos\PageRepo;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Views;
use GatherContent\Htmldiff\Htmldiff;
class PageController extends Controller
{
@ -43,20 +44,53 @@ class PageController extends Controller
/**
* Show the form for creating a new page.
* @param string $bookSlug
* @param bool $chapterSlug
* @param string $chapterSlug
* @return Response
* @internal param bool $pageSlug
*/
public function create($bookSlug, $chapterSlug = false)
public function create($bookSlug, $chapterSlug = null)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$chapter = $chapterSlug ? $this->chapterRepo->getBySlug($chapterSlug, $book->id) : null;
$parent = $chapter ? $chapter : $book;
$this->checkOwnablePermission('page-create', $parent);
$this->setPageTitle('Create New Page');
$draft = $this->pageRepo->getDraftPage($book, $chapter);
return redirect($draft->getUrl());
// Redirect to draft edit screen if signed in
if ($this->signedIn) {
$draft = $this->pageRepo->getDraftPage($book, $chapter);
return redirect($draft->getUrl());
}
// Otherwise show edit view
$this->setPageTitle('Create New Page');
return view('pages/guest-create', ['parent' => $parent]);
}
/**
* Create a new page as a guest user.
* @param Request $request
* @param string $bookSlug
* @param string|null $chapterSlug
* @return mixed
* @throws NotFoundException
*/
public function createAsGuest(Request $request, $bookSlug, $chapterSlug = null)
{
$this->validate($request, [
'name' => 'required|string|max:255'
]);
$book = $this->bookRepo->getBySlug($bookSlug);
$chapter = $chapterSlug ? $this->chapterRepo->getBySlug($chapterSlug, $book->id) : null;
$parent = $chapter ? $chapter : $book;
$this->checkOwnablePermission('page-create', $parent);
$page = $this->pageRepo->getDraftPage($book, $chapter);
$this->pageRepo->publishDraft($page, [
'name' => $request->get('name'),
'html' => ''
]);
return redirect($page->getUrl('/edit'));
}
/**
@ -72,7 +106,13 @@ class PageController extends Controller
$this->checkOwnablePermission('page-create', $book);
$this->setPageTitle('Edit Page Draft');
return view('pages/edit', ['page' => $draft, 'book' => $book, 'isDraft' => true]);
$draftsEnabled = $this->signedIn;
return view('pages/edit', [
'page' => $draft,
'book' => $book,
'isDraft' => true,
'draftsEnabled' => $draftsEnabled
]);
}
/**
@ -182,7 +222,13 @@ class PageController extends Controller
if (count($warnings) > 0) session()->flash('warning', implode("\n", $warnings));
return view('pages/edit', ['page' => $page, 'book' => $book, 'current' => $page]);
$draftsEnabled = $this->signedIn;
return view('pages/edit', [
'page' => $page,
'book' => $book,
'current' => $page,
'draftsEnabled' => $draftsEnabled
]);
}
/**
@ -215,6 +261,14 @@ class PageController extends Controller
{
$page = $this->pageRepo->getById($pageId, true);
$this->checkOwnablePermission('page-update', $page);
if (!$this->signedIn) {
return response()->json([
'status' => 'error',
'message' => 'Guests cannot save drafts',
], 500);
}
if ($page->draft) {
$draft = $this->pageRepo->updateDraftPage($page, $request->only(['name', 'html', 'markdown']));
} else {
@ -335,9 +389,41 @@ class PageController extends Controller
$book = $this->bookRepo->getBySlug($bookSlug);
$page = $this->pageRepo->getBySlug($pageSlug, $book->id);
$revision = $this->pageRepo->getRevisionById($revisionId);
$page->fill($revision->toArray());
$this->setPageTitle('Page Revision For ' . $page->getShortName());
return view('pages/revision', ['page' => $page, 'book' => $book]);
return view('pages/revision', [
'page' => $page,
'book' => $book,
]);
}
/**
* Shows the changes of a single revision
* @param string $bookSlug
* @param string $pageSlug
* @param int $revisionId
* @return \Illuminate\View\View
*/
public function showRevisionChanges($bookSlug, $pageSlug, $revisionId)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$page = $this->pageRepo->getBySlug($pageSlug, $book->id);
$revision = $this->pageRepo->getRevisionById($revisionId);
$prev = $revision->getPrevious();
$prevContent = ($prev === null) ? '' : $prev->html;
$diff = (new Htmldiff)->diff($prevContent, $revision->html);
$page->fill($revision->toArray());
$this->setPageTitle('Page Revision For ' . $page->getShortName());
return view('pages/revision', [
'page' => $page,
'book' => $book,
'diff' => $diff,
]);
}
/**

View File

@ -17,10 +17,7 @@ class SettingController extends Controller
$this->setPageTitle('Settings');
// Get application version
$version = false;
if (function_exists('exec')) {
$version = exec('git describe --always --tags ');
}
$version = trim(file_get_contents(base_path('version')));
return view('settings/index', ['version' => $version]);
}

View File

@ -57,7 +57,7 @@ class UserController extends Controller
{
$this->checkPermission('users-manage');
$authMethod = config('auth.method');
$roles = $this->userRepo->getAssignableRoles();
$roles = $this->userRepo->getAllRoles();
return view('users/create', ['authMethod' => $authMethod, 'roles' => $roles]);
}
@ -126,12 +126,13 @@ class UserController extends Controller
return $this->currentUser->id == $id;
});
$authMethod = config('auth.method');
$user = $this->user->findOrFail($id);
$authMethod = ($user->system_name) ? 'system' : config('auth.method');
$activeSocialDrivers = $socialAuthService->getActiveDrivers();
$this->setPageTitle('User Profile');
$roles = $this->userRepo->getAssignableRoles();
$roles = $this->userRepo->getAllRoles();
return view('users/edit', ['user' => $user, 'activeSocialDrivers' => $activeSocialDrivers, 'authMethod' => $authMethod, 'roles' => $roles]);
}
@ -186,7 +187,7 @@ class UserController extends Controller
/**
* Show the user delete page.
* @param $id
* @param int $id
* @return \Illuminate\View\View
*/
public function delete($id)
@ -219,6 +220,11 @@ class UserController extends Controller
return redirect($user->getEditUrl());
}
if ($user->system_name === 'public') {
session()->flash('error', 'You cannot delete the guest user');
return redirect($user->getEditUrl());
}
$this->userRepo->destroy($user);
session()->flash('success', 'User successfully removed');

View File

@ -54,6 +54,15 @@ class Page extends Entity
return $this->hasMany(PageRevision::class)->where('type', '=', 'version')->orderBy('created_at', 'desc');
}
/**
* Get the attachments assigned to this page.
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function attachments()
{
return $this->hasMany(Attachment::class, 'uploaded_to')->orderBy('order', 'asc');
}
/**
* Get the url for this page.
* @param string|bool $path
@ -63,13 +72,13 @@ class Page extends Entity
{
$bookSlug = $this->getAttribute('bookSlug') ? $this->getAttribute('bookSlug') : $this->book->slug;
$midText = $this->draft ? '/draft/' : '/page/';
$idComponent = $this->draft ? $this->id : $this->slug;
$idComponent = $this->draft ? $this->id : urlencode($this->slug);
if ($path !== false) {
return baseUrl('/books/' . $bookSlug . $midText . $idComponent . '/' . trim($path, '/'));
return baseUrl('/books/' . urlencode($bookSlug) . $midText . $idComponent . '/' . trim($path, '/'));
}
return baseUrl('/books/' . $bookSlug . $midText . $idComponent);
return baseUrl('/books/' . urlencode($bookSlug) . $midText . $idComponent);
}
/**

View File

@ -25,11 +25,26 @@ class PageRevision extends Model
/**
* Get the url for this revision.
* @param null|string $path
* @return string
*/
public function getUrl()
public function getUrl($path = null)
{
return $this->page->getUrl() . '/revisions/' . $this->id;
$url = $this->page->getUrl() . '/revisions/' . $this->id;
if ($path) return $url . '/' . trim($path, '/');
return $url;
}
/**
* Get the previous revision for the same page if existing
* @return \BookStack\PageRevision|null
*/
public function getPrevious()
{
if ($id = static::where('page_id', '=', $this->page_id)->where('id', '<', $this->id)->max('id')) {
return static::find($id);
}
return null;
}
}

View File

@ -132,8 +132,8 @@ class BookRepo extends EntityRepo
{
$book = $this->book->newInstance($input);
$book->slug = $this->findSuitableSlug($book->name);
$book->created_by = auth()->user()->id;
$book->updated_by = auth()->user()->id;
$book->created_by = user()->id;
$book->updated_by = user()->id;
$book->save();
$this->permissionService->buildJointPermissionsForEntity($book);
return $book;
@ -147,9 +147,11 @@ class BookRepo extends EntityRepo
*/
public function updateFromInput(Book $book, $input)
{
if ($book->name !== $input['name']) {
$book->slug = $this->findSuitableSlug($input['name'], $book->id);
}
$book->fill($input);
$book->slug = $this->findSuitableSlug($book->name, $book->id);
$book->updated_by = auth()->user()->id;
$book->updated_by = user()->id;
$book->save();
$this->permissionService->buildJointPermissionsForEntity($book);
return $book;
@ -208,8 +210,7 @@ class BookRepo extends EntityRepo
*/
public function findSuitableSlug($name, $currentId = false)
{
$slug = Str::slug($name);
if ($slug === "") $slug = substr(md5(rand(1, 500)), 0, 5);
$slug = $this->nameToSlug($name);
while ($this->doesSlugExist($slug, $currentId)) {
$slug .= '-' . substr(md5(rand(1, 500)), 0, 3);
}

View File

@ -98,8 +98,8 @@ class ChapterRepo extends EntityRepo
{
$chapter = $this->chapter->newInstance($input);
$chapter->slug = $this->findSuitableSlug($chapter->name, $book->id);
$chapter->created_by = auth()->user()->id;
$chapter->updated_by = auth()->user()->id;
$chapter->created_by = user()->id;
$chapter->updated_by = user()->id;
$chapter = $book->chapters()->save($chapter);
$this->permissionService->buildJointPermissionsForEntity($chapter);
return $chapter;
@ -150,8 +150,7 @@ class ChapterRepo extends EntityRepo
*/
public function findSuitableSlug($name, $bookId, $currentId = false)
{
$slug = Str::slug($name);
if ($slug === "") $slug = substr(md5(rand(1, 500)), 0, 5);
$slug = $this->nameToSlug($name);
while ($this->doesSlugExist($slug, $bookId, $currentId)) {
$slug .= '-' . substr(md5(rand(1, 500)), 0, 3);
}

View File

@ -132,9 +132,8 @@ class EntityRepo
*/
public function getUserDraftPages($count = 20, $page = 0)
{
$user = auth()->user();
return $this->page->where('draft', '=', true)
->where('created_by', '=', $user->id)
->where('created_by', '=', user()->id)
->orderBy('updated_at', 'desc')
->skip($count * $page)->take($count)->get();
}
@ -270,6 +269,19 @@ class EntityRepo
$this->permissionService->buildJointPermissionsForEntities($collection);
}
/**
* Format a name as a url slug.
* @param $name
* @return string
*/
protected function nameToSlug($name)
{
$slug = str_replace(' ', '-', strtolower($name));
$slug = preg_replace('/[\+\/\\\?\@\}\{\.\,\=\[\]\#\&\!\*\'\;\:\$\%]/', '', $slug);
if ($slug === "") $slug = substr(md5(rand(1, 500)), 0, 5);
return $slug;
}
}

View File

@ -5,6 +5,7 @@ use BookStack\Image;
use BookStack\Page;
use BookStack\Services\ImageService;
use BookStack\Services\PermissionService;
use Illuminate\Contracts\Filesystem\FileNotFoundException;
use Setting;
use Symfony\Component\HttpFoundation\File\UploadedFile;
@ -191,7 +192,12 @@ class ImageRepo
*/
public function getThumbnail(Image $image, $width = 220, $height = 220, $keepRatio = false)
{
return $this->imageService->getThumbnail($image, $width, $height, $keepRatio);
try {
return $this->imageService->getThumbnail($image, $width, $height, $keepRatio);
} catch (FileNotFoundException $exception) {
$image->delete();
return [];
}
}

View File

@ -5,6 +5,7 @@ use BookStack\Book;
use BookStack\Chapter;
use BookStack\Entity;
use BookStack\Exceptions\NotFoundException;
use BookStack\Services\AttachmentService;
use Carbon\Carbon;
use DOMDocument;
use DOMXPath;
@ -48,7 +49,7 @@ class PageRepo extends EntityRepo
* Get a page via a specific ID.
* @param $id
* @param bool $allowDrafts
* @return mixed
* @return Page
*/
public function getById($id, $allowDrafts = false)
{
@ -59,7 +60,7 @@ class PageRepo extends EntityRepo
* Get a page identified by the given slug.
* @param $slug
* @param $bookId
* @return mixed
* @return Page
* @throws NotFoundException
*/
public function getBySlug($slug, $bookId)
@ -148,8 +149,8 @@ class PageRepo extends EntityRepo
{
$page = $this->page->newInstance();
$page->name = 'New Page';
$page->created_by = auth()->user()->id;
$page->updated_by = auth()->user()->id;
$page->created_by = user()->id;
$page->updated_by = user()->id;
$page->draft = true;
if ($chapter) $page->chapter_id = $chapter->id;
@ -330,7 +331,7 @@ class PageRepo extends EntityRepo
}
// Update with new details
$userId = auth()->user()->id;
$userId = user()->id;
$page->fill($input);
$page->html = $this->formatHtml($input['html']);
$page->text = strip_tags($page->html);
@ -363,7 +364,7 @@ class PageRepo extends EntityRepo
$page->fill($revision->toArray());
$page->slug = $this->findSuitableSlug($page->name, $book->id, $page->id);
$page->text = strip_tags($page->html);
$page->updated_by = auth()->user()->id;
$page->updated_by = user()->id;
$page->save();
return $page;
}
@ -381,7 +382,7 @@ class PageRepo extends EntityRepo
$revision->page_id = $page->id;
$revision->slug = $page->slug;
$revision->book_slug = $page->book->slug;
$revision->created_by = auth()->user()->id;
$revision->created_by = user()->id;
$revision->created_at = $page->updated_at;
$revision->type = 'version';
$revision->summary = $summary;
@ -404,7 +405,7 @@ class PageRepo extends EntityRepo
*/
public function saveUpdateDraft(Page $page, $data = [])
{
$userId = auth()->user()->id;
$userId = user()->id;
$drafts = $this->userUpdateDraftsQuery($page, $userId)->get();
if ($drafts->count() > 0) {
@ -535,7 +536,7 @@ class PageRepo extends EntityRepo
$query = $this->pageRevision->where('type', '=', 'update_draft')
->where('page_id', '=', $page->id)
->where('updated_at', '>', $page->updated_at)
->where('created_by', '!=', auth()->user()->id)
->where('created_by', '!=', user()->id)
->with('createdBy');
if ($minRange !== null) {
@ -548,7 +549,7 @@ class PageRepo extends EntityRepo
/**
* Gets a single revision via it's id.
* @param $id
* @return mixed
* @return PageRevision
*/
public function getRevisionById($id)
{
@ -613,8 +614,7 @@ class PageRepo extends EntityRepo
*/
public function findSuitableSlug($name, $bookId, $currentId = false)
{
$slug = Str::slug($name);
if ($slug === "") $slug = substr(md5(rand(1, 500)), 0, 5);
$slug = $this->nameToSlug($name);
while ($this->doesSlugExist($slug, $bookId, $currentId)) {
$slug .= '-' . substr(md5(rand(1, 500)), 0, 3);
}
@ -633,12 +633,20 @@ class PageRepo extends EntityRepo
$page->revisions()->delete();
$page->permissions()->delete();
$this->permissionService->deleteJointPermissionsForEntity($page);
// Delete AttachedFiles
$attachmentService = app(AttachmentService::class);
foreach ($page->attachments as $attachment) {
$attachmentService->deleteFile($attachment);
}
$page->delete();
}
/**
* Get the latest pages added to the system.
* @param $count
* @return mixed
*/
public function getRecentlyCreatedPaginated($count = 20)
{
@ -648,6 +656,7 @@ class PageRepo extends EntityRepo
/**
* Get the latest pages added to the system.
* @param $count
* @return mixed
*/
public function getRecentlyUpdatedPaginated($count = 20)
{

View File

@ -35,7 +35,7 @@ class PermissionsRepo
*/
public function getAllRoles()
{
return $this->role->where('hidden', '=', false)->get();
return $this->role->all();
}
/**
@ -45,7 +45,7 @@ class PermissionsRepo
*/
public function getAllRolesExcept(Role $role)
{
return $this->role->where('id', '!=', $role->id)->where('hidden', '=', false)->get();
return $this->role->where('id', '!=', $role->id)->get();
}
/**
@ -90,8 +90,6 @@ class PermissionsRepo
{
$role = $this->role->findOrFail($roleId);
if ($role->hidden) throw new PermissionsException("Cannot update a hidden role");
$permissions = isset($roleData['permissions']) ? array_keys($roleData['permissions']) : [];
$this->assignRolePermissions($role, $permissions);

View File

@ -199,9 +199,9 @@ class UserRepo
* Get the roles in the system that are assignable to a user.
* @return mixed
*/
public function getAssignableRoles()
public function getAllRoles()
{
return $this->role->visible();
return $this->role->all();
}
/**
@ -211,7 +211,7 @@ class UserRepo
*/
public function getRestrictableRoles()
{
return $this->role->where('hidden', '=', false)->where('system_name', '=', '')->get();
return $this->role->where('system_name', '!=', 'admin')->get();
}
}

View File

@ -66,7 +66,7 @@ class Role extends Model
/**
* Get the role object for the specified role.
* @param $roleName
* @return mixed
* @return Role
*/
public static function getRole($roleName)
{
@ -76,7 +76,7 @@ class Role extends Model
/**
* Get the role object for the specified system role.
* @param $roleName
* @return mixed
* @return Role
*/
public static function getSystemRole($roleName)
{

View File

@ -19,7 +19,7 @@ class ActivityService
{
$this->activity = $activity;
$this->permissionService = $permissionService;
$this->user = auth()->user();
$this->user = user();
}
/**

View File

@ -0,0 +1,201 @@
<?php namespace BookStack\Services;
use BookStack\Exceptions\FileUploadException;
use BookStack\Attachment;
use Exception;
use Symfony\Component\HttpFoundation\File\UploadedFile;
class AttachmentService extends UploadService
{
/**
* Get an attachment from storage.
* @param Attachment $attachment
* @return string
*/
public function getAttachmentFromStorage(Attachment $attachment)
{
$attachmentPath = $this->getStorageBasePath() . $attachment->path;
return $this->getStorage()->get($attachmentPath);
}
/**
* Store a new attachment upon user upload.
* @param UploadedFile $uploadedFile
* @param int $page_id
* @return Attachment
* @throws FileUploadException
*/
public function saveNewUpload(UploadedFile $uploadedFile, $page_id)
{
$attachmentName = $uploadedFile->getClientOriginalName();
$attachmentPath = $this->putFileInStorage($attachmentName, $uploadedFile);
$largestExistingOrder = Attachment::where('uploaded_to', '=', $page_id)->max('order');
$attachment = Attachment::forceCreate([
'name' => $attachmentName,
'path' => $attachmentPath,
'extension' => $uploadedFile->getClientOriginalExtension(),
'uploaded_to' => $page_id,
'created_by' => user()->id,
'updated_by' => user()->id,
'order' => $largestExistingOrder + 1
]);
return $attachment;
}
/**
* Store a upload, saving to a file and deleting any existing uploads
* attached to that file.
* @param UploadedFile $uploadedFile
* @param Attachment $attachment
* @return Attachment
* @throws FileUploadException
*/
public function saveUpdatedUpload(UploadedFile $uploadedFile, Attachment $attachment)
{
if (!$attachment->external) {
$this->deleteFileInStorage($attachment);
}
$attachmentName = $uploadedFile->getClientOriginalName();
$attachmentPath = $this->putFileInStorage($attachmentName, $uploadedFile);
$attachment->name = $attachmentName;
$attachment->path = $attachmentPath;
$attachment->external = false;
$attachment->extension = $uploadedFile->getClientOriginalExtension();
$attachment->save();
return $attachment;
}
/**
* Save a new File attachment from a given link and name.
* @param string $name
* @param string $link
* @param int $page_id
* @return Attachment
*/
public function saveNewFromLink($name, $link, $page_id)
{
$largestExistingOrder = Attachment::where('uploaded_to', '=', $page_id)->max('order');
return Attachment::forceCreate([
'name' => $name,
'path' => $link,
'external' => true,
'extension' => '',
'uploaded_to' => $page_id,
'created_by' => user()->id,
'updated_by' => user()->id,
'order' => $largestExistingOrder + 1
]);
}
/**
* Get the file storage base path, amended for storage type.
* This allows us to keep a generic path in the database.
* @return string
*/
private function getStorageBasePath()
{
return $this->isLocal() ? 'storage/' : '';
}
/**
* Updates the file ordering for a listing of attached files.
* @param array $attachmentList
* @param $pageId
*/
public function updateFileOrderWithinPage($attachmentList, $pageId)
{
foreach ($attachmentList as $index => $attachment) {
Attachment::where('uploaded_to', '=', $pageId)->where('id', '=', $attachment['id'])->update(['order' => $index]);
}
}
/**
* Update the details of a file.
* @param Attachment $attachment
* @param $requestData
* @return Attachment
*/
public function updateFile(Attachment $attachment, $requestData)
{
$attachment->name = $requestData['name'];
if (isset($requestData['link']) && trim($requestData['link']) !== '') {
$attachment->path = $requestData['link'];
if (!$attachment->external) {
$this->deleteFileInStorage($attachment);
$attachment->external = true;
}
}
$attachment->save();
return $attachment;
}
/**
* Delete a File from the database and storage.
* @param Attachment $attachment
*/
public function deleteFile(Attachment $attachment)
{
if ($attachment->external) {
$attachment->delete();
return;
}
$this->deleteFileInStorage($attachment);
$attachment->delete();
}
/**
* Delete a file from the filesystem it sits on.
* Cleans any empty leftover folders.
* @param Attachment $attachment
*/
protected function deleteFileInStorage(Attachment $attachment)
{
$storedFilePath = $this->getStorageBasePath() . $attachment->path;
$storage = $this->getStorage();
$dirPath = dirname($storedFilePath);
$storage->delete($storedFilePath);
if (count($storage->allFiles($dirPath)) === 0) {
$storage->deleteDirectory($dirPath);
}
}
/**
* Store a file in storage with the given filename
* @param $attachmentName
* @param UploadedFile $uploadedFile
* @return string
* @throws FileUploadException
*/
protected function putFileInStorage($attachmentName, UploadedFile $uploadedFile)
{
$attachmentData = file_get_contents($uploadedFile->getRealPath());
$storage = $this->getStorage();
$attachmentBasePath = 'uploads/files/' . Date('Y-m-M') . '/';
$storageBasePath = $this->getStorageBasePath() . $attachmentBasePath;
$uploadFileName = $attachmentName;
while ($storage->exists($storageBasePath . $uploadFileName)) {
$uploadFileName = str_random(3) . $uploadFileName;
}
$attachmentPath = $attachmentBasePath . $uploadFileName;
$attachmentStoragePath = $this->getStorageBasePath() . $attachmentPath;
try {
$storage->put($attachmentStoragePath, $attachmentData);
} catch (Exception $e) {
throw new FileUploadException('File path ' . $attachmentStoragePath . ' could not be uploaded to. Ensure it is writable to the server.');
}
return $attachmentPath;
}
}

View File

@ -9,20 +9,13 @@ use Intervention\Image\ImageManager;
use Illuminate\Contracts\Filesystem\Factory as FileSystem;
use Illuminate\Contracts\Filesystem\Filesystem as FileSystemInstance;
use Illuminate\Contracts\Cache\Repository as Cache;
use Setting;
use Symfony\Component\HttpFoundation\File\UploadedFile;
class ImageService
class ImageService extends UploadService
{
protected $imageTool;
protected $fileSystem;
protected $cache;
/**
* @var FileSystemInstance
*/
protected $storageInstance;
protected $storageUrl;
/**
@ -34,8 +27,8 @@ class ImageService
public function __construct(ImageManager $imageTool, FileSystem $fileSystem, Cache $cache)
{
$this->imageTool = $imageTool;
$this->fileSystem = $fileSystem;
$this->cache = $cache;
parent::__construct($fileSystem);
}
/**
@ -88,6 +81,9 @@ class ImageService
if ($secureUploads) $imageName = str_random(16) . '-' . $imageName;
$imagePath = '/uploads/images/' . $type . '/' . Date('Y-m-M') . '/';
if ($this->isLocal()) $imagePath = '/public' . $imagePath;
while ($storage->exists($imagePath . $imageName)) {
$imageName = str_random(3) . $imageName;
}
@ -100,6 +96,8 @@ class ImageService
throw new ImageUploadException('Image Path ' . $fullPath . ' is not writable by the server.');
}
if ($this->isLocal()) $fullPath = str_replace_first('/public', '', $fullPath);
$imageDetails = [
'name' => $imageName,
'path' => $fullPath,
@ -108,8 +106,8 @@ class ImageService
'uploaded_to' => $uploadedTo
];
if (auth()->user() && auth()->user()->id !== 0) {
$userId = auth()->user()->id;
if (user()->id !== 0) {
$userId = user()->id;
$imageDetails['created_by'] = $userId;
$imageDetails['updated_by'] = $userId;
}
@ -119,6 +117,16 @@ class ImageService
return $image;
}
/**
* Get the storage path, Dependant of storage type.
* @param Image $image
* @return mixed|string
*/
protected function getPath(Image $image)
{
return ($this->isLocal()) ? ('public/' . $image->path) : $image->path;
}
/**
* Get the thumbnail for an image.
* If $keepRatio is true only the width will be used.
@ -135,7 +143,8 @@ class ImageService
public function getThumbnail(Image $image, $width = 220, $height = 220, $keepRatio = false)
{
$thumbDirName = '/' . ($keepRatio ? 'scaled-' : 'thumbs-') . $width . '-' . $height . '/';
$thumbFilePath = dirname($image->path) . $thumbDirName . basename($image->path);
$imagePath = $this->getPath($image);
$thumbFilePath = dirname($imagePath) . $thumbDirName . basename($imagePath);
if ($this->cache->has('images-' . $image->id . '-' . $thumbFilePath) && $this->cache->get('images-' . $thumbFilePath)) {
return $this->getPublicUrl($thumbFilePath);
@ -148,7 +157,7 @@ class ImageService
}
try {
$thumb = $this->imageTool->make($storage->get($image->path));
$thumb = $this->imageTool->make($storage->get($imagePath));
} catch (Exception $e) {
if ($e instanceof \ErrorException || $e instanceof NotSupportedException) {
throw new ImageUploadException('The server cannot create thumbnails. Please check you have the GD PHP extension installed.');
@ -183,8 +192,8 @@ class ImageService
{
$storage = $this->getStorage();
$imageFolder = dirname($image->path);
$imageFileName = basename($image->path);
$imageFolder = dirname($this->getPath($image));
$imageFileName = basename($this->getPath($image));
$allImages = collect($storage->allFiles($imageFolder));
$imagesToDelete = $allImages->filter(function ($imagePath) use ($imageFileName) {
@ -222,35 +231,9 @@ class ImageService
return $image;
}
/**
* Get the storage that will be used for storing images.
* @return FileSystemInstance
*/
private function getStorage()
{
if ($this->storageInstance !== null) return $this->storageInstance;
$storageType = config('filesystems.default');
$this->storageInstance = $this->fileSystem->disk($storageType);
return $this->storageInstance;
}
/**
* Check whether or not a folder is empty.
* @param $path
* @return int
*/
private function isFolderEmpty($path)
{
$files = $this->getStorage()->files($path);
$folders = $this->getStorage()->directories($path);
return count($files) === 0 && count($folders) === 0;
}
/**
* Gets a public facing url for an image by checking relevant environment variables.
* @param $filePath
* @param string $filePath
* @return string
*/
private function getPublicUrl($filePath)
@ -273,6 +256,8 @@ class ImageService
$this->storageUrl = $storageUrl;
}
if ($this->isLocal()) $filePath = str_replace_first('public/', '', $filePath);
return ($this->storageUrl == false ? rtrim(baseUrl(''), '/') : rtrim($this->storageUrl, '/')) . $filePath;
}

View File

@ -614,7 +614,7 @@ class PermissionService
private function currentUser()
{
if ($this->currentUserModel === false) {
$this->currentUserModel = auth()->user() ? auth()->user() : new User();
$this->currentUserModel = user();
}
return $this->currentUserModel;

View File

@ -100,7 +100,7 @@ class SocialAuthService
$socialAccount = $this->socialAccount->where('driver_id', '=', $socialId)->first();
$user = $this->userRepo->getByEmail($socialUser->getEmail());
$isLoggedIn = auth()->check();
$currentUser = auth()->user();
$currentUser = user();
// When a user is not logged in and a matching SocialAccount exists,
// Simply log the user into the application.
@ -214,9 +214,9 @@ class SocialAuthService
public function detachSocialAccount($socialDriver)
{
session();
auth()->user()->socialAccounts()->where('driver', '=', $socialDriver)->delete();
user()->socialAccounts()->where('driver', '=', $socialDriver)->delete();
session()->flash('success', title_case($socialDriver) . ' account successfully detached');
return redirect(auth()->user()->getEditUrl());
return redirect(user()->getEditUrl());
}
}

View File

@ -0,0 +1,64 @@
<?php namespace BookStack\Services;
use Illuminate\Contracts\Filesystem\Factory as FileSystem;
use Illuminate\Contracts\Filesystem\Filesystem as FileSystemInstance;
class UploadService
{
/**
* @var FileSystem
*/
protected $fileSystem;
/**
* @var FileSystemInstance
*/
protected $storageInstance;
/**
* FileService constructor.
* @param $fileSystem
*/
public function __construct(FileSystem $fileSystem)
{
$this->fileSystem = $fileSystem;
}
/**
* Get the storage that will be used for storing images.
* @return FileSystemInstance
*/
protected function getStorage()
{
if ($this->storageInstance !== null) return $this->storageInstance;
$storageType = config('filesystems.default');
$this->storageInstance = $this->fileSystem->disk($storageType);
return $this->storageInstance;
}
/**
* Check whether or not a folder is empty.
* @param $path
* @return bool
*/
protected function isFolderEmpty($path)
{
$files = $this->getStorage()->files($path);
$folders = $this->getStorage()->directories($path);
return (count($files) === 0 && count($folders) === 0);
}
/**
* Check if using a local filesystem.
* @return bool
*/
protected function isLocal()
{
return strtolower(config('filesystems.default')) === 'local';
}
}

View File

@ -18,7 +18,7 @@ class ViewService
public function __construct(View $view, PermissionService $permissionService)
{
$this->view = $view;
$this->user = auth()->user();
$this->user = user();
$this->permissionService = $permissionService;
}
@ -84,7 +84,7 @@ class ViewService
->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);
$query = $query->where('user_id', '=', user()->id);
$viewables = $query->with('viewable')->orderBy('updated_at', 'desc')
->skip($count * $page)->take($count)->get()->pluck('viewable');

View File

@ -5,6 +5,7 @@ use Illuminate\Auth\Authenticatable;
use Illuminate\Auth\Passwords\CanResetPassword;
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Notifications\Notifiable;
class User extends Model implements AuthenticatableContract, CanResetPasswordContract
@ -36,21 +37,30 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
protected $permissions;
/**
* Returns a default guest user.
* Returns the default public user.
* @return User
*/
public static function getDefault()
{
return new static([
'email' => 'guest',
'name' => 'Guest'
]);
return static::where('system_name', '=', 'public')->first();
}
/**
* Check if the user is the default public user.
* @return bool
*/
public function isDefault()
{
return $this->system_name === 'public';
}
/**
* The roles that belong to the user.
* @return BelongsToMany
*/
public function roles()
{
if ($this->id === 0) return ;
return $this->belongsToMany(Role::class);
}

View File

@ -11,29 +11,30 @@ use BookStack\Ownable;
*/
function versioned_asset($file = '')
{
// Don't require css and JS assets for testing
if (config('app.env') === 'testing') return '';
static $version = null;
static $manifest = null;
$manifestPath = 'build/manifest.json';
if (is_null($manifest) && file_exists($manifestPath)) {
$manifest = json_decode(file_get_contents(public_path($manifestPath)), true);
} else if (!file_exists($manifestPath)) {
if (config('app.env') !== 'production') {
$path = public_path($manifestPath);
$error = "No {$path} file found, Ensure you have built the css/js assets using gulp.";
} else {
$error = "No {$manifestPath} file found, Ensure you are using the release version of BookStack";
}
throw new \Exception($error);
if (is_null($version)) {
$versionFile = base_path('version');
$version = trim(file_get_contents($versionFile));
}
if (isset($manifest[$file])) {
return baseUrl($manifest[$file]);
$additional = '';
if (config('app.env') === 'development') {
$additional = sha1_file(public_path($file));
}
throw new InvalidArgumentException("File {$file} not defined in asset manifest.");
$path = $file . '?version=' . urlencode($version) . $additional;
return baseUrl($path);
}
/**
* Helper method to get the current User.
* Defaults to public 'Guest' user if not logged in.
* @return \BookStack\User
*/
function user()
{
return auth()->user() ?: \BookStack\User::getDefault();
}
/**
@ -47,7 +48,7 @@ function versioned_asset($file = '')
function userCan($permission, Ownable $ownable = null)
{
if ($ownable === null) {
return auth()->user() && auth()->user()->can($permission);
return user() && user()->can($permission);
}
// Check permission on ownable item
@ -128,14 +129,14 @@ function sortUrl($path, $data, $overrideData = [])
{
$queryStringSections = [];
$queryData = array_merge($data, $overrideData);
// Change sorting direction is already sorted on current attribute
if (isset($overrideData['sort']) && $overrideData['sort'] === $data['sort']) {
$queryData['order'] = ($data['order'] === 'asc') ? 'desc' : 'asc';
} else {
$queryData['order'] = 'asc';
}
foreach ($queryData as $name => $value) {
$trimmedVal = trim($value);
if ($trimmedVal === '') continue;
@ -145,4 +146,4 @@ function sortUrl($path, $data, $overrideData = [])
if (count($queryStringSections) === 0) return $path;
return baseUrl($path . '?' . implode('&', $queryStringSections));
}
}

View File

@ -7,13 +7,15 @@
"require": {
"php": ">=5.6.4",
"laravel/framework": "^5.3.4",
"ext-tidy": "*",
"intervention/image": "^2.3",
"laravel/socialite": "^2.0",
"barryvdh/laravel-ide-helper": "^2.1",
"barryvdh/laravel-debugbar": "^2.2.3",
"league/flysystem-aws-s3-v3": "^1.0",
"barryvdh/laravel-dompdf": "^0.7",
"predis/predis": "^1.1"
"predis/predis": "^1.1",
"gathercontent/htmldiff": "^0.2.1"
},
"require-dev": {
"fzaninotto/faker": "~1.4",

218
composer.lock generated
View File

@ -4,21 +4,21 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file",
"This file is @generated automatically"
],
"hash": "c90a6e41767306ceb3b8cedb91468390",
"content-hash": "3b5d2d6b77fbe71101e7e8eaff0754fe",
"hash": "3124d900cfe857392a94de479f3ff6d4",
"content-hash": "a968767a73f77e66e865c276cf76eedf",
"packages": [
{
"name": "aws/aws-sdk-php",
"version": "3.19.6",
"version": "3.19.11",
"source": {
"type": "git",
"url": "https://github.com/aws/aws-sdk-php.git",
"reference": "34060bf0db260031697b17dbb37fa1bbec92f1c4"
"reference": "19bac3bdd7988cbf7f89d5ce8e2748d774e2cde8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/34060bf0db260031697b17dbb37fa1bbec92f1c4",
"reference": "34060bf0db260031697b17dbb37fa1bbec92f1c4",
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/19bac3bdd7988cbf7f89d5ce8e2748d774e2cde8",
"reference": "19bac3bdd7988cbf7f89d5ce8e2748d774e2cde8",
"shasum": ""
},
"require": {
@ -85,32 +85,32 @@
"s3",
"sdk"
],
"time": "2016-09-08 20:27:15"
"time": "2016-09-27 19:38:36"
},
{
"name": "barryvdh/laravel-debugbar",
"version": "V2.2.3",
"version": "v2.3.0",
"source": {
"type": "git",
"url": "https://github.com/barryvdh/laravel-debugbar.git",
"reference": "ecd1ce5c4a827e2f6a8fb41bcf67713beb1c1cbd"
"reference": "0c87981df959c7c1943abe227baf607c92f204f9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/barryvdh/laravel-debugbar/zipball/ecd1ce5c4a827e2f6a8fb41bcf67713beb1c1cbd",
"reference": "ecd1ce5c4a827e2f6a8fb41bcf67713beb1c1cbd",
"url": "https://api.github.com/repos/barryvdh/laravel-debugbar/zipball/0c87981df959c7c1943abe227baf607c92f204f9",
"reference": "0c87981df959c7c1943abe227baf607c92f204f9",
"shasum": ""
},
"require": {
"illuminate/support": "5.1.*|5.2.*|5.3.*",
"maximebf/debugbar": "~1.11.0|~1.12.0",
"maximebf/debugbar": "~1.13.0",
"php": ">=5.5.9",
"symfony/finder": "~2.7|~3.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.2-dev"
"dev-master": "2.3-dev"
}
},
"autoload": {
@ -139,7 +139,7 @@
"profiler",
"webprofiler"
],
"time": "2016-07-29 15:00:36"
"time": "2016-09-15 14:05:56"
},
{
"name": "barryvdh/laravel-dompdf",
@ -358,6 +358,57 @@
],
"time": "2015-11-09 22:51:51"
},
{
"name": "cogpowered/finediff",
"version": "0.3.1",
"source": {
"type": "git",
"url": "https://github.com/cogpowered/FineDiff.git",
"reference": "339ddc8c3afb656efed4f2f0a80e5c3d026f8ea8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/cogpowered/FineDiff/zipball/339ddc8c3afb656efed4f2f0a80e5c3d026f8ea8",
"reference": "339ddc8c3afb656efed4f2f0a80e5c3d026f8ea8",
"shasum": ""
},
"require": {
"php": ">=5.3.0"
},
"require-dev": {
"mockery/mockery": "*",
"phpunit/phpunit": "*"
},
"type": "library",
"autoload": {
"psr-0": {
"cogpowered\\FineDiff": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Rob Crowe",
"email": "rob@cogpowered.com"
},
{
"name": "Raymond Hill"
}
],
"description": "PHP implementation of a Fine granularity Diff engine",
"homepage": "https://github.com/cogpowered/FineDiff",
"keywords": [
"diff",
"finediff",
"opcode",
"string",
"text"
],
"time": "2014-05-19 10:25:02"
},
{
"name": "dnoegel/php-xdg-base-dir",
"version": "0.1",
@ -519,6 +570,55 @@
"homepage": "https://github.com/dompdf/dompdf",
"time": "2016-05-11 00:36:29"
},
{
"name": "gathercontent/htmldiff",
"version": "0.2.1",
"source": {
"type": "git",
"url": "https://github.com/gathercontent/htmldiff.git",
"reference": "24674a62315f64330134b4a4c5b01a7b59193c93"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/gathercontent/htmldiff/zipball/24674a62315f64330134b4a4c5b01a7b59193c93",
"reference": "24674a62315f64330134b4a4c5b01a7b59193c93",
"shasum": ""
},
"require": {
"cogpowered/finediff": "0.3.1",
"ext-tidy": "*"
},
"require-dev": {
"phpunit/phpunit": "4.*",
"squizlabs/php_codesniffer": "1.*"
},
"type": "library",
"autoload": {
"psr-0": {
"GatherContent\\Htmldiff": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Andrew Cairns",
"email": "andrew@gathercontent.com"
},
{
"name": "Mathew Chapman",
"email": "mat@gathercontent.com"
},
{
"name": "Peter Legierski",
"email": "peter@gathercontent.com"
}
],
"description": "Compare two HTML strings",
"time": "2015-04-15 15:39:46"
},
{
"name": "guzzlehttp/guzzle",
"version": "6.2.1",
@ -899,16 +999,16 @@
},
{
"name": "laravel/framework",
"version": "v5.3.9",
"version": "v5.3.11",
"source": {
"type": "git",
"url": "https://github.com/laravel/framework.git",
"reference": "f6fbb481672f8dc4bc6882d5d654bbfa3588c8ec"
"reference": "ca48001b95a0543fb39fcd7219de960bbc03eaa5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/framework/zipball/f6fbb481672f8dc4bc6882d5d654bbfa3588c8ec",
"reference": "f6fbb481672f8dc4bc6882d5d654bbfa3588c8ec",
"url": "https://api.github.com/repos/laravel/framework/zipball/ca48001b95a0543fb39fcd7219de960bbc03eaa5",
"reference": "ca48001b95a0543fb39fcd7219de960bbc03eaa5",
"shasum": ""
},
"require": {
@ -956,6 +1056,7 @@
"illuminate/http": "self.version",
"illuminate/log": "self.version",
"illuminate/mail": "self.version",
"illuminate/notifications": "self.version",
"illuminate/pagination": "self.version",
"illuminate/pipeline": "self.version",
"illuminate/queue": "self.version",
@ -1022,7 +1123,7 @@
"framework",
"laravel"
],
"time": "2016-09-12 14:08:29"
"time": "2016-09-28 02:15:37"
},
{
"name": "laravel/socialite",
@ -1273,16 +1374,16 @@
},
{
"name": "maximebf/debugbar",
"version": "v1.12.0",
"version": "v1.13.0",
"source": {
"type": "git",
"url": "https://github.com/maximebf/php-debugbar.git",
"reference": "e634fbd32cd6bc3fa0e8c972b52d4bf49bab3988"
"reference": "5f49a5ed6cfde81d31d89378806670d77462526e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/maximebf/php-debugbar/zipball/e634fbd32cd6bc3fa0e8c972b52d4bf49bab3988",
"reference": "e634fbd32cd6bc3fa0e8c972b52d4bf49bab3988",
"url": "https://api.github.com/repos/maximebf/php-debugbar/zipball/5f49a5ed6cfde81d31d89378806670d77462526e",
"reference": "5f49a5ed6cfde81d31d89378806670d77462526e",
"shasum": ""
},
"require": {
@ -1301,7 +1402,7 @@
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.12-dev"
"dev-master": "1.13-dev"
}
},
"autoload": {
@ -1330,7 +1431,7 @@
"debug",
"debugbar"
],
"time": "2016-05-15 13:11:34"
"time": "2016-09-15 14:01:59"
},
{
"name": "monolog/monolog",
@ -1558,16 +1659,16 @@
},
{
"name": "nikic/php-parser",
"version": "v2.1.0",
"version": "v2.1.1",
"source": {
"type": "git",
"url": "https://github.com/nikic/PHP-Parser.git",
"reference": "47b254ea51f1d6d5dc04b9b299e88346bf2369e3"
"reference": "4dd659edadffdc2143e4753df655d866dbfeedf0"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/47b254ea51f1d6d5dc04b9b299e88346bf2369e3",
"reference": "47b254ea51f1d6d5dc04b9b299e88346bf2369e3",
"url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/4dd659edadffdc2143e4753df655d866dbfeedf0",
"reference": "4dd659edadffdc2143e4753df655d866dbfeedf0",
"shasum": ""
},
"require": {
@ -1605,7 +1706,7 @@
"parser",
"php"
],
"time": "2016-04-19 13:41:41"
"time": "2016-09-16 12:04:44"
},
{
"name": "paragonie/random_compat",
@ -1825,22 +1926,30 @@
},
{
"name": "psr/log",
"version": "1.0.0",
"version": "1.0.1",
"source": {
"type": "git",
"url": "https://github.com/php-fig/log.git",
"reference": "fe0936ee26643249e916849d48e3a51d5f5e278b"
"reference": "5277094ed527a1c4477177d102fe4c53551953e0"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/log/zipball/fe0936ee26643249e916849d48e3a51d5f5e278b",
"reference": "fe0936ee26643249e916849d48e3a51d5f5e278b",
"url": "https://api.github.com/repos/php-fig/log/zipball/5277094ed527a1c4477177d102fe4c53551953e0",
"reference": "5277094ed527a1c4477177d102fe4c53551953e0",
"shasum": ""
},
"require": {
"php": ">=5.3.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0.x-dev"
}
},
"autoload": {
"psr-0": {
"Psr\\Log\\": ""
"psr-4": {
"Psr\\Log\\": "Psr/Log/"
}
},
"notification-url": "https://packagist.org/downloads/",
@ -1854,12 +1963,13 @@
}
],
"description": "Common interface for logging libraries",
"homepage": "https://github.com/php-fig/log",
"keywords": [
"log",
"psr",
"psr-3"
],
"time": "2012-12-21 11:40:51"
"time": "2016-09-19 16:02:08"
},
{
"name": "psy/psysh",
@ -3167,16 +3277,16 @@
},
{
"name": "myclabs/deep-copy",
"version": "1.5.2",
"version": "1.5.4",
"source": {
"type": "git",
"url": "https://github.com/myclabs/DeepCopy.git",
"reference": "da8529775f14f4fdae33f916eb0cf65f6afbddbc"
"reference": "ea74994a3dc7f8d2f65a06009348f2d63c81e61f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/da8529775f14f4fdae33f916eb0cf65f6afbddbc",
"reference": "da8529775f14f4fdae33f916eb0cf65f6afbddbc",
"url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/ea74994a3dc7f8d2f65a06009348f2d63c81e61f",
"reference": "ea74994a3dc7f8d2f65a06009348f2d63c81e61f",
"shasum": ""
},
"require": {
@ -3205,7 +3315,7 @@
"object",
"object graph"
],
"time": "2016-09-06 16:07:05"
"time": "2016-09-16 13:37:59"
},
{
"name": "phpdocumentor/reflection-common",
@ -3661,24 +3771,24 @@
},
{
"name": "phpunit/phpunit",
"version": "5.5.4",
"version": "5.5.5",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git",
"reference": "3e6e88e56c912133de6e99b87728cca7ed70c5f5"
"reference": "a57126dc681b08289fef6ac96a48e30656f84350"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/3e6e88e56c912133de6e99b87728cca7ed70c5f5",
"reference": "3e6e88e56c912133de6e99b87728cca7ed70c5f5",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/a57126dc681b08289fef6ac96a48e30656f84350",
"reference": "a57126dc681b08289fef6ac96a48e30656f84350",
"shasum": ""
},
"require": {
"ext-dom": "*",
"ext-json": "*",
"ext-pcre": "*",
"ext-reflection": "*",
"ext-spl": "*",
"ext-libxml": "*",
"ext-mbstring": "*",
"ext-xml": "*",
"myclabs/deep-copy": "~1.3",
"php": "^5.6 || ^7.0",
"phpspec/prophecy": "^1.3.1",
@ -3700,7 +3810,12 @@
"conflict": {
"phpdocumentor/reflection-docblock": "3.0.2"
},
"require-dev": {
"ext-pdo": "*"
},
"suggest": {
"ext-tidy": "*",
"ext-xdebug": "*",
"phpunit/php-invoker": "~1.1"
},
"bin": [
@ -3735,7 +3850,7 @@
"testing",
"xunit"
],
"time": "2016-08-26 07:11:44"
"time": "2016-09-21 14:40:13"
},
{
"name": "phpunit/phpunit-mock-objects",
@ -4524,7 +4639,8 @@
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
"php": ">=5.6.4"
"php": ">=5.6.4",
"ext-tidy": "*"
},
"platform-dev": []
}

View File

@ -57,7 +57,7 @@ return [
|
*/
'locale' => 'en',
'locale' => env('APP_LANG', 'en'),
/*
|--------------------------------------------------------------------------

View File

@ -56,7 +56,7 @@ return [
'local' => [
'driver' => 'local',
'root' => public_path(),
'root' => base_path(),
],
'ftp' => [

View File

@ -9,6 +9,8 @@ return [
'app-name-header' => true,
'app-editor' => 'wysiwyg',
'app-color' => '#0288D1',
'app-color-light' => 'rgba(21, 101, 192, 0.15)'
'app-color-light' => 'rgba(21, 101, 192, 0.15)',
'app-custom-head' => false,
'registration-enabled' => false,
];

View File

@ -0,0 +1,66 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class RemoveHiddenRoles extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
// Remove the hidden property from roles
Schema::table('roles', function(Blueprint $table) {
$table->dropColumn('hidden');
});
// Add column to mark system users
Schema::table('users', function(Blueprint $table) {
$table->string('system_name')->nullable()->index();
});
// Insert our new public system user.
$publicUserId = DB::table('users')->insertGetId([
'email' => 'guest@example.com',
'name' => 'Guest',
'system_name' => 'public',
'email_confirmed' => true,
'created_at' => \Carbon\Carbon::now(),
'updated_at' => \Carbon\Carbon::now(),
]);
// Get the public role
$publicRole = DB::table('roles')->where('system_name', '=', 'public')->first();
// Connect the new public user to the public role
DB::table('role_user')->insert([
'user_id' => $publicUserId,
'role_id' => $publicRole->id
]);
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('roles', function(Blueprint $table) {
$table->boolean('hidden')->default(false);
$table->index('hidden');
});
DB::table('users')->where('system_name', '=', 'public')->delete();
Schema::table('users', function(Blueprint $table) {
$table->dropColumn('system_name');
});
DB::table('roles')->where('system_name', '=', 'public')->update(['hidden' => true]);
}
}

View File

@ -0,0 +1,71 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateAttachmentsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('attachments', function (Blueprint $table) {
$table->increments('id');
$table->string('name');
$table->string('path');
$table->string('extension', 20);
$table->integer('uploaded_to');
$table->boolean('external');
$table->integer('order');
$table->integer('created_by');
$table->integer('updated_by');
$table->index('uploaded_to');
$table->timestamps();
});
// Get roles with permissions we need to change
$adminRoleId = DB::table('roles')->where('system_name', '=', 'admin')->first()->id;
// Create & attach new entity permissions
$ops = ['Create All', 'Create Own', 'Update All', 'Update Own', 'Delete All', 'Delete Own'];
$entity = 'Attachment';
foreach ($ops as $op) {
$permissionId = DB::table('role_permissions')->insertGetId([
'name' => strtolower($entity) . '-' . strtolower(str_replace(' ', '-', $op)),
'display_name' => $op . ' ' . $entity . 's',
'created_at' => \Carbon\Carbon::now()->toDateTimeString(),
'updated_at' => \Carbon\Carbon::now()->toDateTimeString()
]);
DB::table('permission_role')->insert([
'role_id' => $adminRoleId,
'permission_id' => $permissionId
]);
}
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('attachments');
// Create & attach new entity permissions
$ops = ['Create All', 'Create Own', 'Update All', 'Update Own', 'Delete All', 'Delete Own'];
$entity = 'Attachment';
foreach ($ops as $op) {
$permName = strtolower($entity) . '-' . strtolower(str_replace(' ', '-', $op));
DB::table('role_permissions')->where('name', '=', $permName)->delete();
}
}
}

View File

@ -1,27 +1,8 @@
var elixir = require('laravel-elixir');
// Custom extensions
var gulp = require('gulp');
var Task = elixir.Task;
var fs = require('fs');
elixir.extend('queryVersion', function(inputFiles) {
new Task('queryVersion', function() {
var manifestObject = {};
var uidString = Date.now().toString(16).slice(4);
for (var i = 0; i < inputFiles.length; i++) {
var file = inputFiles[i];
manifestObject[file] = file + '?version=' + uidString;
}
var fileContents = JSON.stringify(manifestObject, null, 1);
fs.writeFileSync('public/build/manifest.json', fileContents);
}).watch(['./public/css/*.css', './public/js/*.js']);
});
elixir(function(mix) {
mix.sass('styles.scss')
.sass('print-styles.scss')
.sass('export-styles.scss')
.browserify('global.js', 'public/js/common.js')
.queryVersion(['css/styles.css', 'css/print-styles.css', 'js/common.js']);
elixir(mix => {
mix.sass('styles.scss');
mix.sass('print-styles.scss');
mix.sass('export-styles.scss');
mix.browserify('global.js', './public/js/common.js');
});

View File

@ -1,18 +1,19 @@
{
"private": true,
"devDependencies": {
"gulp": "^3.9.0"
"scripts": {
"prod": "gulp --production",
"dev": "gulp watch"
},
"dependencies": {
"devDependencies": {
"angular": "^1.5.5",
"angular-animate": "^1.5.5",
"angular-resource": "^1.5.5",
"angular-sanitize": "^1.5.5",
"angular-ui-sortable": "^0.14.0",
"babel-runtime": "^5.8.29",
"bootstrap-sass": "^3.0.0",
"angular-ui-sortable": "^0.15.0",
"dropzone": "^4.0.1",
"laravel-elixir": "^5.0.0",
"gulp": "^3.9.0",
"laravel-elixir": "^6.0.0-11",
"laravel-elixir-browserify-official": "^0.1.3",
"marked": "^0.3.5",
"moment": "^2.12.0",
"zeroclipboard": "^2.2.0"

View File

@ -30,6 +30,7 @@
<env name="AUTH_METHOD" value="standard"/>
<env name="DISABLE_EXTERNAL_SERVICES" value="true"/>
<env name="LDAP_VERSION" value="3"/>
<env name="STORAGE_TYPE" value="local"/>
<env name="GITHUB_APP_ID" value="aaaaaaaaaaaaaa"/>
<env name="GITHUB_APP_SECRET" value="aaaaaaaaaaaaaa"/>
<env name="GOOGLE_APP_ID" value="aaaaaaaaaaaaaa"/>

View File

@ -2,13 +2,15 @@
[![GitHub release](https://img.shields.io/github/release/ssddanbrown/BookStack.svg?maxAge=2592000)](https://github.com/ssddanbrown/BookStack/releases/latest)
[![license](https://img.shields.io/github/license/ssddanbrown/BookStack.svg?maxAge=2592000)](https://github.com/ssddanbrown/BookStack/blob/master/LICENSE)
[![Build Status](https://travis-ci.org/ssddanbrown/BookStack.svg)](https://travis-ci.org/ssddanbrown/BookStack)
[![Build Status](https://travis-ci.org/BookStackApp/BookStack.svg)](https://travis-ci.org/BookStackApp/BookStack)
A platform for storing and organising information and documentation. General information and documentation for BookStack can be found at https://www.bookstackapp.com/.
* [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`)*
* [Demo Instance](https://demo.bookstackapp.com)
* *Username: `admin@example.com`*
* *Password: `password`*
* [BookStack Blog](https://www.bookstackapp.com/blog)
## Development & Testing
@ -29,7 +31,7 @@ php artisan migrate --database=mysql_testing
php artisan db:seed --class=DummyContentSeeder --database=mysql_testing
```
Once done you can run `phpunit` (or `./vendor/bin/phpunit` if `phpunit` is not found) in the application root directory to run all tests.
Once done you can run `phpunit` in the application root directory to run all tests.
## License
@ -51,3 +53,5 @@ These are the great projects used to help build BookStack:
* [TinyColorPicker](http://www.dematte.at/tinyColorPicker/index.html)
* [Marked](https://github.com/chjj/marked)
* [Moment.js](http://momentjs.com/)
Additionally, Thank you [BrowserStack](https://www.browserstack.com/) for supporting us and making cross-browser testing easy.

View File

@ -1,3 +0,0 @@
<div class="dropzone-container">
<div class="dz-message">Drop files or click here to upload</div>
</div>

View File

@ -1,15 +0,0 @@
<div class="image-picker">
<div>
<img ng-if="image && image !== 'none'" ng-src="{{image}}" ng-class="{{imageClass}}" alt="Image Preview">
<img ng-if="image === '' && defaultImage" ng-src="{{defaultImage}}" ng-class="{{imageClass}}" alt="Image Preview">
</div>
<button class="button" type="button" ng-click="showImageManager()">Select Image</button>
<br>
<button class="text-button" ng-click="reset()" type="button">Reset</button>
<span ng-show="showRemove" class="sep">|</span>
<button ng-show="showRemove" class="text-button neg" ng-click="remove()" type="button">Remove</button>
<input type="hidden" ng-attr-name="{{name}}" ng-attr-id="{{name}}" ng-attr-value="{{value}}">
</div>

View File

@ -1,4 +0,0 @@
<div class="toggle-switch" ng-click="switch()" ng-class="{'active': isActive}">
<input type="hidden" ng-attr-name="{{name}}" ng-attr-value="{{value}}"/>
<div class="switch-handle"></div>
</div>

View File

@ -1,8 +1,10 @@
"use strict";
const moment = require('moment');
import moment from 'moment';
import 'moment/locale/en-gb';
moment.locale('en-gb');
module.exports = function (ngApp, events) {
export default function (ngApp, events) {
ngApp.controller('ImageManagerController', ['$scope', '$attrs', '$http', '$timeout', 'imageManagerService',
function ($scope, $attrs, $http, $timeout, imageManagerService) {
@ -17,7 +19,7 @@ module.exports = function (ngApp, events) {
$scope.imageDeleteSuccess = false;
$scope.uploadedTo = $attrs.uploadedTo;
$scope.view = 'all';
$scope.searching = false;
$scope.searchTerm = '';
@ -48,7 +50,7 @@ module.exports = function (ngApp, events) {
$scope.hasMore = preSearchHasMore;
}
$scope.cancelSearch = cancelSearch;
/**
* Runs on image upload, Adds an image to local list of images
@ -162,7 +164,6 @@ module.exports = function (ngApp, events) {
/**
* Start a search operation
* @param searchTerm
*/
$scope.searchImages = function() {
@ -196,7 +197,7 @@ module.exports = function (ngApp, events) {
$scope.view = viewName;
baseUrl = window.baseUrl('/images/' + $scope.imageType + '/' + viewName + '/');
fetchData();
}
};
/**
* Save the details of an image.
@ -205,7 +206,7 @@ module.exports = function (ngApp, events) {
$scope.saveImageDetails = function (event) {
event.preventDefault();
var url = window.baseUrl('/images/update/' + $scope.selectedImage.id);
$http.put(url, this.selectedImage).then((response) => {
$http.put(url, this.selectedImage).then(response => {
events.emit('success', 'Image details updated');
}, (response) => {
if (response.status === 422) {
@ -300,15 +301,16 @@ module.exports = function (ngApp, events) {
var isEdit = pageId !== 0;
var autosaveFrequency = 30; // AutoSave interval in seconds.
var isMarkdown = $attrs.editorType === 'markdown';
$scope.draftsEnabled = $attrs.draftsEnabled === 'true';
$scope.isUpdateDraft = Number($attrs.pageUpdateDraft) === 1;
$scope.isNewPageDraft = Number($attrs.pageNewDraft) === 1;
// Set inital header draft text
// Set initial header draft text
if ($scope.isUpdateDraft || $scope.isNewPageDraft) {
$scope.draftText = 'Editing Draft'
} else {
$scope.draftText = 'Editing Page'
};
}
var autoSave = false;
@ -317,7 +319,7 @@ module.exports = function (ngApp, events) {
html: false
};
if (isEdit) {
if (isEdit && $scope.draftsEnabled) {
setTimeout(() => {
startAutoSave();
}, 1000);
@ -366,6 +368,7 @@ module.exports = function (ngApp, events) {
* Save a draft update into the system via an AJAX request.
*/
function saveDraft() {
if (!$scope.draftsEnabled) return;
var data = {
name: $('#name').val(),
html: isMarkdown ? $sce.getTrustedHtml($scope.displayContent) : $scope.editContent
@ -435,7 +438,7 @@ module.exports = function (ngApp, events) {
const pageId = Number($attrs.pageId);
$scope.tags = [];
$scope.sortOptions = {
handle: '.handle',
items: '> tr',
@ -458,7 +461,7 @@ module.exports = function (ngApp, events) {
* Get all tags for the current book and add into scope.
*/
function getTags() {
let url = window.baseUrl('/ajax/tags/get/page/' + pageId);
let url = window.baseUrl(`/ajax/tags/get/page/${pageId}`);
$http.get(url).then((responseData) => {
$scope.tags = responseData.data;
addEmptyTag();
@ -527,21 +530,201 @@ module.exports = function (ngApp, events) {
}]);
ngApp.controller('PageAttachmentController', ['$scope', '$http', '$attrs',
function ($scope, $http, $attrs) {
const pageId = $scope.uploadedTo = $attrs.pageId;
let currentOrder = '';
$scope.files = [];
$scope.editFile = false;
$scope.file = getCleanFile();
$scope.errors = {
link: {},
edit: {}
};
function getCleanFile() {
return {
page_id: pageId
};
}
// Angular-UI-Sort options
$scope.sortOptions = {
handle: '.handle',
items: '> tr',
containment: "parent",
axis: "y",
stop: sortUpdate,
};
/**
* Event listener for sort changes.
* Updates the file ordering on the server.
* @param event
* @param ui
*/
function sortUpdate(event, ui) {
let newOrder = $scope.files.map(file => {return file.id}).join(':');
if (newOrder === currentOrder) return;
currentOrder = newOrder;
$http.put(window.baseUrl(`/attachments/sort/page/${pageId}`), {files: $scope.files}).then(resp => {
events.emit('success', resp.data.message);
}, checkError('sort'));
}
/**
* Used by dropzone to get the endpoint to upload to.
* @returns {string}
*/
$scope.getUploadUrl = function (file) {
let suffix = (typeof file !== 'undefined') ? `/${file.id}` : '';
return window.baseUrl(`/attachments/upload${suffix}`);
};
/**
* Get files for the current page from the server.
*/
function getFiles() {
let url = window.baseUrl(`/attachments/get/page/${pageId}`)
$http.get(url).then(resp => {
$scope.files = resp.data;
currentOrder = resp.data.map(file => {return file.id}).join(':');
}, checkError('get'));
}
getFiles();
/**
* Runs on file upload, Adds an file to local file list
* and shows a success message to the user.
* @param file
* @param data
*/
$scope.uploadSuccess = function (file, data) {
$scope.$apply(() => {
$scope.files.push(data);
});
events.emit('success', 'File uploaded');
};
/**
* Upload and overwrite an existing file.
* @param file
* @param data
*/
$scope.uploadSuccessUpdate = function (file, data) {
$scope.$apply(() => {
let search = filesIndexOf(data);
if (search !== -1) $scope.files[search] = data;
if ($scope.editFile) {
$scope.editFile = angular.copy(data);
data.link = '';
}
});
events.emit('success', 'File updated');
};
/**
* Delete a file from the server and, on success, the local listing.
* @param file
*/
$scope.deleteFile = function(file) {
if (!file.deleting) {
file.deleting = true;
return;
}
$http.delete(window.baseUrl(`/attachments/${file.id}`)).then(resp => {
events.emit('success', resp.data.message);
$scope.files.splice($scope.files.indexOf(file), 1);
}, checkError('delete'));
};
/**
* Attach a link to a page.
* @param file
*/
$scope.attachLinkSubmit = function(file) {
file.uploaded_to = pageId;
$http.post(window.baseUrl('/attachments/link'), file).then(resp => {
$scope.files.push(resp.data);
events.emit('success', 'Link attached');
$scope.file = getCleanFile();
}, checkError('link'));
};
/**
* Start the edit mode for a file.
* @param file
*/
$scope.startEdit = function(file) {
$scope.editFile = angular.copy(file);
$scope.editFile.link = (file.external) ? file.path : '';
};
/**
* Cancel edit mode
*/
$scope.cancelEdit = function() {
$scope.editFile = false;
};
/**
* Update the name and link of a file.
* @param file
*/
$scope.updateFile = function(file) {
$http.put(window.baseUrl(`/attachments/${file.id}`), file).then(resp => {
let search = filesIndexOf(resp.data);
if (search !== -1) $scope.files[search] = resp.data;
if ($scope.editFile && !file.external) {
$scope.editFile.link = '';
}
$scope.editFile = false;
events.emit('success', 'Attachment details updated');
}, checkError('edit'));
};
/**
* Get the url of a file.
*/
$scope.getFileUrl = function(file) {
return window.baseUrl('/attachments/' + file.id);
};
/**
* Search the local files via another file object.
* Used to search via object copies.
* @param file
* @returns int
*/
function filesIndexOf(file) {
for (let i = 0; i < $scope.files.length; i++) {
if ($scope.files[i].id == file.id) return i;
}
return -1;
}
/**
* Check for an error response in a ajax request.
* @param errorGroupName
*/
function checkError(errorGroupName) {
$scope.errors[errorGroupName] = {};
return function(response) {
if (typeof response.data !== 'undefined' && typeof response.data.error !== 'undefined') {
events.emit('error', response.data.error);
}
if (typeof response.data !== 'undefined' && typeof response.data.validation !== 'undefined') {
$scope.errors[errorGroupName] = response.data.validation;
console.log($scope.errors[errorGroupName])
}
}
}
}]);
};

View File

@ -2,10 +2,6 @@
const DropZone = require('dropzone');
const markdown = require('marked');
const toggleSwitchTemplate = require('./components/toggle-switch.html');
const imagePickerTemplate = require('./components/image-picker.html');
const dropZoneTemplate = require('./components/drop-zone.html');
module.exports = function (ngApp, events) {
/**
@ -16,7 +12,12 @@ module.exports = function (ngApp, events) {
ngApp.directive('toggleSwitch', function () {
return {
restrict: 'A',
template: toggleSwitchTemplate,
template: `
<div class="toggle-switch" ng-click="switch()" ng-class="{'active': isActive}">
<input type="hidden" ng-attr-name="{{name}}" ng-attr-value="{{value}}"/>
<div class="switch-handle"></div>
</div>
`,
scope: true,
link: function (scope, element, attrs) {
scope.name = attrs.name;
@ -33,6 +34,59 @@ module.exports = function (ngApp, events) {
};
});
/**
* Common tab controls using simple jQuery functions.
*/
ngApp.directive('tabContainer', function() {
return {
restrict: 'A',
link: function (scope, element, attrs) {
const $content = element.find('[tab-content]');
const $buttons = element.find('[tab-button]');
if (attrs.tabContainer) {
let initial = attrs.tabContainer;
$buttons.filter(`[tab-button="${initial}"]`).addClass('selected');
$content.hide().filter(`[tab-content="${initial}"]`).show();
} else {
$content.hide().first().show();
$buttons.first().addClass('selected');
}
$buttons.click(function() {
let clickedTab = $(this);
$buttons.removeClass('selected');
$content.hide();
let name = clickedTab.addClass('selected').attr('tab-button');
$content.filter(`[tab-content="${name}"]`).show();
});
}
};
});
/**
* Sub form component to allow inner-form sections to act like thier own forms.
*/
ngApp.directive('subForm', function() {
return {
restrict: 'A',
link: function (scope, element, attrs) {
element.on('keypress', e => {
if (e.keyCode === 13) {
submitEvent(e);
}
});
element.find('button[type="submit"]').click(submitEvent);
function submitEvent(e) {
e.preventDefault()
if (attrs.subForm) scope.$eval(attrs.subForm);
}
}
};
});
/**
* Image Picker
@ -41,7 +95,22 @@ module.exports = function (ngApp, events) {
ngApp.directive('imagePicker', ['$http', 'imageManagerService', function ($http, imageManagerService) {
return {
restrict: 'E',
template: imagePickerTemplate,
template: `
<div class="image-picker">
<div>
<img ng-if="image && image !== 'none'" ng-src="{{image}}" ng-class="{{imageClass}}" alt="Image Preview">
<img ng-if="image === '' && defaultImage" ng-src="{{defaultImage}}" ng-class="{{imageClass}}" alt="Image Preview">
</div>
<button class="button" type="button" ng-click="showImageManager()">Select Image</button>
<br>
<button class="text-button" ng-click="reset()" type="button">Reset</button>
<span ng-show="showRemove" class="sep">|</span>
<button ng-show="showRemove" class="text-button neg" ng-click="remove()" type="button">Remove</button>
<input type="hidden" ng-attr-name="{{name}}" ng-attr-id="{{name}}" ng-attr-value="{{value}}">
</div>
`,
scope: {
name: '@',
resizeHeight: '@',
@ -108,7 +177,11 @@ module.exports = function (ngApp, events) {
ngApp.directive('dropZone', [function () {
return {
restrict: 'E',
template: dropZoneTemplate,
template: `
<div class="dropzone-container">
<div class="dz-message">Drop files or click here to upload</div>
</div>
`,
scope: {
uploadUrl: '@',
eventSuccess: '=',
@ -116,6 +189,7 @@ module.exports = function (ngApp, events) {
uploadedTo: '@'
},
link: function (scope, element, attrs) {
if (attrs.placeholder) element[0].querySelector('.dz-message').textContent = attrs.placeholder;
var dropZone = new DropZone(element[0].querySelector('.dropzone-container'), {
url: scope.uploadUrl,
init: function () {
@ -488,8 +562,8 @@ module.exports = function (ngApp, events) {
link: function (scope, elem, attrs) {
// Get common elements
const $buttons = elem.find('[tab-button]');
const $content = elem.find('[tab-content]');
const $buttons = elem.find('[toolbox-tab-button]');
const $content = elem.find('[toolbox-tab-content]');
const $toggle = elem.find('[toolbox-toggle]');
// Handle toolbox toggle click
@ -501,17 +575,17 @@ module.exports = function (ngApp, events) {
function setActive(tabName, openToolbox) {
$buttons.removeClass('active');
$content.hide();
$buttons.filter(`[tab-button="${tabName}"]`).addClass('active');
$content.filter(`[tab-content="${tabName}"]`).show();
$buttons.filter(`[toolbox-tab-button="${tabName}"]`).addClass('active');
$content.filter(`[toolbox-tab-content="${tabName}"]`).show();
if (openToolbox) elem.addClass('open');
}
// Set the first tab content active on load
setActive($content.first().attr('tab-content'), false);
setActive($content.first().attr('toolbox-tab-content'), false);
// Handle tab button click
$buttons.click(function (e) {
let name = $(this).attr('tab-button');
let name = $(this).attr('toolbox-tab-button');
setActive(name, true);
});
}
@ -549,7 +623,7 @@ module.exports = function (ngApp, events) {
let val = $input.val();
let url = $input.attr('autosuggest');
let type = $input.attr('autosuggest-type');
// Add name param to request if for a value
if (type.toLowerCase() === 'value') {
let $nameInput = $input.closest('tr').find('[autosuggest-type="name"]').first();
@ -850,17 +924,3 @@ module.exports = function (ngApp, events) {
};
}]);
};

View File

@ -38,13 +38,17 @@ class EventManager {
this.listeners[eventName].push(callback);
return this;
}
};
}
window.Events = new EventManager();
var services = require('./services')(ngApp, window.Events);
var directives = require('./directives')(ngApp, window.Events);
var controllers = require('./controllers')(ngApp, window.Events);
// Load in angular specific items
import Services from './services';
import Directives from './directives';
import Controllers from './controllers';
Services(ngApp, window.Events);
Directives(ngApp, window.Events);
Controllers(ngApp, window.Events);
//Global jQuery Config & Extensions

View File

@ -6,11 +6,11 @@
* @param editor - editor instance
*/
function editorPaste(e, editor) {
if (!e.clipboardData) return
if (!e.clipboardData) return;
let items = e.clipboardData.items;
if (!items) return;
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf("image") === -1) return
if (items[i].type.indexOf("image") === -1) return;
let file = items[i].getAsFile();
let formData = new FormData();

View File

@ -135,6 +135,10 @@
border-left: 3px solid #BBB;
background-color: #EEE;
padding: $-s;
display: block;
> * {
display: inline-block;
}
&:before {
font-family: 'Material-Design-Iconic-Font';
padding-right: $-s;

View File

@ -43,10 +43,6 @@
}
}
//body.ie .popup-body {
// min-height: 100%;
//}
.corner-button {
position: absolute;
top: 0;
@ -82,7 +78,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
min-height: 70vh;
}
#image-manager .dropzone-container {
.dropzone-container {
position: relative;
border: 3px dashed #DDD;
}
@ -456,3 +452,17 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
border-right: 6px solid transparent;
border-bottom: 6px solid $negative;
}
[tab-container] .nav-tabs {
text-align: left;
border-bottom: 1px solid #DDD;
margin-bottom: $-m;
.tab-item {
padding: $-s;
color: #666;
&.selected {
border-bottom-width: 3px;
}
}
}

View File

@ -110,6 +110,8 @@
border-left: 0px solid #FFF;
background-color: #FFF;
&.fixed {
background-color: #FFF;
z-index: 5;
position: fixed;
top: 0;
padding-left: $-l;

56
resources/assets/sass/_pages.scss Normal file → Executable file
View File

@ -71,6 +71,18 @@
max-width: 100%;
height: auto !important;
}
// diffs
ins,
del {
text-decoration: none;
}
ins {
background: #dbffdb;
}
del {
background: #FFECEC;
}
}
// Page content pointers
@ -138,7 +150,6 @@
background-color: #FFF;
border: 1px solid #DDD;
right: $-xl*2;
z-index: 99;
width: 48px;
overflow: hidden;
align-items: stretch;
@ -189,7 +200,7 @@
color: #444;
background-color: rgba(0, 0, 0, 0.1);
}
div[tab-content] {
div[toolbox-tab-content] {
padding-bottom: 45px;
display: flex;
flex: 1;
@ -197,7 +208,7 @@
min-height: 0px;
overflow-y: scroll;
}
div[tab-content] .padded {
div[toolbox-tab-content] .padded {
flex: 1;
padding-top: 0;
}
@ -216,21 +227,6 @@
padding-top: $-s;
position: relative;
}
button.pos {
position: absolute;
bottom: 0;
display: block;
width: 100%;
padding: $-s;
height: 45px;
border: 0;
margin: 0;
box-shadow: none;
border-radius: 0;
&:hover{
box-shadow: none;
}
}
.handle {
user-select: none;
cursor: move;
@ -242,36 +238,42 @@
flex-direction: column;
overflow-y: scroll;
}
table td, table th {
overflow: visible;
}
}
[tab-content] {
[toolbox-tab-content] {
display: none;
}
.tag-display {
margin: $-xl $-m;
border: 1px solid #DDD;
min-width: 180px;
max-width: 320px;
opacity: 0.7;
z-index: 5;
width: 100%;
//opacity: 0.7;
position: relative;
table {
width: 100%;
margin: 0;
padding: 0;
}
tr:first-child td {
padding-top: 0;
}
.heading th {
padding: $-xs $-s;
color: #333;
color: rgba(100, 100, 100, 0.7);
border: 0;
font-weight: 400;
}
td {
border: 0;
border-bottom: 1px solid #DDD;
border-bottom: 1px solid #EEE;
padding: $-xs $-s;
color: #444;
}
tr td:first-child {
padding-left:0;
}
.tag-value {
color: #888;
}

View File

@ -51,4 +51,14 @@ table.list-table {
vertical-align: middle;
padding: $-xs;
}
}
table.file-table {
@extend .no-style;
td {
padding: $-xs;
}
.ui-sortable-helper {
display: table;
}
}

View File

@ -193,7 +193,7 @@ p.neg, p .neg, span.neg, .text-neg {
p.muted, p .muted, span.muted, .text-muted {
color: lighten($text-dark, 26%);
&.small, .small {
color: lighten($text-dark, 42%);
color: lighten($text-dark, 32%);
}
}
@ -262,7 +262,7 @@ ul {
ol {
list-style: decimal;
padding-left: $-m * 1.3;
padding-left: $-m * 2;
overflow: hidden;
}

View File

@ -0,0 +1,40 @@
<?php
return [
/**
* Activity text strings.
* Is used for all the text within activity logs & notifications.
*/
// Pages
'page_create' => 'Seite erstellt',
'page_create_notification' => 'Seite erfolgreich erstellt',
'page_update' => 'Seite aktualisiert',
'page_update_notification' => 'Seite erfolgreich aktualisiert',
'page_delete' => 'Seite gel&ouml;scht',
'page_delete_notification' => 'Seite erfolgreich gel&ouml;scht',
'page_restore' => 'Seite wiederhergstellt',
'page_restore_notification' => 'Seite erfolgreich wiederhergstellt',
'page_move' => 'Seite verschoben',
// Chapters
'chapter_create' => 'Kapitel erstellt',
'chapter_create_notification' => 'Kapitel erfolgreich erstellt',
'chapter_update' => 'Kapitel aktualisiert',
'chapter_update_notification' => 'Kapitel erfolgreich aktualisiert',
'chapter_delete' => 'Kapitel gel&ouml;scht',
'chapter_delete_notification' => 'Kapitel erfolgreich gel&ouml;scht',
'chapter_move' => 'Kapitel verschoben',
// Books
'book_create' => 'Buch erstellt',
'book_create_notification' => 'Buch erfolgreich erstellt',
'book_update' => 'Buch aktualisiert',
'book_update_notification' => 'Buch erfolgreich aktualisiert',
'book_delete' => 'Buch gel&ouml;scht',
'book_delete_notification' => 'Buch erfolgreich gel&ouml;scht',
'book_sort' => 'Buch sortiert',
'book_sort_notification' => 'Buch erfolgreich neu sortiert',
];

View File

@ -0,0 +1,26 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Authentication Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are used during authentication for various
| messages that we need to display to the user. You are free to modify
| these language lines according to your application's requirements.
|
*/
'failed' => 'Dies sind keine g&uuml;ltigen Anmeldedaten.',
'throttle' => 'Zu viele Anmeldeversuche. Bitte versuchen sie es in :seconds Sekunden erneut.',
/**
* Email Confirmation Text
*/
'email_confirm_subject' => 'Best&auml;tigen sie ihre E-Mail Adresse bei :appName',
'email_confirm_greeting' => 'Danke, dass sie :appName beigetreten sind!',
'email_confirm_text' => 'Bitte best&auml;tigen sie ihre E-Mail Adresse, indem sie auf den Button klicken:',
'email_confirm_action' => 'E-Mail Adresse best&auml;tigen',
'email_confirm_send_error' => 'Best&auml;tigungs-E-Mail ben&ouml;tigt, aber das System konnte die E-Mail nicht versenden. Kontaktieren sie den Administrator, um sicherzustellen, dass das Sytsem korrekt eingerichtet ist.',
'email_confirm_success' => 'Ihre E-Mail Adresse wurde best&auml;tigt!',
'email_confirm_resent' => 'Best&auml;tigungs-E-Mail wurde erneut versendet, bitte &uuml;berpr&uuml;fen sie ihren Posteingang.',
];

View File

@ -0,0 +1,12 @@
<?php
return [
/**
* Error text strings.
*/
// Pages
'permission' => 'Sie haben keine Berechtigung auf diese Seite zuzugreifen.',
'permissionJson' => 'Sie haben keine Berechtigung die angeforderte Aktion auszuf&uuml;hren.'
];

View File

@ -0,0 +1,19 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Pagination Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are used by the paginator library to build
| the simple pagination links. You are free to change them to anything
| you want to customize your views to better match your application.
|
*/
'previous' => '&laquo; Vorherige',
'next' => 'N&auml;chste &raquo;',
];

View File

@ -0,0 +1,22 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Password Reminder Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are the default lines which match reasons
| that are given by the password broker for a password update attempt
| has failed, such as for an invalid token or invalid new password.
|
*/
'password' => 'Pass&ouml;rter m&uuml;ssen mindestens sechs Zeichen enthalten und die Wiederholung muss identisch sein.',
'user' => "Wir k&ouml;nnen keinen Benutzer mit dieser E-Mail Adresse finden.",
'token' => 'Dieser Passwort-Reset-Token ist ung&uuml;ltig.',
'sent' => 'Wir haben ihnen eine E-Mail mit einem Link zum Zurücksetzen des Passworts zugesendet!',
'reset' => 'Ihr Passwort wurde zur&uuml;ckgesetzt!',
];

View File

@ -0,0 +1,39 @@
<?php
return [
/**
* Settings text strings
* Contains all text strings used in the general settings sections of BookStack
* including users and roles.
*/
'settings' => 'Einstellungen',
'settings_save' => 'Einstellungen speichern',
'app_settings' => 'Anwendungseinstellungen',
'app_name' => 'Anwendungsname',
'app_name_desc' => 'Dieser Name wird im Header und E-Mails angezeigt.',
'app_name_header' => 'Anwendungsname im Header anzeigen?',
'app_public_viewing' => '&Ouml;ffentliche Ansicht erlauben?',
'app_secure_images' => 'Erh&oml;hte Sicherheit f&uuml;r Bilduploads aktivieren?',
'app_secure_images_desc' => 'Aus Leistungsgr&uuml;nden sind alle Bilder &ouml;ffentlich sichtbar. Diese Option f&uuml;gt zuf&auml;llige, schwer zu eratene, Zeichenketten vor die Bild-URLs hinzu. Stellen sie sicher, dass Verzeichnindexes deaktiviert sind, um einen einfachen Zugrif zu verhindern.',
'app_editor' => 'Seiteneditor',
'app_editor_desc' => 'W&auml;hlen sie den Editor aus, der von allen Benutzern genutzt werden soll, um Seiten zu editieren.',
'app_custom_html' => 'Benutzerdefinierter HTML <head> Inhalt',
'app_custom_html_desc' => 'Jeder Inhalt, der hier hinzugef&uuml;gt wird, wird am Ende der <head> Sektion jeder Seite eingef&uuml;gt. Diese kann praktisch sein, um CSS Styles anzupassen oder Analytics Code hinzuzuf&uuml;gen.',
'app_logo' => 'Anwendungslogo',
'app_logo_desc' => 'Dieses Bild sollte 43px hoch sein. <br>Gr&ouml;&szlig;ere Bilder werden verkleinert.',
'app_primary_color' => 'Prim&auml;re Anwendungsfarbe',
'app_primary_color_desc' => 'Dies sollte ein HEX Wert sein. <br>Leer lassen des Feldes setzt auf die Standard-Anwendungsfarbe zur&uuml;ck.',
'reg_settings' => 'Registrierungseinstellungen',
'reg_allow' => 'Registrierung erlauben?',
'reg_default_role' => 'Standard-Benutzerrolle nach Registrierung',
'reg_confirm_email' => 'Best&auml;tigung per E-Mail erforderlich?',
'reg_confirm_email_desc' => 'Falls die Einschr&auml;nkung f&uumlr; Domains genutzt wird, ist die Best&auml;tigung per E-Mail zwingend erforderlich und der untenstehende Wert wird ignoriert.',
'reg_confirm_restrict_domain' => 'Registrierung auf bestimmte Domains einschr&auml;nken',
'reg_confirm_restrict_domain_desc' => 'F&uuml;gen sie eine, durch Komma getrennte, Liste von E-Mail Domains hinzu, auf die die Registrierung eingeschr&auml;nkt werden soll. Benutzern wird eine E-Mail gesendet, um ihre E-Mail Adresse zu best&auml;tigen, bevor sie diese Anwendung nutzen k&ouml;nnen. <br> Hinweis: Benutzer k&ouml;nnen ihre E-Mail Adresse nach erfolgreicher Registrierung &auml;ndern.',
'reg_confirm_restrict_domain_placeholder' => 'Keine Einschr&auml;nkung gesetzt',
];

View File

@ -0,0 +1,108 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Validation Language Lines
|--------------------------------------------------------------------------
|
| following language lines contain default error messages used by
| validator class. Some of these rules have multiple versions such
| as size rules. Feel free to tweak each of these messages here.
|
*/
'accepted' => ':attribute muss akzeptiert werden.',
'active_url' => ':attribute ist keine valide URL.',
'after' => ':attribute muss ein Datum nach :date sein.',
'alpha' => ':attribute kann nur Buchstaben enthalten.',
'alpha_dash' => ':attribute kann nur Buchstaben, Zahlen und Bindestriche enthalten.',
'alpha_num' => ':attribute kann nur Buchstaben und Zahlen enthalten.',
'array' => ':attribute muss eine Array sein.',
'before' => ':attribute muss ein Datum vor :date sein.',
'between' => [
'numeric' => ':attribute muss zwischen :min und :max liegen.',
'file' => ':attribute muss zwischen :min und :max Kilobytes gro&szlig; sein.',
'string' => ':attribute muss zwischen :min und :max Zeichen lang sein.',
'array' => ':attribute muss zwischen :min und :max Elemente enthalten.',
],
'boolean' => ':attribute Feld muss wahr oder falsch sein.',
'confirmed' => ':attribute Best&auml;tigung stimmt nicht &uuml;berein.',
'date' => ':attribute ist kein valides Datum.',
'date_format' => ':attribute entspricht nicht dem Format :format.',
'different' => ':attribute und :other m&uuml;ssen unterschiedlich sein.',
'digits' => ':attribute muss :digits Stellen haben.',
'digits_between' => ':attribute muss zwischen :min und :max Stellen haben.',
'email' => ':attribute muss eine valide E-Mail Adresse sein.',
'filled' => ':attribute Feld ist erforderlich.',
'exists' => 'Markiertes :attribute ist ung&uuml;ltig.',
'image' => ':attribute muss ein Bild sein.',
'in' => 'Markiertes :attribute ist ung&uuml;ltig.',
'integer' => ':attribute muss eine Zahl sein.',
'ip' => ':attribute muss eine valide IP-Adresse sein.',
'max' => [
'numeric' => ':attribute darf nicht gr&ouml;&szlig;er als :max sein.',
'file' => ':attribute darf nicht gr&ouml;&szlig;er als :max Kilobyte sein.',
'string' => ':attribute darf nicht l&auml;nger als :max Zeichen sein.',
'array' => ':attribute darf nicht mehr als :max Elemente enthalten.',
],
'mimes' => ':attribute muss eine Datei vom Typ: :values sein.',
'min' => [
'numeric' => ':attribute muss mindestens :min. sein',
'file' => ':attribute muss mindestens :min Kilobyte gro&szlig; sein.',
'string' => ':attribute muss mindestens :min Zeichen lang sein.',
'array' => ':attribute muss mindesten :min Elemente enthalten.',
],
'not_in' => 'Markiertes :attribute ist ung&uuml;ltig.',
'numeric' => ':attribute muss eine Zahl sein.',
'regex' => ':attribute Format ist ung&uuml;ltig.',
'required' => ':attribute Feld ist erforderlich.',
'required_if' => ':attribute Feld ist erforderlich, wenn :other :value ist.',
'required_with' => ':attribute Feld ist erforderlich, wenn :values vorhanden ist.',
'required_with_all' => ':attribute Feld ist erforderlich, wenn :values vorhanden sind.',
'required_without' => ':attribute Feld ist erforderlich, wenn :values nicht vorhanden ist.',
'required_without_all' => ':attribute Feld ist erforderlich, wenn :values nicht vorhanden sind.',
'same' => ':attribute und :other muss &uuml;bereinstimmen.',
'size' => [
'numeric' => ':attribute muss :size sein.',
'file' => ':attribute muss :size Kilobytes gro&szlig; sein.',
'string' => ':attribute muss :size Zeichen lang sein.',
'array' => ':attribute muss :size Elemente enthalten.',
],
'string' => ':attribute muss eine Zeichenkette sein.',
'timezone' => ':attribute muss eine valide zeitzone sein.',
'unique' => ':attribute wird bereits verwendet.',
'url' => ':attribute ist kein valides Format.',
/*
|--------------------------------------------------------------------------
| Custom Validation Language Lines
|--------------------------------------------------------------------------
|
| Here you may specify custom validation messages for attributes using the
| convention "attribute.rule" to name lines. This makes it quick to
| specify a specific custom language line for a given attribute rule.
|
*/
'custom' => [
'attribute-name' => [
'rule-name' => 'custom-message',
],
],
/*
|--------------------------------------------------------------------------
| Custom Validation Attributes
|--------------------------------------------------------------------------
|
| following language lines are used to swap attribute place-holders
| with something more reader friendly such as E-Mail Address instead
| of "email". This simply helps us make messages a little cleaner.
|
*/
'attributes' => [],
];

View File

@ -1,5 +1,12 @@
@extends('public')
@section('header-buttons')
<a href="{{ baseUrl("/login") }}"><i class="zmdi zmdi-sign-in"></i>Sign in</a>
@if(setting('registration-enabled'))
<a href="{{ baseUrl("/register") }}"><i class="zmdi zmdi-account-add"></i>Sign up</a>
@endif
@stop
@section('content')

View File

@ -1,5 +1,12 @@
@extends('public')
@section('header-buttons')
<a href="{{ baseUrl("/login") }}"><i class="zmdi zmdi-sign-in"></i>Sign in</a>
@if(setting('registration-enabled'))
<a href="{{ baseUrl("/register") }}"><i class="zmdi zmdi-account-add"></i>Sign up</a>
@endif
@stop
@section('body-class', 'image-cover login')
@section('content')

View File

@ -23,7 +23,7 @@
@include('partials/custom-styles')
<!-- Custom user content -->
@if(setting('app-custom-head', false))
@if(setting('app-custom-head'))
{!! setting('app-custom-head') !!}
@endif
</head>

View File

@ -23,10 +23,4 @@
@include('partials/image-manager', ['imageType' => 'gallery', 'uploaded_to' => $page->id])
@include('partials/entity-selector-popup')
<script>
(function() {
})();
</script>
@stop

View File

@ -3,10 +3,13 @@
<div class="tabs primary-background-light">
<span toolbox-toggle><i class="zmdi zmdi-caret-left-circle"></i></span>
<span tab-button="tags" title="Page Tags" class="active"><i class="zmdi zmdi-tag"></i></span>
<span toolbox-tab-button="tags" title="Page Tags" class="active"><i class="zmdi zmdi-tag"></i></span>
@if(userCan('attachment-create-all'))
<span toolbox-tab-button="files" title="Attachments"><i class="zmdi zmdi-attachment"></i></span>
@endif
</div>
<div tab-content="tags" ng-controller="PageTagController" page-id="{{ $page->id or 0 }}">
<div toolbox-tab-content="tags" ng-controller="PageTagController" page-id="{{ $page->id or 0 }}">
<h4>Page Tags</h4>
<div class="padded tags">
<p class="muted small">Add some tags to better categorise your content. <br> You can assign a value to a tag for more in-depth organisation.</p>
@ -34,4 +37,98 @@
</div>
</div>
@if(userCan('attachment-create-all'))
<div toolbox-tab-content="files" ng-controller="PageAttachmentController" page-id="{{ $page->id or 0 }}">
<h4>Attachments</h4>
<div class="padded files">
<div id="file-list" ng-show="!editFile">
<p class="muted small">Upload some files or attach some link to display on your page. These are visible in the page sidebar. <span class="secondary">Changes here are saved instantly.</span></p>
<div tab-container>
<div class="nav-tabs">
<div tab-button="list" class="tab-item">Attached Items</div>
<div tab-button="file" class="tab-item">Upload File</div>
<div tab-button="link" class="tab-item">Attach Link</div>
</div>
<div tab-content="list">
<table class="file-table" style="width: 100%;">
<tbody ui-sortable="sortOptions" ng-model="files" >
<tr ng-repeat="file in files track by $index">
<td width="20" ><i class="handle zmdi zmdi-menu"></i></td>
<td>
<a ng-href="@{{getFileUrl(file)}}" target="_blank" ng-bind="file.name"></a>
<div ng-if="file.deleting">
<span class="neg small">Click delete again to confirm you want to delete this attachment.</span>
<br>
<span class="text-primary small" ng-click="file.deleting=false;">Cancel</span>
</div>
</td>
<td width="10" ng-click="startEdit(file)" class="text-center text-primary" style="padding: 0;"><i class="zmdi zmdi-edit"></i></td>
<td width="5"></td>
<td width="10" ng-click="deleteFile(file)" class="text-center text-neg" style="padding: 0;"><i class="zmdi zmdi-close"></i></td>
</tr>
</tbody>
</table>
<p class="small muted" ng-if="files.length == 0">
No files have been uploaded.
</p>
</div>
<div tab-content="file">
<drop-zone upload-url="@{{getUploadUrl()}}" uploaded-to="@{{uploadedTo}}" event-success="uploadSuccess"></drop-zone>
</div>
<div tab-content="link" sub-form="attachLinkSubmit(file)">
<p class="muted small">You can attach a link if you'd prefer not to upload a file. This can be a link to another page or a link to a file in the cloud.</p>
<div class="form-group">
<label for="attachment-via-link">Link Name</label>
<input type="text" placeholder="Link name" ng-model="file.name">
<p class="small neg" ng-repeat="error in errors.link.name" ng-bind="error"></p>
</div>
<div class="form-group">
<label for="attachment-via-link">Link to file</label>
<input type="text" placeholder="Url of site or file" ng-model="file.link">
<p class="small neg" ng-repeat="error in errors.link.link" ng-bind="error"></p>
</div>
<button type="submit" class="button pos">Attach</button>
</div>
</div>
</div>
<div id="file-edit" ng-if="editFile" sub-form="updateFile(editFile)">
<h5>Edit File</h5>
<div class="form-group">
<label for="attachment-name-edit">File Name</label>
<input type="text" id="attachment-name-edit" placeholder="File name" ng-model="editFile.name">
<p class="small neg" ng-repeat="error in errors.edit.name" ng-bind="error"></p>
</div>
<div tab-container="@{{ editFile.external ? 'link' : 'file' }}">
<div class="nav-tabs">
<div tab-button="file" class="tab-item">Upload File</div>
<div tab-button="link" class="tab-item">Set Link</div>
</div>
<div tab-content="file">
<drop-zone upload-url="@{{getUploadUrl(editFile)}}" uploaded-to="@{{uploadedTo}}" placeholder="Drop files or click here to upload and overwrite" event-success="uploadSuccessUpdate"></drop-zone>
<br>
</div>
<div tab-content="link">
<div class="form-group">
<label for="attachment-link-edit">Link to file</label>
<input type="text" id="attachment-link-edit" placeholder="Attachment link" ng-model="editFile.link">
<p class="small neg" ng-repeat="error in errors.edit.link" ng-bind="error"></p>
</div>
</div>
</div>
<button type="button" class="button" ng-click="cancelEdit()">Back</button>
<button type="submit" class="button pos">Save</button>
</div>
</div>
</div>
@endif
</div>

View File

@ -1,7 +1,9 @@
<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 }}">
<div class="page-editor flex-fill flex" ng-controller="PageEditController" drafts-enabled="{{ $draftsEnabled ? 'true' : 'false' }}" 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() }}
{{--Header Bar--}}
<div class="faded-small toolbar">
<div class="container">
<div class="row">
@ -13,7 +15,7 @@
</div>
<div class="col-sm-4 faded text-center">
<div dropdown class="dropdown-container draft-display">
<div ng-show="draftsEnabled" dropdown class="dropdown-container draft-display">
<a dropdown-toggle class="text-primary text-button"><span class="faded-text" ng-bind="draftText"></span>&nbsp; <i class="zmdi zmdi-more-vert"></i></a>
<i class="zmdi zmdi-check-circle text-pos draft-notification" ng-class="{visible: draftUpdated}"></i>
<ul>
@ -48,13 +50,17 @@
</div>
</div>
{{--Title input--}}
<div class="title-input page-title clearfix" ng-non-bindable>
<div class="input">
@include('form/text', ['name' => 'name', 'placeholder' => 'Page Title'])
</div>
</div>
{{--Editors--}}
<div class="edit-area flex-fill flex">
{{--WYSIWYG Editor--}}
@if(setting('app-editor') === 'wysiwyg')
<div tinymce="editorOptions" mce-change="editorChange" mce-model="editContent" class="flex-fill flex">
<textarea id="html-editor" name="html" rows="5" ng-non-bindable
@ -66,6 +72,7 @@
@endif
@endif
{{--Markdown Editor--}}
@if(setting('app-editor') === 'markdown')
<div id="markdown-editor" markdown-editor class="flex-fill flex">
@ -102,7 +109,7 @@
@if($errors->has('markdown'))
<div class="text-neg text-small">{{ $errors->first('markdown') }}</div>
@endif
@endif
</div>
</div>

View File

@ -0,0 +1,25 @@
@extends('base')
@section('content')
<div class="container small" ng-non-bindable>
<h1>Create Page</h1>
<form action="{{ $parent->getUrl('/page/create/guest') }}" method="POST">
{!! csrf_field() !!}
<div class="form-group title-input">
<label for="name">Page Name</label>
@include('form/text', ['name' => 'name'])
</div>
<div class="form-group">
<a href="{{ $parent->getUrl() }}" class="button muted">Cancel</a>
<button type="submit" class="button pos">Continue</button>
</div>
</form>
</div>
@stop

View File

@ -2,27 +2,11 @@
<h1 id="bkmrk-page-title" class="float left">{{$page->name}}</h1>
@if(count($page->tags) > 0)
<div class="tag-display float right">
<table>
<thead>
<tr class="text-left heading primary-background-light">
<th colspan="2">Page Tags</th>
</tr>
</thead>
<tbody>
@foreach($page->tags as $tag)
<tr class="tag">
<td @if(!$tag->value) colspan="2" @endif><a href="{{ baseUrl('/search/all?term=%5B' . urlencode($tag->name) .'%5D') }}">{{ $tag->name }}</a></td>
@if($tag->value) <td class="tag-value"><a href="{{ baseUrl('/search/all?term=%5B' . urlencode($tag->name) .'%3D' . urlencode($tag->value) . '%5D') }}">{{$tag->value}}</a></td> @endif
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
<div style="clear:left;"></div>
{!! $page->html !!}
@if (isset($diff) && $diff)
{!! $diff !!}
@else
{!! $page->html !!}
@endif
</div>

View File

@ -14,7 +14,7 @@
table {
max-width: 800px !important;
font-size: 0.8em;
width: auto !important;
width: 100% !important;
}
table td {

View File

@ -32,11 +32,11 @@
<table class="table">
<tr>
<th width="25%">Name</th>
<th colspan="2" width="10%">Created By</th>
<th width="23%">Name</th>
<th colspan="2" width="8%">Created By</th>
<th width="15%">Revision Date</th>
<th width="25%">Changelog</th>
<th width="15%">Actions</th>
<th width="20%">Actions</th>
</tr>
@foreach($page->revisions as $index => $revision)
<tr>
@ -49,15 +49,18 @@
<td> @if($revision->createdBy) {{ $revision->createdBy->name }} @else Deleted User @endif</td>
<td><small>{{ $revision->created_at->format('jS F, Y H:i:s') }} <br> ({{ $revision->created_at->diffForHumans() }})</small></td>
<td>{{ $revision->summary }}</td>
@if ($index !== 0)
<td>
<td>
<a href="{{ $revision->getUrl('changes') }}" target="_blank">Changes</a>
<span class="text-muted">&nbsp;|&nbsp;</span>
@if ($index === 0)
<a target="_blank" href="{{ $page->getUrl() }}"><i>Current Version</i></a>
@else
<a href="{{ $revision->getUrl() }}" target="_blank">Preview</a>
<span class="text-muted">&nbsp;|&nbsp;</span>
<a href="{{ $revision->getUrl() }}/restore">Restore</a>
</td>
@else
<td><a target="_blank" href="{{ $page->getUrl() }}"><i>Current Version</i></a></td>
@endif
<a href="{{ $revision->getUrl('restore') }}" target="_blank">Restore</a>
@endif
</td>
</tr>
@endforeach
</table>

View File

@ -115,6 +115,8 @@
</div>
@endif
@include('pages/sidebar-tree-list', ['book' => $book, 'sidebarTree' => $sidebarTree, 'pageNav' => $pageNav])
</div>

View File

@ -1,6 +1,31 @@
<div class="book-tree" ng-non-bindable>
@if(isset($page) && $page->tags->count() > 0)
<div class="tag-display">
<h6 class="text-muted">Page Tags</h6>
<table>
<tbody>
@foreach($page->tags as $tag)
<tr class="tag">
<td @if(!$tag->value) colspan="2" @endif><a href="{{ baseUrl('/search/all?term=%5B' . urlencode($tag->name) .'%5D') }}">{{ $tag->name }}</a></td>
@if($tag->value) <td class="tag-value"><a href="{{ baseUrl('/search/all?term=%5B' . urlencode($tag->name) .'%3D' . urlencode($tag->value) . '%5D') }}">{{$tag->value}}</a></td> @endif
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
@if (isset($page) && $page->attachments->count() > 0)
<h6 class="text-muted">Attachments</h6>
@foreach($page->attachments as $attachment)
<div class="attachment">
<a href="{{ $attachment->getUrl() }}" @if($attachment->external) target="_blank" @endif><i class="zmdi zmdi-{{ $attachment->external ? 'open-in-new' : 'file' }}"></i> {{ $attachment->name }}</a>
</div>
@endforeach
@endif
@if (isset($pageNav) && $pageNav)
<h6 class="text-muted">Page Navigation</h6>
<div class="sidebar-page-nav menu">
@ -10,8 +35,6 @@
</li>
@endforeach
</div>
@endif
<h6 class="text-muted">Book Navigation</h6>

View File

@ -14,7 +14,7 @@
.nav-tabs a.selected, .nav-tabs .tab-item.selected {
border-bottom-color: {{ setting('app-color') }};
}
p.primary:hover, p .primary:hover, span.primary:hover, .text-primary:hover, a, a:hover, a:focus, .text-button, .text-button:hover, .text-button:focus {
.text-primary, p.primary, p .primary, span.primary:hover, .text-primary:hover, a, a:hover, a:focus, .text-button, .text-button:hover, .text-button:focus {
color: {{ setting('app-color') }};
}
</style>

View File

@ -17,6 +17,11 @@
<!-- Scripts -->
<script src="{{ baseUrl("/libs/jquery/jquery.min.js?version=2.1.4") }}"></script>
@include('partials/custom-styles')
<!-- Custom user content -->
@if(setting('app-custom-head'))
{!! setting('app-custom-head') !!}
@endif
</head>
<body class="@yield('body-class')" ng-app="bookStack">

View File

@ -79,7 +79,7 @@
<div class="form-group">
<label for="setting-registration-role">{{ trans('settings.reg_default_role') }}</label>
<select id="setting-registration-role" name="setting-registration-role" @if($errors->has('setting-registration-role')) class="neg" @endif>
@foreach(\BookStack\Role::visible() as $role)
@foreach(\BookStack\Role::all() as $role)
<option value="{{$role->id}}" data-role-name="{{ $role->name }}"
@if(setting('registration-role', \BookStack\Role::first()->id) == $role->id) selected @endif
>

View File

@ -106,6 +106,19 @@
<label>@include('settings/roles/checkbox', ['permission' => 'image-delete-all']) All</label>
</td>
</tr>
<tr>
<td>Attachments</td>
<td>@include('settings/roles/checkbox', ['permission' => 'attachment-create-all'])</td>
<td style="line-height:1.2;"><small class="faded">Controlled by the asset they are uploaded to</small></td>
<td>
<label>@include('settings/roles/checkbox', ['permission' => 'attachment-update-own']) Own</label>
<label>@include('settings/roles/checkbox', ['permission' => 'attachment-update-all']) All</label>
</td>
<td>
<label>@include('settings/roles/checkbox', ['permission' => 'attachment-delete-own']) Own</label>
<label>@include('settings/roles/checkbox', ['permission' => 'attachment-delete-all']) All</label>
</td>
</tr>
</table>
</div>
</div>

View File

@ -15,7 +15,9 @@
</div>
<div class="col-sm-4">
<p></p>
<a href="{{ baseUrl("/settings/users/{$user->id}/delete") }}" class="neg button float right">Delete User</a>
@if($authMethod !== 'system')
<a href="{{ baseUrl("/settings/users/{$user->id}/delete") }}" class="neg button float right">Delete User</a>
@endif
</div>
</div>
<div class="row">

View File

@ -0,0 +1,25 @@
@if($user->system_name == 'public')
<p>This user represents any guest users that visit your instance. It cannot be used for logins but is assigned&nbsp;automatically.</p>
@endif
<div class="form-group">
<label for="name">Name</label>
@include('form.text', ['name' => 'name'])
</div>
<div class="form-group">
<label for="email">Email</label>
@include('form.text', ['name' => 'email'])
</div>
@if(userCan('users-manage'))
<div class="form-group">
<label for="role">User Role</label>
@include('form/role-checkboxes', ['name' => 'roles', 'roles' => $roles])
</div>
@endif
<div class="form-group">
<a href="{{ baseUrl("/settings/users") }}" class="button muted">Cancel</a>
<button class="button pos" type="submit">Save</button>
</div>

View File

@ -27,6 +27,7 @@ Route::group(['middleware' => 'auth'], function () {
// Pages
Route::get('/{bookSlug}/page/create', 'PageController@create');
Route::post('/{bookSlug}/page/create/guest', 'PageController@createAsGuest');
Route::get('/{bookSlug}/draft/{pageId}', 'PageController@editDraft');
Route::post('/{bookSlug}/draft/{pageId}', 'PageController@store');
Route::get('/{bookSlug}/page/{pageSlug}', 'PageController@show');
@ -47,10 +48,12 @@ Route::group(['middleware' => 'auth'], function () {
// Revisions
Route::get('/{bookSlug}/page/{pageSlug}/revisions', 'PageController@showRevisions');
Route::get('/{bookSlug}/page/{pageSlug}/revisions/{revId}', 'PageController@showRevision');
Route::get('/{bookSlug}/page/{pageSlug}/revisions/{revId}/changes', 'PageController@showRevisionChanges');
Route::get('/{bookSlug}/page/{pageSlug}/revisions/{revId}/restore', 'PageController@restoreRevision');
// Chapters
Route::get('/{bookSlug}/chapter/{chapterSlug}/create-page', 'PageController@create');
Route::post('/{bookSlug}/chapter/{chapterSlug}/page/create/guest', 'PageController@createAsGuest');
Route::get('/{bookSlug}/chapter/create', 'ChapterController@create');
Route::post('/{bookSlug}/chapter/create', 'ChapterController@store');
Route::get('/{bookSlug}/chapter/{chapterSlug}', 'ChapterController@show');
@ -84,6 +87,16 @@ Route::group(['middleware' => 'auth'], function () {
Route::delete('/{imageId}', 'ImageController@destroy');
});
// Attachments routes
Route::get('/attachments/{id}', 'AttachmentController@get');
Route::post('/attachments/upload', 'AttachmentController@upload');
Route::post('/attachments/upload/{id}', 'AttachmentController@uploadUpdate');
Route::post('/attachments/link', 'AttachmentController@attachLink');
Route::put('/attachments/{id}', 'AttachmentController@update');
Route::get('/attachments/get/page/{pageId}', 'AttachmentController@listForPage');
Route::put('/attachments/sort/page/{pageId}', 'AttachmentController@sortForPage');
Route::delete('/attachments/{id}', 'AttachmentController@delete');
// AJAX routes
Route::put('/ajax/page/{id}/save-draft', 'PageController@saveDraft');
Route::get('/ajax/page/{id}', 'PageController@getPageAjax');
@ -140,7 +153,7 @@ Route::group(['middleware' => 'auth'], function () {
});
// Social auth routes
Route::get('/login/service/{socialDriver}', 'Auth\RegisterController@getSocialLogin');
Route::get('/login/service/{socialDriver}', 'Auth\LoginController@getSocialLogin');
Route::get('/login/service/{socialDriver}/callback', 'Auth\RegisterController@socialCallback');
Route::get('/login/service/{socialDriver}/detach', 'Auth\RegisterController@detachSocialAccount');
Route::get('/register/service/{socialDriver}', 'Auth\RegisterController@socialRegister');

0
public/build/.gitignore → storage/uploads/files/.gitignore vendored Normal file → Executable file
View File

201
tests/AttachmentTest.php Normal file
View File

@ -0,0 +1,201 @@
<?php
class AttachmentTest extends TestCase
{
/**
* Get a test file that can be uploaded
* @param $fileName
* @return \Illuminate\Http\UploadedFile
*/
protected function getTestFile($fileName)
{
return new \Illuminate\Http\UploadedFile(base_path('tests/test-data/test-file.txt'), $fileName, 'text/plain', 55, null, true);
}
/**
* Uploads a file with the given name.
* @param $name
* @param int $uploadedTo
* @return string
*/
protected function uploadFile($name, $uploadedTo = 0)
{
$file = $this->getTestFile($name);
return $this->call('POST', '/attachments/upload', ['uploaded_to' => $uploadedTo], [], ['file' => $file], []);
}
/**
* Get the expected upload path for a file.
* @param $fileName
* @return string
*/
protected function getUploadPath($fileName)
{
return 'uploads/files/' . Date('Y-m-M') . '/' . $fileName;
}
/**
* Delete all uploaded files.
* To assist with cleanup.
*/
protected function deleteUploads()
{
$fileService = $this->app->make(\BookStack\Services\AttachmentService::class);
foreach (\BookStack\Attachment::all() as $file) {
$fileService->deleteFile($file);
}
}
public function test_file_upload()
{
$page = \BookStack\Page::first();
$this->asAdmin();
$admin = $this->getAdmin();
$fileName = 'upload_test_file.txt';
$expectedResp = [
'name' => $fileName,
'uploaded_to'=> $page->id,
'extension' => 'txt',
'order' => 1,
'created_by' => $admin->id,
'updated_by' => $admin->id,
'path' => $this->getUploadPath($fileName)
];
$this->uploadFile($fileName, $page->id);
$this->assertResponseOk();
$this->seeJsonContains($expectedResp);
$this->seeInDatabase('attachments', $expectedResp);
$this->deleteUploads();
}
public function test_file_display_and_access()
{
$page = \BookStack\Page::first();
$this->asAdmin();
$admin = $this->getAdmin();
$fileName = 'upload_test_file.txt';
$this->uploadFile($fileName, $page->id);
$this->assertResponseOk();
$this->visit($page->getUrl())
->seeLink($fileName)
->click($fileName)
->see('Hi, This is a test file for testing the upload process.');
$this->deleteUploads();
}
public function test_attaching_link_to_page()
{
$page = \BookStack\Page::first();
$admin = $this->getAdmin();
$this->asAdmin();
$this->call('POST', 'attachments/link', [
'link' => 'https://example.com',
'name' => 'Example Attachment Link',
'uploaded_to' => $page->id,
]);
$expectedResp = [
'path' => 'https://example.com',
'name' => 'Example Attachment Link',
'uploaded_to' => $page->id,
'created_by' => $admin->id,
'updated_by' => $admin->id,
'external' => true,
'order' => 1,
'extension' => ''
];
$this->assertResponseOk();
$this->seeJsonContains($expectedResp);
$this->seeInDatabase('attachments', $expectedResp);
$this->visit($page->getUrl())->seeLink('Example Attachment Link')
->click('Example Attachment Link')->seePageIs('https://example.com');
$this->deleteUploads();
}
public function test_attachment_updating()
{
$page = \BookStack\Page::first();
$this->asAdmin();
$this->call('POST', 'attachments/link', [
'link' => 'https://example.com',
'name' => 'Example Attachment Link',
'uploaded_to' => $page->id,
]);
$attachmentId = \BookStack\Attachment::first()->id;
$this->call('PUT', 'attachments/' . $attachmentId, [
'uploaded_to' => $page->id,
'name' => 'My new attachment name',
'link' => 'https://test.example.com'
]);
$expectedResp = [
'path' => 'https://test.example.com',
'name' => 'My new attachment name',
'uploaded_to' => $page->id
];
$this->assertResponseOk();
$this->seeJsonContains($expectedResp);
$this->seeInDatabase('attachments', $expectedResp);
$this->deleteUploads();
}
public function test_file_deletion()
{
$page = \BookStack\Page::first();
$this->asAdmin();
$fileName = 'deletion_test.txt';
$this->uploadFile($fileName, $page->id);
$filePath = base_path('storage/' . $this->getUploadPath($fileName));
$this->assertTrue(file_exists($filePath), 'File at path ' . $filePath . ' does not exist');
$attachmentId = \BookStack\Attachment::first()->id;
$this->call('DELETE', 'attachments/' . $attachmentId);
$this->dontSeeInDatabase('attachments', [
'name' => $fileName
]);
$this->assertFalse(file_exists($filePath), 'File at path ' . $filePath . ' was not deleted as expected');
$this->deleteUploads();
}
public function test_attachment_deletion_on_page_deletion()
{
$page = \BookStack\Page::first();
$this->asAdmin();
$fileName = 'deletion_test.txt';
$this->uploadFile($fileName, $page->id);
$filePath = base_path('storage/' . $this->getUploadPath($fileName));
$this->assertTrue(file_exists($filePath), 'File at path ' . $filePath . ' does not exist');
$this->seeInDatabase('attachments', [
'name' => $fileName
]);
$this->call('DELETE', $page->getUrl());
$this->dontSeeInDatabase('attachments', [
'name' => $fileName
]);
$this->assertFalse(file_exists($filePath), 'File at path ' . $filePath . ' was not deleted as expected');
$this->deleteUploads();
}
}

View File

@ -146,7 +146,7 @@ class AuthTest extends TestCase
public function test_user_updating()
{
$user = \BookStack\User::all()->last();
$user = $this->getNormalUser();
$password = $user->password;
$this->asAdmin()
->visit('/settings/users')
@ -162,7 +162,7 @@ class AuthTest extends TestCase
public function test_user_password_update()
{
$user = \BookStack\User::all()->last();
$user = $this->getNormalUser();
$userProfilePage = '/settings/users/' . $user->id;
$this->asAdmin()
->visit($userProfilePage)
@ -218,6 +218,37 @@ class AuthTest extends TestCase
->seePageIs('/login');
}
public function test_reset_password_flow()
{
$this->visit('/login')->click('Forgot Password?')
->seePageIs('/password/email')
->type('admin@admin.com', 'email')
->press('Send Reset Link')
->see('A password reset link has been sent to admin@admin.com');
$this->seeInDatabase('password_resets', [
'email' => 'admin@admin.com'
]);
$reset = DB::table('password_resets')->where('email', '=', 'admin@admin.com')->first();
$this->visit('/password/reset/' . $reset->token)
->see('Reset Password')
->submitForm('Reset Password', [
'email' => 'admin@admin.com',
'password' => 'randompass',
'password_confirmation' => 'randompass'
])->seePageIs('/')
->see('Your password has been successfully reset');
}
public function test_reset_password_page_shows_sign_links()
{
$this->setSettings(['registration-enabled' => 'true']);
$this->visit('/password/email')
->seeLink('Sign in')
->seeLink('Sign up');
}
/**
* Perform a login
* @param string $email

View File

@ -108,7 +108,7 @@ class LdapTest extends \TestCase
public function test_user_edit_form()
{
$editUser = User::all()->last();
$editUser = $this->getNormalUser();
$this->asAdmin()->visit('/settings/users/' . $editUser->id)
->see('Edit User')
->dontSee('Password')
@ -126,7 +126,7 @@ class LdapTest extends \TestCase
public function test_non_admins_cannot_change_auth_id()
{
$testUser = User::all()->last();
$testUser = $this->getNormalUser();
$this->actingAs($testUser)->visit('/settings/users/' . $testUser->id)
->dontSee('External Authentication');
}

View File

@ -91,6 +91,45 @@ class EntitySearchTest extends TestCase
->see('Book Search Results')->see('.entity-list', $book->name);
}
public function test_searching_hypen_doesnt_break()
{
$this->visit('/search/all?term=cat+-')
->seeStatusCode(200);
}
public function test_tag_search()
{
$newTags = [
new \BookStack\Tag([
'name' => 'animal',
'value' => 'cat'
]),
new \BookStack\Tag([
'name' => 'color',
'value' => 'red'
])
];
$pageA = \BookStack\Page::first();
$pageA->tags()->saveMany($newTags);
$pageB = \BookStack\Page::all()->last();
$pageB->tags()->create(['name' => 'animal', 'value' => 'dog']);
$this->asAdmin()->visit('/search/all?term=%5Banimal%5D')
->seeLink($pageA->name)
->seeLink($pageB->name);
$this->visit('/search/all?term=%5Bcolor%5D')
->seeLink($pageA->name)
->dontSeeLink($pageB->name);
$this->visit('/search/all?term=%5Banimal%3Dcat%5D')
->seeLink($pageA->name)
->dontSeeLink($pageB->name);
}
public function test_ajax_entity_search()
{
$page = \BookStack\Page::all()->last();

View File

@ -10,7 +10,7 @@ class ImageTest extends TestCase
*/
protected function getTestImage($fileName)
{
return new \Illuminate\Http\UploadedFile(base_path('tests/test-image.jpg'), $fileName, 'image/jpeg', 5238);
return new \Illuminate\Http\UploadedFile(base_path('tests/test-data/test-image.jpg'), $fileName, 'image/jpeg', 5238);
}
/**
@ -57,7 +57,7 @@ class ImageTest extends TestCase
$relPath = $this->uploadImage($imageName, $page->id);
$this->assertResponseOk();
$this->assertTrue(file_exists(public_path($relPath)), 'Uploaded image exists');
$this->assertTrue(file_exists(public_path($relPath)), 'Uploaded image not found at path: '. public_path($relPath));
$this->deleteImage($relPath);
@ -70,7 +70,6 @@ class ImageTest extends TestCase
'updated_by' => $admin->id,
'name' => $imageName
]);
}

View File

@ -544,27 +544,38 @@ class RolesTest extends TestCase
->dontSeeInElement('.book-content', $otherPage->name);
}
public function test_public_role_not_visible_in_user_edit_screen()
public function test_public_role_visible_in_user_edit_screen()
{
$user = \BookStack\User::first();
$this->asAdmin()->visit('/settings/users/' . $user->id)
->seeElement('#roles-admin')
->dontSeeElement('#roles-public');
->seeElement('#roles-public');
}
public function test_public_role_not_visible_in_role_listing()
public function test_public_role_visible_in_role_listing()
{
$this->asAdmin()->visit('/settings/roles')
->see('Admin')
->dontSee('Public');
->see('Public');
}
public function test_public_role_not_visible_in_default_role_setting()
public function test_public_role_visible_in_default_role_setting()
{
$this->asAdmin()->visit('/settings')
->seeElement('[data-role-name="admin"]')
->dontSeeElement('[data-role-name="public"]');
->seeElement('[data-role-name="public"]');
}
public function test_public_role_not_deleteable()
{
$this->asAdmin()->visit('/settings/roles')
->click('Public')
->see('Edit Role')
->click('Delete Role')
->press('Confirm')
->see('Delete Role')
->see('Cannot be deleted');
}
}

View File

@ -0,0 +1,83 @@
<?php
class PublicActionTest extends TestCase
{
public function test_app_not_public()
{
$this->setSettings(['app-public' => 'false']);
$book = \BookStack\Book::orderBy('name', 'asc')->first();
$this->visit('/books')->seePageIs('/login');
$this->visit($book->getUrl())->seePageIs('/login');
$page = \BookStack\Page::first();
$this->visit($page->getUrl())->seePageIs('/login');
}
public function test_books_viewable()
{
$this->setSettings(['app-public' => 'true']);
$books = \BookStack\Book::orderBy('name', 'asc')->take(10)->get();
$bookToVisit = $books[1];
// Check books index page is showing
$this->visit('/books')
->seeStatusCode(200)
->see($books[0]->name)
// Check individual book page is showing and it's child contents are visible.
->click($bookToVisit->name)
->seePageIs($bookToVisit->getUrl())
->see($bookToVisit->name)
->see($bookToVisit->chapters()->first()->name);
}
public function test_chapters_viewable()
{
$this->setSettings(['app-public' => 'true']);
$chapterToVisit = \BookStack\Chapter::first();
$pageToVisit = $chapterToVisit->pages()->first();
// Check chapters index page is showing
$this->visit($chapterToVisit->getUrl())
->seeStatusCode(200)
->see($chapterToVisit->name)
// Check individual chapter page is showing and it's child contents are visible.
->see($pageToVisit->name)
->click($pageToVisit->name)
->see($chapterToVisit->book->name)
->see($chapterToVisit->name)
->seePageIs($pageToVisit->getUrl());
}
public function test_public_page_creation()
{
$this->setSettings(['app-public' => 'true']);
$publicRole = \BookStack\Role::getSystemRole('public');
// Grant all permissions to public
$publicRole->permissions()->detach();
foreach (\BookStack\RolePermission::all() as $perm) {
$publicRole->attachPermission($perm);
}
$this->app[\BookStack\Services\PermissionService::class]->buildJointPermissionForRole($publicRole);
$chapter = \BookStack\Chapter::first();
$this->visit($chapter->book->getUrl());
$this->visit($chapter->getUrl())
->click('New Page')
->see('Create Page')
->seePageIs($chapter->getUrl('/create-page'));
$this->submitForm('Continue', [
'name' => 'My guest page'
])->seePageIs($chapter->book->getUrl('/page/my-guest-page/edit'));
$user = \BookStack\User::getDefault();
$this->seeInDatabase('pages', [
'name' => 'My guest page',
'chapter_id' => $chapter->id,
'created_by' => $user->id,
'updated_by' => $user->id
]);
}
}

View File

@ -1,41 +0,0 @@
<?php
class PublicViewTest extends TestCase
{
public function test_books_viewable()
{
$this->setSettings(['app-public' => 'true']);
$books = \BookStack\Book::orderBy('name', 'asc')->take(10)->get();
$bookToVisit = $books[1];
// Check books index page is showing
$this->visit('/books')
->seeStatusCode(200)
->see($books[0]->name)
// Check individual book page is showing and it's child contents are visible.
->click($bookToVisit->name)
->seePageIs($bookToVisit->getUrl())
->see($bookToVisit->name)
->see($bookToVisit->chapters()->first()->name);
}
public function test_chapters_viewable()
{
$this->setSettings(['app-public' => 'true']);
$chapterToVisit = \BookStack\Chapter::first();
$pageToVisit = $chapterToVisit->pages()->first();
// Check chapters index page is showing
$this->visit($chapterToVisit->getUrl())
->seeStatusCode(200)
->see($chapterToVisit->name)
// Check individual chapter page is showing and it's child contents are visible.
->see($pageToVisit->name)
->click($pageToVisit->name)
->see($chapterToVisit->book->name)
->see($chapterToVisit->name)
->seePageIs($pageToVisit->getUrl());
}
}

View File

@ -66,6 +66,14 @@ class TestCase extends Illuminate\Foundation\Testing\TestCase
return $this->actingAs($this->editor);
}
/**
* Get a user that's not a system user such as the guest user.
*/
public function getNormalUser()
{
return \BookStack\User::where('system_name', '=', null)->get()->last();
}
/**
* Quickly sets an array of settings.
* @param $settingsArray

View File

@ -76,5 +76,23 @@ class UserProfileTest extends TestCase
->seePageIs('/user/' . $newUser->id)
->see($newUser->name);
}
public function test_guest_profile_shows_limited_form()
{
$this->asAdmin()
->visit('/settings/users')
->click('Guest')
->dontSeeElement('#password');
}
public function test_guest_profile_cannot_be_deleted()
{
$guestUser = \BookStack\User::getDefault();
$this->asAdmin()->visit('/settings/users/' . $guestUser->id . '/delete')
->see('Delete User')->see('Guest')
->press('Confirm')
->seePageIs('/settings/users/' . $guestUser->id)
->see('cannot delete the guest user');
}
}

View File

@ -0,0 +1 @@
Hi, This is a test file for testing the upload process.

View File

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

1
version Normal file
View File

@ -0,0 +1 @@
v0.13-dev