diff --git a/.gitignore b/.gitignore index 7417bbdd8..362df57e1 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,5 @@ Homestead.yaml /storage/images _ide_helper.php /storage/debugbar -.phpstorm.meta.php \ No newline at end of file +.phpstorm.meta.php +yarn.lock diff --git a/app/Attachment.php b/app/Attachment.php new file mode 100644 index 000000000..fe291bec2 --- /dev/null +++ b/app/Attachment.php @@ -0,0 +1,36 @@ +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); + } + +} diff --git a/app/Book.php b/app/Book.php index aa2dee9c0..91f74ca64 100644 --- a/app/Book.php +++ b/app/Book.php @@ -13,9 +13,9 @@ class Book extends Entity public function getUrl($path = false) { if ($path !== false) { - return baseUrl('/books/' . $this->slug . '/' . trim($path, '/')); + return baseUrl('/books/' . urlencode($this->slug) . '/' . trim($path, '/')); } - return baseUrl('/books/' . $this->slug); + return baseUrl('/books/' . urlencode($this->slug)); } /* diff --git a/app/Chapter.php b/app/Chapter.php index 8f0453172..cc5518b7a 100644 --- a/app/Chapter.php +++ b/app/Chapter.php @@ -32,9 +32,9 @@ class Chapter extends Entity { $bookSlug = $this->getAttribute('bookSlug') ? $this->getAttribute('bookSlug') : $this->book->slug; if ($path !== false) { - return baseUrl('/books/' . $bookSlug. '/chapter/' . $this->slug . '/' . trim($path, '/')); + return baseUrl('/books/' . urlencode($bookSlug) . '/chapter/' . urlencode($this->slug) . '/' . trim($path, '/')); } - return baseUrl('/books/' . $bookSlug. '/chapter/' . $this->slug); + return baseUrl('/books/' . urlencode($bookSlug) . '/chapter/' . urlencode($this->slug)); } /** diff --git a/app/Entity.php b/app/Entity.php index 496d20a33..186059f00 100644 --- a/app/Entity.php +++ b/app/Entity.php @@ -160,44 +160,50 @@ class Entity extends Ownable public function fullTextSearchQuery($fieldsToSearch, $terms, $wheres = []) { $exactTerms = []; - if (count($terms) === 0) { - $search = $this; - $orderBy = 'updated_at'; - } else { - foreach ($terms as $key => $term) { - $term = htmlentities($term, ENT_QUOTES); - $term = preg_replace('/[+\-><\(\)~*\"@]+/', ' ', $term); - if (preg_match('/".*?"/', $term)) { - $term = str_replace('"', '', $term); - $exactTerms[] = '%' . $term . '%'; - $term = '"' . $term . '"'; - } else { - $term = '' . $term . '*'; - } - if ($term !== '*') $terms[$key] = $term; - } - $termString = implode(' ', $terms); - $fields = implode(',', $fieldsToSearch); - $search = static::selectRaw('*, MATCH(name) AGAINST(? IN BOOLEAN MODE) AS title_relevance', [$termString]); - $search = $search->whereRaw('MATCH(' . $fields . ') AGAINST(? IN BOOLEAN MODE)', [$termString]); + $fuzzyTerms = []; + $search = static::newQuery(); - // Ensure at least one exact term matches if in search - if (count($exactTerms) > 0) { - $search = $search->where(function ($query) use ($exactTerms, $fieldsToSearch) { - foreach ($exactTerms as $exactTerm) { - foreach ($fieldsToSearch as $field) { - $query->orWhere($field, 'like', $exactTerm); - } - } - }); + foreach ($terms as $key => $term) { + $term = htmlentities($term, ENT_QUOTES); + $term = preg_replace('/[+\-><\(\)~*\"@]+/', ' ', $term); + if (preg_match('/".*?"/', $term) || is_numeric($term)) { + $term = str_replace('"', '', $term); + $exactTerms[] = '%' . $term . '%'; + } else { + $term = '' . $term . '*'; + if ($term !== '*') $fuzzyTerms[] = $term; } - $orderBy = 'title_relevance'; - }; + } + + $isFuzzy = count($exactTerms) === 0 && count($fuzzyTerms) > 0; + + + // Perform fulltext search if relevant terms exist. + if ($isFuzzy) { + $termString = implode(' ', $fuzzyTerms); + $fields = implode(',', $fieldsToSearch); + $search = $search->selectRaw('*, MATCH(name) AGAINST(? IN BOOLEAN MODE) AS title_relevance', [$termString]); + $search = $search->whereRaw('MATCH(' . $fields . ') AGAINST(? IN BOOLEAN MODE)', [$termString]); + } + + // Ensure at least one exact term matches if in search + if (count($exactTerms) > 0) { + $search = $search->where(function ($query) use ($exactTerms, $fieldsToSearch) { + foreach ($exactTerms as $exactTerm) { + foreach ($fieldsToSearch as $field) { + $query->orWhere($field, 'like', $exactTerm); + } + } + }); + } + + $orderBy = $isFuzzy ? 'title_relevance' : 'updated_at'; // Add additional where terms foreach ($wheres as $whereTerm) { $search->where($whereTerm[0], $whereTerm[1], $whereTerm[2]); } + // Load in relations if ($this->isA('page')) { $search = $search->with('book', 'chapter', 'createdBy', 'updatedBy'); diff --git a/app/Exceptions/FileUploadException.php b/app/Exceptions/FileUploadException.php new file mode 100644 index 000000000..af976072a --- /dev/null +++ b/app/Exceptions/FileUploadException.php @@ -0,0 +1,4 @@ +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']); + } +} diff --git a/app/Http/Controllers/Auth/ForgotPasswordController.php b/app/Http/Controllers/Auth/ForgotPasswordController.php index d93854e23..45e40e6fe 100644 --- a/app/Http/Controllers/Auth/ForgotPasswordController.php +++ b/app/Http/Controllers/Auth/ForgotPasswordController.php @@ -4,6 +4,8 @@ namespace BookStack\Http\Controllers\Auth; use BookStack\Http\Controllers\Controller; use Illuminate\Foundation\Auth\SendsPasswordResetEmails; +use Illuminate\Http\Request; +use Password; class ForgotPasswordController extends Controller { @@ -30,4 +32,37 @@ class ForgotPasswordController extends Controller $this->middleware('guest'); parent::__construct(); } + + + /** + * Send a reset link to the given user. + * + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Http\RedirectResponse + */ + public function sendResetLinkEmail(Request $request) + { + $this->validate($request, ['email' => 'required|email']); + + // We will send the password reset link to this user. Once we have attempted + // to send the link, we will examine the response then see the message we + // need to show to the user. Finally, we'll send out a proper response. + $response = $this->broker()->sendResetLink( + $request->only('email') + ); + + if ($response === Password::RESET_LINK_SENT) { + $message = 'A password reset link has been sent to ' . $request->get('email') . '.'; + session()->flash('success', $message); + return back()->with('status', trans($response)); + } + + // If an error was returned by the password broker, we will get this message + // translated so we can notify a user of the problem. We'll redirect back + // to where the users came from so they can attempt this process again. + return back()->withErrors( + ['email' => trans($response)] + ); + } + } \ No newline at end of file diff --git a/app/Http/Controllers/Auth/LoginController.php b/app/Http/Controllers/Auth/LoginController.php index 0de4a8282..c9d6a5496 100644 --- a/app/Http/Controllers/Auth/LoginController.php +++ b/app/Http/Controllers/Auth/LoginController.php @@ -2,6 +2,7 @@ namespace BookStack\Http\Controllers\Auth; +use BookStack\Exceptions\AuthException; use BookStack\Http\Controllers\Controller; use BookStack\Repos\UserRepo; use BookStack\Services\SocialAuthService; diff --git a/app/Http/Controllers/Auth/RegisterController.php b/app/Http/Controllers/Auth/RegisterController.php index 6bba6de04..d9bb500b4 100644 --- a/app/Http/Controllers/Auth/RegisterController.php +++ b/app/Http/Controllers/Auth/RegisterController.php @@ -51,7 +51,7 @@ class RegisterController extends Controller */ public function __construct(SocialAuthService $socialAuthService, EmailConfirmationService $emailConfirmationService, UserRepo $userRepo) { - $this->middleware('guest'); + $this->middleware('guest')->except(['socialCallback', 'detachSocialAccount']); $this->socialAuthService = $socialAuthService; $this->emailConfirmationService = $emailConfirmationService; $this->userRepo = $userRepo; @@ -297,5 +297,4 @@ class RegisterController extends Controller return $this->registerUser($userData, $socialAccount); } - } \ No newline at end of file diff --git a/app/Http/Controllers/Auth/ResetPasswordController.php b/app/Http/Controllers/Auth/ResetPasswordController.php index 656b8cc42..bd64793f9 100644 --- a/app/Http/Controllers/Auth/ResetPasswordController.php +++ b/app/Http/Controllers/Auth/ResetPasswordController.php @@ -20,6 +20,8 @@ class ResetPasswordController extends Controller use ResetsPasswords; + protected $redirectTo = '/'; + /** * Create a new controller instance. * @@ -30,4 +32,18 @@ class ResetPasswordController extends Controller $this->middleware('guest'); parent::__construct(); } + + /** + * Get the response for a successful password reset. + * + * @param string $response + * @return \Illuminate\Http\Response + */ + protected function sendResetResponse($response) + { + $message = 'Your password has been successfully reset.'; + session()->flash('success', $message); + return redirect($this->redirectPath()) + ->with('status', trans($response)); + } } \ No newline at end of file diff --git a/app/Http/Controllers/ChapterController.php b/app/Http/Controllers/ChapterController.php index 03ec2c110..a3fb600fd 100644 --- a/app/Http/Controllers/ChapterController.php +++ b/app/Http/Controllers/ChapterController.php @@ -115,9 +115,11 @@ class ChapterController extends Controller $book = $this->bookRepo->getBySlug($bookSlug); $chapter = $this->chapterRepo->getBySlug($chapterSlug, $book->id); $this->checkOwnablePermission('chapter-update', $chapter); + if ($chapter->name !== $request->get('name')) { + $chapter->slug = $this->chapterRepo->findSuitableSlug($request->get('name'), $book->id, $chapter->id); + } $chapter->fill($request->all()); - $chapter->slug = $this->chapterRepo->findSuitableSlug($chapter->name, $book->id, $chapter->id); - $chapter->updated_by = auth()->user()->id; + $chapter->updated_by = user()->id; $chapter->save(); Activity::add($chapter, 'chapter_update', $book->id); return redirect($chapter->getUrl()); diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index 43292d941..2b6c88fe0 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -3,13 +3,11 @@ namespace BookStack\Http\Controllers; use BookStack\Ownable; -use HttpRequestException; use Illuminate\Foundation\Bus\DispatchesJobs; use Illuminate\Http\Exception\HttpResponseException; +use Illuminate\Http\Request; use Illuminate\Routing\Controller as BaseController; use Illuminate\Foundation\Validation\ValidatesRequests; -use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Session; use BookStack\User; abstract class Controller extends BaseController @@ -33,17 +31,16 @@ abstract class Controller extends BaseController $this->middleware(function ($request, $next) { // Get a user instance for the current user - $user = auth()->user(); - if (!$user) $user = User::getDefault(); - - // Share variables with views - view()->share('signedIn', auth()->check()); - view()->share('currentUser', $user); + $user = user(); // Share variables with controllers $this->currentUser = $user; $this->signedIn = auth()->check(); + // Share variables with views + view()->share('signedIn', $this->signedIn); + view()->share('currentUser', $user); + return $next($request); }); } @@ -72,8 +69,13 @@ abstract class Controller extends BaseController */ protected function showPermissionError() { - Session::flash('error', trans('errors.permission')); - $response = request()->wantsJson() ? response()->json(['error' => trans('errors.permissionJson')], 403) : redirect('/'); + if (request()->wantsJson()) { + $response = response()->json(['error' => trans('errors.permissionJson')], 403); + } else { + $response = redirect('/'); + session()->flash('error', trans('errors.permission')); + } + throw new HttpResponseException($response); } @@ -84,7 +86,7 @@ abstract class Controller extends BaseController */ protected function checkPermission($permissionName) { - if (!$this->currentUser || !$this->currentUser->can($permissionName)) { + if (!user() || !user()->can($permissionName)) { $this->showPermissionError(); } return true; @@ -126,4 +128,22 @@ abstract class Controller extends BaseController return response()->json(['message' => $messageText], $statusCode); } + /** + * Create the response for when a request fails validation. + * + * @param \Illuminate\Http\Request $request + * @param array $errors + * @return \Symfony\Component\HttpFoundation\Response + */ + protected function buildFailedValidationResponse(Request $request, array $errors) + { + if ($request->expectsJson()) { + return response()->json(['validation' => $errors], 422); + } + + return redirect()->to($this->getRedirectUrl()) + ->withInput($request->input()) + ->withErrors($errors, $this->errorBag()); + } + } diff --git a/app/Http/Controllers/PageController.php b/app/Http/Controllers/PageController.php index 3d6abe5b4..c2d8e257c 100644 --- a/app/Http/Controllers/PageController.php +++ b/app/Http/Controllers/PageController.php @@ -12,6 +12,7 @@ use BookStack\Repos\ChapterRepo; use BookStack\Repos\PageRepo; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Views; +use GatherContent\Htmldiff\Htmldiff; class PageController extends Controller { @@ -43,20 +44,53 @@ class PageController extends Controller /** * Show the form for creating a new page. * @param string $bookSlug - * @param bool $chapterSlug + * @param string $chapterSlug * @return Response * @internal param bool $pageSlug */ - public function create($bookSlug, $chapterSlug = false) + public function create($bookSlug, $chapterSlug = null) { $book = $this->bookRepo->getBySlug($bookSlug); $chapter = $chapterSlug ? $this->chapterRepo->getBySlug($chapterSlug, $book->id) : null; $parent = $chapter ? $chapter : $book; $this->checkOwnablePermission('page-create', $parent); - $this->setPageTitle('Create New Page'); - $draft = $this->pageRepo->getDraftPage($book, $chapter); - return redirect($draft->getUrl()); + // Redirect to draft edit screen if signed in + if ($this->signedIn) { + $draft = $this->pageRepo->getDraftPage($book, $chapter); + return redirect($draft->getUrl()); + } + + // Otherwise show edit view + $this->setPageTitle('Create New Page'); + return view('pages/guest-create', ['parent' => $parent]); + } + + /** + * Create a new page as a guest user. + * @param Request $request + * @param string $bookSlug + * @param string|null $chapterSlug + * @return mixed + * @throws NotFoundException + */ + public function createAsGuest(Request $request, $bookSlug, $chapterSlug = null) + { + $this->validate($request, [ + 'name' => 'required|string|max:255' + ]); + + $book = $this->bookRepo->getBySlug($bookSlug); + $chapter = $chapterSlug ? $this->chapterRepo->getBySlug($chapterSlug, $book->id) : null; + $parent = $chapter ? $chapter : $book; + $this->checkOwnablePermission('page-create', $parent); + + $page = $this->pageRepo->getDraftPage($book, $chapter); + $this->pageRepo->publishDraft($page, [ + 'name' => $request->get('name'), + 'html' => '' + ]); + return redirect($page->getUrl('/edit')); } /** @@ -72,7 +106,13 @@ class PageController extends Controller $this->checkOwnablePermission('page-create', $book); $this->setPageTitle('Edit Page Draft'); - return view('pages/edit', ['page' => $draft, 'book' => $book, 'isDraft' => true]); + $draftsEnabled = $this->signedIn; + return view('pages/edit', [ + 'page' => $draft, + 'book' => $book, + 'isDraft' => true, + 'draftsEnabled' => $draftsEnabled + ]); } /** @@ -182,7 +222,13 @@ class PageController extends Controller if (count($warnings) > 0) session()->flash('warning', implode("\n", $warnings)); - return view('pages/edit', ['page' => $page, 'book' => $book, 'current' => $page]); + $draftsEnabled = $this->signedIn; + return view('pages/edit', [ + 'page' => $page, + 'book' => $book, + 'current' => $page, + 'draftsEnabled' => $draftsEnabled + ]); } /** @@ -215,6 +261,14 @@ class PageController extends Controller { $page = $this->pageRepo->getById($pageId, true); $this->checkOwnablePermission('page-update', $page); + + if (!$this->signedIn) { + return response()->json([ + 'status' => 'error', + 'message' => 'Guests cannot save drafts', + ], 500); + } + if ($page->draft) { $draft = $this->pageRepo->updateDraftPage($page, $request->only(['name', 'html', 'markdown'])); } else { @@ -335,9 +389,41 @@ class PageController extends Controller $book = $this->bookRepo->getBySlug($bookSlug); $page = $this->pageRepo->getBySlug($pageSlug, $book->id); $revision = $this->pageRepo->getRevisionById($revisionId); + $page->fill($revision->toArray()); $this->setPageTitle('Page Revision For ' . $page->getShortName()); - return view('pages/revision', ['page' => $page, 'book' => $book]); + + return view('pages/revision', [ + 'page' => $page, + 'book' => $book, + ]); + } + + /** + * Shows the changes of a single revision + * @param string $bookSlug + * @param string $pageSlug + * @param int $revisionId + * @return \Illuminate\View\View + */ + public function showRevisionChanges($bookSlug, $pageSlug, $revisionId) + { + $book = $this->bookRepo->getBySlug($bookSlug); + $page = $this->pageRepo->getBySlug($pageSlug, $book->id); + $revision = $this->pageRepo->getRevisionById($revisionId); + + $prev = $revision->getPrevious(); + $prevContent = ($prev === null) ? '' : $prev->html; + $diff = (new Htmldiff)->diff($prevContent, $revision->html); + + $page->fill($revision->toArray()); + $this->setPageTitle('Page Revision For ' . $page->getShortName()); + + return view('pages/revision', [ + 'page' => $page, + 'book' => $book, + 'diff' => $diff, + ]); } /** diff --git a/app/Http/Controllers/SettingController.php b/app/Http/Controllers/SettingController.php index 61ce55fa9..65135eda3 100644 --- a/app/Http/Controllers/SettingController.php +++ b/app/Http/Controllers/SettingController.php @@ -17,10 +17,7 @@ class SettingController extends Controller $this->setPageTitle('Settings'); // Get application version - $version = false; - if (function_exists('exec')) { - $version = exec('git describe --always --tags '); - } + $version = trim(file_get_contents(base_path('version'))); return view('settings/index', ['version' => $version]); } diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 4c56516dc..18ef1a671 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -57,7 +57,7 @@ class UserController extends Controller { $this->checkPermission('users-manage'); $authMethod = config('auth.method'); - $roles = $this->userRepo->getAssignableRoles(); + $roles = $this->userRepo->getAllRoles(); return view('users/create', ['authMethod' => $authMethod, 'roles' => $roles]); } @@ -126,12 +126,13 @@ class UserController extends Controller return $this->currentUser->id == $id; }); - $authMethod = config('auth.method'); - $user = $this->user->findOrFail($id); + + $authMethod = ($user->system_name) ? 'system' : config('auth.method'); + $activeSocialDrivers = $socialAuthService->getActiveDrivers(); $this->setPageTitle('User Profile'); - $roles = $this->userRepo->getAssignableRoles(); + $roles = $this->userRepo->getAllRoles(); return view('users/edit', ['user' => $user, 'activeSocialDrivers' => $activeSocialDrivers, 'authMethod' => $authMethod, 'roles' => $roles]); } @@ -186,7 +187,7 @@ class UserController extends Controller /** * Show the user delete page. - * @param $id + * @param int $id * @return \Illuminate\View\View */ public function delete($id) @@ -219,6 +220,11 @@ class UserController extends Controller return redirect($user->getEditUrl()); } + if ($user->system_name === 'public') { + session()->flash('error', 'You cannot delete the guest user'); + return redirect($user->getEditUrl()); + } + $this->userRepo->destroy($user); session()->flash('success', 'User successfully removed'); diff --git a/app/Page.php b/app/Page.php index 1961a4f7f..3ee9e90f4 100644 --- a/app/Page.php +++ b/app/Page.php @@ -54,6 +54,15 @@ class Page extends Entity return $this->hasMany(PageRevision::class)->where('type', '=', 'version')->orderBy('created_at', 'desc'); } + /** + * Get the attachments assigned to this page. + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function attachments() + { + return $this->hasMany(Attachment::class, 'uploaded_to')->orderBy('order', 'asc'); + } + /** * Get the url for this page. * @param string|bool $path @@ -63,13 +72,13 @@ class Page extends Entity { $bookSlug = $this->getAttribute('bookSlug') ? $this->getAttribute('bookSlug') : $this->book->slug; $midText = $this->draft ? '/draft/' : '/page/'; - $idComponent = $this->draft ? $this->id : $this->slug; + $idComponent = $this->draft ? $this->id : urlencode($this->slug); if ($path !== false) { - return baseUrl('/books/' . $bookSlug . $midText . $idComponent . '/' . trim($path, '/')); + return baseUrl('/books/' . urlencode($bookSlug) . $midText . $idComponent . '/' . trim($path, '/')); } - return baseUrl('/books/' . $bookSlug . $midText . $idComponent); + return baseUrl('/books/' . urlencode($bookSlug) . $midText . $idComponent); } /** diff --git a/app/PageRevision.php b/app/PageRevision.php index 1ffd63dbd..ff469f0ed 100644 --- a/app/PageRevision.php +++ b/app/PageRevision.php @@ -25,11 +25,26 @@ class PageRevision extends Model /** * Get the url for this revision. + * @param null|string $path * @return string */ - public function getUrl() + public function getUrl($path = null) { - return $this->page->getUrl() . '/revisions/' . $this->id; + $url = $this->page->getUrl() . '/revisions/' . $this->id; + if ($path) return $url . '/' . trim($path, '/'); + return $url; + } + + /** + * Get the previous revision for the same page if existing + * @return \BookStack\PageRevision|null + */ + public function getPrevious() + { + if ($id = static::where('page_id', '=', $this->page_id)->where('id', '<', $this->id)->max('id')) { + return static::find($id); + } + return null; } } diff --git a/app/Repos/BookRepo.php b/app/Repos/BookRepo.php index fdc4dd8d4..7bb91f472 100644 --- a/app/Repos/BookRepo.php +++ b/app/Repos/BookRepo.php @@ -132,8 +132,8 @@ class BookRepo extends EntityRepo { $book = $this->book->newInstance($input); $book->slug = $this->findSuitableSlug($book->name); - $book->created_by = auth()->user()->id; - $book->updated_by = auth()->user()->id; + $book->created_by = user()->id; + $book->updated_by = user()->id; $book->save(); $this->permissionService->buildJointPermissionsForEntity($book); return $book; @@ -147,9 +147,11 @@ class BookRepo extends EntityRepo */ public function updateFromInput(Book $book, $input) { + if ($book->name !== $input['name']) { + $book->slug = $this->findSuitableSlug($input['name'], $book->id); + } $book->fill($input); - $book->slug = $this->findSuitableSlug($book->name, $book->id); - $book->updated_by = auth()->user()->id; + $book->updated_by = user()->id; $book->save(); $this->permissionService->buildJointPermissionsForEntity($book); return $book; @@ -208,8 +210,7 @@ class BookRepo extends EntityRepo */ public function findSuitableSlug($name, $currentId = false) { - $slug = Str::slug($name); - if ($slug === "") $slug = substr(md5(rand(1, 500)), 0, 5); + $slug = $this->nameToSlug($name); while ($this->doesSlugExist($slug, $currentId)) { $slug .= '-' . substr(md5(rand(1, 500)), 0, 3); } diff --git a/app/Repos/ChapterRepo.php b/app/Repos/ChapterRepo.php index c12a9f0f2..4c13b9aaf 100644 --- a/app/Repos/ChapterRepo.php +++ b/app/Repos/ChapterRepo.php @@ -98,8 +98,8 @@ class ChapterRepo extends EntityRepo { $chapter = $this->chapter->newInstance($input); $chapter->slug = $this->findSuitableSlug($chapter->name, $book->id); - $chapter->created_by = auth()->user()->id; - $chapter->updated_by = auth()->user()->id; + $chapter->created_by = user()->id; + $chapter->updated_by = user()->id; $chapter = $book->chapters()->save($chapter); $this->permissionService->buildJointPermissionsForEntity($chapter); return $chapter; @@ -150,8 +150,7 @@ class ChapterRepo extends EntityRepo */ public function findSuitableSlug($name, $bookId, $currentId = false) { - $slug = Str::slug($name); - if ($slug === "") $slug = substr(md5(rand(1, 500)), 0, 5); + $slug = $this->nameToSlug($name); while ($this->doesSlugExist($slug, $bookId, $currentId)) { $slug .= '-' . substr(md5(rand(1, 500)), 0, 3); } diff --git a/app/Repos/EntityRepo.php b/app/Repos/EntityRepo.php index c94601738..7ecfb758c 100644 --- a/app/Repos/EntityRepo.php +++ b/app/Repos/EntityRepo.php @@ -132,9 +132,8 @@ class EntityRepo */ public function getUserDraftPages($count = 20, $page = 0) { - $user = auth()->user(); return $this->page->where('draft', '=', true) - ->where('created_by', '=', $user->id) + ->where('created_by', '=', user()->id) ->orderBy('updated_at', 'desc') ->skip($count * $page)->take($count)->get(); } @@ -270,6 +269,19 @@ class EntityRepo $this->permissionService->buildJointPermissionsForEntities($collection); } + /** + * Format a name as a url slug. + * @param $name + * @return string + */ + protected function nameToSlug($name) + { + $slug = str_replace(' ', '-', strtolower($name)); + $slug = preg_replace('/[\+\/\\\?\@\}\{\.\,\=\[\]\#\&\!\*\'\;\:\$\%]/', '', $slug); + if ($slug === "") $slug = substr(md5(rand(1, 500)), 0, 5); + return $slug; + } + } diff --git a/app/Repos/ImageRepo.php b/app/Repos/ImageRepo.php index 435b8bbd7..8ddde7b0f 100644 --- a/app/Repos/ImageRepo.php +++ b/app/Repos/ImageRepo.php @@ -5,6 +5,7 @@ use BookStack\Image; use BookStack\Page; use BookStack\Services\ImageService; use BookStack\Services\PermissionService; +use Illuminate\Contracts\Filesystem\FileNotFoundException; use Setting; use Symfony\Component\HttpFoundation\File\UploadedFile; @@ -191,7 +192,12 @@ class ImageRepo */ public function getThumbnail(Image $image, $width = 220, $height = 220, $keepRatio = false) { - return $this->imageService->getThumbnail($image, $width, $height, $keepRatio); + try { + return $this->imageService->getThumbnail($image, $width, $height, $keepRatio); + } catch (FileNotFoundException $exception) { + $image->delete(); + return []; + } } diff --git a/app/Repos/PageRepo.php b/app/Repos/PageRepo.php index c64da1267..e6d713f77 100644 --- a/app/Repos/PageRepo.php +++ b/app/Repos/PageRepo.php @@ -5,6 +5,7 @@ use BookStack\Book; use BookStack\Chapter; use BookStack\Entity; use BookStack\Exceptions\NotFoundException; +use BookStack\Services\AttachmentService; use Carbon\Carbon; use DOMDocument; use DOMXPath; @@ -48,7 +49,7 @@ class PageRepo extends EntityRepo * Get a page via a specific ID. * @param $id * @param bool $allowDrafts - * @return mixed + * @return Page */ public function getById($id, $allowDrafts = false) { @@ -59,7 +60,7 @@ class PageRepo extends EntityRepo * Get a page identified by the given slug. * @param $slug * @param $bookId - * @return mixed + * @return Page * @throws NotFoundException */ public function getBySlug($slug, $bookId) @@ -148,8 +149,8 @@ class PageRepo extends EntityRepo { $page = $this->page->newInstance(); $page->name = 'New Page'; - $page->created_by = auth()->user()->id; - $page->updated_by = auth()->user()->id; + $page->created_by = user()->id; + $page->updated_by = user()->id; $page->draft = true; if ($chapter) $page->chapter_id = $chapter->id; @@ -330,7 +331,7 @@ class PageRepo extends EntityRepo } // Update with new details - $userId = auth()->user()->id; + $userId = user()->id; $page->fill($input); $page->html = $this->formatHtml($input['html']); $page->text = strip_tags($page->html); @@ -363,7 +364,7 @@ class PageRepo extends EntityRepo $page->fill($revision->toArray()); $page->slug = $this->findSuitableSlug($page->name, $book->id, $page->id); $page->text = strip_tags($page->html); - $page->updated_by = auth()->user()->id; + $page->updated_by = user()->id; $page->save(); return $page; } @@ -381,7 +382,7 @@ class PageRepo extends EntityRepo $revision->page_id = $page->id; $revision->slug = $page->slug; $revision->book_slug = $page->book->slug; - $revision->created_by = auth()->user()->id; + $revision->created_by = user()->id; $revision->created_at = $page->updated_at; $revision->type = 'version'; $revision->summary = $summary; @@ -404,7 +405,7 @@ class PageRepo extends EntityRepo */ public function saveUpdateDraft(Page $page, $data = []) { - $userId = auth()->user()->id; + $userId = user()->id; $drafts = $this->userUpdateDraftsQuery($page, $userId)->get(); if ($drafts->count() > 0) { @@ -535,7 +536,7 @@ class PageRepo extends EntityRepo $query = $this->pageRevision->where('type', '=', 'update_draft') ->where('page_id', '=', $page->id) ->where('updated_at', '>', $page->updated_at) - ->where('created_by', '!=', auth()->user()->id) + ->where('created_by', '!=', user()->id) ->with('createdBy'); if ($minRange !== null) { @@ -548,7 +549,7 @@ class PageRepo extends EntityRepo /** * Gets a single revision via it's id. * @param $id - * @return mixed + * @return PageRevision */ public function getRevisionById($id) { @@ -613,8 +614,7 @@ class PageRepo extends EntityRepo */ public function findSuitableSlug($name, $bookId, $currentId = false) { - $slug = Str::slug($name); - if ($slug === "") $slug = substr(md5(rand(1, 500)), 0, 5); + $slug = $this->nameToSlug($name); while ($this->doesSlugExist($slug, $bookId, $currentId)) { $slug .= '-' . substr(md5(rand(1, 500)), 0, 3); } @@ -633,12 +633,20 @@ class PageRepo extends EntityRepo $page->revisions()->delete(); $page->permissions()->delete(); $this->permissionService->deleteJointPermissionsForEntity($page); + + // Delete AttachedFiles + $attachmentService = app(AttachmentService::class); + foreach ($page->attachments as $attachment) { + $attachmentService->deleteFile($attachment); + } + $page->delete(); } /** * Get the latest pages added to the system. * @param $count + * @return mixed */ public function getRecentlyCreatedPaginated($count = 20) { @@ -648,6 +656,7 @@ class PageRepo extends EntityRepo /** * Get the latest pages added to the system. * @param $count + * @return mixed */ public function getRecentlyUpdatedPaginated($count = 20) { diff --git a/app/Repos/PermissionsRepo.php b/app/Repos/PermissionsRepo.php index e026d83e8..24497c911 100644 --- a/app/Repos/PermissionsRepo.php +++ b/app/Repos/PermissionsRepo.php @@ -35,7 +35,7 @@ class PermissionsRepo */ public function getAllRoles() { - return $this->role->where('hidden', '=', false)->get(); + return $this->role->all(); } /** @@ -45,7 +45,7 @@ class PermissionsRepo */ public function getAllRolesExcept(Role $role) { - return $this->role->where('id', '!=', $role->id)->where('hidden', '=', false)->get(); + return $this->role->where('id', '!=', $role->id)->get(); } /** @@ -90,8 +90,6 @@ class PermissionsRepo { $role = $this->role->findOrFail($roleId); - if ($role->hidden) throw new PermissionsException("Cannot update a hidden role"); - $permissions = isset($roleData['permissions']) ? array_keys($roleData['permissions']) : []; $this->assignRolePermissions($role, $permissions); diff --git a/app/Repos/UserRepo.php b/app/Repos/UserRepo.php index 127db9fb5..ab3716fca 100644 --- a/app/Repos/UserRepo.php +++ b/app/Repos/UserRepo.php @@ -199,9 +199,9 @@ class UserRepo * Get the roles in the system that are assignable to a user. * @return mixed */ - public function getAssignableRoles() + public function getAllRoles() { - return $this->role->visible(); + return $this->role->all(); } /** @@ -211,7 +211,7 @@ class UserRepo */ public function getRestrictableRoles() { - return $this->role->where('hidden', '=', false)->where('system_name', '=', '')->get(); + return $this->role->where('system_name', '!=', 'admin')->get(); } } \ No newline at end of file diff --git a/app/Role.php b/app/Role.php index 8d0a79e75..bf9685ee2 100644 --- a/app/Role.php +++ b/app/Role.php @@ -66,7 +66,7 @@ class Role extends Model /** * Get the role object for the specified role. * @param $roleName - * @return mixed + * @return Role */ public static function getRole($roleName) { @@ -76,7 +76,7 @@ class Role extends Model /** * Get the role object for the specified system role. * @param $roleName - * @return mixed + * @return Role */ public static function getSystemRole($roleName) { diff --git a/app/Services/ActivityService.php b/app/Services/ActivityService.php index f6fea33a1..e41036238 100644 --- a/app/Services/ActivityService.php +++ b/app/Services/ActivityService.php @@ -19,7 +19,7 @@ class ActivityService { $this->activity = $activity; $this->permissionService = $permissionService; - $this->user = auth()->user(); + $this->user = user(); } /** diff --git a/app/Services/AttachmentService.php b/app/Services/AttachmentService.php new file mode 100644 index 000000000..e0ee3a04d --- /dev/null +++ b/app/Services/AttachmentService.php @@ -0,0 +1,201 @@ +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 diff --git a/app/Services/ImageService.php b/app/Services/ImageService.php index aa1375487..dfe2cf453 100644 --- a/app/Services/ImageService.php +++ b/app/Services/ImageService.php @@ -9,20 +9,13 @@ use Intervention\Image\ImageManager; use Illuminate\Contracts\Filesystem\Factory as FileSystem; use Illuminate\Contracts\Filesystem\Filesystem as FileSystemInstance; use Illuminate\Contracts\Cache\Repository as Cache; -use Setting; use Symfony\Component\HttpFoundation\File\UploadedFile; -class ImageService +class ImageService extends UploadService { protected $imageTool; - protected $fileSystem; protected $cache; - - /** - * @var FileSystemInstance - */ - protected $storageInstance; protected $storageUrl; /** @@ -34,8 +27,8 @@ class ImageService public function __construct(ImageManager $imageTool, FileSystem $fileSystem, Cache $cache) { $this->imageTool = $imageTool; - $this->fileSystem = $fileSystem; $this->cache = $cache; + parent::__construct($fileSystem); } /** @@ -88,6 +81,9 @@ class ImageService if ($secureUploads) $imageName = str_random(16) . '-' . $imageName; $imagePath = '/uploads/images/' . $type . '/' . Date('Y-m-M') . '/'; + + if ($this->isLocal()) $imagePath = '/public' . $imagePath; + while ($storage->exists($imagePath . $imageName)) { $imageName = str_random(3) . $imageName; } @@ -100,6 +96,8 @@ class ImageService throw new ImageUploadException('Image Path ' . $fullPath . ' is not writable by the server.'); } + if ($this->isLocal()) $fullPath = str_replace_first('/public', '', $fullPath); + $imageDetails = [ 'name' => $imageName, 'path' => $fullPath, @@ -108,8 +106,8 @@ class ImageService 'uploaded_to' => $uploadedTo ]; - if (auth()->user() && auth()->user()->id !== 0) { - $userId = auth()->user()->id; + if (user()->id !== 0) { + $userId = user()->id; $imageDetails['created_by'] = $userId; $imageDetails['updated_by'] = $userId; } @@ -119,6 +117,16 @@ class ImageService return $image; } + /** + * Get the storage path, Dependant of storage type. + * @param Image $image + * @return mixed|string + */ + protected function getPath(Image $image) + { + return ($this->isLocal()) ? ('public/' . $image->path) : $image->path; + } + /** * Get the thumbnail for an image. * If $keepRatio is true only the width will be used. @@ -135,7 +143,8 @@ class ImageService public function getThumbnail(Image $image, $width = 220, $height = 220, $keepRatio = false) { $thumbDirName = '/' . ($keepRatio ? 'scaled-' : 'thumbs-') . $width . '-' . $height . '/'; - $thumbFilePath = dirname($image->path) . $thumbDirName . basename($image->path); + $imagePath = $this->getPath($image); + $thumbFilePath = dirname($imagePath) . $thumbDirName . basename($imagePath); if ($this->cache->has('images-' . $image->id . '-' . $thumbFilePath) && $this->cache->get('images-' . $thumbFilePath)) { return $this->getPublicUrl($thumbFilePath); @@ -148,7 +157,7 @@ class ImageService } try { - $thumb = $this->imageTool->make($storage->get($image->path)); + $thumb = $this->imageTool->make($storage->get($imagePath)); } catch (Exception $e) { if ($e instanceof \ErrorException || $e instanceof NotSupportedException) { throw new ImageUploadException('The server cannot create thumbnails. Please check you have the GD PHP extension installed.'); @@ -183,8 +192,8 @@ class ImageService { $storage = $this->getStorage(); - $imageFolder = dirname($image->path); - $imageFileName = basename($image->path); + $imageFolder = dirname($this->getPath($image)); + $imageFileName = basename($this->getPath($image)); $allImages = collect($storage->allFiles($imageFolder)); $imagesToDelete = $allImages->filter(function ($imagePath) use ($imageFileName) { @@ -222,35 +231,9 @@ class ImageService return $image; } - /** - * Get the storage that will be used for storing images. - * @return FileSystemInstance - */ - private function getStorage() - { - if ($this->storageInstance !== null) return $this->storageInstance; - - $storageType = config('filesystems.default'); - $this->storageInstance = $this->fileSystem->disk($storageType); - - return $this->storageInstance; - } - - /** - * Check whether or not a folder is empty. - * @param $path - * @return int - */ - private function isFolderEmpty($path) - { - $files = $this->getStorage()->files($path); - $folders = $this->getStorage()->directories($path); - return count($files) === 0 && count($folders) === 0; - } - /** * Gets a public facing url for an image by checking relevant environment variables. - * @param $filePath + * @param string $filePath * @return string */ private function getPublicUrl($filePath) @@ -273,6 +256,8 @@ class ImageService $this->storageUrl = $storageUrl; } + if ($this->isLocal()) $filePath = str_replace_first('public/', '', $filePath); + return ($this->storageUrl == false ? rtrim(baseUrl(''), '/') : rtrim($this->storageUrl, '/')) . $filePath; } diff --git a/app/Services/PermissionService.php b/app/Services/PermissionService.php index 341a69edb..bb78f0b0a 100644 --- a/app/Services/PermissionService.php +++ b/app/Services/PermissionService.php @@ -614,7 +614,7 @@ class PermissionService private function currentUser() { if ($this->currentUserModel === false) { - $this->currentUserModel = auth()->user() ? auth()->user() : new User(); + $this->currentUserModel = user(); } return $this->currentUserModel; diff --git a/app/Services/SocialAuthService.php b/app/Services/SocialAuthService.php index b28a97ea4..d76a7231b 100644 --- a/app/Services/SocialAuthService.php +++ b/app/Services/SocialAuthService.php @@ -100,7 +100,7 @@ class SocialAuthService $socialAccount = $this->socialAccount->where('driver_id', '=', $socialId)->first(); $user = $this->userRepo->getByEmail($socialUser->getEmail()); $isLoggedIn = auth()->check(); - $currentUser = auth()->user(); + $currentUser = user(); // When a user is not logged in and a matching SocialAccount exists, // Simply log the user into the application. @@ -214,9 +214,9 @@ class SocialAuthService public function detachSocialAccount($socialDriver) { session(); - auth()->user()->socialAccounts()->where('driver', '=', $socialDriver)->delete(); + user()->socialAccounts()->where('driver', '=', $socialDriver)->delete(); session()->flash('success', title_case($socialDriver) . ' account successfully detached'); - return redirect(auth()->user()->getEditUrl()); + return redirect(user()->getEditUrl()); } } \ No newline at end of file diff --git a/app/Services/UploadService.php b/app/Services/UploadService.php new file mode 100644 index 000000000..44d4bb4f7 --- /dev/null +++ b/app/Services/UploadService.php @@ -0,0 +1,64 @@ +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 diff --git a/app/Services/ViewService.php b/app/Services/ViewService.php index aac9831f7..1a9ee5f70 100644 --- a/app/Services/ViewService.php +++ b/app/Services/ViewService.php @@ -18,7 +18,7 @@ class ViewService public function __construct(View $view, PermissionService $permissionService) { $this->view = $view; - $this->user = auth()->user(); + $this->user = user(); $this->permissionService = $permissionService; } @@ -84,7 +84,7 @@ class ViewService ->filterRestrictedEntityRelations($this->view, 'views', 'viewable_id', 'viewable_type'); if ($filterModel) $query = $query->where('viewable_type', '=', get_class($filterModel)); - $query = $query->where('user_id', '=', auth()->user()->id); + $query = $query->where('user_id', '=', user()->id); $viewables = $query->with('viewable')->orderBy('updated_at', 'desc') ->skip($count * $page)->take($count)->get()->pluck('viewable'); diff --git a/app/User.php b/app/User.php index 8c39d81be..09b189cbb 100644 --- a/app/User.php +++ b/app/User.php @@ -5,6 +5,7 @@ use Illuminate\Auth\Authenticatable; use Illuminate\Auth\Passwords\CanResetPassword; use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract; use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Notifications\Notifiable; class User extends Model implements AuthenticatableContract, CanResetPasswordContract @@ -36,21 +37,30 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon protected $permissions; /** - * Returns a default guest user. + * Returns the default public user. + * @return User */ public static function getDefault() { - return new static([ - 'email' => 'guest', - 'name' => 'Guest' - ]); + return static::where('system_name', '=', 'public')->first(); + } + + /** + * Check if the user is the default public user. + * @return bool + */ + public function isDefault() + { + return $this->system_name === 'public'; } /** * The roles that belong to the user. + * @return BelongsToMany */ public function roles() { + if ($this->id === 0) return ; return $this->belongsToMany(Role::class); } diff --git a/app/helpers.php b/app/helpers.php index dd835fbf6..b5be0fd11 100644 --- a/app/helpers.php +++ b/app/helpers.php @@ -11,29 +11,30 @@ use BookStack\Ownable; */ function versioned_asset($file = '') { - // Don't require css and JS assets for testing - if (config('app.env') === 'testing') return ''; + static $version = null; - static $manifest = null; - $manifestPath = 'build/manifest.json'; - - if (is_null($manifest) && file_exists($manifestPath)) { - $manifest = json_decode(file_get_contents(public_path($manifestPath)), true); - } else if (!file_exists($manifestPath)) { - if (config('app.env') !== 'production') { - $path = public_path($manifestPath); - $error = "No {$path} file found, Ensure you have built the css/js assets using gulp."; - } else { - $error = "No {$manifestPath} file found, Ensure you are using the release version of BookStack"; - } - throw new \Exception($error); + if (is_null($version)) { + $versionFile = base_path('version'); + $version = trim(file_get_contents($versionFile)); } - if (isset($manifest[$file])) { - return baseUrl($manifest[$file]); + $additional = ''; + if (config('app.env') === 'development') { + $additional = sha1_file(public_path($file)); } - throw new InvalidArgumentException("File {$file} not defined in asset manifest."); + $path = $file . '?version=' . urlencode($version) . $additional; + return baseUrl($path); +} + +/** + * Helper method to get the current User. + * Defaults to public 'Guest' user if not logged in. + * @return \BookStack\User + */ +function user() +{ + return auth()->user() ?: \BookStack\User::getDefault(); } /** @@ -47,7 +48,7 @@ function versioned_asset($file = '') function userCan($permission, Ownable $ownable = null) { if ($ownable === null) { - return auth()->user() && auth()->user()->can($permission); + return user() && user()->can($permission); } // Check permission on ownable item @@ -128,14 +129,14 @@ function sortUrl($path, $data, $overrideData = []) { $queryStringSections = []; $queryData = array_merge($data, $overrideData); - + // Change sorting direction is already sorted on current attribute if (isset($overrideData['sort']) && $overrideData['sort'] === $data['sort']) { $queryData['order'] = ($data['order'] === 'asc') ? 'desc' : 'asc'; } else { $queryData['order'] = 'asc'; } - + foreach ($queryData as $name => $value) { $trimmedVal = trim($value); if ($trimmedVal === '') continue; @@ -145,4 +146,4 @@ function sortUrl($path, $data, $overrideData = []) if (count($queryStringSections) === 0) return $path; return baseUrl($path . '?' . implode('&', $queryStringSections)); -} \ No newline at end of file +} diff --git a/composer.json b/composer.json index d96037019..7d4b5e62b 100644 --- a/composer.json +++ b/composer.json @@ -7,13 +7,15 @@ "require": { "php": ">=5.6.4", "laravel/framework": "^5.3.4", + "ext-tidy": "*", "intervention/image": "^2.3", "laravel/socialite": "^2.0", "barryvdh/laravel-ide-helper": "^2.1", "barryvdh/laravel-debugbar": "^2.2.3", "league/flysystem-aws-s3-v3": "^1.0", "barryvdh/laravel-dompdf": "^0.7", - "predis/predis": "^1.1" + "predis/predis": "^1.1", + "gathercontent/htmldiff": "^0.2.1" }, "require-dev": { "fzaninotto/faker": "~1.4", diff --git a/composer.lock b/composer.lock index c1c80e100..74a090288 100644 --- a/composer.lock +++ b/composer.lock @@ -4,21 +4,21 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", "This file is @generated automatically" ], - "hash": "c90a6e41767306ceb3b8cedb91468390", - "content-hash": "3b5d2d6b77fbe71101e7e8eaff0754fe", + "hash": "3124d900cfe857392a94de479f3ff6d4", + "content-hash": "a968767a73f77e66e865c276cf76eedf", "packages": [ { "name": "aws/aws-sdk-php", - "version": "3.19.6", + "version": "3.19.11", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "34060bf0db260031697b17dbb37fa1bbec92f1c4" + "reference": "19bac3bdd7988cbf7f89d5ce8e2748d774e2cde8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/34060bf0db260031697b17dbb37fa1bbec92f1c4", - "reference": "34060bf0db260031697b17dbb37fa1bbec92f1c4", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/19bac3bdd7988cbf7f89d5ce8e2748d774e2cde8", + "reference": "19bac3bdd7988cbf7f89d5ce8e2748d774e2cde8", "shasum": "" }, "require": { @@ -85,32 +85,32 @@ "s3", "sdk" ], - "time": "2016-09-08 20:27:15" + "time": "2016-09-27 19:38:36" }, { "name": "barryvdh/laravel-debugbar", - "version": "V2.2.3", + "version": "v2.3.0", "source": { "type": "git", "url": "https://github.com/barryvdh/laravel-debugbar.git", - "reference": "ecd1ce5c4a827e2f6a8fb41bcf67713beb1c1cbd" + "reference": "0c87981df959c7c1943abe227baf607c92f204f9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/barryvdh/laravel-debugbar/zipball/ecd1ce5c4a827e2f6a8fb41bcf67713beb1c1cbd", - "reference": "ecd1ce5c4a827e2f6a8fb41bcf67713beb1c1cbd", + "url": "https://api.github.com/repos/barryvdh/laravel-debugbar/zipball/0c87981df959c7c1943abe227baf607c92f204f9", + "reference": "0c87981df959c7c1943abe227baf607c92f204f9", "shasum": "" }, "require": { "illuminate/support": "5.1.*|5.2.*|5.3.*", - "maximebf/debugbar": "~1.11.0|~1.12.0", + "maximebf/debugbar": "~1.13.0", "php": ">=5.5.9", "symfony/finder": "~2.7|~3.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.2-dev" + "dev-master": "2.3-dev" } }, "autoload": { @@ -139,7 +139,7 @@ "profiler", "webprofiler" ], - "time": "2016-07-29 15:00:36" + "time": "2016-09-15 14:05:56" }, { "name": "barryvdh/laravel-dompdf", @@ -358,6 +358,57 @@ ], "time": "2015-11-09 22:51:51" }, + { + "name": "cogpowered/finediff", + "version": "0.3.1", + "source": { + "type": "git", + "url": "https://github.com/cogpowered/FineDiff.git", + "reference": "339ddc8c3afb656efed4f2f0a80e5c3d026f8ea8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/cogpowered/FineDiff/zipball/339ddc8c3afb656efed4f2f0a80e5c3d026f8ea8", + "reference": "339ddc8c3afb656efed4f2f0a80e5c3d026f8ea8", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "mockery/mockery": "*", + "phpunit/phpunit": "*" + }, + "type": "library", + "autoload": { + "psr-0": { + "cogpowered\\FineDiff": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Rob Crowe", + "email": "rob@cogpowered.com" + }, + { + "name": "Raymond Hill" + } + ], + "description": "PHP implementation of a Fine granularity Diff engine", + "homepage": "https://github.com/cogpowered/FineDiff", + "keywords": [ + "diff", + "finediff", + "opcode", + "string", + "text" + ], + "time": "2014-05-19 10:25:02" + }, { "name": "dnoegel/php-xdg-base-dir", "version": "0.1", @@ -519,6 +570,55 @@ "homepage": "https://github.com/dompdf/dompdf", "time": "2016-05-11 00:36:29" }, + { + "name": "gathercontent/htmldiff", + "version": "0.2.1", + "source": { + "type": "git", + "url": "https://github.com/gathercontent/htmldiff.git", + "reference": "24674a62315f64330134b4a4c5b01a7b59193c93" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/gathercontent/htmldiff/zipball/24674a62315f64330134b4a4c5b01a7b59193c93", + "reference": "24674a62315f64330134b4a4c5b01a7b59193c93", + "shasum": "" + }, + "require": { + "cogpowered/finediff": "0.3.1", + "ext-tidy": "*" + }, + "require-dev": { + "phpunit/phpunit": "4.*", + "squizlabs/php_codesniffer": "1.*" + }, + "type": "library", + "autoload": { + "psr-0": { + "GatherContent\\Htmldiff": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andrew Cairns", + "email": "andrew@gathercontent.com" + }, + { + "name": "Mathew Chapman", + "email": "mat@gathercontent.com" + }, + { + "name": "Peter Legierski", + "email": "peter@gathercontent.com" + } + ], + "description": "Compare two HTML strings", + "time": "2015-04-15 15:39:46" + }, { "name": "guzzlehttp/guzzle", "version": "6.2.1", @@ -899,16 +999,16 @@ }, { "name": "laravel/framework", - "version": "v5.3.9", + "version": "v5.3.11", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "f6fbb481672f8dc4bc6882d5d654bbfa3588c8ec" + "reference": "ca48001b95a0543fb39fcd7219de960bbc03eaa5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/f6fbb481672f8dc4bc6882d5d654bbfa3588c8ec", - "reference": "f6fbb481672f8dc4bc6882d5d654bbfa3588c8ec", + "url": "https://api.github.com/repos/laravel/framework/zipball/ca48001b95a0543fb39fcd7219de960bbc03eaa5", + "reference": "ca48001b95a0543fb39fcd7219de960bbc03eaa5", "shasum": "" }, "require": { @@ -956,6 +1056,7 @@ "illuminate/http": "self.version", "illuminate/log": "self.version", "illuminate/mail": "self.version", + "illuminate/notifications": "self.version", "illuminate/pagination": "self.version", "illuminate/pipeline": "self.version", "illuminate/queue": "self.version", @@ -1022,7 +1123,7 @@ "framework", "laravel" ], - "time": "2016-09-12 14:08:29" + "time": "2016-09-28 02:15:37" }, { "name": "laravel/socialite", @@ -1273,16 +1374,16 @@ }, { "name": "maximebf/debugbar", - "version": "v1.12.0", + "version": "v1.13.0", "source": { "type": "git", "url": "https://github.com/maximebf/php-debugbar.git", - "reference": "e634fbd32cd6bc3fa0e8c972b52d4bf49bab3988" + "reference": "5f49a5ed6cfde81d31d89378806670d77462526e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/maximebf/php-debugbar/zipball/e634fbd32cd6bc3fa0e8c972b52d4bf49bab3988", - "reference": "e634fbd32cd6bc3fa0e8c972b52d4bf49bab3988", + "url": "https://api.github.com/repos/maximebf/php-debugbar/zipball/5f49a5ed6cfde81d31d89378806670d77462526e", + "reference": "5f49a5ed6cfde81d31d89378806670d77462526e", "shasum": "" }, "require": { @@ -1301,7 +1402,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.12-dev" + "dev-master": "1.13-dev" } }, "autoload": { @@ -1330,7 +1431,7 @@ "debug", "debugbar" ], - "time": "2016-05-15 13:11:34" + "time": "2016-09-15 14:01:59" }, { "name": "monolog/monolog", @@ -1558,16 +1659,16 @@ }, { "name": "nikic/php-parser", - "version": "v2.1.0", + "version": "v2.1.1", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "47b254ea51f1d6d5dc04b9b299e88346bf2369e3" + "reference": "4dd659edadffdc2143e4753df655d866dbfeedf0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/47b254ea51f1d6d5dc04b9b299e88346bf2369e3", - "reference": "47b254ea51f1d6d5dc04b9b299e88346bf2369e3", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/4dd659edadffdc2143e4753df655d866dbfeedf0", + "reference": "4dd659edadffdc2143e4753df655d866dbfeedf0", "shasum": "" }, "require": { @@ -1605,7 +1706,7 @@ "parser", "php" ], - "time": "2016-04-19 13:41:41" + "time": "2016-09-16 12:04:44" }, { "name": "paragonie/random_compat", @@ -1825,22 +1926,30 @@ }, { "name": "psr/log", - "version": "1.0.0", + "version": "1.0.1", "source": { "type": "git", "url": "https://github.com/php-fig/log.git", - "reference": "fe0936ee26643249e916849d48e3a51d5f5e278b" + "reference": "5277094ed527a1c4477177d102fe4c53551953e0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/fe0936ee26643249e916849d48e3a51d5f5e278b", - "reference": "fe0936ee26643249e916849d48e3a51d5f5e278b", + "url": "https://api.github.com/repos/php-fig/log/zipball/5277094ed527a1c4477177d102fe4c53551953e0", + "reference": "5277094ed527a1c4477177d102fe4c53551953e0", "shasum": "" }, + "require": { + "php": ">=5.3.0" + }, "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, "autoload": { - "psr-0": { - "Psr\\Log\\": "" + "psr-4": { + "Psr\\Log\\": "Psr/Log/" } }, "notification-url": "https://packagist.org/downloads/", @@ -1854,12 +1963,13 @@ } ], "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", "keywords": [ "log", "psr", "psr-3" ], - "time": "2012-12-21 11:40:51" + "time": "2016-09-19 16:02:08" }, { "name": "psy/psysh", @@ -3167,16 +3277,16 @@ }, { "name": "myclabs/deep-copy", - "version": "1.5.2", + "version": "1.5.4", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "da8529775f14f4fdae33f916eb0cf65f6afbddbc" + "reference": "ea74994a3dc7f8d2f65a06009348f2d63c81e61f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/da8529775f14f4fdae33f916eb0cf65f6afbddbc", - "reference": "da8529775f14f4fdae33f916eb0cf65f6afbddbc", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/ea74994a3dc7f8d2f65a06009348f2d63c81e61f", + "reference": "ea74994a3dc7f8d2f65a06009348f2d63c81e61f", "shasum": "" }, "require": { @@ -3205,7 +3315,7 @@ "object", "object graph" ], - "time": "2016-09-06 16:07:05" + "time": "2016-09-16 13:37:59" }, { "name": "phpdocumentor/reflection-common", @@ -3661,24 +3771,24 @@ }, { "name": "phpunit/phpunit", - "version": "5.5.4", + "version": "5.5.5", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "3e6e88e56c912133de6e99b87728cca7ed70c5f5" + "reference": "a57126dc681b08289fef6ac96a48e30656f84350" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/3e6e88e56c912133de6e99b87728cca7ed70c5f5", - "reference": "3e6e88e56c912133de6e99b87728cca7ed70c5f5", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/a57126dc681b08289fef6ac96a48e30656f84350", + "reference": "a57126dc681b08289fef6ac96a48e30656f84350", "shasum": "" }, "require": { "ext-dom": "*", "ext-json": "*", - "ext-pcre": "*", - "ext-reflection": "*", - "ext-spl": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", "myclabs/deep-copy": "~1.3", "php": "^5.6 || ^7.0", "phpspec/prophecy": "^1.3.1", @@ -3700,7 +3810,12 @@ "conflict": { "phpdocumentor/reflection-docblock": "3.0.2" }, + "require-dev": { + "ext-pdo": "*" + }, "suggest": { + "ext-tidy": "*", + "ext-xdebug": "*", "phpunit/php-invoker": "~1.1" }, "bin": [ @@ -3735,7 +3850,7 @@ "testing", "xunit" ], - "time": "2016-08-26 07:11:44" + "time": "2016-09-21 14:40:13" }, { "name": "phpunit/phpunit-mock-objects", @@ -4524,7 +4639,8 @@ "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": ">=5.6.4" + "php": ">=5.6.4", + "ext-tidy": "*" }, "platform-dev": [] } diff --git a/config/app.php b/config/app.php index a5b0d2fe0..786f005ac 100644 --- a/config/app.php +++ b/config/app.php @@ -57,7 +57,7 @@ return [ | */ - 'locale' => 'en', + 'locale' => env('APP_LANG', 'en'), /* |-------------------------------------------------------------------------- diff --git a/config/filesystems.php b/config/filesystems.php index dbcb03db1..836f68d3d 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -56,7 +56,7 @@ return [ 'local' => [ 'driver' => 'local', - 'root' => public_path(), + 'root' => base_path(), ], 'ftp' => [ diff --git a/config/setting-defaults.php b/config/setting-defaults.php index 5482c1331..c681bb7f5 100644 --- a/config/setting-defaults.php +++ b/config/setting-defaults.php @@ -9,6 +9,8 @@ return [ 'app-name-header' => true, 'app-editor' => 'wysiwyg', 'app-color' => '#0288D1', - 'app-color-light' => 'rgba(21, 101, 192, 0.15)' + 'app-color-light' => 'rgba(21, 101, 192, 0.15)', + 'app-custom-head' => false, + 'registration-enabled' => false, ]; \ No newline at end of file diff --git a/database/migrations/2016_09_29_101449_remove_hidden_roles.php b/database/migrations/2016_09_29_101449_remove_hidden_roles.php new file mode 100644 index 000000000..f666cad2c --- /dev/null +++ b/database/migrations/2016_09_29_101449_remove_hidden_roles.php @@ -0,0 +1,66 @@ +dropColumn('hidden'); + }); + + // Add column to mark system users + Schema::table('users', function(Blueprint $table) { + $table->string('system_name')->nullable()->index(); + }); + + // Insert our new public system user. + $publicUserId = DB::table('users')->insertGetId([ + 'email' => 'guest@example.com', + 'name' => 'Guest', + 'system_name' => 'public', + 'email_confirmed' => true, + 'created_at' => \Carbon\Carbon::now(), + 'updated_at' => \Carbon\Carbon::now(), + ]); + + // Get the public role + $publicRole = DB::table('roles')->where('system_name', '=', 'public')->first(); + + // Connect the new public user to the public role + DB::table('role_user')->insert([ + 'user_id' => $publicUserId, + 'role_id' => $publicRole->id + ]); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('roles', function(Blueprint $table) { + $table->boolean('hidden')->default(false); + $table->index('hidden'); + }); + + DB::table('users')->where('system_name', '=', 'public')->delete(); + + Schema::table('users', function(Blueprint $table) { + $table->dropColumn('system_name'); + }); + + DB::table('roles')->where('system_name', '=', 'public')->update(['hidden' => true]); + } +} diff --git a/database/migrations/2016_10_09_142037_create_attachments_table.php b/database/migrations/2016_10_09_142037_create_attachments_table.php new file mode 100644 index 000000000..627c237c4 --- /dev/null +++ b/database/migrations/2016_10_09_142037_create_attachments_table.php @@ -0,0 +1,71 @@ +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(); + } + } +} diff --git a/gulpfile.js b/gulpfile.js index 7deefc71a..9d789d9b4 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -1,27 +1,8 @@ var elixir = require('laravel-elixir'); -// Custom extensions -var gulp = require('gulp'); -var Task = elixir.Task; -var fs = require('fs'); - -elixir.extend('queryVersion', function(inputFiles) { - new Task('queryVersion', function() { - var manifestObject = {}; - var uidString = Date.now().toString(16).slice(4); - for (var i = 0; i < inputFiles.length; i++) { - var file = inputFiles[i]; - manifestObject[file] = file + '?version=' + uidString; - } - var fileContents = JSON.stringify(manifestObject, null, 1); - fs.writeFileSync('public/build/manifest.json', fileContents); - }).watch(['./public/css/*.css', './public/js/*.js']); -}); - -elixir(function(mix) { - mix.sass('styles.scss') - .sass('print-styles.scss') - .sass('export-styles.scss') - .browserify('global.js', 'public/js/common.js') - .queryVersion(['css/styles.css', 'css/print-styles.css', 'js/common.js']); +elixir(mix => { + mix.sass('styles.scss'); + mix.sass('print-styles.scss'); + mix.sass('export-styles.scss'); + mix.browserify('global.js', './public/js/common.js'); }); diff --git a/package.json b/package.json index fde090beb..30f288d45 100644 --- a/package.json +++ b/package.json @@ -1,18 +1,19 @@ { "private": true, - "devDependencies": { - "gulp": "^3.9.0" + "scripts": { + "prod": "gulp --production", + "dev": "gulp watch" }, - "dependencies": { + "devDependencies": { "angular": "^1.5.5", "angular-animate": "^1.5.5", "angular-resource": "^1.5.5", "angular-sanitize": "^1.5.5", - "angular-ui-sortable": "^0.14.0", - "babel-runtime": "^5.8.29", - "bootstrap-sass": "^3.0.0", + "angular-ui-sortable": "^0.15.0", "dropzone": "^4.0.1", - "laravel-elixir": "^5.0.0", + "gulp": "^3.9.0", + "laravel-elixir": "^6.0.0-11", + "laravel-elixir-browserify-official": "^0.1.3", "marked": "^0.3.5", "moment": "^2.12.0", "zeroclipboard": "^2.2.0" diff --git a/phpunit.xml b/phpunit.xml index a2b26d413..72e06a3fc 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -30,6 +30,7 @@ + diff --git a/readme.md b/readme.md index 3a745beb1..5d3e79a2e 100644 --- a/readme.md +++ b/readme.md @@ -2,13 +2,15 @@ [![GitHub release](https://img.shields.io/github/release/ssddanbrown/BookStack.svg?maxAge=2592000)](https://github.com/ssddanbrown/BookStack/releases/latest) [![license](https://img.shields.io/github/license/ssddanbrown/BookStack.svg?maxAge=2592000)](https://github.com/ssddanbrown/BookStack/blob/master/LICENSE) -[![Build Status](https://travis-ci.org/ssddanbrown/BookStack.svg)](https://travis-ci.org/ssddanbrown/BookStack) +[![Build Status](https://travis-ci.org/BookStackApp/BookStack.svg)](https://travis-ci.org/BookStackApp/BookStack) A platform for storing and organising information and documentation. General information and documentation for BookStack can be found at https://www.bookstackapp.com/. * [Installation Instructions](https://www.bookstackapp.com/docs/admin/installation) * [Documentation](https://www.bookstackapp.com/docs) -* [Demo Instance](https://demo.bookstackapp.com) *(Login username: `admin@example.com`. Password: `password`)* +* [Demo Instance](https://demo.bookstackapp.com) + * *Username: `admin@example.com`* + * *Password: `password`* * [BookStack Blog](https://www.bookstackapp.com/blog) ## Development & Testing @@ -29,7 +31,7 @@ php artisan migrate --database=mysql_testing php artisan db:seed --class=DummyContentSeeder --database=mysql_testing ``` -Once done you can run `phpunit` (or `./vendor/bin/phpunit` if `phpunit` is not found) in the application root directory to run all tests. +Once done you can run `phpunit` in the application root directory to run all tests. ## License @@ -51,3 +53,5 @@ These are the great projects used to help build BookStack: * [TinyColorPicker](http://www.dematte.at/tinyColorPicker/index.html) * [Marked](https://github.com/chjj/marked) * [Moment.js](http://momentjs.com/) + +Additionally, Thank you [BrowserStack](https://www.browserstack.com/) for supporting us and making cross-browser testing easy. diff --git a/resources/assets/js/components/drop-zone.html b/resources/assets/js/components/drop-zone.html deleted file mode 100644 index 26e0ee2aa..000000000 --- a/resources/assets/js/components/drop-zone.html +++ /dev/null @@ -1,3 +0,0 @@ -
-
Drop files or click here to upload
-
\ No newline at end of file diff --git a/resources/assets/js/components/image-picker.html b/resources/assets/js/components/image-picker.html deleted file mode 100644 index 1a07b9274..000000000 --- a/resources/assets/js/components/image-picker.html +++ /dev/null @@ -1,15 +0,0 @@ - -
-
- Image Preview - Image Preview -
- -
- - - | - - - -
\ No newline at end of file diff --git a/resources/assets/js/components/toggle-switch.html b/resources/assets/js/components/toggle-switch.html deleted file mode 100644 index 455969a84..000000000 --- a/resources/assets/js/components/toggle-switch.html +++ /dev/null @@ -1,4 +0,0 @@ -
- -
-
\ No newline at end of file diff --git a/resources/assets/js/controllers.js b/resources/assets/js/controllers.js index 2c0cf3e2b..9d7f7ad70 100644 --- a/resources/assets/js/controllers.js +++ b/resources/assets/js/controllers.js @@ -1,8 +1,10 @@ "use strict"; -const moment = require('moment'); +import moment from 'moment'; +import 'moment/locale/en-gb'; +moment.locale('en-gb'); -module.exports = function (ngApp, events) { +export default function (ngApp, events) { ngApp.controller('ImageManagerController', ['$scope', '$attrs', '$http', '$timeout', 'imageManagerService', function ($scope, $attrs, $http, $timeout, imageManagerService) { @@ -17,7 +19,7 @@ module.exports = function (ngApp, events) { $scope.imageDeleteSuccess = false; $scope.uploadedTo = $attrs.uploadedTo; $scope.view = 'all'; - + $scope.searching = false; $scope.searchTerm = ''; @@ -48,7 +50,7 @@ module.exports = function (ngApp, events) { $scope.hasMore = preSearchHasMore; } $scope.cancelSearch = cancelSearch; - + /** * Runs on image upload, Adds an image to local list of images @@ -162,7 +164,6 @@ module.exports = function (ngApp, events) { /** * Start a search operation - * @param searchTerm */ $scope.searchImages = function() { @@ -196,7 +197,7 @@ module.exports = function (ngApp, events) { $scope.view = viewName; baseUrl = window.baseUrl('/images/' + $scope.imageType + '/' + viewName + '/'); fetchData(); - } + }; /** * Save the details of an image. @@ -205,7 +206,7 @@ module.exports = function (ngApp, events) { $scope.saveImageDetails = function (event) { event.preventDefault(); var url = window.baseUrl('/images/update/' + $scope.selectedImage.id); - $http.put(url, this.selectedImage).then((response) => { + $http.put(url, this.selectedImage).then(response => { events.emit('success', 'Image details updated'); }, (response) => { if (response.status === 422) { @@ -300,15 +301,16 @@ module.exports = function (ngApp, events) { var isEdit = pageId !== 0; var autosaveFrequency = 30; // AutoSave interval in seconds. var isMarkdown = $attrs.editorType === 'markdown'; + $scope.draftsEnabled = $attrs.draftsEnabled === 'true'; $scope.isUpdateDraft = Number($attrs.pageUpdateDraft) === 1; $scope.isNewPageDraft = Number($attrs.pageNewDraft) === 1; - // Set inital header draft text + // Set initial header draft text if ($scope.isUpdateDraft || $scope.isNewPageDraft) { $scope.draftText = 'Editing Draft' } else { $scope.draftText = 'Editing Page' - }; + } var autoSave = false; @@ -317,7 +319,7 @@ module.exports = function (ngApp, events) { html: false }; - if (isEdit) { + if (isEdit && $scope.draftsEnabled) { setTimeout(() => { startAutoSave(); }, 1000); @@ -366,6 +368,7 @@ module.exports = function (ngApp, events) { * Save a draft update into the system via an AJAX request. */ function saveDraft() { + if (!$scope.draftsEnabled) return; var data = { name: $('#name').val(), html: isMarkdown ? $sce.getTrustedHtml($scope.displayContent) : $scope.editContent @@ -435,7 +438,7 @@ module.exports = function (ngApp, events) { const pageId = Number($attrs.pageId); $scope.tags = []; - + $scope.sortOptions = { handle: '.handle', items: '> tr', @@ -458,7 +461,7 @@ module.exports = function (ngApp, events) { * Get all tags for the current book and add into scope. */ function getTags() { - let url = window.baseUrl('/ajax/tags/get/page/' + pageId); + let url = window.baseUrl(`/ajax/tags/get/page/${pageId}`); $http.get(url).then((responseData) => { $scope.tags = responseData.data; addEmptyTag(); @@ -527,21 +530,201 @@ module.exports = function (ngApp, events) { }]); + + ngApp.controller('PageAttachmentController', ['$scope', '$http', '$attrs', + function ($scope, $http, $attrs) { + + const pageId = $scope.uploadedTo = $attrs.pageId; + let currentOrder = ''; + $scope.files = []; + $scope.editFile = false; + $scope.file = getCleanFile(); + $scope.errors = { + link: {}, + edit: {} + }; + + function getCleanFile() { + return { + page_id: pageId + }; + } + + // Angular-UI-Sort options + $scope.sortOptions = { + handle: '.handle', + items: '> tr', + containment: "parent", + axis: "y", + stop: sortUpdate, + }; + + /** + * Event listener for sort changes. + * Updates the file ordering on the server. + * @param event + * @param ui + */ + function sortUpdate(event, ui) { + let newOrder = $scope.files.map(file => {return file.id}).join(':'); + if (newOrder === currentOrder) return; + + currentOrder = newOrder; + $http.put(window.baseUrl(`/attachments/sort/page/${pageId}`), {files: $scope.files}).then(resp => { + events.emit('success', resp.data.message); + }, checkError('sort')); + } + + /** + * Used by dropzone to get the endpoint to upload to. + * @returns {string} + */ + $scope.getUploadUrl = function (file) { + let suffix = (typeof file !== 'undefined') ? `/${file.id}` : ''; + return window.baseUrl(`/attachments/upload${suffix}`); + }; + + /** + * Get files for the current page from the server. + */ + function getFiles() { + let url = window.baseUrl(`/attachments/get/page/${pageId}`) + $http.get(url).then(resp => { + $scope.files = resp.data; + currentOrder = resp.data.map(file => {return file.id}).join(':'); + }, checkError('get')); + } + getFiles(); + + /** + * Runs on file upload, Adds an file to local file list + * and shows a success message to the user. + * @param file + * @param data + */ + $scope.uploadSuccess = function (file, data) { + $scope.$apply(() => { + $scope.files.push(data); + }); + events.emit('success', 'File uploaded'); + }; + + /** + * Upload and overwrite an existing file. + * @param file + * @param data + */ + $scope.uploadSuccessUpdate = function (file, data) { + $scope.$apply(() => { + let search = filesIndexOf(data); + if (search !== -1) $scope.files[search] = data; + + if ($scope.editFile) { + $scope.editFile = angular.copy(data); + data.link = ''; + } + }); + events.emit('success', 'File updated'); + }; + + /** + * Delete a file from the server and, on success, the local listing. + * @param file + */ + $scope.deleteFile = function(file) { + if (!file.deleting) { + file.deleting = true; + return; + } + $http.delete(window.baseUrl(`/attachments/${file.id}`)).then(resp => { + events.emit('success', resp.data.message); + $scope.files.splice($scope.files.indexOf(file), 1); + }, checkError('delete')); + }; + + /** + * Attach a link to a page. + * @param file + */ + $scope.attachLinkSubmit = function(file) { + file.uploaded_to = pageId; + $http.post(window.baseUrl('/attachments/link'), file).then(resp => { + $scope.files.push(resp.data); + events.emit('success', 'Link attached'); + $scope.file = getCleanFile(); + }, checkError('link')); + }; + + /** + * Start the edit mode for a file. + * @param file + */ + $scope.startEdit = function(file) { + $scope.editFile = angular.copy(file); + $scope.editFile.link = (file.external) ? file.path : ''; + }; + + /** + * Cancel edit mode + */ + $scope.cancelEdit = function() { + $scope.editFile = false; + }; + + /** + * Update the name and link of a file. + * @param file + */ + $scope.updateFile = function(file) { + $http.put(window.baseUrl(`/attachments/${file.id}`), file).then(resp => { + let search = filesIndexOf(resp.data); + if (search !== -1) $scope.files[search] = resp.data; + + if ($scope.editFile && !file.external) { + $scope.editFile.link = ''; + } + $scope.editFile = false; + events.emit('success', 'Attachment details updated'); + }, checkError('edit')); + }; + + /** + * Get the url of a file. + */ + $scope.getFileUrl = function(file) { + return window.baseUrl('/attachments/' + file.id); + }; + + /** + * Search the local files via another file object. + * Used to search via object copies. + * @param file + * @returns int + */ + function filesIndexOf(file) { + for (let i = 0; i < $scope.files.length; i++) { + if ($scope.files[i].id == file.id) return i; + } + return -1; + } + + /** + * Check for an error response in a ajax request. + * @param errorGroupName + */ + function checkError(errorGroupName) { + $scope.errors[errorGroupName] = {}; + return function(response) { + if (typeof response.data !== 'undefined' && typeof response.data.error !== 'undefined') { + events.emit('error', response.data.error); + } + if (typeof response.data !== 'undefined' && typeof response.data.validation !== 'undefined') { + $scope.errors[errorGroupName] = response.data.validation; + console.log($scope.errors[errorGroupName]) + } + } + } + + }]); + }; - - - - - - - - - - - - - - - - - diff --git a/resources/assets/js/directives.js b/resources/assets/js/directives.js index 933bbf5ff..44d1a14e1 100644 --- a/resources/assets/js/directives.js +++ b/resources/assets/js/directives.js @@ -2,10 +2,6 @@ const DropZone = require('dropzone'); const markdown = require('marked'); -const toggleSwitchTemplate = require('./components/toggle-switch.html'); -const imagePickerTemplate = require('./components/image-picker.html'); -const dropZoneTemplate = require('./components/drop-zone.html'); - module.exports = function (ngApp, events) { /** @@ -16,7 +12,12 @@ module.exports = function (ngApp, events) { ngApp.directive('toggleSwitch', function () { return { restrict: 'A', - template: toggleSwitchTemplate, + template: ` +
+ +
+
+ `, scope: true, link: function (scope, element, attrs) { scope.name = attrs.name; @@ -33,6 +34,59 @@ module.exports = function (ngApp, events) { }; }); + /** + * Common tab controls using simple jQuery functions. + */ + ngApp.directive('tabContainer', function() { + return { + restrict: 'A', + link: function (scope, element, attrs) { + const $content = element.find('[tab-content]'); + const $buttons = element.find('[tab-button]'); + + if (attrs.tabContainer) { + let initial = attrs.tabContainer; + $buttons.filter(`[tab-button="${initial}"]`).addClass('selected'); + $content.hide().filter(`[tab-content="${initial}"]`).show(); + } else { + $content.hide().first().show(); + $buttons.first().addClass('selected'); + } + + $buttons.click(function() { + let clickedTab = $(this); + $buttons.removeClass('selected'); + $content.hide(); + let name = clickedTab.addClass('selected').attr('tab-button'); + $content.filter(`[tab-content="${name}"]`).show(); + }); + } + }; + }); + + /** + * Sub form component to allow inner-form sections to act like thier own forms. + */ + ngApp.directive('subForm', function() { + return { + restrict: 'A', + link: function (scope, element, attrs) { + element.on('keypress', e => { + if (e.keyCode === 13) { + submitEvent(e); + } + }); + + element.find('button[type="submit"]').click(submitEvent); + + function submitEvent(e) { + e.preventDefault() + if (attrs.subForm) scope.$eval(attrs.subForm); + } + } + }; + }); + /** * Image Picker @@ -41,7 +95,22 @@ module.exports = function (ngApp, events) { ngApp.directive('imagePicker', ['$http', 'imageManagerService', function ($http, imageManagerService) { return { restrict: 'E', - template: imagePickerTemplate, + template: ` +
+
+ Image Preview + Image Preview +
+ +
+ + + | + + + +
+ `, scope: { name: '@', resizeHeight: '@', @@ -108,7 +177,11 @@ module.exports = function (ngApp, events) { ngApp.directive('dropZone', [function () { return { restrict: 'E', - template: dropZoneTemplate, + template: ` +
+
Drop files or click here to upload
+
+ `, scope: { uploadUrl: '@', eventSuccess: '=', @@ -116,6 +189,7 @@ module.exports = function (ngApp, events) { uploadedTo: '@' }, link: function (scope, element, attrs) { + if (attrs.placeholder) element[0].querySelector('.dz-message').textContent = attrs.placeholder; var dropZone = new DropZone(element[0].querySelector('.dropzone-container'), { url: scope.uploadUrl, init: function () { @@ -488,8 +562,8 @@ module.exports = function (ngApp, events) { link: function (scope, elem, attrs) { // Get common elements - const $buttons = elem.find('[tab-button]'); - const $content = elem.find('[tab-content]'); + const $buttons = elem.find('[toolbox-tab-button]'); + const $content = elem.find('[toolbox-tab-content]'); const $toggle = elem.find('[toolbox-toggle]'); // Handle toolbox toggle click @@ -501,17 +575,17 @@ module.exports = function (ngApp, events) { function setActive(tabName, openToolbox) { $buttons.removeClass('active'); $content.hide(); - $buttons.filter(`[tab-button="${tabName}"]`).addClass('active'); - $content.filter(`[tab-content="${tabName}"]`).show(); + $buttons.filter(`[toolbox-tab-button="${tabName}"]`).addClass('active'); + $content.filter(`[toolbox-tab-content="${tabName}"]`).show(); if (openToolbox) elem.addClass('open'); } // Set the first tab content active on load - setActive($content.first().attr('tab-content'), false); + setActive($content.first().attr('toolbox-tab-content'), false); // Handle tab button click $buttons.click(function (e) { - let name = $(this).attr('tab-button'); + let name = $(this).attr('toolbox-tab-button'); setActive(name, true); }); } @@ -549,7 +623,7 @@ module.exports = function (ngApp, events) { let val = $input.val(); let url = $input.attr('autosuggest'); let type = $input.attr('autosuggest-type'); - + // Add name param to request if for a value if (type.toLowerCase() === 'value') { let $nameInput = $input.closest('tr').find('[autosuggest-type="name"]').first(); @@ -850,17 +924,3 @@ module.exports = function (ngApp, events) { }; }]); }; - - - - - - - - - - - - - - diff --git a/resources/assets/js/global.js b/resources/assets/js/global.js index 9ca335ee7..9aa5dff52 100644 --- a/resources/assets/js/global.js +++ b/resources/assets/js/global.js @@ -38,13 +38,17 @@ class EventManager { this.listeners[eventName].push(callback); return this; } -}; +} + window.Events = new EventManager(); - -var services = require('./services')(ngApp, window.Events); -var directives = require('./directives')(ngApp, window.Events); -var controllers = require('./controllers')(ngApp, window.Events); +// Load in angular specific items +import Services from './services'; +import Directives from './directives'; +import Controllers from './controllers'; +Services(ngApp, window.Events); +Directives(ngApp, window.Events); +Controllers(ngApp, window.Events); //Global jQuery Config & Extensions diff --git a/resources/assets/js/pages/page-form.js b/resources/assets/js/pages/page-form.js index c1e6a92df..1fb8b915f 100644 --- a/resources/assets/js/pages/page-form.js +++ b/resources/assets/js/pages/page-form.js @@ -6,11 +6,11 @@ * @param editor - editor instance */ function editorPaste(e, editor) { - if (!e.clipboardData) return + if (!e.clipboardData) return; let items = e.clipboardData.items; if (!items) return; for (let i = 0; i < items.length; i++) { - if (items[i].type.indexOf("image") === -1) return + if (items[i].type.indexOf("image") === -1) return; let file = items[i].getAsFile(); let formData = new FormData(); diff --git a/resources/assets/sass/_blocks.scss b/resources/assets/sass/_blocks.scss index 3c7f7490b..7eb595d36 100644 --- a/resources/assets/sass/_blocks.scss +++ b/resources/assets/sass/_blocks.scss @@ -135,6 +135,10 @@ border-left: 3px solid #BBB; background-color: #EEE; padding: $-s; + display: block; + > * { + display: inline-block; + } &:before { font-family: 'Material-Design-Iconic-Font'; padding-right: $-s; diff --git a/resources/assets/sass/_components.scss b/resources/assets/sass/_components.scss index ccb69b44e..2f9051a52 100644 --- a/resources/assets/sass/_components.scss +++ b/resources/assets/sass/_components.scss @@ -43,10 +43,6 @@ } } -//body.ie .popup-body { -// min-height: 100%; -//} - .corner-button { position: absolute; top: 0; @@ -82,7 +78,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { min-height: 70vh; } -#image-manager .dropzone-container { +.dropzone-container { position: relative; border: 3px dashed #DDD; } @@ -456,3 +452,17 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { border-right: 6px solid transparent; border-bottom: 6px solid $negative; } + + +[tab-container] .nav-tabs { + text-align: left; + border-bottom: 1px solid #DDD; + margin-bottom: $-m; + .tab-item { + padding: $-s; + color: #666; + &.selected { + border-bottom-width: 3px; + } + } +} \ No newline at end of file diff --git a/resources/assets/sass/_lists.scss b/resources/assets/sass/_lists.scss index 54fd55dff..e98e5bfcd 100644 --- a/resources/assets/sass/_lists.scss +++ b/resources/assets/sass/_lists.scss @@ -110,6 +110,8 @@ border-left: 0px solid #FFF; background-color: #FFF; &.fixed { + background-color: #FFF; + z-index: 5; position: fixed; top: 0; padding-left: $-l; diff --git a/resources/assets/sass/_pages.scss b/resources/assets/sass/_pages.scss old mode 100644 new mode 100755 index 42ca0a21f..0052a3319 --- a/resources/assets/sass/_pages.scss +++ b/resources/assets/sass/_pages.scss @@ -71,6 +71,18 @@ max-width: 100%; height: auto !important; } + + // diffs + ins, + del { + text-decoration: none; + } + ins { + background: #dbffdb; + } + del { + background: #FFECEC; + } } // Page content pointers @@ -138,7 +150,6 @@ background-color: #FFF; border: 1px solid #DDD; right: $-xl*2; - z-index: 99; width: 48px; overflow: hidden; align-items: stretch; @@ -189,7 +200,7 @@ color: #444; background-color: rgba(0, 0, 0, 0.1); } - div[tab-content] { + div[toolbox-tab-content] { padding-bottom: 45px; display: flex; flex: 1; @@ -197,7 +208,7 @@ min-height: 0px; overflow-y: scroll; } - div[tab-content] .padded { + div[toolbox-tab-content] .padded { flex: 1; padding-top: 0; } @@ -216,21 +227,6 @@ padding-top: $-s; position: relative; } - button.pos { - position: absolute; - bottom: 0; - display: block; - width: 100%; - padding: $-s; - height: 45px; - border: 0; - margin: 0; - box-shadow: none; - border-radius: 0; - &:hover{ - box-shadow: none; - } - } .handle { user-select: none; cursor: move; @@ -242,36 +238,42 @@ flex-direction: column; overflow-y: scroll; } + table td, table th { + overflow: visible; + } } -[tab-content] { +[toolbox-tab-content] { display: none; } .tag-display { - margin: $-xl $-m; - border: 1px solid #DDD; - min-width: 180px; - max-width: 320px; - opacity: 0.7; - z-index: 5; + width: 100%; + //opacity: 0.7; position: relative; table { width: 100%; margin: 0; padding: 0; } + tr:first-child td { + padding-top: 0; + } .heading th { padding: $-xs $-s; - color: #333; + color: rgba(100, 100, 100, 0.7); + border: 0; font-weight: 400; } td { border: 0; - border-bottom: 1px solid #DDD; + border-bottom: 1px solid #EEE; padding: $-xs $-s; color: #444; } + tr td:first-child { + padding-left:0; + } .tag-value { color: #888; } diff --git a/resources/assets/sass/_tables.scss b/resources/assets/sass/_tables.scss index 1fc8e11c2..37c61159d 100644 --- a/resources/assets/sass/_tables.scss +++ b/resources/assets/sass/_tables.scss @@ -51,4 +51,14 @@ table.list-table { vertical-align: middle; padding: $-xs; } +} + +table.file-table { + @extend .no-style; + td { + padding: $-xs; + } + .ui-sortable-helper { + display: table; + } } \ No newline at end of file diff --git a/resources/assets/sass/_text.scss b/resources/assets/sass/_text.scss index fd993b685..9bad2e83d 100644 --- a/resources/assets/sass/_text.scss +++ b/resources/assets/sass/_text.scss @@ -193,7 +193,7 @@ p.neg, p .neg, span.neg, .text-neg { p.muted, p .muted, span.muted, .text-muted { color: lighten($text-dark, 26%); &.small, .small { - color: lighten($text-dark, 42%); + color: lighten($text-dark, 32%); } } @@ -262,7 +262,7 @@ ul { ol { list-style: decimal; - padding-left: $-m * 1.3; + padding-left: $-m * 2; overflow: hidden; } diff --git a/resources/lang/de/activities.php b/resources/lang/de/activities.php new file mode 100644 index 000000000..c2d20b3a6 --- /dev/null +++ b/resources/lang/de/activities.php @@ -0,0 +1,40 @@ + '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', + +]; diff --git a/resources/lang/de/auth.php b/resources/lang/de/auth.php new file mode 100644 index 000000000..c58d9d974 --- /dev/null +++ b/resources/lang/de/auth.php @@ -0,0 +1,26 @@ + '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.', +]; diff --git a/resources/lang/de/errors.php b/resources/lang/de/errors.php new file mode 100644 index 000000000..697952086 --- /dev/null +++ b/resources/lang/de/errors.php @@ -0,0 +1,12 @@ + 'Sie haben keine Berechtigung auf diese Seite zuzugreifen.', + 'permissionJson' => 'Sie haben keine Berechtigung die angeforderte Aktion auszuführen.' +]; diff --git a/resources/lang/de/pagination.php b/resources/lang/de/pagination.php new file mode 100644 index 000000000..a3bf7c8c8 --- /dev/null +++ b/resources/lang/de/pagination.php @@ -0,0 +1,19 @@ + '« Vorherige', + 'next' => 'Nächste »', + +]; diff --git a/resources/lang/de/passwords.php b/resources/lang/de/passwords.php new file mode 100644 index 000000000..f71358055 --- /dev/null +++ b/resources/lang/de/passwords.php @@ -0,0 +1,22 @@ + '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!', + +]; diff --git a/resources/lang/de/settings.php b/resources/lang/de/settings.php new file mode 100644 index 000000000..183480faa --- /dev/null +++ b/resources/lang/de/settings.php @@ -0,0 +1,39 @@ + '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 Inhalt', + 'app_custom_html_desc' => 'Jeder Inhalt, der hier hinzugefügt wird, wird am Ende der 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.
Größere Bilder werden verkleinert.', + 'app_primary_color' => 'Primäre Anwendungsfarbe', + 'app_primary_color_desc' => 'Dies sollte ein HEX Wert sein.
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.
Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung ändern.', + 'reg_confirm_restrict_domain_placeholder' => 'Keine Einschränkung gesetzt', + +]; diff --git a/resources/lang/de/validation.php b/resources/lang/de/validation.php new file mode 100644 index 000000000..3a6a1bc15 --- /dev/null +++ b/resources/lang/de/validation.php @@ -0,0 +1,108 @@ + ':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' => [], + +]; diff --git a/resources/views/auth/passwords/email.blade.php b/resources/views/auth/passwords/email.blade.php index d8536efa7..115785ab2 100644 --- a/resources/views/auth/passwords/email.blade.php +++ b/resources/views/auth/passwords/email.blade.php @@ -1,5 +1,12 @@ @extends('public') +@section('header-buttons') + Sign in + @if(setting('registration-enabled')) + Sign up + @endif +@stop + @section('content') diff --git a/resources/views/auth/passwords/reset.blade.php b/resources/views/auth/passwords/reset.blade.php index 9a9a65ff0..612b50ff8 100644 --- a/resources/views/auth/passwords/reset.blade.php +++ b/resources/views/auth/passwords/reset.blade.php @@ -1,5 +1,12 @@ @extends('public') +@section('header-buttons') + Sign in + @if(setting('registration-enabled')) + Sign up + @endif +@stop + @section('body-class', 'image-cover login') @section('content') diff --git a/resources/views/base.blade.php b/resources/views/base.blade.php index 1deed0a3f..08acf725d 100644 --- a/resources/views/base.blade.php +++ b/resources/views/base.blade.php @@ -23,7 +23,7 @@ @include('partials/custom-styles') - @if(setting('app-custom-head', false)) + @if(setting('app-custom-head')) {!! setting('app-custom-head') !!} @endif diff --git a/resources/views/pages/edit.blade.php b/resources/views/pages/edit.blade.php index d39e24e92..e50cc7c5b 100644 --- a/resources/views/pages/edit.blade.php +++ b/resources/views/pages/edit.blade.php @@ -23,10 +23,4 @@ @include('partials/image-manager', ['imageType' => 'gallery', 'uploaded_to' => $page->id]) @include('partials/entity-selector-popup') - - @stop \ No newline at end of file diff --git a/resources/views/pages/form-toolbox.blade.php b/resources/views/pages/form-toolbox.blade.php index a03a208b6..a6e66a24a 100644 --- a/resources/views/pages/form-toolbox.blade.php +++ b/resources/views/pages/form-toolbox.blade.php @@ -3,10 +3,13 @@
- + + @if(userCan('attachment-create-all')) + + @endif
-
+

Page Tags

Add some tags to better categorise your content.
You can assign a value to a tag for more in-depth organisation.

@@ -34,4 +37,98 @@
+ @if(userCan('attachment-create-all')) +
+

Attachments

+
+ +
+

Upload some files or attach some link to display on your page. These are visible in the page sidebar. Changes here are saved instantly.

+ +
+ +
+ + + + + + + + + + +
+ +
+ Click delete again to confirm you want to delete this attachment. +
+ Cancel +
+
+

+ No files have been uploaded. +

+
+
+ +
+
+

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.

+
+ + +

+
+
+ + +

+
+ + +
+
+ +
+ +
+
Edit File
+ +
+ + +

+
+ +
+ +
+ +
+
+
+
+ + +

+
+
+
+ + + +
+ +
+
+ @endif +
\ No newline at end of file diff --git a/resources/views/pages/form.blade.php b/resources/views/pages/form.blade.php index 0e0c3672e..c4baf38f7 100644 --- a/resources/views/pages/form.blade.php +++ b/resources/views/pages/form.blade.php @@ -1,7 +1,9 @@ -
+
{{ csrf_field() }} + + {{--Header Bar--}}
@@ -13,7 +15,7 @@
- + {{--Title input--}}
@include('form/text', ['name' => 'name', 'placeholder' => 'Page Title'])
+ {{--Editors--}}
+ + {{--WYSIWYG Editor--}} @if(setting('app-editor') === 'wysiwyg')