/storage/images
_ide_helper.php
/storage/debugbar
-.phpstorm.meta.php
\ No newline at end of file
+.phpstorm.meta.php
+yarn.lock
--- /dev/null
+<?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);
+ }
+
+}
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));
}
/*
{
$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));
}
/**
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('/".*?"/', $term)) {
- $term = str_replace('"', '', $term);
- $exactTerms[] = '%' . $term . '%';
- $term = '"' . $term . '"';
- } else {
- $term = '' . $term . '*';
- }
- if ($term !== '*') $terms[$key] = $term;
+ $fuzzyTerms = [];
+ $search = static::newQuery();
+
+ foreach ($terms as $key => $term) {
+ $term = htmlentities($term, ENT_QUOTES);
+ $term = preg_replace('/[+\-><\(\)~*\"@]+/', ' ', $term);
+ if (preg_match('/".*?"/', $term) || is_numeric($term)) {
+ $term = str_replace('"', '', $term);
+ $exactTerms[] = '%' . $term . '%';
+ } else {
+ $term = '' . $term . '*';
+ if ($term !== '*') $fuzzyTerms[] = $term;
}
- $termString = implode(' ', $terms);
+ }
+
+ $isFuzzy = count($exactTerms) === 0 && count($fuzzyTerms) > 0;
+
+
+ // Perform fulltext search if relevant terms exist.
+ if ($isFuzzy) {
+ $termString = implode(' ', $fuzzyTerms);
$fields = implode(',', $fieldsToSearch);
- $search = static::selectRaw('*, MATCH(name) AGAINST(? IN BOOLEAN MODE) AS title_relevance', [$termString]);
+ $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);
- }
+ // 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 = 'title_relevance';
- };
+ }
+ });
+ }
+
+ $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');
--- /dev/null
+<?php namespace BookStack\Exceptions;
+
+
+class FileUploadException extends PrettyException {}
\ No newline at end of file
--- /dev/null
+<?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']);
+ }
+}
use BookStack\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\SendsPasswordResetEmails;
+use Illuminate\Http\Request;
+use Password;
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)]
+ );
+ }
+
}
\ No newline at end of file
namespace BookStack\Http\Controllers\Auth;
+use BookStack\Exceptions\AuthException;
use BookStack\Http\Controllers\Controller;
use BookStack\Repos\UserRepo;
use BookStack\Services\SocialAuthService;
*/
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;
return $this->registerUser($userData, $socialAccount);
}
-
}
\ No newline at end of file
use ResetsPasswords;
+ protected $redirectTo = '/';
+
/**
* Create a new controller instance.
*
$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));
+ }
}
\ No newline at end of file
$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());
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
$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);
});
}
*/
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);
}
*/
protected function checkPermission($permissionName)
{
- if (!$this->currentUser || !$this->currentUser->can($permissionName)) {
+ if (!user() || !user()->can($permissionName)) {
$this->showPermissionError();
}
return true;
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());
+ }
+
}
use BookStack\Repos\PageRepo;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Views;
+use GatherContent\Htmldiff\Htmldiff;
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);
+
+ // 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);
- $draft = $this->pageRepo->getDraftPage($book, $chapter);
- return redirect($draft->getUrl());
+ $page = $this->pageRepo->getDraftPage($book, $chapter);
+ $this->pageRepo->publishDraft($page, [
+ 'name' => $request->get('name'),
+ 'html' => ''
+ ]);
+ return redirect($page->getUrl('/edit'));
}
/**
$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
+ ]);
}
/**
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
+ ]);
}
/**
{
$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 {
$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,
+ ]);
}
/**
$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]);
}
{
$this->checkPermission('users-manage');
$authMethod = config('auth.method');
- $roles = $this->userRepo->getAssignableRoles();
+ $roles = $this->userRepo->getAllRoles();
return view('users/create', ['authMethod' => $authMethod, 'roles' => $roles]);
}
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]);
}
/**
* Show the user delete page.
- * @param $id
+ * @param int $id
* @return \Illuminate\View\View
*/
public function delete($id)
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');
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
{
$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);
}
/**
/**
* 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;
}
}
{
$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;
*/
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;
*/
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);
}
{
$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;
*/
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);
}
*/
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();
}
$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;
+ }
+
}
use BookStack\Page;
use BookStack\Services\ImageService;
use BookStack\Services\PermissionService;
+use Illuminate\Contracts\Filesystem\FileNotFoundException;
use Setting;
use Symfony\Component\HttpFoundation\File\UploadedFile;
*/
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 [];
+ }
}
use BookStack\Chapter;
use BookStack\Entity;
use BookStack\Exceptions\NotFoundException;
+use BookStack\Services\AttachmentService;
use Carbon\Carbon;
use DOMDocument;
use DOMXPath;
* Get a page via a specific ID.
* @param $id
* @param bool $allowDrafts
- * @return mixed
+ * @return Page
*/
public function getById($id, $allowDrafts = false)
{
* Get a page identified by the given slug.
* @param $slug
* @param $bookId
- * @return mixed
+ * @return Page
* @throws NotFoundException
*/
public function getBySlug($slug, $bookId)
{
$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;
}
// 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);
$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;
}
$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;
*/
public function saveUpdateDraft(Page $page, $data = [])
{
- $userId = auth()->user()->id;
+ $userId = user()->id;
$drafts = $this->userUpdateDraftsQuery($page, $userId)->get();
if ($drafts->count() > 0) {
$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) {
/**
* Gets a single revision via it's id.
* @param $id
- * @return mixed
+ * @return PageRevision
*/
public function getRevisionById($id)
{
*/
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);
}
$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)
{
/**
* Get the latest pages added to the system.
* @param $count
+ * @return mixed
*/
public function getRecentlyUpdatedPaginated($count = 20)
{
*/
public function getAllRoles()
{
- return $this->role->where('hidden', '=', false)->get();
+ return $this->role->all();
}
/**
*/
public function getAllRolesExcept(Role $role)
{
- return $this->role->where('id', '!=', $role->id)->where('hidden', '=', false)->get();
+ return $this->role->where('id', '!=', $role->id)->get();
}
/**
{
$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);
* 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();
}
/**
*/
public function getRestrictableRoles()
{
- return $this->role->where('hidden', '=', false)->where('system_name', '=', '')->get();
+ return $this->role->where('system_name', '!=', 'admin')->get();
}
}
\ No newline at end of file
/**
* Get the role object for the specified role.
* @param $roleName
- * @return mixed
+ * @return Role
*/
public static function getRole($roleName)
{
/**
* Get the role object for the specified system role.
* @param $roleName
- * @return mixed
+ * @return Role
*/
public static function getSystemRole($roleName)
{
{
$this->activity = $activity;
$this->permissionService = $permissionService;
- $this->user = auth()->user();
+ $this->user = user();
}
/**
--- /dev/null
+<?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;
+ }
+
+}
\ No newline at end of file
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;
/**
public function __construct(ImageManager $imageTool, FileSystem $fileSystem, Cache $cache)
{
$this->imageTool = $imageTool;
- $this->fileSystem = $fileSystem;
$this->cache = $cache;
+ parent::__construct($fileSystem);
}
/**
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;
}
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,
'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;
}
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.
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);
}
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.');
{
$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) {
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)
$this->storageUrl = $storageUrl;
}
+ if ($this->isLocal()) $filePath = str_replace_first('public/', '', $filePath);
+
return ($this->storageUrl == false ? rtrim(baseUrl(''), '/') : rtrim($this->storageUrl, '/')) . $filePath;
}
private function currentUser()
{
if ($this->currentUserModel === false) {
- $this->currentUserModel = auth()->user() ? auth()->user() : new User();
+ $this->currentUserModel = user();
}
return $this->currentUserModel;
$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.
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());
}
}
\ No newline at end of file
--- /dev/null
+<?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';
+ }
+}
\ No newline at end of file
public function __construct(View $view, PermissionService $permissionService)
{
$this->view = $view;
- $this->user = auth()->user();
+ $this->user = user();
$this->permissionService = $permissionService;
}
->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');
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
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);
}
*/
function versioned_asset($file = '')
{
- // Don't require css and JS assets for testing
- if (config('app.env') === 'testing') return '';
-
- 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);
+ static $version = null;
+
+ 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();
}
/**
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
{
$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;
if (count($queryStringSections) === 0) return $path;
return baseUrl($path . '?' . implode('&', $queryStringSections));
-}
\ No newline at end of file
+}
"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",
"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": {
"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": {
"profiler",
"webprofiler"
],
- "time": "2016-07-29 15:00:36"
+ "time": "2016-09-15 14:05:56"
},
{
"name": "barryvdh/laravel-dompdf",
],
"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",
+ },
+ {
+ "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",
"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",
+ },
+ {
+ "name": "Mathew Chapman",
+ },
+ {
+ "name": "Peter Legierski",
+ }
+ ],
+ "description": "Compare two HTML strings",
+ "time": "2015-04-15 15:39:46"
+ },
{
"name": "guzzlehttp/guzzle",
"version": "6.2.1",
},
{
"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": {
"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",
"framework",
"laravel"
],
- "time": "2016-09-12 14:08:29"
+ "time": "2016-09-28 02:15:37"
},
{
"name": "laravel/socialite",
},
{
"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": {
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "1.12-dev"
+ "dev-master": "1.13-dev"
}
},
"autoload": {
"debug",
"debugbar"
],
- "time": "2016-05-15 13:11:34"
+ "time": "2016-09-15 14:01:59"
},
{
"name": "monolog/monolog",
},
{
"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": {
"parser",
"php"
],
- "time": "2016-04-19 13:41:41"
+ "time": "2016-09-16 12:04:44"
},
{
"name": "paragonie/random_compat",
},
{
"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/",
}
],
"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",
},
{
"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": {
"object",
"object graph"
],
- "time": "2016-09-06 16:07:05"
+ "time": "2016-09-16 13:37:59"
},
{
"name": "phpdocumentor/reflection-common",
},
{
"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",
"conflict": {
"phpdocumentor/reflection-docblock": "3.0.2"
},
+ "require-dev": {
+ "ext-pdo": "*"
+ },
"suggest": {
+ "ext-tidy": "*",
+ "ext-xdebug": "*",
"phpunit/php-invoker": "~1.1"
},
"bin": [
"testing",
"xunit"
],
- "time": "2016-08-26 07:11:44"
+ "time": "2016-09-21 14:40:13"
},
{
"name": "phpunit/phpunit-mock-objects",
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
- "php": ">=5.6.4"
+ "php": ">=5.6.4",
+ "ext-tidy": "*"
},
"platform-dev": []
}
|
*/
- 'locale' => 'en',
+ 'locale' => env('APP_LANG', 'en'),
/*
|--------------------------------------------------------------------------
'local' => [
'driver' => 'local',
- 'root' => public_path(),
+ 'root' => base_path(),
],
'ftp' => [
'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,
];
\ No newline at end of file
--- /dev/null
+<?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([
+ '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]);
+ }
+}
--- /dev/null
+<?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();
+ }
+ }
+}
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');
});
{
"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"
<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"/>
[](https://github.com/ssddanbrown/BookStack/releases/latest)
[](https://github.com/ssddanbrown/BookStack/blob/master/LICENSE)
-[](https://travis-ci.org/ssddanbrown/BookStack)
+[](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: `
[email protected]`. Password: `password`)*
+* [Demo Instance](https://demo.bookstackapp.com)
+ * *Password: `password`*
* [BookStack Blog](https://www.bookstackapp.com/blog)
## Development & 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
* [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.
+++ /dev/null
-<div class="dropzone-container">
- <div class="dz-message">Drop files or click here to upload</div>
-</div>
\ No newline at end of file
+++ /dev/null
-
-<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>
\ No newline at end of file
+++ /dev/null
-<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>
\ No newline at end of file
"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) {
$scope.imageDeleteSuccess = false;
$scope.uploadedTo = $attrs.uploadedTo;
$scope.view = 'all';
-
+
$scope.searching = false;
$scope.searchTerm = '';
$scope.hasMore = preSearchHasMore;
}
$scope.cancelSearch = cancelSearch;
-
+
/**
* Runs on image upload, Adds an image to local list of images
/**
* Start a search operation
- * @param searchTerm
*/
$scope.searchImages = function() {
$scope.view = viewName;
baseUrl = window.baseUrl('/images/' + $scope.imageType + '/' + viewName + '/');
fetchData();
- }
+ };
/**
* Save the details of an image.
$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) {
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;
html: false
};
- if (isEdit) {
+ if (isEdit && $scope.draftsEnabled) {
setTimeout(() => {
startAutoSave();
}, 1000);
* 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
const pageId = Number($attrs.pageId);
$scope.tags = [];
-
+
$scope.sortOptions = {
handle: '.handle',
items: '> tr',
* 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();
}]);
-};
+ 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])
+ }
+ }
+ }
+ }]);
+};
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) {
/**
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;
};
});
+ /**
+ * 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
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: '@',
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: '=',
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 () {
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
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);
});
}
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();
};
}]);
};
-
-
-
-
-
-
-
-
-
-
-
-
-
-
this.listeners[eventName].push(callback);
return this;
}
-};
-window.Events = new EventManager();
+}
+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
* @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();
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;
}
}
-//body.ie .popup-body {
-// min-height: 100%;
-//}
-
.corner-button {
position: absolute;
top: 0;
min-height: 70vh;
}
-#image-manager .dropzone-container {
+.dropzone-container {
position: relative;
border: 3px dashed #DDD;
}
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;
+ }
+ }
+}
\ No newline at end of file
border-left: 0px solid #FFF;
background-color: #FFF;
&.fixed {
+ background-color: #FFF;
+ z-index: 5;
position: fixed;
top: 0;
padding-left: $-l;
max-width: 100%;
height: auto !important;
}
+
+ // diffs
+ ins,
+ del {
+ text-decoration: none;
+ }
+ ins {
+ background: #dbffdb;
+ }
+ del {
+ background: #FFECEC;
+ }
}
// Page content pointers
background-color: #FFF;
border: 1px solid #DDD;
right: $-xl*2;
- z-index: 99;
width: 48px;
overflow: hidden;
align-items: stretch;
color: #444;
background-color: rgba(0, 0, 0, 0.1);
}
- div[tab-content] {
+ div[toolbox-tab-content] {
padding-bottom: 45px;
display: flex;
flex: 1;
min-height: 0px;
overflow-y: scroll;
}
- div[tab-content] .padded {
+ div[toolbox-tab-content] .padded {
flex: 1;
padding-top: 0;
}
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;
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;
}
vertical-align: middle;
padding: $-xs;
}
+}
+
+table.file-table {
+ @extend .no-style;
+ td {
+ padding: $-xs;
+ }
+ .ui-sortable-helper {
+ display: table;
+ }
}
\ No newline at end of file
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%);
}
}
ol {
list-style: decimal;
- padding-left: $-m * 1.3;
+ padding-left: $-m * 2;
overflow: hidden;
}
--- /dev/null
+<?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öscht',
+ 'page_delete_notification' => 'Seite erfolgreich gelö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öscht',
+ 'chapter_delete_notification' => 'Kapitel erfolgreich gelö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öscht',
+ 'book_delete_notification' => 'Buch erfolgreich gelöscht',
+ 'book_sort' => 'Buch sortiert',
+ 'book_sort_notification' => 'Buch erfolgreich neu sortiert',
+
+];
--- /dev/null
+<?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ültigen Anmeldedaten.',
+ 'throttle' => 'Zu viele Anmeldeversuche. Bitte versuchen sie es in :seconds Sekunden erneut.',
+
+ /**
+ * Email Confirmation Text
+ */
+ 'email_confirm_subject' => 'Bestätigen sie ihre E-Mail Adresse bei :appName',
+ 'email_confirm_greeting' => 'Danke, dass sie :appName beigetreten sind!',
+ 'email_confirm_text' => 'Bitte bestätigen sie ihre E-Mail Adresse, indem sie auf den Button klicken:',
+ 'email_confirm_action' => 'E-Mail Adresse bestätigen',
+ 'email_confirm_send_error' => 'Bestätigungs-E-Mail benö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ätigt!',
+ 'email_confirm_resent' => 'Bestätigungs-E-Mail wurde erneut versendet, bitte überprüfen sie ihren Posteingang.',
+];
--- /dev/null
+<?php
+
+return [
+
+ /**
+ * Error text strings.
+ */
+
+ // Pages
+ 'permission' => 'Sie haben keine Berechtigung auf diese Seite zuzugreifen.',
+ 'permissionJson' => 'Sie haben keine Berechtigung die angeforderte Aktion auszuführen.'
+];
--- /dev/null
+<?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' => '« Vorherige',
+ 'next' => 'Nächste »',
+
+];
--- /dev/null
+<?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örter müssen mindestens sechs Zeichen enthalten und die Wiederholung muss identisch sein.',
+ 'user' => "Wir können keinen Benutzer mit dieser E-Mail Adresse finden.",
+ 'token' => 'Dieser Passwort-Reset-Token ist ungültig.',
+ 'sent' => 'Wir haben ihnen eine E-Mail mit einem Link zum Zurücksetzen des Passworts zugesendet!',
+ 'reset' => 'Ihr Passwort wurde zurückgesetzt!',
+
+];
--- /dev/null
+<?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' => 'Öffentliche Ansicht erlauben?',
+ 'app_secure_images' => 'Erh&oml;hte Sicherheit für Bilduploads aktivieren?',
+ 'app_secure_images_desc' => 'Aus Leistungsgründen sind alle Bilder öffentlich sichtbar. Diese Option fügt zufä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ä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ügt wird, wird am Ende der <head> Sektion jeder Seite eingefügt. Diese kann praktisch sein, um CSS Styles anzupassen oder Analytics Code hinzuzufügen.',
+ 'app_logo' => 'Anwendungslogo',
+ 'app_logo_desc' => 'Dieses Bild sollte 43px hoch sein. <br>Größere Bilder werden verkleinert.',
+ 'app_primary_color' => 'Primäre Anwendungsfarbe',
+ 'app_primary_color_desc' => 'Dies sollte ein HEX Wert sein. <br>Leer lassen des Feldes setzt auf die Standard-Anwendungsfarbe zurück.',
+
+ 'reg_settings' => 'Registrierungseinstellungen',
+ 'reg_allow' => 'Registrierung erlauben?',
+ 'reg_default_role' => 'Standard-Benutzerrolle nach Registrierung',
+ 'reg_confirm_email' => 'Bestätigung per E-Mail erforderlich?',
+ 'reg_confirm_email_desc' => 'Falls die Einschränkung für; Domains genutzt wird, ist die Bestätigung per E-Mail zwingend erforderlich und der untenstehende Wert wird ignoriert.',
+ 'reg_confirm_restrict_domain' => 'Registrierung auf bestimmte Domains einschränken',
+ 'reg_confirm_restrict_domain_desc' => 'Fügen sie eine, durch Komma getrennte, Liste von E-Mail Domains hinzu, auf die die Registrierung eingeschränkt werden soll. Benutzern wird eine E-Mail gesendet, um ihre E-Mail Adresse zu bestätigen, bevor sie diese Anwendung nutzen können. <br> Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung ändern.',
+ 'reg_confirm_restrict_domain_placeholder' => 'Keine Einschränkung gesetzt',
+
+];
--- /dev/null
+<?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ß 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ätigung stimmt nicht überein.',
+ 'date' => ':attribute ist kein valides Datum.',
+ 'date_format' => ':attribute entspricht nicht dem Format :format.',
+ 'different' => ':attribute und :other mü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ültig.',
+ 'image' => ':attribute muss ein Bild sein.',
+ 'in' => 'Markiertes :attribute ist ungültig.',
+ 'integer' => ':attribute muss eine Zahl sein.',
+ 'ip' => ':attribute muss eine valide IP-Adresse sein.',
+ 'max' => [
+ 'numeric' => ':attribute darf nicht größer als :max sein.',
+ 'file' => ':attribute darf nicht größer als :max Kilobyte sein.',
+ 'string' => ':attribute darf nicht lä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ß sein.',
+ 'string' => ':attribute muss mindestens :min Zeichen lang sein.',
+ 'array' => ':attribute muss mindesten :min Elemente enthalten.',
+ ],
+ 'not_in' => 'Markiertes :attribute ist ungültig.',
+ 'numeric' => ':attribute muss eine Zahl sein.',
+ 'regex' => ':attribute Format ist ungü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 übereinstimmen.',
+ 'size' => [
+ 'numeric' => ':attribute muss :size sein.',
+ 'file' => ':attribute muss :size Kilobytes groß 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' => [],
+
+];
@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')
@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')
@include('partials/custom-styles')
<!-- Custom user content -->
- @if(setting('app-custom-head', false))
+ @if(setting('app-custom-head'))
{!! setting('app-custom-head') !!}
@endif
</head>
@include('partials/image-manager', ['imageType' => 'gallery', 'uploaded_to' => $page->id])
@include('partials/entity-selector-popup')
- <script>
- (function() {
-
- })();
- </script>
-
@stop
\ No newline at end of file
<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>
</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>
\ No newline at end of file
-<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">
</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> <i class="zmdi zmdi-more-vert"></i></a>
<i class="zmdi zmdi-check-circle text-pos draft-notification" ng-class="{visible: draftUpdated}"></i>
<ul>
</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
@endif
@endif
+ {{--Markdown Editor--}}
@if(setting('app-editor') === 'markdown')
<div id="markdown-editor" markdown-editor class="flex-fill flex">
@if($errors->has('markdown'))
<div class="text-neg text-small">{{ $errors->first('markdown') }}</div>
@endif
-
@endif
+
</div>
</div>
\ No newline at end of file
--- /dev/null
+@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
\ No newline at end of file
<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>
\ No newline at end of file
table {
max-width: 800px !important;
font-size: 0.8em;
- width: auto !important;
+ width: 100% !important;
}
table td {
<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>
<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"> | </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"> | </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>
</div>
@endif
+
+
@include('pages/sidebar-tree-list', ['book' => $book, 'sidebarTree' => $sidebarTree, 'pageNav' => $pageNav])
</div>
<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">
</li>
@endforeach
</div>
-
-
@endif
<h6 class="text-muted">Book Navigation</h6>
.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>
\ No newline at end of file
<!-- 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">
<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
>
<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>
</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">
--- /dev/null
+@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 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>
// 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');
// 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');
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');
});
// 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');
--- /dev/null
+<?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();
+ }
+}
public function test_user_updating()
{
- $user = \BookStack\User::all()->last();
+ $user = $this->getNormalUser();
$password = $user->password;
$this->asAdmin()
->visit('/settings/users')
public function test_user_password_update()
{
- $user = \BookStack\User::all()->last();
+ $user = $this->getNormalUser();
$userProfilePage = '/settings/users/' . $user->id;
$this->asAdmin()
->visit($userProfilePage)
->seePageIs('/login');
}
+ public function test_reset_password_flow()
+ {
+ $this->visit('/login')->click('Forgot Password?')
+ ->seePageIs('/password/email')
+ ->press('Send Reset Link')
+
+ $this->seeInDatabase('password_resets', [
+ ]);
+
+ $reset = DB::table('password_resets')->where('email', '=', '
[email protected]')->first();
+ $this->visit('/password/reset/' . $reset->token)
+ ->see('Reset Password')
+ ->submitForm('Reset Password', [
+ '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
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')
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');
}
->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();
*/
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);
}
/**
$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);
'updated_by' => $admin->id,
'name' => $imageName
]);
-
}
->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');
+ }
+
}
--- /dev/null
+<?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
+ ]);
+ }
+
+}
\ No newline at end of file
+++ /dev/null
-<?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());
- }
-
-}
\ No newline at end of file
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
->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');
+ }
}
--- /dev/null
+Hi, This is a test file for testing the upload process.
\ No newline at end of file