]> BookStack Code Mirror - bookstack/commitdiff
Merge pull request #2 from BookStackApp/master
authorAbijeet Patro <redacted>
Mon, 28 Nov 2016 18:53:30 +0000 (00:23 +0530)
committerGitHub <redacted>
Mon, 28 Nov 2016 18:53:30 +0000 (00:23 +0530)
Getting the latest

99 files changed:
.gitignore
app/Attachment.php [new file with mode: 0644]
app/Book.php
app/Chapter.php
app/Entity.php
app/Exceptions/FileUploadException.php [new file with mode: 0644]
app/Http/Controllers/AttachmentController.php [new file with mode: 0644]
app/Http/Controllers/Auth/ForgotPasswordController.php
app/Http/Controllers/Auth/LoginController.php
app/Http/Controllers/Auth/RegisterController.php
app/Http/Controllers/Auth/ResetPasswordController.php
app/Http/Controllers/ChapterController.php
app/Http/Controllers/Controller.php
app/Http/Controllers/PageController.php
app/Http/Controllers/SettingController.php
app/Http/Controllers/UserController.php
app/Page.php
app/PageRevision.php
app/Repos/BookRepo.php
app/Repos/ChapterRepo.php
app/Repos/EntityRepo.php
app/Repos/ImageRepo.php
app/Repos/PageRepo.php
app/Repos/PermissionsRepo.php
app/Repos/UserRepo.php
app/Role.php
app/Services/ActivityService.php
app/Services/AttachmentService.php [new file with mode: 0644]
app/Services/ImageService.php
app/Services/PermissionService.php
app/Services/SocialAuthService.php
app/Services/UploadService.php [new file with mode: 0644]
app/Services/ViewService.php
app/User.php
app/helpers.php
composer.json
composer.lock
config/app.php
config/filesystems.php
config/setting-defaults.php
database/migrations/2016_09_29_101449_remove_hidden_roles.php [new file with mode: 0644]
database/migrations/2016_10_09_142037_create_attachments_table.php [new file with mode: 0644]
gulpfile.js
package.json
phpunit.xml
readme.md
resources/assets/js/components/drop-zone.html [deleted file]
resources/assets/js/components/image-picker.html [deleted file]
resources/assets/js/components/toggle-switch.html [deleted file]
resources/assets/js/controllers.js
resources/assets/js/directives.js
resources/assets/js/global.js
resources/assets/js/pages/page-form.js
resources/assets/sass/_blocks.scss
resources/assets/sass/_components.scss
resources/assets/sass/_lists.scss
resources/assets/sass/_pages.scss [changed mode: 0644->0755]
resources/assets/sass/_tables.scss
resources/assets/sass/_text.scss
resources/lang/de/activities.php [new file with mode: 0644]
resources/lang/de/auth.php [new file with mode: 0644]
resources/lang/de/errors.php [new file with mode: 0644]
resources/lang/de/pagination.php [new file with mode: 0644]
resources/lang/de/passwords.php [new file with mode: 0644]
resources/lang/de/settings.php [new file with mode: 0644]
resources/lang/de/validation.php [new file with mode: 0644]
resources/views/auth/passwords/email.blade.php
resources/views/auth/passwords/reset.blade.php
resources/views/base.blade.php
resources/views/pages/edit.blade.php
resources/views/pages/form-toolbox.blade.php
resources/views/pages/form.blade.php
resources/views/pages/guest-create.blade.php [new file with mode: 0644]
resources/views/pages/page-display.blade.php
resources/views/pages/pdf.blade.php
resources/views/pages/revisions.blade.php
resources/views/pages/show.blade.php
resources/views/pages/sidebar-tree-list.blade.php
resources/views/partials/custom-styles.blade.php
resources/views/public.blade.php
resources/views/settings/index.blade.php
resources/views/settings/roles/form.blade.php
resources/views/users/edit.blade.php
resources/views/users/forms/system.blade.php [new file with mode: 0644]
routes/web.php
storage/uploads/files/.gitignore [moved from public/build/.gitignore with 100% similarity, mode: 0755]
tests/AttachmentTest.php [new file with mode: 0644]
tests/Auth/AuthTest.php
tests/Auth/LdapTest.php
tests/Entity/EntitySearchTest.php
tests/ImageTest.php
tests/Permissions/RolesTest.php
tests/PublicActionTest.php [new file with mode: 0644]
tests/PublicViewTest.php [deleted file]
tests/TestCase.php
tests/UserProfileTest.php
tests/test-data/test-file.txt [new file with mode: 0644]
tests/test-data/test-image.jpg [moved from tests/test-image.jpg with 100% similarity]
version [new file with mode: 0644]

index 7417bbdd840a06f545974781e4e656673545c951..362df57e13a1f29f4daf103ff8d5bf7e63267ace 100644 (file)
@@ -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 (file)
index 0000000..fe291be
--- /dev/null
@@ -0,0 +1,36 @@
+<?php namespace BookStack;
+
+
+class Attachment extends Ownable
+{
+    protected $fillable = ['name', 'order'];
+
+    /**
+     * Get the downloadable file name for this upload.
+     * @return mixed|string
+     */
+    public function getFileName()
+    {
+        if (str_contains($this->name, '.')) return $this->name;
+        return $this->name . '.' . $this->extension;
+    }
+
+    /**
+     * Get the page this file was uploaded to.
+     * @return Page
+     */
+    public function page()
+    {
+        return $this->belongsTo(Page::class, 'uploaded_to');
+    }
+
+    /**
+     * Get the url of this file.
+     * @return string
+     */
+    public function getUrl()
+    {
+        return baseUrl('/attachments/' . $this->id);
+    }
+
+}
index aa2dee9c0a4540f018857e0d7fcb8693c630250b..91f74ca6428f89ae5c54c675ac5b56ed62a532e0 100644 (file)
@@ -13,9 +13,9 @@ class Book extends Entity
     public function getUrl($path = false)
     {
         if ($path !== false) {
-            return baseUrl('/books/' . $this->slug . '/' . trim($path, '/'));
+            return baseUrl('/books/' . urlencode($this->slug) . '/' . trim($path, '/'));
         }
-        return baseUrl('/books/' . $this->slug);
+        return baseUrl('/books/' . urlencode($this->slug));
     }
 
     /*
index 8f0453172ff17447e9f86eef125cad7d8c12b445..cc5518b7a831ea73a41681043a3deddeb214d946 100644 (file)
@@ -32,9 +32,9 @@ class Chapter extends Entity
     {
         $bookSlug = $this->getAttribute('bookSlug') ? $this->getAttribute('bookSlug') : $this->book->slug;
         if ($path !== false) {
-            return baseUrl('/books/' . $bookSlug. '/chapter/' . $this->slug . '/' . trim($path, '/'));
+            return baseUrl('/books/' . urlencode($bookSlug) . '/chapter/' . urlencode($this->slug) . '/' . trim($path, '/'));
         }
-        return baseUrl('/books/' . $bookSlug. '/chapter/' . $this->slug);
+        return baseUrl('/books/' . urlencode($bookSlug) . '/chapter/' . urlencode($this->slug));
     }
 
     /**
index 496d20a333fa1069cd7c1ac19491df94b3f82b29..186059f00e13848d88d5db6dd82b932366bbb7f3 100644 (file)
@@ -160,44 +160,50 @@ class Entity extends Ownable
     public function fullTextSearchQuery($fieldsToSearch, $terms, $wheres = [])
     {
         $exactTerms = [];
-        if (count($terms) === 0) {
-            $search = $this;
-            $orderBy = 'updated_at';
-        } else {
-            foreach ($terms as $key => $term) {
-                $term = htmlentities($term, ENT_QUOTES);
-                $term = preg_replace('/[+\-><\(\)~*\"@]+/', ' ', $term);
-                if (preg_match('/&quot;.*?&quot;/', $term)) {
-                    $term = str_replace('&quot;', '', $term);
-                    $exactTerms[] = '%' . $term . '%';
-                    $term = '"' . $term . '"';
-                } else {
-                    $term = '' . $term . '*';
-                }
-                if ($term !== '*') $terms[$key] = $term;
+        $fuzzyTerms = [];
+        $search = static::newQuery();
+
+        foreach ($terms as $key => $term) {
+            $term = htmlentities($term, ENT_QUOTES);
+            $term = preg_replace('/[+\-><\(\)~*\"@]+/', ' ', $term);
+            if (preg_match('/&quot;.*?&quot;/', $term) || is_numeric($term)) {
+                $term = str_replace('&quot;', '', $term);
+                $exactTerms[] = '%' . $term . '%';
+            } else {
+                $term = '' . $term . '*';
+                if ($term !== '*') $fuzzyTerms[] = $term;
             }
-            $termString = implode(' ', $terms);
+        }
+
+        $isFuzzy = count($exactTerms) === 0 && count($fuzzyTerms) > 0;
+
+
+        // Perform fulltext search if relevant terms exist.
+        if ($isFuzzy) {
+            $termString = implode(' ', $fuzzyTerms);
             $fields = implode(',', $fieldsToSearch);
-            $search = static::selectRaw('*, MATCH(name) AGAINST(? IN BOOLEAN MODE) AS title_relevance', [$termString]);
+            $search = $search->selectRaw('*, MATCH(name) AGAINST(? IN BOOLEAN MODE) AS title_relevance', [$termString]);
             $search = $search->whereRaw('MATCH(' . $fields . ') AGAINST(? IN BOOLEAN MODE)', [$termString]);
+        }
 
-            // Ensure at least one exact term matches if in search
-            if (count($exactTerms) > 0) {
-                $search = $search->where(function ($query) use ($exactTerms, $fieldsToSearch) {
-                    foreach ($exactTerms as $exactTerm) {
-                        foreach ($fieldsToSearch as $field) {
-                            $query->orWhere($field, 'like', $exactTerm);
-                        }
+        // Ensure at least one exact term matches if in search
+        if (count($exactTerms) > 0) {
+            $search = $search->where(function ($query) use ($exactTerms, $fieldsToSearch) {
+                foreach ($exactTerms as $exactTerm) {
+                    foreach ($fieldsToSearch as $field) {
+                        $query->orWhere($field, 'like', $exactTerm);
                     }
-                });
-            }
-            $orderBy = 'title_relevance';
-        };
+                }
+            });
+        }
+
+        $orderBy = $isFuzzy ? 'title_relevance' : 'updated_at';
 
         // Add additional where terms
         foreach ($wheres as $whereTerm) {
             $search->where($whereTerm[0], $whereTerm[1], $whereTerm[2]);
         }
+
         // Load in relations
         if ($this->isA('page')) {
             $search = $search->with('book', 'chapter', 'createdBy', 'updatedBy');
diff --git a/app/Exceptions/FileUploadException.php b/app/Exceptions/FileUploadException.php
new file mode 100644 (file)
index 0000000..af97607
--- /dev/null
@@ -0,0 +1,4 @@
+<?php namespace BookStack\Exceptions;
+
+
+class FileUploadException extends PrettyException {}
\ No newline at end of file
diff --git a/app/Http/Controllers/AttachmentController.php b/app/Http/Controllers/AttachmentController.php
new file mode 100644 (file)
index 0000000..62be0b8
--- /dev/null
@@ -0,0 +1,215 @@
+<?php namespace BookStack\Http\Controllers;
+
+use BookStack\Exceptions\FileUploadException;
+use BookStack\Attachment;
+use BookStack\Repos\PageRepo;
+use BookStack\Services\AttachmentService;
+use Illuminate\Http\Request;
+
+class AttachmentController extends Controller
+{
+    protected $attachmentService;
+    protected $attachment;
+    protected $pageRepo;
+
+    /**
+     * AttachmentController constructor.
+     * @param AttachmentService $attachmentService
+     * @param Attachment $attachment
+     * @param PageRepo $pageRepo
+     */
+    public function __construct(AttachmentService $attachmentService, Attachment $attachment, PageRepo $pageRepo)
+    {
+        $this->attachmentService = $attachmentService;
+        $this->attachment = $attachment;
+        $this->pageRepo = $pageRepo;
+        parent::__construct();
+    }
+
+
+    /**
+     * Endpoint at which attachments are uploaded to.
+     * @param Request $request
+     * @return \Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Http\JsonResponse|\Symfony\Component\HttpFoundation\Response
+     */
+    public function upload(Request $request)
+    {
+        $this->validate($request, [
+            'uploaded_to' => 'required|integer|exists:pages,id',
+            'file' => 'required|file'
+        ]);
+
+        $pageId = $request->get('uploaded_to');
+        $page = $this->pageRepo->getById($pageId, true);
+
+        $this->checkPermission('attachment-create-all');
+        $this->checkOwnablePermission('page-update', $page);
+
+        $uploadedFile = $request->file('file');
+
+        try {
+            $attachment = $this->attachmentService->saveNewUpload($uploadedFile, $pageId);
+        } catch (FileUploadException $e) {
+            return response($e->getMessage(), 500);
+        }
+
+        return response()->json($attachment);
+    }
+
+    /**
+     * Update an uploaded attachment.
+     * @param int $attachmentId
+     * @param Request $request
+     * @return mixed
+     */
+    public function uploadUpdate($attachmentId, Request $request)
+    {
+        $this->validate($request, [
+            'uploaded_to' => 'required|integer|exists:pages,id',
+            'file' => 'required|file'
+        ]);
+
+        $pageId = $request->get('uploaded_to');
+        $page = $this->pageRepo->getById($pageId, true);
+        $attachment = $this->attachment->findOrFail($attachmentId);
+
+        $this->checkOwnablePermission('page-update', $page);
+        $this->checkOwnablePermission('attachment-create', $attachment);
+        
+        if (intval($pageId) !== intval($attachment->uploaded_to)) {
+            return $this->jsonError('Page mismatch during attached file update');
+        }
+
+        $uploadedFile = $request->file('file');
+
+        try {
+            $attachment = $this->attachmentService->saveUpdatedUpload($uploadedFile, $attachment);
+        } catch (FileUploadException $e) {
+            return response($e->getMessage(), 500);
+        }
+
+        return response()->json($attachment);
+    }
+
+    /**
+     * Update the details of an existing file.
+     * @param $attachmentId
+     * @param Request $request
+     * @return Attachment|mixed
+     */
+    public function update($attachmentId, Request $request)
+    {
+        $this->validate($request, [
+            'uploaded_to' => 'required|integer|exists:pages,id',
+            'name' => 'required|string|min:1|max:255',
+            'link' =>  'url|min:1|max:255'
+        ]);
+
+        $pageId = $request->get('uploaded_to');
+        $page = $this->pageRepo->getById($pageId, true);
+        $attachment = $this->attachment->findOrFail($attachmentId);
+
+        $this->checkOwnablePermission('page-update', $page);
+        $this->checkOwnablePermission('attachment-create', $attachment);
+
+        if (intval($pageId) !== intval($attachment->uploaded_to)) {
+            return $this->jsonError('Page mismatch during attachment update');
+        }
+
+        $attachment = $this->attachmentService->updateFile($attachment, $request->all());
+        return $attachment;
+    }
+
+    /**
+     * Attach a link to a page.
+     * @param Request $request
+     * @return mixed
+     */
+    public function attachLink(Request $request)
+    {
+        $this->validate($request, [
+            'uploaded_to' => 'required|integer|exists:pages,id',
+            'name' => 'required|string|min:1|max:255',
+            'link' =>  'required|url|min:1|max:255'
+        ]);
+
+        $pageId = $request->get('uploaded_to');
+        $page = $this->pageRepo->getById($pageId, true);
+
+        $this->checkPermission('attachment-create-all');
+        $this->checkOwnablePermission('page-update', $page);
+
+        $attachmentName = $request->get('name');
+        $link = $request->get('link');
+        $attachment = $this->attachmentService->saveNewFromLink($attachmentName, $link, $pageId);
+
+        return response()->json($attachment);
+    }
+
+    /**
+     * Get the attachments for a specific page.
+     * @param $pageId
+     * @return mixed
+     */
+    public function listForPage($pageId)
+    {
+        $page = $this->pageRepo->getById($pageId, true);
+        $this->checkOwnablePermission('page-view', $page);
+        return response()->json($page->attachments);
+    }
+
+    /**
+     * Update the attachment sorting.
+     * @param $pageId
+     * @param Request $request
+     * @return mixed
+     */
+    public function sortForPage($pageId, Request $request)
+    {
+        $this->validate($request, [
+            'files' => 'required|array',
+            'files.*.id' => 'required|integer',
+        ]);
+        $page = $this->pageRepo->getById($pageId);
+        $this->checkOwnablePermission('page-update', $page);
+
+        $attachments = $request->get('files');
+        $this->attachmentService->updateFileOrderWithinPage($attachments, $pageId);
+        return response()->json(['message' => 'Attachment order updated']);
+    }
+
+    /**
+     * Get an attachment from storage.
+     * @param $attachmentId
+     * @return \Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector|\Symfony\Component\HttpFoundation\Response
+     */
+    public function get($attachmentId)
+    {
+        $attachment = $this->attachment->findOrFail($attachmentId);
+        $page = $this->pageRepo->getById($attachment->uploaded_to);
+        $this->checkOwnablePermission('page-view', $page);
+
+        if ($attachment->external) {
+            return redirect($attachment->path);
+        }
+
+        $attachmentContents = $this->attachmentService->getAttachmentFromStorage($attachment);
+        return response($attachmentContents, 200, [
+            'Content-Type' => 'application/octet-stream',
+            'Content-Disposition' => 'attachment; filename="'. $attachment->getFileName() .'"'
+        ]);
+    }
+
+    /**
+     * Delete a specific attachment in the system.
+     * @param $attachmentId
+     * @return mixed
+     */
+    public function delete($attachmentId)
+    {
+        $attachment = $this->attachment->findOrFail($attachmentId);
+        $this->checkOwnablePermission('attachment-delete', $attachment);
+        $this->attachmentService->deleteFile($attachment);
+        return response()->json(['message' => 'Attachment deleted']);
+    }
+}
index d93854e23a49d947ffc68188f8929956d92874b1..45e40e6fe8371a5d8034ca2d5627f2ff63621acc 100644 (file)
@@ -4,6 +4,8 @@ namespace BookStack\Http\Controllers\Auth;
 
 use BookStack\Http\Controllers\Controller;
 use Illuminate\Foundation\Auth\SendsPasswordResetEmails;
+use Illuminate\Http\Request;
+use Password;
 
 class ForgotPasswordController extends Controller
 {
@@ -30,4 +32,37 @@ class ForgotPasswordController extends Controller
         $this->middleware('guest');
         parent::__construct();
     }
+
+
+    /**
+     * Send a reset link to the given user.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\RedirectResponse
+     */
+    public function sendResetLinkEmail(Request $request)
+    {
+        $this->validate($request, ['email' => 'required|email']);
+
+        // We will send the password reset link to this user. Once we have attempted
+        // to send the link, we will examine the response then see the message we
+        // need to show to the user. Finally, we'll send out a proper response.
+        $response = $this->broker()->sendResetLink(
+            $request->only('email')
+        );
+
+        if ($response === Password::RESET_LINK_SENT) {
+            $message = 'A password reset link has been sent to ' . $request->get('email') . '.';
+            session()->flash('success', $message);
+            return back()->with('status', trans($response));
+        }
+
+        // If an error was returned by the password broker, we will get this message
+        // translated so we can notify a user of the problem. We'll redirect back
+        // to where the users came from so they can attempt this process again.
+        return back()->withErrors(
+            ['email' => trans($response)]
+        );
+    }
+
 }
\ No newline at end of file
index 0de4a8282b9d61e0f9ffabe75093446996e6c7e0..c9d6a5496ef8c99d3f2fd80feaa835641d22ec34 100644 (file)
@@ -2,6 +2,7 @@
 
 namespace BookStack\Http\Controllers\Auth;
 
+use BookStack\Exceptions\AuthException;
 use BookStack\Http\Controllers\Controller;
 use BookStack\Repos\UserRepo;
 use BookStack\Services\SocialAuthService;
index 6bba6de045f8d371e3f5b5d92d59b093ddd90df5..d9bb500b448cdbfa230c614b414a7b8f5332f247 100644 (file)
@@ -51,7 +51,7 @@ class RegisterController extends Controller
      */
     public function __construct(SocialAuthService $socialAuthService, EmailConfirmationService $emailConfirmationService, UserRepo $userRepo)
     {
-        $this->middleware('guest');
+        $this->middleware('guest')->except(['socialCallback', 'detachSocialAccount']);
         $this->socialAuthService = $socialAuthService;
         $this->emailConfirmationService = $emailConfirmationService;
         $this->userRepo = $userRepo;
@@ -297,5 +297,4 @@ class RegisterController extends Controller
         return $this->registerUser($userData, $socialAccount);
     }
 
-
 }
\ No newline at end of file
index 656b8cc42418a63840fe2c32946100e952ee64f0..bd64793f9223078d375da6577af9a01cfd1a9fae 100644 (file)
@@ -20,6 +20,8 @@ class ResetPasswordController extends Controller
 
     use ResetsPasswords;
 
+    protected $redirectTo = '/';
+
     /**
      * Create a new controller instance.
      *
@@ -30,4 +32,18 @@ class ResetPasswordController extends Controller
         $this->middleware('guest');
         parent::__construct();
     }
+
+    /**
+     * Get the response for a successful password reset.
+     *
+     * @param  string  $response
+     * @return \Illuminate\Http\Response
+     */
+    protected function sendResetResponse($response)
+    {
+        $message = 'Your password has been successfully reset.';
+        session()->flash('success', $message);
+        return redirect($this->redirectPath())
+            ->with('status', trans($response));
+    }
 }
\ No newline at end of file
index 03ec2c1109973ed3013fb0d579e65a6abfdbb558..a3fb600fd037b82adb0850cf7549fe7fd74c0703 100644 (file)
@@ -115,9 +115,11 @@ class ChapterController extends Controller
         $book = $this->bookRepo->getBySlug($bookSlug);
         $chapter = $this->chapterRepo->getBySlug($chapterSlug, $book->id);
         $this->checkOwnablePermission('chapter-update', $chapter);
+        if ($chapter->name !== $request->get('name')) {
+            $chapter->slug = $this->chapterRepo->findSuitableSlug($request->get('name'), $book->id, $chapter->id);
+        }
         $chapter->fill($request->all());
-        $chapter->slug = $this->chapterRepo->findSuitableSlug($chapter->name, $book->id, $chapter->id);
-        $chapter->updated_by = auth()->user()->id;
+        $chapter->updated_by = user()->id;
         $chapter->save();
         Activity::add($chapter, 'chapter_update', $book->id);
         return redirect($chapter->getUrl());
index 43292d941a19140f7b341430187173aa1493d4ff..2b6c88fe0b73748ab5cbdf61a64138dcdc4d5c49 100644 (file)
@@ -3,13 +3,11 @@
 namespace BookStack\Http\Controllers;
 
 use BookStack\Ownable;
-use HttpRequestException;
 use Illuminate\Foundation\Bus\DispatchesJobs;
 use Illuminate\Http\Exception\HttpResponseException;
+use Illuminate\Http\Request;
 use Illuminate\Routing\Controller as BaseController;
 use Illuminate\Foundation\Validation\ValidatesRequests;
-use Illuminate\Support\Facades\Auth;
-use Illuminate\Support\Facades\Session;
 use BookStack\User;
 
 abstract class Controller extends BaseController
@@ -33,17 +31,16 @@ abstract class Controller extends BaseController
         $this->middleware(function ($request, $next) {
 
             // Get a user instance for the current user
-            $user = auth()->user();
-            if (!$user) $user = User::getDefault();
-
-            // Share variables with views
-            view()->share('signedIn', auth()->check());
-            view()->share('currentUser', $user);
+            $user = user();
 
             // Share variables with controllers
             $this->currentUser = $user;
             $this->signedIn = auth()->check();
 
+            // Share variables with views
+            view()->share('signedIn', $this->signedIn);
+            view()->share('currentUser', $user);
+
             return $next($request);
         });
     }
@@ -72,8 +69,13 @@ abstract class Controller extends BaseController
      */
     protected function showPermissionError()
     {
-        Session::flash('error', trans('errors.permission'));
-        $response = request()->wantsJson() ? response()->json(['error' => trans('errors.permissionJson')], 403) : redirect('/');
+        if (request()->wantsJson()) {
+            $response = response()->json(['error' => trans('errors.permissionJson')], 403);
+        } else {
+            $response = redirect('/');
+            session()->flash('error', trans('errors.permission'));
+        }
+
         throw new HttpResponseException($response);
     }
 
@@ -84,7 +86,7 @@ abstract class Controller extends BaseController
      */
     protected function checkPermission($permissionName)
     {
-        if (!$this->currentUser || !$this->currentUser->can($permissionName)) {
+        if (!user() || !user()->can($permissionName)) {
             $this->showPermissionError();
         }
         return true;
@@ -126,4 +128,22 @@ abstract class Controller extends BaseController
         return response()->json(['message' => $messageText], $statusCode);
     }
 
+    /**
+     * Create the response for when a request fails validation.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  array  $errors
+     * @return \Symfony\Component\HttpFoundation\Response
+     */
+    protected function buildFailedValidationResponse(Request $request, array $errors)
+    {
+        if ($request->expectsJson()) {
+            return response()->json(['validation' => $errors], 422);
+        }
+
+        return redirect()->to($this->getRedirectUrl())
+            ->withInput($request->input())
+            ->withErrors($errors, $this->errorBag());
+    }
+
 }
index 3d6abe5b4e97ea6f33041d2402f8c2002677333a..c2d8e257cb33297e28b49c448075017de8a0ad70 100644 (file)
@@ -12,6 +12,7 @@ use BookStack\Repos\ChapterRepo;
 use BookStack\Repos\PageRepo;
 use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
 use Views;
+use GatherContent\Htmldiff\Htmldiff;
 
 class PageController extends Controller
 {
@@ -43,20 +44,53 @@ class PageController extends Controller
     /**
      * Show the form for creating a new page.
      * @param string $bookSlug
-     * @param bool $chapterSlug
+     * @param string $chapterSlug
      * @return Response
      * @internal param bool $pageSlug
      */
-    public function create($bookSlug, $chapterSlug = false)
+    public function create($bookSlug, $chapterSlug = null)
     {
         $book = $this->bookRepo->getBySlug($bookSlug);
         $chapter = $chapterSlug ? $this->chapterRepo->getBySlug($chapterSlug, $book->id) : null;
         $parent = $chapter ? $chapter : $book;
         $this->checkOwnablePermission('page-create', $parent);
+
+        // Redirect to draft edit screen if signed in
+        if ($this->signedIn) {
+            $draft = $this->pageRepo->getDraftPage($book, $chapter);
+            return redirect($draft->getUrl());
+        }
+
+        // Otherwise show edit view
         $this->setPageTitle('Create New Page');
+        return view('pages/guest-create', ['parent' => $parent]);
+    }
+
+    /**
+     * Create a new page as a guest user.
+     * @param Request $request
+     * @param string $bookSlug
+     * @param string|null $chapterSlug
+     * @return mixed
+     * @throws NotFoundException
+     */
+    public function createAsGuest(Request $request, $bookSlug, $chapterSlug = null)
+    {
+        $this->validate($request, [
+            'name' => 'required|string|max:255'
+        ]);
+
+        $book = $this->bookRepo->getBySlug($bookSlug);
+        $chapter = $chapterSlug ? $this->chapterRepo->getBySlug($chapterSlug, $book->id) : null;
+        $parent = $chapter ? $chapter : $book;
+        $this->checkOwnablePermission('page-create', $parent);
 
-        $draft = $this->pageRepo->getDraftPage($book, $chapter);
-        return redirect($draft->getUrl());
+        $page = $this->pageRepo->getDraftPage($book, $chapter);
+        $this->pageRepo->publishDraft($page, [
+            'name' => $request->get('name'),
+            'html' => ''
+        ]);
+        return redirect($page->getUrl('/edit'));
     }
 
     /**
@@ -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,
+        ]);
     }
 
     /**
index 61ce55fa9405921c65cd5684c33fb4b42cb9d0d1..65135eda3816079eea164c229acd89d9e57fe9ad 100644 (file)
@@ -17,10 +17,7 @@ class SettingController extends Controller
         $this->setPageTitle('Settings');
 
         // Get application version
-        $version = false;
-        if (function_exists('exec')) {
-            $version = exec('git describe --always --tags ');
-        }
+        $version = trim(file_get_contents(base_path('version')));
 
         return view('settings/index', ['version' => $version]);
     }
index 4c56516dc670f098933ae73de2796cdb8a6b673c..18ef1a671844fc4943e7df9673f62de42bcc5cea 100644 (file)
@@ -57,7 +57,7 @@ class UserController extends Controller
     {
         $this->checkPermission('users-manage');
         $authMethod = config('auth.method');
-        $roles = $this->userRepo->getAssignableRoles();
+        $roles = $this->userRepo->getAllRoles();
         return view('users/create', ['authMethod' => $authMethod, 'roles' => $roles]);
     }
 
@@ -126,12 +126,13 @@ class UserController extends Controller
             return $this->currentUser->id == $id;
         });
 
-        $authMethod = config('auth.method');
-
         $user = $this->user->findOrFail($id);
+
+        $authMethod = ($user->system_name) ? 'system' : config('auth.method');
+
         $activeSocialDrivers = $socialAuthService->getActiveDrivers();
         $this->setPageTitle('User Profile');
-        $roles = $this->userRepo->getAssignableRoles();
+        $roles = $this->userRepo->getAllRoles();
         return view('users/edit', ['user' => $user, 'activeSocialDrivers' => $activeSocialDrivers, 'authMethod' => $authMethod, 'roles' => $roles]);
     }
 
@@ -186,7 +187,7 @@ class UserController extends Controller
 
     /**
      * Show the user delete page.
-     * @param $id
+     * @param int $id
      * @return \Illuminate\View\View
      */
     public function delete($id)
@@ -219,6 +220,11 @@ class UserController extends Controller
             return redirect($user->getEditUrl());
         }
 
+        if ($user->system_name === 'public') {
+            session()->flash('error', 'You cannot delete the guest user');
+            return redirect($user->getEditUrl());
+        }
+
         $this->userRepo->destroy($user);
         session()->flash('success', 'User successfully removed');
 
index 1961a4f7f6bc2ec0e66824397df51c2ec8652f8e..3ee9e90f43d7d110699076b7ca0502822d00241d 100644 (file)
@@ -54,6 +54,15 @@ class Page extends Entity
         return $this->hasMany(PageRevision::class)->where('type', '=', 'version')->orderBy('created_at', 'desc');
     }
 
+    /**
+     * Get the attachments assigned to this page.
+     * @return \Illuminate\Database\Eloquent\Relations\HasMany
+     */
+    public function attachments()
+    {
+        return $this->hasMany(Attachment::class, 'uploaded_to')->orderBy('order', 'asc');
+    }
+
     /**
      * Get the url for this page.
      * @param string|bool $path
@@ -63,13 +72,13 @@ class Page extends Entity
     {
         $bookSlug = $this->getAttribute('bookSlug') ? $this->getAttribute('bookSlug') : $this->book->slug;
         $midText = $this->draft ? '/draft/' : '/page/';
-        $idComponent = $this->draft ? $this->id : $this->slug;
+        $idComponent = $this->draft ? $this->id : urlencode($this->slug);
 
         if ($path !== false) {
-            return baseUrl('/books/' . $bookSlug . $midText . $idComponent . '/' . trim($path, '/'));
+            return baseUrl('/books/' . urlencode($bookSlug) . $midText . $idComponent . '/' . trim($path, '/'));
         }
 
-        return baseUrl('/books/' . $bookSlug . $midText . $idComponent);
+        return baseUrl('/books/' . urlencode($bookSlug) . $midText . $idComponent);
     }
 
     /**
index 1ffd63dbd584700376c983e39a4f4c1c8ce36800..ff469f0ed3a78b3d3750c3ff6d07b6b2d787c226 100644 (file)
@@ -25,11 +25,26 @@ class PageRevision extends Model
 
     /**
      * Get the url for this revision.
+     * @param null|string $path
      * @return string
      */
-    public function getUrl()
+    public function getUrl($path = null)
     {
-        return $this->page->getUrl() . '/revisions/' . $this->id;
+        $url = $this->page->getUrl() . '/revisions/' . $this->id;
+        if ($path) return $url . '/' . trim($path, '/');
+        return $url;
+    }
+
+    /**
+     * Get the previous revision for the same page if existing
+     * @return \BookStack\PageRevision|null
+     */
+    public function getPrevious()
+    {
+        if ($id = static::where('page_id', '=', $this->page_id)->where('id', '<', $this->id)->max('id')) {
+            return static::find($id);
+        }
+        return null;
     }
 
 }
index fdc4dd8d47fc9c2611e6dba07f4c1fb6bf470da2..7bb91f4723d84efe49d01897e5f3766520554a1b 100644 (file)
@@ -132,8 +132,8 @@ class BookRepo extends EntityRepo
     {
         $book = $this->book->newInstance($input);
         $book->slug = $this->findSuitableSlug($book->name);
-        $book->created_by = auth()->user()->id;
-        $book->updated_by = auth()->user()->id;
+        $book->created_by = user()->id;
+        $book->updated_by = user()->id;
         $book->save();
         $this->permissionService->buildJointPermissionsForEntity($book);
         return $book;
@@ -147,9 +147,11 @@ class BookRepo extends EntityRepo
      */
     public function updateFromInput(Book $book, $input)
     {
+        if ($book->name !== $input['name']) {
+            $book->slug = $this->findSuitableSlug($input['name'], $book->id);
+        }
         $book->fill($input);
-        $book->slug = $this->findSuitableSlug($book->name, $book->id);
-        $book->updated_by = auth()->user()->id;
+        $book->updated_by = user()->id;
         $book->save();
         $this->permissionService->buildJointPermissionsForEntity($book);
         return $book;
@@ -208,8 +210,7 @@ class BookRepo extends EntityRepo
      */
     public function findSuitableSlug($name, $currentId = false)
     {
-        $slug = Str::slug($name);
-        if ($slug === "") $slug = substr(md5(rand(1, 500)), 0, 5);
+        $slug = $this->nameToSlug($name);
         while ($this->doesSlugExist($slug, $currentId)) {
             $slug .= '-' . substr(md5(rand(1, 500)), 0, 3);
         }
index c12a9f0f206811eec5b14dd1e27e04f20ccf5275..4c13b9aafd3c4c95d0c13367261629e43329cd3e 100644 (file)
@@ -98,8 +98,8 @@ class ChapterRepo extends EntityRepo
     {
         $chapter = $this->chapter->newInstance($input);
         $chapter->slug = $this->findSuitableSlug($chapter->name, $book->id);
-        $chapter->created_by = auth()->user()->id;
-        $chapter->updated_by = auth()->user()->id;
+        $chapter->created_by = user()->id;
+        $chapter->updated_by = user()->id;
         $chapter = $book->chapters()->save($chapter);
         $this->permissionService->buildJointPermissionsForEntity($chapter);
         return $chapter;
@@ -150,8 +150,7 @@ class ChapterRepo extends EntityRepo
      */
     public function findSuitableSlug($name, $bookId, $currentId = false)
     {
-        $slug = Str::slug($name);
-        if ($slug === "") $slug = substr(md5(rand(1, 500)), 0, 5);
+        $slug = $this->nameToSlug($name);
         while ($this->doesSlugExist($slug, $bookId, $currentId)) {
             $slug .= '-' . substr(md5(rand(1, 500)), 0, 3);
         }
index c94601738dc2f926b8ea0775982a8c3467efbba6..7ecfb758c9e31364414987cc1121fa1df18f377d 100644 (file)
@@ -132,9 +132,8 @@ class EntityRepo
      */
     public function getUserDraftPages($count = 20, $page = 0)
     {
-        $user = auth()->user();
         return $this->page->where('draft', '=', true)
-            ->where('created_by', '=', $user->id)
+            ->where('created_by', '=', user()->id)
             ->orderBy('updated_at', 'desc')
             ->skip($count * $page)->take($count)->get();
     }
@@ -270,6 +269,19 @@ class EntityRepo
         $this->permissionService->buildJointPermissionsForEntities($collection);
     }
 
+    /**
+     * Format a name as a url slug.
+     * @param $name
+     * @return string
+     */
+    protected function nameToSlug($name)
+    {
+        $slug = str_replace(' ', '-', strtolower($name));
+        $slug = preg_replace('/[\+\/\\\?\@\}\{\.\,\=\[\]\#\&\!\*\'\;\:\$\%]/', '', $slug);
+        if ($slug === "") $slug = substr(md5(rand(1, 500)), 0, 5);
+        return $slug;
+    }
+
 }
 
 
index 435b8bbd795666bddc12d1b8e0e687eecb4bcb8b..8ddde7b0f9aaa3857b44d937a4065595a35e49de 100644 (file)
@@ -5,6 +5,7 @@ use BookStack\Image;
 use BookStack\Page;
 use BookStack\Services\ImageService;
 use BookStack\Services\PermissionService;
+use Illuminate\Contracts\Filesystem\FileNotFoundException;
 use Setting;
 use Symfony\Component\HttpFoundation\File\UploadedFile;
 
@@ -191,7 +192,12 @@ class ImageRepo
      */
     public function getThumbnail(Image $image, $width = 220, $height = 220, $keepRatio = false)
     {
-        return $this->imageService->getThumbnail($image, $width, $height, $keepRatio);
+        try {
+            return $this->imageService->getThumbnail($image, $width, $height, $keepRatio);
+        } catch (FileNotFoundException $exception) {
+            $image->delete();
+            return [];
+        }
     }
 
 
index c64da126774bd3db3180028b8cb4f0a288dcee81..e6d713f77c591f3451c38ff777d5060974946292 100644 (file)
@@ -5,6 +5,7 @@ use BookStack\Book;
 use BookStack\Chapter;
 use BookStack\Entity;
 use BookStack\Exceptions\NotFoundException;
+use BookStack\Services\AttachmentService;
 use Carbon\Carbon;
 use DOMDocument;
 use DOMXPath;
@@ -48,7 +49,7 @@ class PageRepo extends EntityRepo
      * Get a page via a specific ID.
      * @param $id
      * @param bool $allowDrafts
-     * @return mixed
+     * @return Page
      */
     public function getById($id, $allowDrafts = false)
     {
@@ -59,7 +60,7 @@ class PageRepo extends EntityRepo
      * Get a page identified by the given slug.
      * @param $slug
      * @param $bookId
-     * @return mixed
+     * @return Page
      * @throws NotFoundException
      */
     public function getBySlug($slug, $bookId)
@@ -148,8 +149,8 @@ class PageRepo extends EntityRepo
     {
         $page = $this->page->newInstance();
         $page->name = 'New Page';
-        $page->created_by = auth()->user()->id;
-        $page->updated_by = auth()->user()->id;
+        $page->created_by = user()->id;
+        $page->updated_by = user()->id;
         $page->draft = true;
 
         if ($chapter) $page->chapter_id = $chapter->id;
@@ -330,7 +331,7 @@ class PageRepo extends EntityRepo
         }
 
         // Update with new details
-        $userId = auth()->user()->id;
+        $userId = user()->id;
         $page->fill($input);
         $page->html = $this->formatHtml($input['html']);
         $page->text = strip_tags($page->html);
@@ -363,7 +364,7 @@ class PageRepo extends EntityRepo
         $page->fill($revision->toArray());
         $page->slug = $this->findSuitableSlug($page->name, $book->id, $page->id);
         $page->text = strip_tags($page->html);
-        $page->updated_by = auth()->user()->id;
+        $page->updated_by = user()->id;
         $page->save();
         return $page;
     }
@@ -381,7 +382,7 @@ class PageRepo extends EntityRepo
         $revision->page_id = $page->id;
         $revision->slug = $page->slug;
         $revision->book_slug = $page->book->slug;
-        $revision->created_by = auth()->user()->id;
+        $revision->created_by = user()->id;
         $revision->created_at = $page->updated_at;
         $revision->type = 'version';
         $revision->summary = $summary;
@@ -404,7 +405,7 @@ class PageRepo extends EntityRepo
      */
     public function saveUpdateDraft(Page $page, $data = [])
     {
-        $userId = auth()->user()->id;
+        $userId = user()->id;
         $drafts = $this->userUpdateDraftsQuery($page, $userId)->get();
 
         if ($drafts->count() > 0) {
@@ -535,7 +536,7 @@ class PageRepo extends EntityRepo
         $query = $this->pageRevision->where('type', '=', 'update_draft')
             ->where('page_id', '=', $page->id)
             ->where('updated_at', '>', $page->updated_at)
-            ->where('created_by', '!=', auth()->user()->id)
+            ->where('created_by', '!=', user()->id)
             ->with('createdBy');
 
         if ($minRange !== null) {
@@ -548,7 +549,7 @@ class PageRepo extends EntityRepo
     /**
      * Gets a single revision via it's id.
      * @param $id
-     * @return mixed
+     * @return PageRevision
      */
     public function getRevisionById($id)
     {
@@ -613,8 +614,7 @@ class PageRepo extends EntityRepo
      */
     public function findSuitableSlug($name, $bookId, $currentId = false)
     {
-        $slug = Str::slug($name);
-        if ($slug === "") $slug = substr(md5(rand(1, 500)), 0, 5);
+        $slug = $this->nameToSlug($name);
         while ($this->doesSlugExist($slug, $bookId, $currentId)) {
             $slug .= '-' . substr(md5(rand(1, 500)), 0, 3);
         }
@@ -633,12 +633,20 @@ class PageRepo extends EntityRepo
         $page->revisions()->delete();
         $page->permissions()->delete();
         $this->permissionService->deleteJointPermissionsForEntity($page);
+
+        // Delete AttachedFiles
+        $attachmentService = app(AttachmentService::class);
+        foreach ($page->attachments as $attachment) {
+            $attachmentService->deleteFile($attachment);
+        }
+
         $page->delete();
     }
 
     /**
      * Get the latest pages added to the system.
      * @param $count
+     * @return mixed
      */
     public function getRecentlyCreatedPaginated($count = 20)
     {
@@ -648,6 +656,7 @@ class PageRepo extends EntityRepo
     /**
      * Get the latest pages added to the system.
      * @param $count
+     * @return mixed
      */
     public function getRecentlyUpdatedPaginated($count = 20)
     {
index e026d83e8e3fa8e88f8dab8acdf6422e3e39d333..24497c91135fa036825bcb180e88df65ba099727 100644 (file)
@@ -35,7 +35,7 @@ class PermissionsRepo
      */
     public function getAllRoles()
     {
-        return $this->role->where('hidden', '=', false)->get();
+        return $this->role->all();
     }
 
     /**
@@ -45,7 +45,7 @@ class PermissionsRepo
      */
     public function getAllRolesExcept(Role $role)
     {
-        return $this->role->where('id', '!=', $role->id)->where('hidden', '=', false)->get();
+        return $this->role->where('id', '!=', $role->id)->get();
     }
 
     /**
@@ -90,8 +90,6 @@ class PermissionsRepo
     {
         $role = $this->role->findOrFail($roleId);
 
-        if ($role->hidden) throw new PermissionsException("Cannot update a hidden role");
-
         $permissions = isset($roleData['permissions']) ? array_keys($roleData['permissions']) : [];
         $this->assignRolePermissions($role, $permissions);
 
index 127db9fb59c482aab05198277558bf7b6abdcac6..ab3716fca027b857de43f239691b931f6cb95b05 100644 (file)
@@ -199,9 +199,9 @@ class UserRepo
      * Get the roles in the system that are assignable to a user.
      * @return mixed
      */
-    public function getAssignableRoles()
+    public function getAllRoles()
     {
-        return $this->role->visible();
+        return $this->role->all();
     }
 
     /**
@@ -211,7 +211,7 @@ class UserRepo
      */
     public function getRestrictableRoles()
     {
-        return $this->role->where('hidden', '=', false)->where('system_name', '=', '')->get();
+        return $this->role->where('system_name', '!=', 'admin')->get();
     }
 
 }
\ No newline at end of file
index 8d0a79e753316e8070255da2181c072eaafdf6bc..bf9685ee25d597f35edb3403f82a31d33c220651 100644 (file)
@@ -66,7 +66,7 @@ class Role extends Model
     /**
      * Get the role object for the specified role.
      * @param $roleName
-     * @return mixed
+     * @return Role
      */
     public static function getRole($roleName)
     {
@@ -76,7 +76,7 @@ class Role extends Model
     /**
      * Get the role object for the specified system role.
      * @param $roleName
-     * @return mixed
+     * @return Role
      */
     public static function getSystemRole($roleName)
     {
index f6fea33a191ee2d1e02b7e63d394653d8b53322e..e4103623835af2d9c3c170a1a66e962cafb9743e 100644 (file)
@@ -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 (file)
index 0000000..e0ee3a0
--- /dev/null
@@ -0,0 +1,201 @@
+<?php namespace BookStack\Services;
+
+use BookStack\Exceptions\FileUploadException;
+use BookStack\Attachment;
+use Exception;
+use Symfony\Component\HttpFoundation\File\UploadedFile;
+
+class AttachmentService extends UploadService
+{
+
+    /**
+     * Get an attachment from storage.
+     * @param Attachment $attachment
+     * @return string
+     */
+    public function getAttachmentFromStorage(Attachment $attachment)
+    {
+        $attachmentPath = $this->getStorageBasePath() . $attachment->path;
+        return $this->getStorage()->get($attachmentPath);
+    }
+
+    /**
+     * Store a new attachment upon user upload.
+     * @param UploadedFile $uploadedFile
+     * @param int $page_id
+     * @return Attachment
+     * @throws FileUploadException
+     */
+    public function saveNewUpload(UploadedFile $uploadedFile, $page_id)
+    {
+        $attachmentName = $uploadedFile->getClientOriginalName();
+        $attachmentPath = $this->putFileInStorage($attachmentName, $uploadedFile);
+        $largestExistingOrder = Attachment::where('uploaded_to', '=', $page_id)->max('order');
+
+        $attachment = Attachment::forceCreate([
+            'name' => $attachmentName,
+            'path' => $attachmentPath,
+            'extension' => $uploadedFile->getClientOriginalExtension(),
+            'uploaded_to' => $page_id,
+            'created_by' => user()->id,
+            'updated_by' => user()->id,
+            'order' => $largestExistingOrder + 1
+        ]);
+
+        return $attachment;
+    }
+
+    /**
+     * Store a upload, saving to a file and deleting any existing uploads
+     * attached to that file.
+     * @param UploadedFile $uploadedFile
+     * @param Attachment $attachment
+     * @return Attachment
+     * @throws FileUploadException
+     */
+    public function saveUpdatedUpload(UploadedFile $uploadedFile, Attachment $attachment)
+    {
+        if (!$attachment->external) {
+            $this->deleteFileInStorage($attachment);
+        }
+
+        $attachmentName = $uploadedFile->getClientOriginalName();
+        $attachmentPath = $this->putFileInStorage($attachmentName, $uploadedFile);
+
+        $attachment->name = $attachmentName;
+        $attachment->path = $attachmentPath;
+        $attachment->external = false;
+        $attachment->extension = $uploadedFile->getClientOriginalExtension();
+        $attachment->save();
+        return $attachment;
+    }
+
+    /**
+     * Save a new File attachment from a given link and name.
+     * @param string $name
+     * @param string $link
+     * @param int $page_id
+     * @return Attachment
+     */
+    public function saveNewFromLink($name, $link, $page_id)
+    {
+        $largestExistingOrder = Attachment::where('uploaded_to', '=', $page_id)->max('order');
+        return Attachment::forceCreate([
+            'name' => $name,
+            'path' => $link,
+            'external' => true,
+            'extension' => '',
+            'uploaded_to' => $page_id,
+            'created_by' => user()->id,
+            'updated_by' => user()->id,
+            'order' => $largestExistingOrder + 1
+        ]);
+    }
+
+    /**
+     * Get the file storage base path, amended for storage type.
+     * This allows us to keep a generic path in the database.
+     * @return string
+     */
+    private function getStorageBasePath()
+    {
+        return $this->isLocal() ? 'storage/' : '';
+    }
+
+    /**
+     * Updates the file ordering for a listing of attached files.
+     * @param array $attachmentList
+     * @param $pageId
+     */
+    public function updateFileOrderWithinPage($attachmentList, $pageId)
+    {
+        foreach ($attachmentList as $index => $attachment) {
+            Attachment::where('uploaded_to', '=', $pageId)->where('id', '=', $attachment['id'])->update(['order' => $index]);
+        }
+    }
+
+
+    /**
+     * Update the details of a file.
+     * @param Attachment $attachment
+     * @param $requestData
+     * @return Attachment
+     */
+    public function updateFile(Attachment $attachment, $requestData)
+    {
+        $attachment->name = $requestData['name'];
+        if (isset($requestData['link']) && trim($requestData['link']) !== '') {
+            $attachment->path = $requestData['link'];
+            if (!$attachment->external) {
+                $this->deleteFileInStorage($attachment);
+                $attachment->external = true;
+            }
+        }
+        $attachment->save();
+        return $attachment;
+    }
+
+    /**
+     * Delete a File from the database and storage.
+     * @param Attachment $attachment
+     */
+    public function deleteFile(Attachment $attachment)
+    {
+        if ($attachment->external) {
+            $attachment->delete();
+            return;
+        }
+        
+        $this->deleteFileInStorage($attachment);
+        $attachment->delete();
+    }
+
+    /**
+     * Delete a file from the filesystem it sits on.
+     * Cleans any empty leftover folders.
+     * @param Attachment $attachment
+     */
+    protected function deleteFileInStorage(Attachment $attachment)
+    {
+        $storedFilePath = $this->getStorageBasePath() . $attachment->path;
+        $storage = $this->getStorage();
+        $dirPath = dirname($storedFilePath);
+
+        $storage->delete($storedFilePath);
+        if (count($storage->allFiles($dirPath)) === 0) {
+            $storage->deleteDirectory($dirPath);
+        }
+    }
+
+    /**
+     * Store a file in storage with the given filename
+     * @param $attachmentName
+     * @param UploadedFile $uploadedFile
+     * @return string
+     * @throws FileUploadException
+     */
+    protected function putFileInStorage($attachmentName, UploadedFile $uploadedFile)
+    {
+        $attachmentData = file_get_contents($uploadedFile->getRealPath());
+
+        $storage = $this->getStorage();
+        $attachmentBasePath = 'uploads/files/' . Date('Y-m-M') . '/';
+        $storageBasePath = $this->getStorageBasePath() . $attachmentBasePath;
+
+        $uploadFileName = $attachmentName;
+        while ($storage->exists($storageBasePath . $uploadFileName)) {
+            $uploadFileName = str_random(3) . $uploadFileName;
+        }
+
+        $attachmentPath = $attachmentBasePath . $uploadFileName;
+        $attachmentStoragePath = $this->getStorageBasePath() . $attachmentPath;
+
+        try {
+            $storage->put($attachmentStoragePath, $attachmentData);
+        } catch (Exception $e) {
+            throw new FileUploadException('File path ' . $attachmentStoragePath . ' could not be uploaded to. Ensure it is writable to the server.');
+        }
+        return $attachmentPath;
+    }
+
+}
\ No newline at end of file
index aa1375487cd943f26afee06a8500c90cc4cb52d1..dfe2cf453705e07a7b311b73b0f2bffaa28bfb6a 100644 (file)
@@ -9,20 +9,13 @@ use Intervention\Image\ImageManager;
 use Illuminate\Contracts\Filesystem\Factory as FileSystem;
 use Illuminate\Contracts\Filesystem\Filesystem as FileSystemInstance;
 use Illuminate\Contracts\Cache\Repository as Cache;
-use Setting;
 use Symfony\Component\HttpFoundation\File\UploadedFile;
 
-class ImageService
+class ImageService extends UploadService
 {
 
     protected $imageTool;
-    protected $fileSystem;
     protected $cache;
-
-    /**
-     * @var FileSystemInstance
-     */
-    protected $storageInstance;
     protected $storageUrl;
 
     /**
@@ -34,8 +27,8 @@ class ImageService
     public function __construct(ImageManager $imageTool, FileSystem $fileSystem, Cache $cache)
     {
         $this->imageTool = $imageTool;
-        $this->fileSystem = $fileSystem;
         $this->cache = $cache;
+        parent::__construct($fileSystem);
     }
 
     /**
@@ -88,6 +81,9 @@ class ImageService
         if ($secureUploads) $imageName = str_random(16) . '-' . $imageName;
 
         $imagePath = '/uploads/images/' . $type . '/' . Date('Y-m-M') . '/';
+
+        if ($this->isLocal()) $imagePath = '/public' . $imagePath;
+
         while ($storage->exists($imagePath . $imageName)) {
             $imageName = str_random(3) . $imageName;
         }
@@ -100,6 +96,8 @@ class ImageService
             throw new ImageUploadException('Image Path ' . $fullPath . ' is not writable by the server.');
         }
 
+        if ($this->isLocal()) $fullPath = str_replace_first('/public', '', $fullPath);
+
         $imageDetails = [
             'name'       => $imageName,
             'path'       => $fullPath,
@@ -108,8 +106,8 @@ class ImageService
             'uploaded_to' => $uploadedTo
         ];
 
-        if (auth()->user() && auth()->user()->id !== 0) {
-            $userId = auth()->user()->id;
+        if (user()->id !== 0) {
+            $userId = user()->id;
             $imageDetails['created_by'] = $userId;
             $imageDetails['updated_by'] = $userId;
         }
@@ -119,6 +117,16 @@ class ImageService
         return $image;
     }
 
+    /**
+     * Get the storage path, Dependant of storage type.
+     * @param Image $image
+     * @return mixed|string
+     */
+    protected function getPath(Image $image)
+    {
+        return ($this->isLocal()) ? ('public/' . $image->path) : $image->path;
+    }
+
     /**
      * Get the thumbnail for an image.
      * If $keepRatio is true only the width will be used.
@@ -135,7 +143,8 @@ class ImageService
     public function getThumbnail(Image $image, $width = 220, $height = 220, $keepRatio = false)
     {
         $thumbDirName = '/' . ($keepRatio ? 'scaled-' : 'thumbs-') . $width . '-' . $height . '/';
-        $thumbFilePath = dirname($image->path) . $thumbDirName . basename($image->path);
+        $imagePath = $this->getPath($image);
+        $thumbFilePath = dirname($imagePath) . $thumbDirName . basename($imagePath);
 
         if ($this->cache->has('images-' . $image->id . '-' . $thumbFilePath) && $this->cache->get('images-' . $thumbFilePath)) {
             return $this->getPublicUrl($thumbFilePath);
@@ -148,7 +157,7 @@ class ImageService
         }
 
         try {
-            $thumb = $this->imageTool->make($storage->get($image->path));
+            $thumb = $this->imageTool->make($storage->get($imagePath));
         } catch (Exception $e) {
             if ($e instanceof \ErrorException || $e instanceof NotSupportedException) {
                 throw new ImageUploadException('The server cannot create thumbnails. Please check you have the GD PHP extension installed.');
@@ -183,8 +192,8 @@ class ImageService
     {
         $storage = $this->getStorage();
 
-        $imageFolder = dirname($image->path);
-        $imageFileName = basename($image->path);
+        $imageFolder = dirname($this->getPath($image));
+        $imageFileName = basename($this->getPath($image));
         $allImages = collect($storage->allFiles($imageFolder));
 
         $imagesToDelete = $allImages->filter(function ($imagePath) use ($imageFileName) {
@@ -222,35 +231,9 @@ class ImageService
         return $image;
     }
 
-    /**
-     * Get the storage that will be used for storing images.
-     * @return FileSystemInstance
-     */
-    private function getStorage()
-    {
-        if ($this->storageInstance !== null) return $this->storageInstance;
-
-        $storageType = config('filesystems.default');
-        $this->storageInstance = $this->fileSystem->disk($storageType);
-
-        return $this->storageInstance;
-    }
-
-    /**
-     * Check whether or not a folder is empty.
-     * @param $path
-     * @return int
-     */
-    private function isFolderEmpty($path)
-    {
-        $files = $this->getStorage()->files($path);
-        $folders = $this->getStorage()->directories($path);
-        return count($files) === 0 && count($folders) === 0;
-    }
-
     /**
      * Gets a public facing url for an image by checking relevant environment variables.
-     * @param $filePath
+     * @param string $filePath
      * @return string
      */
     private function getPublicUrl($filePath)
@@ -273,6 +256,8 @@ class ImageService
             $this->storageUrl = $storageUrl;
         }
 
+        if ($this->isLocal()) $filePath = str_replace_first('public/', '', $filePath);
+
         return ($this->storageUrl == false ? rtrim(baseUrl(''), '/') : rtrim($this->storageUrl, '/')) . $filePath;
     }
 
index 341a69edb624a4967b86c432c66b044a0ae896aa..bb78f0b0a2c7481cdd9be119d6d2436eb6e16c90 100644 (file)
@@ -614,7 +614,7 @@ class PermissionService
     private function currentUser()
     {
         if ($this->currentUserModel === false) {
-            $this->currentUserModel = auth()->user() ? auth()->user() : new User();
+            $this->currentUserModel = user();
         }
 
         return $this->currentUserModel;
index b28a97ea43660b7cd3829cd6958e3090e20dde68..d76a7231b8049396c1be994a16eea4b5c7500fb6 100644 (file)
@@ -100,7 +100,7 @@ class SocialAuthService
         $socialAccount = $this->socialAccount->where('driver_id', '=', $socialId)->first();
         $user = $this->userRepo->getByEmail($socialUser->getEmail());
         $isLoggedIn = auth()->check();
-        $currentUser = auth()->user();
+        $currentUser = user();
 
         // When a user is not logged in and a matching SocialAccount exists,
         // Simply log the user into the application.
@@ -214,9 +214,9 @@ class SocialAuthService
     public function detachSocialAccount($socialDriver)
     {
         session();
-        auth()->user()->socialAccounts()->where('driver', '=', $socialDriver)->delete();
+        user()->socialAccounts()->where('driver', '=', $socialDriver)->delete();
         session()->flash('success', title_case($socialDriver) . ' account successfully detached');
-        return redirect(auth()->user()->getEditUrl());
+        return redirect(user()->getEditUrl());
     }
 
 }
\ No newline at end of file
diff --git a/app/Services/UploadService.php b/app/Services/UploadService.php
new file mode 100644 (file)
index 0000000..44d4bb4
--- /dev/null
@@ -0,0 +1,64 @@
+<?php namespace BookStack\Services;
+
+use Illuminate\Contracts\Filesystem\Factory as FileSystem;
+use Illuminate\Contracts\Filesystem\Filesystem as FileSystemInstance;
+
+class UploadService
+{
+
+    /**
+     * @var FileSystem
+     */
+    protected $fileSystem;
+
+    /**
+     * @var FileSystemInstance
+     */
+    protected $storageInstance;
+
+
+    /**
+     * FileService constructor.
+     * @param $fileSystem
+     */
+    public function __construct(FileSystem $fileSystem)
+    {
+        $this->fileSystem = $fileSystem;
+    }
+
+    /**
+     * Get the storage that will be used for storing images.
+     * @return FileSystemInstance
+     */
+    protected function getStorage()
+    {
+        if ($this->storageInstance !== null) return $this->storageInstance;
+
+        $storageType = config('filesystems.default');
+        $this->storageInstance = $this->fileSystem->disk($storageType);
+
+        return $this->storageInstance;
+    }
+
+
+    /**
+     * Check whether or not a folder is empty.
+     * @param $path
+     * @return bool
+     */
+    protected function isFolderEmpty($path)
+    {
+        $files = $this->getStorage()->files($path);
+        $folders = $this->getStorage()->directories($path);
+        return (count($files) === 0 && count($folders) === 0);
+    }
+
+    /**
+     * Check if using a local filesystem.
+     * @return bool
+     */
+    protected function isLocal()
+    {
+        return strtolower(config('filesystems.default')) === 'local';
+    }
+}
\ No newline at end of file
index aac9831f74cac2ffee9c84c19f6e94e18934bb34..1a9ee5f707fa8a2eae3264ad284d5a77c96df3f3 100644 (file)
@@ -18,7 +18,7 @@ class ViewService
     public function __construct(View $view, PermissionService $permissionService)
     {
         $this->view = $view;
-        $this->user = auth()->user();
+        $this->user = user();
         $this->permissionService = $permissionService;
     }
 
@@ -84,7 +84,7 @@ class ViewService
             ->filterRestrictedEntityRelations($this->view, 'views', 'viewable_id', 'viewable_type');
 
         if ($filterModel) $query = $query->where('viewable_type', '=', get_class($filterModel));
-        $query = $query->where('user_id', '=', auth()->user()->id);
+        $query = $query->where('user_id', '=', user()->id);
 
         $viewables = $query->with('viewable')->orderBy('updated_at', 'desc')
             ->skip($count * $page)->take($count)->get()->pluck('viewable');
index 8c39d81be5303d32a881b40356ecb477f72aa3eb..09b189cbb55e084419309c3b6f154e77ed28d4ee 100644 (file)
@@ -5,6 +5,7 @@ use Illuminate\Auth\Authenticatable;
 use Illuminate\Auth\Passwords\CanResetPassword;
 use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
 use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;
+use Illuminate\Database\Eloquent\Relations\BelongsToMany;
 use Illuminate\Notifications\Notifiable;
 
 class User extends Model implements AuthenticatableContract, CanResetPasswordContract
@@ -36,21 +37,30 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
     protected $permissions;
 
     /**
-     * Returns a default guest user.
+     * Returns the default public user.
+     * @return User
      */
     public static function getDefault()
     {
-        return new static([
-            'email' => 'guest',
-            'name' => 'Guest'
-        ]);
+        return static::where('system_name', '=', 'public')->first();
+    }
+
+    /**
+     * Check if the user is the default public user.
+     * @return bool
+     */
+    public function isDefault()
+    {
+        return $this->system_name === 'public';
     }
 
     /**
      * The roles that belong to the user.
+     * @return BelongsToMany
      */
     public function roles()
     {
+        if ($this->id === 0) return ;
         return $this->belongsToMany(Role::class);
     }
 
index dd835fbf64755adb17a0568902cff00cd7a75c4f..b5be0fd11bae196ccd085eb8e127ea3f0045d466 100644 (file)
@@ -11,29 +11,30 @@ use BookStack\Ownable;
  */
 function versioned_asset($file = '')
 {
-    // Don't require css and JS assets for testing
-    if (config('app.env') === 'testing') return '';
-
-    static $manifest = null;
-    $manifestPath = 'build/manifest.json';
-
-    if (is_null($manifest) && file_exists($manifestPath)) {
-        $manifest = json_decode(file_get_contents(public_path($manifestPath)), true);
-    } else if (!file_exists($manifestPath)) {
-        if (config('app.env') !== 'production') {
-            $path = public_path($manifestPath);
-            $error = "No {$path} file found, Ensure you have built the css/js assets using gulp.";
-        } else {
-            $error = "No {$manifestPath} file found, Ensure you are using the release version of BookStack";
-        }
-        throw new \Exception($error);
+    static $version = null;
+
+    if (is_null($version)) {
+        $versionFile = base_path('version');
+        $version = trim(file_get_contents($versionFile));
     }
 
-    if (isset($manifest[$file])) {
-        return baseUrl($manifest[$file]);
+    $additional = '';
+    if (config('app.env') === 'development') {
+        $additional = sha1_file(public_path($file));
     }
 
-    throw new InvalidArgumentException("File {$file} not defined in asset manifest.");
+    $path = $file . '?version=' . urlencode($version) . $additional;
+    return baseUrl($path);
+}
+
+/**
+ * Helper method to get the current User.
+ * Defaults to public 'Guest' user if not logged in.
+ * @return \BookStack\User
+ */
+function user()
+{
+    return auth()->user() ?: \BookStack\User::getDefault();
 }
 
 /**
@@ -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
+}
index d9603701998f1fd6db56a73bb12d378ac2641e41..7d4b5e62b03a1132b39efe3d3f39fe9b2f47bf59 100644 (file)
@@ -7,13 +7,15 @@
     "require": {
         "php": ">=5.6.4",
         "laravel/framework": "^5.3.4",
+        "ext-tidy": "*",
         "intervention/image": "^2.3",
         "laravel/socialite": "^2.0",
         "barryvdh/laravel-ide-helper": "^2.1",
         "barryvdh/laravel-debugbar": "^2.2.3",
         "league/flysystem-aws-s3-v3": "^1.0",
         "barryvdh/laravel-dompdf": "^0.7",
-        "predis/predis": "^1.1"
+        "predis/predis": "^1.1",
+        "gathercontent/htmldiff": "^0.2.1"
     },
     "require-dev": {
         "fzaninotto/faker": "~1.4",
index c1c80e100b9b11cf2cd648b3785dd43caa3f30ec..74a0902882376a9d16fc1a8ac18f5398a0c1c33f 100644 (file)
@@ -4,21 +4,21 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file",
         "This file is @generated automatically"
     ],
-    "hash": "c90a6e41767306ceb3b8cedb91468390",
-    "content-hash": "3b5d2d6b77fbe71101e7e8eaff0754fe",
+    "hash": "3124d900cfe857392a94de479f3ff6d4",
+    "content-hash": "a968767a73f77e66e865c276cf76eedf",
     "packages": [
         {
             "name": "aws/aws-sdk-php",
-            "version": "3.19.6",
+            "version": "3.19.11",
             "source": {
                 "type": "git",
                 "url": "https://github.com/aws/aws-sdk-php.git",
-                "reference": "34060bf0db260031697b17dbb37fa1bbec92f1c4"
+                "reference": "19bac3bdd7988cbf7f89d5ce8e2748d774e2cde8"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/34060bf0db260031697b17dbb37fa1bbec92f1c4",
-                "reference": "34060bf0db260031697b17dbb37fa1bbec92f1c4",
+                "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/19bac3bdd7988cbf7f89d5ce8e2748d774e2cde8",
+                "reference": "19bac3bdd7988cbf7f89d5ce8e2748d774e2cde8",
                 "shasum": ""
             },
             "require": {
                 "s3",
                 "sdk"
             ],
-            "time": "2016-09-08 20:27:15"
+            "time": "2016-09-27 19:38:36"
         },
         {
             "name": "barryvdh/laravel-debugbar",
-            "version": "V2.2.3",
+            "version": "v2.3.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/barryvdh/laravel-debugbar.git",
-                "reference": "ecd1ce5c4a827e2f6a8fb41bcf67713beb1c1cbd"
+                "reference": "0c87981df959c7c1943abe227baf607c92f204f9"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/barryvdh/laravel-debugbar/zipball/ecd1ce5c4a827e2f6a8fb41bcf67713beb1c1cbd",
-                "reference": "ecd1ce5c4a827e2f6a8fb41bcf67713beb1c1cbd",
+                "url": "https://api.github.com/repos/barryvdh/laravel-debugbar/zipball/0c87981df959c7c1943abe227baf607c92f204f9",
+                "reference": "0c87981df959c7c1943abe227baf607c92f204f9",
                 "shasum": ""
             },
             "require": {
                 "illuminate/support": "5.1.*|5.2.*|5.3.*",
-                "maximebf/debugbar": "~1.11.0|~1.12.0",
+                "maximebf/debugbar": "~1.13.0",
                 "php": ">=5.5.9",
                 "symfony/finder": "~2.7|~3.0"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "2.2-dev"
+                    "dev-master": "2.3-dev"
                 }
             },
             "autoload": {
                 "profiler",
                 "webprofiler"
             ],
-            "time": "2016-07-29 15:00:36"
+            "time": "2016-09-15 14:05:56"
         },
         {
             "name": "barryvdh/laravel-dompdf",
             ],
             "time": "2015-11-09 22:51:51"
         },
+        {
+            "name": "cogpowered/finediff",
+            "version": "0.3.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/cogpowered/FineDiff.git",
+                "reference": "339ddc8c3afb656efed4f2f0a80e5c3d026f8ea8"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/cogpowered/FineDiff/zipball/339ddc8c3afb656efed4f2f0a80e5c3d026f8ea8",
+                "reference": "339ddc8c3afb656efed4f2f0a80e5c3d026f8ea8",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.3.0"
+            },
+            "require-dev": {
+                "mockery/mockery": "*",
+                "phpunit/phpunit": "*"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-0": {
+                    "cogpowered\\FineDiff": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Rob Crowe",
+                    "email": "[email protected]"
+                },
+                {
+                    "name": "Raymond Hill"
+                }
+            ],
+            "description": "PHP implementation of a Fine granularity Diff engine",
+            "homepage": "https://github.com/cogpowered/FineDiff",
+            "keywords": [
+                "diff",
+                "finediff",
+                "opcode",
+                "string",
+                "text"
+            ],
+            "time": "2014-05-19 10:25:02"
+        },
         {
             "name": "dnoegel/php-xdg-base-dir",
             "version": "0.1",
             "homepage": "https://github.com/dompdf/dompdf",
             "time": "2016-05-11 00:36:29"
         },
+        {
+            "name": "gathercontent/htmldiff",
+            "version": "0.2.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/gathercontent/htmldiff.git",
+                "reference": "24674a62315f64330134b4a4c5b01a7b59193c93"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/gathercontent/htmldiff/zipball/24674a62315f64330134b4a4c5b01a7b59193c93",
+                "reference": "24674a62315f64330134b4a4c5b01a7b59193c93",
+                "shasum": ""
+            },
+            "require": {
+                "cogpowered/finediff": "0.3.1",
+                "ext-tidy": "*"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "4.*",
+                "squizlabs/php_codesniffer": "1.*"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-0": {
+                    "GatherContent\\Htmldiff": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Andrew Cairns",
+                    "email": "[email protected]"
+                },
+                {
+                    "name": "Mathew Chapman",
+                    "email": "[email protected]"
+                },
+                {
+                    "name": "Peter Legierski",
+                    "email": "[email protected]"
+                }
+            ],
+            "description": "Compare two HTML strings",
+            "time": "2015-04-15 15:39:46"
+        },
         {
             "name": "guzzlehttp/guzzle",
             "version": "6.2.1",
         },
         {
             "name": "laravel/framework",
-            "version": "v5.3.9",
+            "version": "v5.3.11",
             "source": {
                 "type": "git",
                 "url": "https://github.com/laravel/framework.git",
-                "reference": "f6fbb481672f8dc4bc6882d5d654bbfa3588c8ec"
+                "reference": "ca48001b95a0543fb39fcd7219de960bbc03eaa5"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/laravel/framework/zipball/f6fbb481672f8dc4bc6882d5d654bbfa3588c8ec",
-                "reference": "f6fbb481672f8dc4bc6882d5d654bbfa3588c8ec",
+                "url": "https://api.github.com/repos/laravel/framework/zipball/ca48001b95a0543fb39fcd7219de960bbc03eaa5",
+                "reference": "ca48001b95a0543fb39fcd7219de960bbc03eaa5",
                 "shasum": ""
             },
             "require": {
                 "illuminate/http": "self.version",
                 "illuminate/log": "self.version",
                 "illuminate/mail": "self.version",
+                "illuminate/notifications": "self.version",
                 "illuminate/pagination": "self.version",
                 "illuminate/pipeline": "self.version",
                 "illuminate/queue": "self.version",
                 "framework",
                 "laravel"
             ],
-            "time": "2016-09-12 14:08:29"
+            "time": "2016-09-28 02:15:37"
         },
         {
             "name": "laravel/socialite",
         },
         {
             "name": "maximebf/debugbar",
-            "version": "v1.12.0",
+            "version": "v1.13.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/maximebf/php-debugbar.git",
-                "reference": "e634fbd32cd6bc3fa0e8c972b52d4bf49bab3988"
+                "reference": "5f49a5ed6cfde81d31d89378806670d77462526e"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/maximebf/php-debugbar/zipball/e634fbd32cd6bc3fa0e8c972b52d4bf49bab3988",
-                "reference": "e634fbd32cd6bc3fa0e8c972b52d4bf49bab3988",
+                "url": "https://api.github.com/repos/maximebf/php-debugbar/zipball/5f49a5ed6cfde81d31d89378806670d77462526e",
+                "reference": "5f49a5ed6cfde81d31d89378806670d77462526e",
                 "shasum": ""
             },
             "require": {
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.12-dev"
+                    "dev-master": "1.13-dev"
                 }
             },
             "autoload": {
                 "debug",
                 "debugbar"
             ],
-            "time": "2016-05-15 13:11:34"
+            "time": "2016-09-15 14:01:59"
         },
         {
             "name": "monolog/monolog",
         },
         {
             "name": "nikic/php-parser",
-            "version": "v2.1.0",
+            "version": "v2.1.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/nikic/PHP-Parser.git",
-                "reference": "47b254ea51f1d6d5dc04b9b299e88346bf2369e3"
+                "reference": "4dd659edadffdc2143e4753df655d866dbfeedf0"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/47b254ea51f1d6d5dc04b9b299e88346bf2369e3",
-                "reference": "47b254ea51f1d6d5dc04b9b299e88346bf2369e3",
+                "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/4dd659edadffdc2143e4753df655d866dbfeedf0",
+                "reference": "4dd659edadffdc2143e4753df655d866dbfeedf0",
                 "shasum": ""
             },
             "require": {
                 "parser",
                 "php"
             ],
-            "time": "2016-04-19 13:41:41"
+            "time": "2016-09-16 12:04:44"
         },
         {
             "name": "paragonie/random_compat",
         },
         {
             "name": "psr/log",
-            "version": "1.0.0",
+            "version": "1.0.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/php-fig/log.git",
-                "reference": "fe0936ee26643249e916849d48e3a51d5f5e278b"
+                "reference": "5277094ed527a1c4477177d102fe4c53551953e0"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/php-fig/log/zipball/fe0936ee26643249e916849d48e3a51d5f5e278b",
-                "reference": "fe0936ee26643249e916849d48e3a51d5f5e278b",
+                "url": "https://api.github.com/repos/php-fig/log/zipball/5277094ed527a1c4477177d102fe4c53551953e0",
+                "reference": "5277094ed527a1c4477177d102fe4c53551953e0",
                 "shasum": ""
             },
+            "require": {
+                "php": ">=5.3.0"
+            },
             "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.0.x-dev"
+                }
+            },
             "autoload": {
-                "psr-0": {
-                    "Psr\\Log\\": ""
+                "psr-4": {
+                    "Psr\\Log\\": "Psr/Log/"
                 }
             },
             "notification-url": "https://packagist.org/downloads/",
                 }
             ],
             "description": "Common interface for logging libraries",
+            "homepage": "https://github.com/php-fig/log",
             "keywords": [
                 "log",
                 "psr",
                 "psr-3"
             ],
-            "time": "2012-12-21 11:40:51"
+            "time": "2016-09-19 16:02:08"
         },
         {
             "name": "psy/psysh",
         },
         {
             "name": "myclabs/deep-copy",
-            "version": "1.5.2",
+            "version": "1.5.4",
             "source": {
                 "type": "git",
                 "url": "https://github.com/myclabs/DeepCopy.git",
-                "reference": "da8529775f14f4fdae33f916eb0cf65f6afbddbc"
+                "reference": "ea74994a3dc7f8d2f65a06009348f2d63c81e61f"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/da8529775f14f4fdae33f916eb0cf65f6afbddbc",
-                "reference": "da8529775f14f4fdae33f916eb0cf65f6afbddbc",
+                "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/ea74994a3dc7f8d2f65a06009348f2d63c81e61f",
+                "reference": "ea74994a3dc7f8d2f65a06009348f2d63c81e61f",
                 "shasum": ""
             },
             "require": {
                 "object",
                 "object graph"
             ],
-            "time": "2016-09-06 16:07:05"
+            "time": "2016-09-16 13:37:59"
         },
         {
             "name": "phpdocumentor/reflection-common",
         },
         {
             "name": "phpunit/phpunit",
-            "version": "5.5.4",
+            "version": "5.5.5",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/phpunit.git",
-                "reference": "3e6e88e56c912133de6e99b87728cca7ed70c5f5"
+                "reference": "a57126dc681b08289fef6ac96a48e30656f84350"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/3e6e88e56c912133de6e99b87728cca7ed70c5f5",
-                "reference": "3e6e88e56c912133de6e99b87728cca7ed70c5f5",
+                "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/a57126dc681b08289fef6ac96a48e30656f84350",
+                "reference": "a57126dc681b08289fef6ac96a48e30656f84350",
                 "shasum": ""
             },
             "require": {
                 "ext-dom": "*",
                 "ext-json": "*",
-                "ext-pcre": "*",
-                "ext-reflection": "*",
-                "ext-spl": "*",
+                "ext-libxml": "*",
+                "ext-mbstring": "*",
+                "ext-xml": "*",
                 "myclabs/deep-copy": "~1.3",
                 "php": "^5.6 || ^7.0",
                 "phpspec/prophecy": "^1.3.1",
             "conflict": {
                 "phpdocumentor/reflection-docblock": "3.0.2"
             },
+            "require-dev": {
+                "ext-pdo": "*"
+            },
             "suggest": {
+                "ext-tidy": "*",
+                "ext-xdebug": "*",
                 "phpunit/php-invoker": "~1.1"
             },
             "bin": [
                 "testing",
                 "xunit"
             ],
-            "time": "2016-08-26 07:11:44"
+            "time": "2016-09-21 14:40:13"
         },
         {
             "name": "phpunit/phpunit-mock-objects",
     "prefer-stable": false,
     "prefer-lowest": false,
     "platform": {
-        "php": ">=5.6.4"
+        "php": ">=5.6.4",
+        "ext-tidy": "*"
     },
     "platform-dev": []
 }
index a5b0d2fe0e325399440d6e2250035fd1412f7e6f..786f005ac963f2bf2836c7038c7f534b7a3088c2 100644 (file)
@@ -57,7 +57,7 @@ return [
     |
     */
 
-    'locale' => 'en',
+    'locale' => env('APP_LANG', 'en'),
 
     /*
     |--------------------------------------------------------------------------
index dbcb03db12d4d3b13d941529370bf729ad412b20..836f68d3d4224ece88983a5c4d4b7b859bc45b50 100644 (file)
@@ -56,7 +56,7 @@ return [
 
         'local' => [
             'driver' => 'local',
-            'root'   => public_path(),
+            'root'   => base_path(),
         ],
 
         'ftp' => [
index 5482c13315e8012f9f90f89644e765d895eb1569..c681bb7f55ddbe97e6ba73b29154373e18d2db55 100644 (file)
@@ -9,6 +9,8 @@ return [
     'app-name-header' => true,
     'app-editor'      => 'wysiwyg',
     'app-color'       => '#0288D1',
-    'app-color-light' => 'rgba(21, 101, 192, 0.15)'
+    'app-color-light' => 'rgba(21, 101, 192, 0.15)',
+    'app-custom-head' => false,
+    'registration-enabled' => false,
 
 ];
\ 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 (file)
index 0000000..f666cad
--- /dev/null
@@ -0,0 +1,66 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+class RemoveHiddenRoles extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        // Remove the hidden property from roles
+        Schema::table('roles', function(Blueprint $table) {
+            $table->dropColumn('hidden');
+        });
+
+        // Add column to mark system users
+        Schema::table('users', function(Blueprint $table) {
+            $table->string('system_name')->nullable()->index();
+        });
+
+        // Insert our new public system user.
+        $publicUserId = DB::table('users')->insertGetId([
+            'email' => '[email protected]',
+            '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 (file)
index 0000000..627c237
--- /dev/null
@@ -0,0 +1,71 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+class CreateAttachmentsTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('attachments', function (Blueprint $table) {
+            $table->increments('id');
+            $table->string('name');
+            $table->string('path');
+            $table->string('extension', 20);
+            $table->integer('uploaded_to');
+
+            $table->boolean('external');
+            $table->integer('order');
+
+            $table->integer('created_by');
+            $table->integer('updated_by');
+
+            $table->index('uploaded_to');
+            $table->timestamps();
+        });
+
+        // Get roles with permissions we need to change
+        $adminRoleId = DB::table('roles')->where('system_name', '=', 'admin')->first()->id;
+
+        // Create & attach new entity permissions
+        $ops = ['Create All', 'Create Own', 'Update All', 'Update Own', 'Delete All', 'Delete Own'];
+        $entity = 'Attachment';
+        foreach ($ops as $op) {
+            $permissionId = DB::table('role_permissions')->insertGetId([
+                'name' => strtolower($entity) . '-' . strtolower(str_replace(' ', '-', $op)),
+                'display_name' => $op . ' ' . $entity . 's',
+                'created_at' => \Carbon\Carbon::now()->toDateTimeString(),
+                'updated_at' => \Carbon\Carbon::now()->toDateTimeString()
+            ]);
+            DB::table('permission_role')->insert([
+                'role_id' => $adminRoleId,
+                'permission_id' => $permissionId
+            ]);
+        }
+
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('attachments');
+
+        // Create & attach new entity permissions
+        $ops = ['Create All', 'Create Own', 'Update All', 'Update Own', 'Delete All', 'Delete Own'];
+        $entity = 'Attachment';
+        foreach ($ops as $op) {
+            $permName = strtolower($entity) . '-' . strtolower(str_replace(' ', '-', $op));
+            DB::table('role_permissions')->where('name', '=', $permName)->delete();
+        }
+    }
+}
index 7deefc71ac2f99b3c27b8c1039b0178ddff98f66..9d789d9b4c40c425cd0e198b504e0aa616abc55a 100644 (file)
@@ -1,27 +1,8 @@
 var elixir = require('laravel-elixir');
 
-// Custom extensions
-var gulp = require('gulp');
-var Task = elixir.Task;
-var fs = require('fs');
-
-elixir.extend('queryVersion', function(inputFiles) {
-     new Task('queryVersion', function() {
-         var manifestObject = {};
-         var uidString = Date.now().toString(16).slice(4);
-         for (var i = 0; i < inputFiles.length; i++) {
-             var file = inputFiles[i];
-             manifestObject[file] = file + '?version=' + uidString;
-         }
-         var fileContents = JSON.stringify(manifestObject, null, 1);
-         fs.writeFileSync('public/build/manifest.json', fileContents);
-     }).watch(['./public/css/*.css', './public/js/*.js']);
-});
-
-elixir(function(mix) {
-    mix.sass('styles.scss')
-        .sass('print-styles.scss')
-        .sass('export-styles.scss')
-        .browserify('global.js', 'public/js/common.js')
-        .queryVersion(['css/styles.css', 'css/print-styles.css', 'js/common.js']);
+elixir(mix => {
+    mix.sass('styles.scss');
+    mix.sass('print-styles.scss');
+    mix.sass('export-styles.scss');
+    mix.browserify('global.js', './public/js/common.js');
 });
index fde090beb73afa28bfadf2d89d6884c908981bad..30f288d451b132ec4944a4d2238e3aa5c3c484b3 100644 (file)
@@ -1,18 +1,19 @@
 {
   "private": true,
-  "devDependencies": {
-    "gulp": "^3.9.0"
+  "scripts": {
+    "prod": "gulp --production",
+    "dev": "gulp watch"
   },
-  "dependencies": {
+  "devDependencies": {
     "angular": "^1.5.5",
     "angular-animate": "^1.5.5",
     "angular-resource": "^1.5.5",
     "angular-sanitize": "^1.5.5",
-    "angular-ui-sortable": "^0.14.0",
-    "babel-runtime": "^5.8.29",
-    "bootstrap-sass": "^3.0.0",
+    "angular-ui-sortable": "^0.15.0",
     "dropzone": "^4.0.1",
-    "laravel-elixir": "^5.0.0",
+    "gulp": "^3.9.0",
+    "laravel-elixir": "^6.0.0-11",
+    "laravel-elixir-browserify-official": "^0.1.3",
     "marked": "^0.3.5",
     "moment": "^2.12.0",
     "zeroclipboard": "^2.2.0"
index a2b26d4132f87c11ba63d470009ae15d6f559eaf..72e06a3fc53f0df5c07d39cf2ff2cd465681f6d9 100644 (file)
@@ -30,6 +30,7 @@
         <env name="AUTH_METHOD" value="standard"/>
         <env name="DISABLE_EXTERNAL_SERVICES" value="true"/>
         <env name="LDAP_VERSION" value="3"/>
+        <env name="STORAGE_TYPE" value="local"/>
         <env name="GITHUB_APP_ID" value="aaaaaaaaaaaaaa"/>
         <env name="GITHUB_APP_SECRET" value="aaaaaaaaaaaaaa"/>
         <env name="GOOGLE_APP_ID" value="aaaaaaaaaaaaaa"/>
index 3a745beb11ad293919f4db1096253f27b36f48e6..5d3e79a2e079b3673210e3c5c33d96cfe1c257fb 100644 (file)
--- 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: `[email protected]`. Password: `password`)*
+* [Demo Instance](https://demo.bookstackapp.com)
+  * *Username: `[email protected]`*
+  * *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 (file)
index 26e0ee2..0000000
+++ /dev/null
@@ -1,3 +0,0 @@
-<div class="dropzone-container">
-    <div class="dz-message">Drop files or click here to upload</div>
-</div>
\ 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 (file)
index 1a07b92..0000000
+++ /dev/null
@@ -1,15 +0,0 @@
-
-<div class="image-picker">
-    <div>
-        <img ng-if="image && image !== 'none'" ng-src="{{image}}" ng-class="{{imageClass}}" alt="Image Preview">
-        <img ng-if="image === '' && defaultImage" ng-src="{{defaultImage}}" ng-class="{{imageClass}}" alt="Image Preview">
-    </div>
-    <button class="button" type="button" ng-click="showImageManager()">Select Image</button>
-    <br>
-
-    <button class="text-button" ng-click="reset()" type="button">Reset</button>
-    <span ng-show="showRemove" class="sep">|</span>
-    <button ng-show="showRemove" class="text-button neg" ng-click="remove()" type="button">Remove</button>
-
-    <input type="hidden" ng-attr-name="{{name}}" ng-attr-id="{{name}}" ng-attr-value="{{value}}">
-</div>
\ 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 (file)
index 455969a..0000000
+++ /dev/null
@@ -1,4 +0,0 @@
-<div class="toggle-switch" ng-click="switch()" ng-class="{'active': isActive}">
-    <input type="hidden" ng-attr-name="{{name}}" ng-attr-value="{{value}}"/>
-    <div class="switch-handle"></div>
-</div>
\ No newline at end of file
index 2c0cf3e2b7af24318f70de2ed79482898c5b172f..9d7f7ad706d1037eef8beee41c075a6ce1fc0b45 100644 (file)
@@ -1,8 +1,10 @@
 "use strict";
 
-const moment = require('moment');
+import moment from 'moment';
+import 'moment/locale/en-gb';
+moment.locale('en-gb');
 
-module.exports = function (ngApp, events) {
+export default function (ngApp, events) {
 
     ngApp.controller('ImageManagerController', ['$scope', '$attrs', '$http', '$timeout', 'imageManagerService',
         function ($scope, $attrs, $http, $timeout, imageManagerService) {
@@ -17,7 +19,7 @@ module.exports = function (ngApp, events) {
             $scope.imageDeleteSuccess = false;
             $scope.uploadedTo = $attrs.uploadedTo;
             $scope.view = 'all';
-            
+
             $scope.searching = false;
             $scope.searchTerm = '';
 
@@ -48,7 +50,7 @@ module.exports = function (ngApp, events) {
                 $scope.hasMore = preSearchHasMore;
             }
             $scope.cancelSearch = cancelSearch;
-            
+
 
             /**
              * Runs on image upload, Adds an image to local list of images
@@ -162,7 +164,6 @@ module.exports = function (ngApp, events) {
 
             /**
              * Start a search operation
-             * @param searchTerm
              */
             $scope.searchImages = function() {
 
@@ -196,7 +197,7 @@ module.exports = function (ngApp, events) {
                 $scope.view = viewName;
                 baseUrl = window.baseUrl('/images/' + $scope.imageType  + '/' + viewName + '/');
                 fetchData();
-            }
+            };
 
             /**
              * Save the details of an image.
@@ -205,7 +206,7 @@ module.exports = function (ngApp, events) {
             $scope.saveImageDetails = function (event) {
                 event.preventDefault();
                 var url = window.baseUrl('/images/update/' + $scope.selectedImage.id);
-                $http.put(url, this.selectedImage).then((response) => {
+                $http.put(url, this.selectedImage).then(response => {
                     events.emit('success', 'Image details updated');
                 }, (response) => {
                     if (response.status === 422) {
@@ -300,15 +301,16 @@ module.exports = function (ngApp, events) {
         var isEdit = pageId !== 0;
         var autosaveFrequency = 30; // AutoSave interval in seconds.
         var isMarkdown = $attrs.editorType === 'markdown';
+        $scope.draftsEnabled = $attrs.draftsEnabled === 'true';
         $scope.isUpdateDraft = Number($attrs.pageUpdateDraft) === 1;
         $scope.isNewPageDraft = Number($attrs.pageNewDraft) === 1;
 
-        // Set inital header draft text
+        // Set initial header draft text
         if ($scope.isUpdateDraft || $scope.isNewPageDraft) {
             $scope.draftText = 'Editing Draft'
         } else {
             $scope.draftText = 'Editing Page'
-        };
+        }
 
         var autoSave = false;
 
@@ -317,7 +319,7 @@ module.exports = function (ngApp, events) {
             html: false
         };
 
-        if (isEdit) {
+        if (isEdit && $scope.draftsEnabled) {
             setTimeout(() => {
                 startAutoSave();
             }, 1000);
@@ -366,6 +368,7 @@ module.exports = function (ngApp, events) {
          * Save a draft update into the system via an AJAX request.
          */
         function saveDraft() {
+            if (!$scope.draftsEnabled) return;
             var data = {
                 name: $('#name').val(),
                 html: isMarkdown ? $sce.getTrustedHtml($scope.displayContent) : $scope.editContent
@@ -435,7 +438,7 @@ module.exports = function (ngApp, events) {
 
             const pageId = Number($attrs.pageId);
             $scope.tags = [];
-            
+
             $scope.sortOptions = {
                 handle: '.handle',
                 items: '> tr',
@@ -458,7 +461,7 @@ module.exports = function (ngApp, events) {
              * Get all tags for the current book and add into scope.
              */
             function getTags() {
-                let url = window.baseUrl('/ajax/tags/get/page/' + pageId);
+                let url = window.baseUrl(`/ajax/tags/get/page/${pageId}`);
                 $http.get(url).then((responseData) => {
                     $scope.tags = responseData.data;
                     addEmptyTag();
@@ -527,21 +530,201 @@ module.exports = function (ngApp, events) {
 
         }]);
 
-};
 
+    ngApp.controller('PageAttachmentController', ['$scope', '$http', '$attrs',
+        function ($scope, $http, $attrs) {
+
+            const pageId = $scope.uploadedTo = $attrs.pageId;
+            let currentOrder = '';
+            $scope.files = [];
+            $scope.editFile = false;
+            $scope.file = getCleanFile();
+            $scope.errors = {
+                link: {},
+                edit: {}
+            };
+
+            function getCleanFile() {
+                return {
+                    page_id: pageId
+                };
+            }
+
+            // Angular-UI-Sort options
+            $scope.sortOptions = {
+                handle: '.handle',
+                items: '> tr',
+                containment: "parent",
+                axis: "y",
+                stop: sortUpdate,
+            };
+
+            /**
+             * Event listener for sort changes.
+             * Updates the file ordering on the server.
+             * @param event
+             * @param ui
+             */
+            function sortUpdate(event, ui) {
+                let newOrder = $scope.files.map(file => {return file.id}).join(':');
+                if (newOrder === currentOrder) return;
+
+                currentOrder = newOrder;
+                $http.put(window.baseUrl(`/attachments/sort/page/${pageId}`), {files: $scope.files}).then(resp => {
+                    events.emit('success', resp.data.message);
+                }, checkError('sort'));
+            }
 
+            /**
+             * Used by dropzone to get the endpoint to upload to.
+             * @returns {string}
+             */
+            $scope.getUploadUrl = function (file) {
+                let suffix = (typeof file !== 'undefined') ? `/${file.id}` : '';
+                return window.baseUrl(`/attachments/upload${suffix}`);
+            };
 
+            /**
+             * Get files for the current page from the server.
+             */
+            function getFiles() {
+                let url = window.baseUrl(`/attachments/get/page/${pageId}`)
+                $http.get(url).then(resp => {
+                    $scope.files = resp.data;
+                    currentOrder = resp.data.map(file => {return file.id}).join(':');
+                }, checkError('get'));
+            }
+            getFiles();
 
+            /**
+             * Runs on file upload, Adds an file to local file list
+             * and shows a success message to the user.
+             * @param file
+             * @param data
+             */
+            $scope.uploadSuccess = function (file, data) {
+                $scope.$apply(() => {
+                    $scope.files.push(data);
+                });
+                events.emit('success', 'File uploaded');
+            };
 
+            /**
+             * Upload and overwrite an existing file.
+             * @param file
+             * @param data
+             */
+            $scope.uploadSuccessUpdate = function (file, data) {
+                $scope.$apply(() => {
+                    let search = filesIndexOf(data);
+                    if (search !== -1) $scope.files[search] = data;
 
+                    if ($scope.editFile) {
+                        $scope.editFile = angular.copy(data);
+                        data.link = '';
+                    }
+                });
+                events.emit('success', 'File updated');
+            };
 
+            /**
+             * Delete a file from the server and, on success, the local listing.
+             * @param file
+             */
+            $scope.deleteFile = function(file) {
+                if (!file.deleting) {
+                    file.deleting = true;
+                    return;
+                }
+                  $http.delete(window.baseUrl(`/attachments/${file.id}`)).then(resp => {
+                      events.emit('success', resp.data.message);
+                      $scope.files.splice($scope.files.indexOf(file), 1);
+                  }, checkError('delete'));
+            };
 
+            /**
+             * Attach a link to a page.
+             * @param file
+             */
+            $scope.attachLinkSubmit = function(file) {
+                file.uploaded_to = pageId;
+                $http.post(window.baseUrl('/attachments/link'), file).then(resp => {
+                    $scope.files.push(resp.data);
+                    events.emit('success', 'Link attached');
+                    $scope.file = getCleanFile();
+                }, checkError('link'));
+            };
 
+            /**
+             * Start the edit mode for a file.
+             * @param file
+             */
+            $scope.startEdit = function(file) {
+                $scope.editFile = angular.copy(file);
+                $scope.editFile.link = (file.external) ? file.path : '';
+            };
 
+            /**
+             * Cancel edit mode
+             */
+            $scope.cancelEdit = function() {
+                $scope.editFile = false;
+            };
 
+            /**
+             * Update the name and link of a file.
+             * @param file
+             */
+            $scope.updateFile = function(file) {
+                $http.put(window.baseUrl(`/attachments/${file.id}`), file).then(resp => {
+                    let search = filesIndexOf(resp.data);
+                    if (search !== -1) $scope.files[search] = resp.data;
 
+                    if ($scope.editFile && !file.external) {
+                        $scope.editFile.link = '';
+                    }
+                    $scope.editFile = false;
+                    events.emit('success', 'Attachment details updated');
+                }, checkError('edit'));
+            };
 
+            /**
+             * Get the url of a file.
+             */
+            $scope.getFileUrl = function(file) {
+                return window.baseUrl('/attachments/' + file.id);
+            };
 
+            /**
+             * Search the local files via another file object.
+             * Used to search via object copies.
+             * @param file
+             * @returns int
+             */
+            function filesIndexOf(file) {
+                for (let i = 0; i < $scope.files.length; i++) {
+                    if ($scope.files[i].id == file.id) return i;
+                }
+                return -1;
+            }
 
+            /**
+             * Check for an error response in a ajax request.
+             * @param errorGroupName
+             */
+            function checkError(errorGroupName) {
+                $scope.errors[errorGroupName] = {};
+                return function(response) {
+                    if (typeof response.data !== 'undefined' && typeof response.data.error !== 'undefined') {
+                        events.emit('error', response.data.error);
+                    }
+                    if (typeof response.data !== 'undefined' && typeof response.data.validation !== 'undefined') {
+                        $scope.errors[errorGroupName] = response.data.validation;
+                        console.log($scope.errors[errorGroupName])
+                    }
+                }
+            }
 
+        }]);
 
+};
index 933bbf5ff5e96c0ad9631a39f72d5f558e880b49..44d1a14e1a139a5bf794aef36a93062d8da9aa14 100644 (file)
@@ -2,10 +2,6 @@
 const DropZone = require('dropzone');
 const markdown = require('marked');
 
-const toggleSwitchTemplate = require('./components/toggle-switch.html');
-const imagePickerTemplate = require('./components/image-picker.html');
-const dropZoneTemplate = require('./components/drop-zone.html');
-
 module.exports = function (ngApp, events) {
 
     /**
@@ -16,7 +12,12 @@ module.exports = function (ngApp, events) {
     ngApp.directive('toggleSwitch', function () {
         return {
             restrict: 'A',
-            template: toggleSwitchTemplate,
+            template: `
+            <div class="toggle-switch" ng-click="switch()" ng-class="{'active': isActive}">
+                <input type="hidden" ng-attr-name="{{name}}" ng-attr-value="{{value}}"/>
+                <div class="switch-handle"></div>
+            </div>
+            `,
             scope: true,
             link: function (scope, element, attrs) {
                 scope.name = attrs.name;
@@ -33,6 +34,59 @@ module.exports = function (ngApp, events) {
         };
     });
 
+    /**
+     * Common tab controls using simple jQuery functions.
+     */
+    ngApp.directive('tabContainer', function() {
+        return {
+            restrict: 'A',
+            link: function (scope, element, attrs) {
+                const $content = element.find('[tab-content]');
+                const $buttons = element.find('[tab-button]');
+
+                if (attrs.tabContainer) {
+                    let initial = attrs.tabContainer;
+                    $buttons.filter(`[tab-button="${initial}"]`).addClass('selected');
+                    $content.hide().filter(`[tab-content="${initial}"]`).show();
+                } else {
+                    $content.hide().first().show();
+                    $buttons.first().addClass('selected');
+                }
+
+                $buttons.click(function() {
+                    let clickedTab = $(this);
+                    $buttons.removeClass('selected');
+                    $content.hide();
+                    let name = clickedTab.addClass('selected').attr('tab-button');
+                    $content.filter(`[tab-content="${name}"]`).show();
+                });
+            }
+        };
+    });
+
+    /**
+     * Sub form component to allow inner-form sections to act like thier own forms.
+     */
+    ngApp.directive('subForm', function() {
+        return {
+            restrict: 'A',
+            link: function (scope, element, attrs) {
+                element.on('keypress', e => {
+                    if (e.keyCode === 13) {
+                        submitEvent(e);
+                    }
+                });
+
+                element.find('button[type="submit"]').click(submitEvent);
+
+                function submitEvent(e) {
+                    e.preventDefault()
+                    if (attrs.subForm) scope.$eval(attrs.subForm);
+                }
+            }
+        };
+    });
+
 
     /**
      * Image Picker
@@ -41,7 +95,22 @@ module.exports = function (ngApp, events) {
     ngApp.directive('imagePicker', ['$http', 'imageManagerService', function ($http, imageManagerService) {
         return {
             restrict: 'E',
-            template: imagePickerTemplate,
+            template: `
+            <div class="image-picker">
+                <div>
+                    <img ng-if="image && image !== 'none'" ng-src="{{image}}" ng-class="{{imageClass}}" alt="Image Preview">
+                    <img ng-if="image === '' && defaultImage" ng-src="{{defaultImage}}" ng-class="{{imageClass}}" alt="Image Preview">
+                </div>
+                <button class="button" type="button" ng-click="showImageManager()">Select Image</button>
+                <br>
+
+                <button class="text-button" ng-click="reset()" type="button">Reset</button>
+                <span ng-show="showRemove" class="sep">|</span>
+                <button ng-show="showRemove" class="text-button neg" ng-click="remove()" type="button">Remove</button>
+
+                <input type="hidden" ng-attr-name="{{name}}" ng-attr-id="{{name}}" ng-attr-value="{{value}}">
+            </div>
+            `,
             scope: {
                 name: '@',
                 resizeHeight: '@',
@@ -108,7 +177,11 @@ module.exports = function (ngApp, events) {
     ngApp.directive('dropZone', [function () {
         return {
             restrict: 'E',
-            template: dropZoneTemplate,
+            template: `
+            <div class="dropzone-container">
+                <div class="dz-message">Drop files or click here to upload</div>
+            </div>
+            `,
             scope: {
                 uploadUrl: '@',
                 eventSuccess: '=',
@@ -116,6 +189,7 @@ module.exports = function (ngApp, events) {
                 uploadedTo: '@'
             },
             link: function (scope, element, attrs) {
+                if (attrs.placeholder) element[0].querySelector('.dz-message').textContent = attrs.placeholder;
                 var dropZone = new DropZone(element[0].querySelector('.dropzone-container'), {
                     url: scope.uploadUrl,
                     init: function () {
@@ -488,8 +562,8 @@ module.exports = function (ngApp, events) {
             link: function (scope, elem, attrs) {
 
                 // Get common elements
-                const $buttons = elem.find('[tab-button]');
-                const $content = elem.find('[tab-content]');
+                const $buttons = elem.find('[toolbox-tab-button]');
+                const $content = elem.find('[toolbox-tab-content]');
                 const $toggle = elem.find('[toolbox-toggle]');
 
                 // Handle toolbox toggle click
@@ -501,17 +575,17 @@ module.exports = function (ngApp, events) {
                 function setActive(tabName, openToolbox) {
                     $buttons.removeClass('active');
                     $content.hide();
-                    $buttons.filter(`[tab-button="${tabName}"]`).addClass('active');
-                    $content.filter(`[tab-content="${tabName}"]`).show();
+                    $buttons.filter(`[toolbox-tab-button="${tabName}"]`).addClass('active');
+                    $content.filter(`[toolbox-tab-content="${tabName}"]`).show();
                     if (openToolbox) elem.addClass('open');
                 }
 
                 // Set the first tab content active on load
-                setActive($content.first().attr('tab-content'), false);
+                setActive($content.first().attr('toolbox-tab-content'), false);
 
                 // Handle tab button click
                 $buttons.click(function (e) {
-                    let name = $(this).attr('tab-button');
+                    let name = $(this).attr('toolbox-tab-button');
                     setActive(name, true);
                 });
             }
@@ -549,7 +623,7 @@ module.exports = function (ngApp, events) {
                     let val = $input.val();
                     let url = $input.attr('autosuggest');
                     let type = $input.attr('autosuggest-type');
-                    
+
                     // Add name param to request if for a value
                     if (type.toLowerCase() === 'value') {
                         let $nameInput = $input.closest('tr').find('[autosuggest-type="name"]').first();
@@ -850,17 +924,3 @@ module.exports = function (ngApp, events) {
         };
     }]);
 };
-
-
-
-
-
-
-
-
-
-
-
-
-
-
index 9ca335ee7c4546f39b902b807d69c60ab4eff366..9aa5dff527adcf04298e80dc28003f004080448f 100644 (file)
@@ -38,13 +38,17 @@ class EventManager {
         this.listeners[eventName].push(callback);
         return this;
     }
-};
-window.Events = new EventManager();
+}
 
+window.Events = new EventManager();
 
-var services = require('./services')(ngApp, window.Events);
-var directives = require('./directives')(ngApp, window.Events);
-var controllers = require('./controllers')(ngApp, window.Events);
+// Load in angular specific items
+import Services from './services';
+import Directives from './directives';
+import Controllers from './controllers';
+Services(ngApp, window.Events);
+Directives(ngApp, window.Events);
+Controllers(ngApp, window.Events);
 
 //Global jQuery Config & Extensions
 
index c1e6a92df26941dc683b92e3990f38aabb8a2bf0..1fb8b915f91482941fd96d6ef48d234311be6ac2 100644 (file)
@@ -6,11 +6,11 @@
  * @param editor - editor instance
  */
 function editorPaste(e, editor) {
-    if (!e.clipboardData) return
+    if (!e.clipboardData) return;
     let items = e.clipboardData.items;
     if (!items) return;
     for (let i = 0; i < items.length; i++) {
-        if (items[i].type.indexOf("image") === -1) return
+        if (items[i].type.indexOf("image") === -1) return;
 
         let file = items[i].getAsFile();
         let formData = new FormData();
index 3c7f7490b0de89c0b83ad694e5f110ed3db486d2..7eb595d3611a5dc705d486416ca708344174080c 100644 (file)
   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;
index ccb69b44e3f91ddef14344a8290678b504cb51d3..2f9051a5258e94d9a821fd71c73aac30569c1501 100644 (file)
   }
 }
 
-//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
index 54fd55dff6f075637a2d943a431e05a05a6458fc..e98e5bfcdf7bd27a4050a123bdf5636659004381 100644 (file)
   border-left: 0px solid #FFF;
   background-color: #FFF;
   &.fixed {
+    background-color: #FFF;
+    z-index: 5;
     position: fixed;
     top: 0;
     padding-left: $-l;
old mode 100644 (file)
new mode 100755 (executable)
index 42ca0a2..0052a33
     max-width: 100%;
     height: auto !important;
   }
+
+  // diffs
+  ins,
+  del {
+    text-decoration: none;
+  }
+  ins {
+    background: #dbffdb;
+  }
+  del {
+    background: #FFECEC;
+  }
 }
 
 // Page content pointers
   background-color: #FFF;
   border: 1px solid #DDD;
   right: $-xl*2;
-  z-index: 99;
   width: 48px;
   overflow: hidden;
   align-items: stretch;
     color: #444;
     background-color: rgba(0, 0, 0, 0.1);
   }
-  div[tab-content] {
+  div[toolbox-tab-content] {
     padding-bottom: 45px;
     display: flex;
     flex: 1;
     min-height: 0px;
     overflow-y: scroll;
   }
-  div[tab-content] .padded {
+  div[toolbox-tab-content] .padded {
     flex: 1;
     padding-top: 0;
   }
     padding-top: $-s;
     position: relative;
   }
-  button.pos {
-    position: absolute;
-    bottom: 0;
-    display: block;
-    width: 100%;
-    padding: $-s;
-    height: 45px;
-    border: 0;
-    margin: 0;
-    box-shadow: none;
-    border-radius: 0;
-    &:hover{
-      box-shadow: none;
-    }
-  }
   .handle {
     user-select: none;
     cursor: move;
     flex-direction: column;
     overflow-y: scroll;
   }
+  table td, table th {
+    overflow: visible;
+  }
 }
 
-[tab-content] {
+[toolbox-tab-content] {
   display: none;
 }
 
 .tag-display {
-  margin: $-xl $-m;
-  border: 1px solid #DDD;
-  min-width: 180px;
-  max-width: 320px;
-  opacity: 0.7;
-  z-index: 5;
+  width: 100%;
+  //opacity: 0.7;
   position: relative;
   table {
     width: 100%;
     margin: 0;
     padding: 0;
   }
+  tr:first-child td {
+    padding-top: 0;
+  }
   .heading th {
     padding: $-xs $-s;
-    color: #333;
+    color: rgba(100, 100, 100, 0.7);
+    border: 0;
     font-weight: 400;
   }
   td {
     border: 0;
-    border-bottom: 1px solid #DDD;
+    border-bottom: 1px solid #EEE;
     padding: $-xs $-s;
     color: #444;
   }
+  tr td:first-child {
+    padding-left:0;
+  }
   .tag-value {
     color: #888;
   }
index 1fc8e11c2266a14ec2edfe70f2fa19fb87832ede..37c61159db077488d7b7d9ed3988bf81a20aa7f2 100644 (file)
@@ -51,4 +51,14 @@ table.list-table {
     vertical-align: middle;
     padding: $-xs;
   }
+}
+
+table.file-table {
+  @extend .no-style;
+  td {
+    padding: $-xs;
+  }
+  .ui-sortable-helper {
+    display: table;
+  }
 }
\ No newline at end of file
index fd993b685402813132cb3a4c0ff50405bc20b153..9bad2e83d15aa2621b04ce27993124fe8dd7306b 100644 (file)
@@ -193,7 +193,7 @@ p.neg, p .neg, span.neg, .text-neg {
 p.muted, p .muted, span.muted, .text-muted {
        color: lighten($text-dark, 26%);
     &.small, .small {
-      color: lighten($text-dark, 42%);
+      color: lighten($text-dark, 32%);
     }
 }
 
@@ -262,7 +262,7 @@ ul {
 
 ol {
   list-style: decimal;
-  padding-left: $-m * 1.3;
+  padding-left: $-m * 2;
   overflow: hidden;
 }
 
diff --git a/resources/lang/de/activities.php b/resources/lang/de/activities.php
new file mode 100644 (file)
index 0000000..c2d20b3
--- /dev/null
@@ -0,0 +1,40 @@
+<?php
+
+return [
+
+    /**
+     * Activity text strings.
+     * Is used for all the text within activity logs & notifications.
+     */
+
+    // Pages
+    'page_create'                 => 'Seite erstellt',
+    'page_create_notification'    => 'Seite erfolgreich erstellt',
+    'page_update'                 => 'Seite aktualisiert',
+    'page_update_notification'    => 'Seite erfolgreich aktualisiert',
+    'page_delete'                 => 'Seite gel&ouml;scht',
+    'page_delete_notification'    => 'Seite erfolgreich gel&ouml;scht',
+    'page_restore'                => 'Seite wiederhergstellt',
+    'page_restore_notification'   => 'Seite erfolgreich wiederhergstellt',
+    'page_move'                   => 'Seite verschoben',
+
+    // Chapters
+    'chapter_create'              => 'Kapitel erstellt',
+    'chapter_create_notification' => 'Kapitel erfolgreich erstellt',
+    'chapter_update'              => 'Kapitel aktualisiert',
+    'chapter_update_notification' => 'Kapitel erfolgreich aktualisiert',
+    'chapter_delete'              => 'Kapitel gel&ouml;scht',
+    'chapter_delete_notification' => 'Kapitel erfolgreich gel&ouml;scht',
+    'chapter_move'                => 'Kapitel verschoben',
+
+    // Books
+    'book_create'                 => 'Buch erstellt',
+    'book_create_notification'    => 'Buch erfolgreich erstellt',
+    'book_update'                 => 'Buch aktualisiert',
+    'book_update_notification'    => 'Buch erfolgreich aktualisiert',
+    'book_delete'                 => 'Buch gel&ouml;scht',
+    'book_delete_notification'    => 'Buch erfolgreich gel&ouml;scht',
+    'book_sort'                   => 'Buch sortiert',
+    'book_sort_notification'      => 'Buch erfolgreich neu sortiert',
+
+];
diff --git a/resources/lang/de/auth.php b/resources/lang/de/auth.php
new file mode 100644 (file)
index 0000000..c58d9d9
--- /dev/null
@@ -0,0 +1,26 @@
+<?php
+return [
+    /*
+    |--------------------------------------------------------------------------
+    | Authentication Language Lines
+    |--------------------------------------------------------------------------
+    |
+    | The following language lines are used during authentication for various
+    | messages that we need to display to the user. You are free to modify
+    | these language lines according to your application's requirements.
+    |
+    */
+    'failed' => 'Dies sind keine g&uuml;ltigen Anmeldedaten.',
+    'throttle' => 'Zu viele Anmeldeversuche. Bitte versuchen sie es in :seconds Sekunden erneut.',
+
+    /**
+     * Email Confirmation Text
+     */
+    'email_confirm_subject' => 'Best&auml;tigen sie ihre E-Mail Adresse bei :appName',
+    'email_confirm_greeting' => 'Danke, dass sie :appName beigetreten sind!',
+    'email_confirm_text' => 'Bitte best&auml;tigen sie ihre E-Mail Adresse, indem sie auf den Button klicken:',
+    'email_confirm_action' => 'E-Mail Adresse best&auml;tigen',
+    'email_confirm_send_error' => 'Best&auml;tigungs-E-Mail ben&ouml;tigt, aber das System konnte die E-Mail nicht versenden. Kontaktieren sie den Administrator, um sicherzustellen, dass das Sytsem korrekt eingerichtet ist.',
+    'email_confirm_success' => 'Ihre E-Mail Adresse wurde best&auml;tigt!',
+    'email_confirm_resent' => 'Best&auml;tigungs-E-Mail wurde erneut versendet, bitte &uuml;berpr&uuml;fen sie ihren Posteingang.',
+];
diff --git a/resources/lang/de/errors.php b/resources/lang/de/errors.php
new file mode 100644 (file)
index 0000000..6979520
--- /dev/null
@@ -0,0 +1,12 @@
+<?php
+
+return [
+
+    /**
+     * Error text strings.
+     */
+
+    // Pages
+    'permission' => 'Sie haben keine Berechtigung auf diese Seite zuzugreifen.',
+    'permissionJson' => 'Sie haben keine Berechtigung die angeforderte Aktion auszuf&uuml;hren.'
+];
diff --git a/resources/lang/de/pagination.php b/resources/lang/de/pagination.php
new file mode 100644 (file)
index 0000000..a3bf7c8
--- /dev/null
@@ -0,0 +1,19 @@
+<?php
+
+return [
+
+    /*
+    |--------------------------------------------------------------------------
+    | Pagination Language Lines
+    |--------------------------------------------------------------------------
+    |
+    | The following language lines are used by the paginator library to build
+    | the simple pagination links. You are free to change them to anything
+    | you want to customize your views to better match your application.
+    |
+    */
+
+    'previous' => '&laquo; Vorherige',
+    'next'     => 'N&auml;chste &raquo;',
+
+];
diff --git a/resources/lang/de/passwords.php b/resources/lang/de/passwords.php
new file mode 100644 (file)
index 0000000..f713580
--- /dev/null
@@ -0,0 +1,22 @@
+<?php
+
+return [
+
+    /*
+    |--------------------------------------------------------------------------
+    | Password Reminder Language Lines
+    |--------------------------------------------------------------------------
+    |
+    | The following language lines are the default lines which match reasons
+    | that are given by the password broker for a password update attempt
+    | has failed, such as for an invalid token or invalid new password.
+    |
+    */
+
+    'password' => 'Pass&ouml;rter m&uuml;ssen mindestens sechs Zeichen enthalten und die Wiederholung muss identisch sein.',
+    'user' => "Wir k&ouml;nnen keinen Benutzer mit dieser E-Mail Adresse finden.",
+    'token' => 'Dieser Passwort-Reset-Token ist ung&uuml;ltig.',
+    'sent' => 'Wir haben ihnen eine E-Mail mit einem Link zum Zurücksetzen des Passworts zugesendet!',
+    'reset' => 'Ihr Passwort wurde zur&uuml;ckgesetzt!',
+
+];
diff --git a/resources/lang/de/settings.php b/resources/lang/de/settings.php
new file mode 100644 (file)
index 0000000..183480f
--- /dev/null
@@ -0,0 +1,39 @@
+<?php
+
+return [
+
+    /**
+     * Settings text strings
+     * Contains all text strings used in the general settings sections of BookStack
+     * including users and roles.
+     */
+
+    'settings' => 'Einstellungen',
+    'settings_save' => 'Einstellungen speichern',
+
+    'app_settings' => 'Anwendungseinstellungen',
+    'app_name' => 'Anwendungsname',
+    'app_name_desc' => 'Dieser Name wird im Header und E-Mails angezeigt.',
+    'app_name_header' => 'Anwendungsname im Header anzeigen?',
+    'app_public_viewing' => '&Ouml;ffentliche Ansicht erlauben?',
+    'app_secure_images' => 'Erh&oml;hte Sicherheit f&uuml;r Bilduploads aktivieren?',
+    'app_secure_images_desc' => 'Aus Leistungsgr&uuml;nden sind alle Bilder &ouml;ffentlich sichtbar. Diese Option f&uuml;gt zuf&auml;llige, schwer zu eratene, Zeichenketten vor die Bild-URLs hinzu. Stellen sie sicher, dass Verzeichnindexes deaktiviert sind, um einen einfachen Zugrif zu verhindern.',
+    'app_editor' => 'Seiteneditor',
+    'app_editor_desc' => 'W&auml;hlen sie den Editor aus, der von allen Benutzern genutzt werden soll, um Seiten zu editieren.',
+    'app_custom_html' => 'Benutzerdefinierter HTML <head> Inhalt',
+    'app_custom_html_desc' => 'Jeder Inhalt, der hier hinzugef&uuml;gt wird, wird am Ende der <head> Sektion jeder Seite eingef&uuml;gt. Diese kann praktisch sein, um CSS Styles anzupassen oder Analytics Code hinzuzuf&uuml;gen.',
+    'app_logo' => 'Anwendungslogo',
+    'app_logo_desc' => 'Dieses Bild sollte 43px hoch sein. <br>Gr&ouml;&szlig;ere Bilder werden verkleinert.',
+    'app_primary_color' => 'Prim&auml;re Anwendungsfarbe',
+    'app_primary_color_desc' => 'Dies sollte ein HEX Wert sein. <br>Leer lassen des Feldes setzt auf die Standard-Anwendungsfarbe zur&uuml;ck.',
+
+    'reg_settings' => 'Registrierungseinstellungen',
+    'reg_allow' => 'Registrierung erlauben?',
+    'reg_default_role' => 'Standard-Benutzerrolle nach Registrierung',
+    'reg_confirm_email' => 'Best&auml;tigung per E-Mail erforderlich?',
+    'reg_confirm_email_desc' => 'Falls die Einschr&auml;nkung f&uumlr; Domains genutzt wird, ist die Best&auml;tigung per E-Mail zwingend erforderlich und der untenstehende Wert wird ignoriert.',
+    'reg_confirm_restrict_domain' => 'Registrierung auf bestimmte Domains einschr&auml;nken',
+    'reg_confirm_restrict_domain_desc' => 'F&uuml;gen sie eine, durch Komma getrennte, Liste von E-Mail Domains hinzu, auf die die Registrierung eingeschr&auml;nkt werden soll. Benutzern wird eine E-Mail gesendet, um ihre E-Mail Adresse zu best&auml;tigen, bevor sie diese Anwendung nutzen k&ouml;nnen. <br> Hinweis: Benutzer k&ouml;nnen ihre E-Mail Adresse nach erfolgreicher Registrierung &auml;ndern.',
+    'reg_confirm_restrict_domain_placeholder' => 'Keine Einschr&auml;nkung gesetzt',
+
+];
diff --git a/resources/lang/de/validation.php b/resources/lang/de/validation.php
new file mode 100644 (file)
index 0000000..3a6a1bc
--- /dev/null
@@ -0,0 +1,108 @@
+<?php
+
+return [
+
+    /*
+    |--------------------------------------------------------------------------
+    | Validation Language Lines
+    |--------------------------------------------------------------------------
+    |
+    | following language lines contain default error messages used by
+    | validator class. Some of these rules have multiple versions such
+    | as size rules. Feel free to tweak each of these messages here.
+    |
+    */
+
+    'accepted'             => ':attribute muss akzeptiert werden.',
+    'active_url'           => ':attribute ist keine valide URL.',
+    'after'                => ':attribute muss ein Datum nach :date sein.',
+    'alpha'                => ':attribute kann nur Buchstaben enthalten.',
+    'alpha_dash'           => ':attribute kann nur Buchstaben, Zahlen und Bindestriche enthalten.',
+    'alpha_num'            => ':attribute kann nur Buchstaben und Zahlen enthalten.',
+    'array'                => ':attribute muss eine Array sein.',
+    'before'               => ':attribute muss ein Datum vor :date sein.',
+    'between'              => [
+        'numeric' => ':attribute muss zwischen :min und :max liegen.',
+        'file'    => ':attribute muss zwischen :min und :max Kilobytes gro&szlig; sein.',
+        'string'  => ':attribute muss zwischen :min und :max Zeichen lang sein.',
+        'array'   => ':attribute muss zwischen :min und :max Elemente enthalten.',
+    ],
+    'boolean'              => ':attribute Feld muss wahr oder falsch sein.',
+    'confirmed'            => ':attribute Best&auml;tigung stimmt nicht &uuml;berein.',
+    'date'                 => ':attribute ist kein valides Datum.',
+    'date_format'          => ':attribute entspricht nicht dem Format :format.',
+    'different'            => ':attribute und :other m&uuml;ssen unterschiedlich sein.',
+    'digits'               => ':attribute muss :digits Stellen haben.',
+    'digits_between'       => ':attribute muss zwischen :min und :max Stellen haben.',
+    'email'                => ':attribute muss eine valide E-Mail Adresse sein.',
+    'filled'               => ':attribute Feld ist erforderlich.',
+    'exists'               => 'Markiertes :attribute ist ung&uuml;ltig.',
+    'image'                => ':attribute muss ein Bild sein.',
+    'in'                   => 'Markiertes :attribute ist ung&uuml;ltig.',
+    'integer'              => ':attribute muss eine Zahl sein.',
+    'ip'                   => ':attribute muss eine valide IP-Adresse sein.',
+    'max'                  => [
+        'numeric' => ':attribute darf nicht gr&ouml;&szlig;er als :max sein.',
+        'file'    => ':attribute darf nicht gr&ouml;&szlig;er als :max Kilobyte sein.',
+        'string'  => ':attribute darf nicht l&auml;nger als :max Zeichen sein.',
+        'array'   => ':attribute darf nicht mehr als :max Elemente enthalten.',
+    ],
+    'mimes'                => ':attribute muss eine Datei vom Typ: :values sein.',
+    'min'                  => [
+        'numeric' => ':attribute muss mindestens :min. sein',
+        'file'    => ':attribute muss mindestens :min Kilobyte gro&szlig; sein.',
+        'string'  => ':attribute muss mindestens :min Zeichen lang sein.',
+        'array'   => ':attribute muss mindesten :min Elemente enthalten.',
+    ],
+    'not_in'               => 'Markiertes :attribute ist ung&uuml;ltig.',
+    'numeric'              => ':attribute muss eine Zahl sein.',
+    'regex'                => ':attribute Format ist ung&uuml;ltig.',
+    'required'             => ':attribute Feld ist erforderlich.',
+    'required_if'          => ':attribute Feld ist erforderlich, wenn :other :value ist.',
+    'required_with'        => ':attribute Feld ist erforderlich, wenn :values vorhanden ist.',
+    'required_with_all'    => ':attribute Feld ist erforderlich, wenn :values vorhanden sind.',
+    'required_without'     => ':attribute Feld ist erforderlich, wenn :values nicht vorhanden ist.',
+    'required_without_all' => ':attribute Feld ist erforderlich, wenn :values nicht vorhanden sind.',
+    'same'                 => ':attribute und :other muss &uuml;bereinstimmen.',
+    'size'                 => [
+        'numeric' => ':attribute muss :size sein.',
+        'file'    => ':attribute muss :size Kilobytes gro&szlig; sein.',
+        'string'  => ':attribute muss :size Zeichen lang sein.',
+        'array'   => ':attribute muss :size Elemente enthalten.',
+    ],
+    'string'               => ':attribute muss eine Zeichenkette sein.',
+    'timezone'             => ':attribute muss eine valide zeitzone sein.',
+    'unique'               => ':attribute wird bereits verwendet.',
+    'url'                  => ':attribute ist kein valides Format.',
+
+    /*
+    |--------------------------------------------------------------------------
+    | Custom Validation Language Lines
+    |--------------------------------------------------------------------------
+    |
+    | Here you may specify custom validation messages for attributes using the
+    | convention "attribute.rule" to name lines. This makes it quick to
+    | specify a specific custom language line for a given attribute rule.
+    |
+    */
+
+    'custom' => [
+        'attribute-name' => [
+            'rule-name' => 'custom-message',
+        ],
+    ],
+
+    /*
+    |--------------------------------------------------------------------------
+    | Custom Validation Attributes
+    |--------------------------------------------------------------------------
+    |
+    | following language lines are used to swap attribute place-holders
+    | with something more reader friendly such as E-Mail Address instead
+    | of "email". This simply helps us make messages a little cleaner.
+    |
+    */
+
+    'attributes' => [],
+
+];
index d8536efa723bebef008e1feb194c682fd773a394..115785ab2eb43893df69e64b3573c7aa3b3a9101 100644 (file)
@@ -1,5 +1,12 @@
 @extends('public')
 
+@section('header-buttons')
+    <a href="{{ baseUrl("/login") }}"><i class="zmdi zmdi-sign-in"></i>Sign in</a>
+    @if(setting('registration-enabled'))
+        <a href="{{ baseUrl("/register") }}"><i class="zmdi zmdi-account-add"></i>Sign up</a>
+    @endif
+@stop
+
 @section('content')
 
 
index 9a9a65ff094516af8ac142e3b898222f0c2589fd..612b50ff835eb069f54b77ae0bf8af91c8d09acf 100644 (file)
@@ -1,5 +1,12 @@
 @extends('public')
 
+@section('header-buttons')
+    <a href="{{ baseUrl("/login") }}"><i class="zmdi zmdi-sign-in"></i>Sign in</a>
+    @if(setting('registration-enabled'))
+        <a href="{{ baseUrl("/register") }}"><i class="zmdi zmdi-account-add"></i>Sign up</a>
+    @endif
+@stop
+
 @section('body-class', 'image-cover login')
 
 @section('content')
index 1deed0a3fbad2057fc5ca0789f2a6029d7f24d45..08acf725d95ea013b54fcd0742060d87e9d571ed 100644 (file)
@@ -23,7 +23,7 @@
     @include('partials/custom-styles')
 
     <!-- Custom user content -->
-    @if(setting('app-custom-head', false))
+    @if(setting('app-custom-head'))
         {!! setting('app-custom-head') !!}
     @endif
 </head>
index d39e24e920aaca0927493b6b05457c5e60275fdf..e50cc7c5bb2dea098b3d1c2f7f5685e62fd2da30 100644 (file)
     @include('partials/image-manager', ['imageType' => 'gallery', 'uploaded_to' => $page->id])
     @include('partials/entity-selector-popup')
 
-    <script>
-        (function() {
-
-        })();
-    </script>
-
 @stop
\ No newline at end of file
index a03a208b6539440cb2cdfa4b8adad76db3b63904..a6e66a24a13f3e778924634faea57ce4af24fd69 100644 (file)
@@ -3,10 +3,13 @@
 
     <div class="tabs primary-background-light">
         <span toolbox-toggle><i class="zmdi zmdi-caret-left-circle"></i></span>
-        <span tab-button="tags" title="Page Tags" class="active"><i class="zmdi zmdi-tag"></i></span>
+        <span toolbox-tab-button="tags" title="Page Tags" class="active"><i class="zmdi zmdi-tag"></i></span>
+        @if(userCan('attachment-create-all'))
+            <span toolbox-tab-button="files" title="Attachments"><i class="zmdi zmdi-attachment"></i></span>
+        @endif
     </div>
 
-    <div tab-content="tags" ng-controller="PageTagController" page-id="{{ $page->id or 0 }}">
+    <div toolbox-tab-content="tags" ng-controller="PageTagController" page-id="{{ $page->id or 0 }}">
         <h4>Page Tags</h4>
         <div class="padded tags">
             <p class="muted small">Add some tags to better categorise your content. <br> You can assign a value to a tag for more in-depth organisation.</p>
         </div>
     </div>
 
+    @if(userCan('attachment-create-all'))
+        <div toolbox-tab-content="files" ng-controller="PageAttachmentController" page-id="{{ $page->id or 0 }}">
+            <h4>Attachments</h4>
+            <div class="padded files">
+
+                <div id="file-list" ng-show="!editFile">
+                    <p class="muted small">Upload some files or attach some link to display on your page. These are visible in the page sidebar. <span class="secondary">Changes here are saved instantly.</span></p>
+
+                    <div tab-container>
+                        <div class="nav-tabs">
+                            <div tab-button="list" class="tab-item">Attached Items</div>
+                            <div tab-button="file" class="tab-item">Upload File</div>
+                            <div tab-button="link" class="tab-item">Attach Link</div>
+                        </div>
+                        <div tab-content="list">
+                            <table class="file-table" style="width: 100%;">
+                                <tbody ui-sortable="sortOptions" ng-model="files" >
+                                <tr ng-repeat="file in files track by $index">
+                                    <td width="20" ><i class="handle zmdi zmdi-menu"></i></td>
+                                    <td>
+                                        <a ng-href="@{{getFileUrl(file)}}" target="_blank" ng-bind="file.name"></a>
+                                        <div ng-if="file.deleting">
+                                            <span class="neg small">Click delete again to confirm you want to delete this attachment.</span>
+                                            <br>
+                                            <span class="text-primary small" ng-click="file.deleting=false;">Cancel</span>
+                                        </div>
+                                    </td>
+                                    <td width="10" ng-click="startEdit(file)" class="text-center text-primary" style="padding: 0;"><i class="zmdi zmdi-edit"></i></td>
+                                    <td width="5"></td>
+                                    <td width="10" ng-click="deleteFile(file)" class="text-center text-neg" style="padding: 0;"><i class="zmdi zmdi-close"></i></td>
+                                </tr>
+                                </tbody>
+                            </table>
+                            <p class="small muted" ng-if="files.length == 0">
+                                No files have been uploaded.
+                            </p>
+                        </div>
+                        <div tab-content="file">
+                            <drop-zone upload-url="@{{getUploadUrl()}}" uploaded-to="@{{uploadedTo}}" event-success="uploadSuccess"></drop-zone>
+                        </div>
+                        <div tab-content="link" sub-form="attachLinkSubmit(file)">
+                            <p class="muted small">You can attach a link if you'd prefer not to upload a file. This can be a link to another page or a link to a file in the cloud.</p>
+                            <div class="form-group">
+                                <label for="attachment-via-link">Link Name</label>
+                                <input type="text" placeholder="Link name" ng-model="file.name">
+                                <p class="small neg" ng-repeat="error in errors.link.name" ng-bind="error"></p>
+                            </div>
+                            <div class="form-group">
+                                <label for="attachment-via-link">Link to file</label>
+                                <input type="text" placeholder="Url of site or file" ng-model="file.link">
+                                <p class="small neg" ng-repeat="error in errors.link.link" ng-bind="error"></p>
+                            </div>
+                            <button type="submit" class="button pos">Attach</button>
+
+                        </div>
+                    </div>
+
+                </div>
+
+                <div id="file-edit" ng-if="editFile" sub-form="updateFile(editFile)">
+                    <h5>Edit File</h5>
+
+                    <div class="form-group">
+                        <label for="attachment-name-edit">File Name</label>
+                        <input type="text" id="attachment-name-edit" placeholder="File name" ng-model="editFile.name">
+                        <p class="small neg" ng-repeat="error in errors.edit.name" ng-bind="error"></p>
+                    </div>
+
+                    <div tab-container="@{{ editFile.external ? 'link' : 'file' }}">
+                        <div class="nav-tabs">
+                            <div tab-button="file" class="tab-item">Upload File</div>
+                            <div tab-button="link" class="tab-item">Set Link</div>
+                        </div>
+                        <div tab-content="file">
+                            <drop-zone upload-url="@{{getUploadUrl(editFile)}}" uploaded-to="@{{uploadedTo}}" placeholder="Drop files or click here to upload and overwrite" event-success="uploadSuccessUpdate"></drop-zone>
+                            <br>
+                        </div>
+                        <div tab-content="link">
+                            <div class="form-group">
+                                <label for="attachment-link-edit">Link to file</label>
+                                <input type="text" id="attachment-link-edit" placeholder="Attachment link" ng-model="editFile.link">
+                                <p class="small neg" ng-repeat="error in errors.edit.link" ng-bind="error"></p>
+                            </div>
+                        </div>
+                    </div>
+
+                    <button type="button" class="button" ng-click="cancelEdit()">Back</button>
+                    <button type="submit" class="button pos">Save</button>
+                </div>
+
+            </div>
+        </div>
+    @endif
+
 </div>
\ No newline at end of file
index 0e0c3672e9df6f25c8518a4258a97cec22319b00..c4baf38f75b352046eb8181b4873c1e624c82169 100644 (file)
@@ -1,7 +1,9 @@
 
-<div class="page-editor flex-fill flex" ng-controller="PageEditController" editor-type="{{ setting('app-editor') }}" page-id="{{ $model->id or 0 }}" page-new-draft="{{ $model->draft or 0 }}" page-update-draft="{{ $model->isDraft or 0 }}">
+<div class="page-editor flex-fill flex" ng-controller="PageEditController" drafts-enabled="{{ $draftsEnabled ? 'true' : 'false' }}" editor-type="{{ setting('app-editor') }}" page-id="{{ $model->id or 0 }}" page-new-draft="{{ $model->draft or 0 }}" page-update-draft="{{ $model->isDraft or 0 }}">
 
     {{ csrf_field() }}
+
+    {{--Header Bar--}}
     <div class="faded-small toolbar">
         <div class="container">
             <div class="row">
@@ -13,7 +15,7 @@
                 </div>
                 <div class="col-sm-4 faded text-center">
 
-                    <div dropdown class="dropdown-container draft-display">
+                    <div ng-show="draftsEnabled" dropdown class="dropdown-container draft-display">
                         <a dropdown-toggle class="text-primary text-button"><span class="faded-text" ng-bind="draftText"></span>&nbsp; <i class="zmdi zmdi-more-vert"></i></a>
                         <i class="zmdi zmdi-check-circle text-pos draft-notification" ng-class="{visible: draftUpdated}"></i>
                         <ul>
         </div>
     </div>
 
+    {{--Title input--}}
     <div class="title-input page-title clearfix" ng-non-bindable>
         <div class="input">
             @include('form/text', ['name' => 'name', 'placeholder' => 'Page Title'])
         </div>
     </div>
 
+    {{--Editors--}}
     <div class="edit-area flex-fill flex">
+
+        {{--WYSIWYG Editor--}}
         @if(setting('app-editor') === 'wysiwyg')
             <div tinymce="editorOptions" mce-change="editorChange" mce-model="editContent" class="flex-fill flex">
                 <textarea id="html-editor"   name="html" rows="5" ng-non-bindable
@@ -66,6 +72,7 @@
             @endif
         @endif
 
+        {{--Markdown Editor--}}
         @if(setting('app-editor') === 'markdown')
             <div id="markdown-editor" markdown-editor class="flex-fill flex">
 
             @if($errors->has('markdown'))
                 <div class="text-neg text-small">{{ $errors->first('markdown') }}</div>
             @endif
-
         @endif
+
     </div>
 </div>
\ No newline at end of file
diff --git a/resources/views/pages/guest-create.blade.php b/resources/views/pages/guest-create.blade.php
new file mode 100644 (file)
index 0000000..00d9f55
--- /dev/null
@@ -0,0 +1,25 @@
+@extends('base')
+
+@section('content')
+
+    <div class="container small" ng-non-bindable>
+        <h1>Create Page</h1>
+        <form action="{{  $parent->getUrl('/page/create/guest') }}" method="POST">
+
+            {!! csrf_field() !!}
+
+            <div class="form-group title-input">
+                <label for="name">Page Name</label>
+                @include('form/text', ['name' => 'name'])
+            </div>
+
+            <div class="form-group">
+                <a href="{{ $parent->getUrl() }}" class="button muted">Cancel</a>
+                <button type="submit" class="button pos">Continue</button>
+            </div>
+
+        </form>
+    </div>
+
+
+@stop
\ No newline at end of file
index 2ddd4e0d8e38830fd0ce9a7375bac5b947e7f358..fb6ca30451e35df171ac5b843612d1d347a5b102 100644 (file)
@@ -2,27 +2,11 @@
 
     <h1 id="bkmrk-page-title" class="float left">{{$page->name}}</h1>
 
-    @if(count($page->tags) > 0)
-        <div class="tag-display float right">
-            <table>
-                <thead>
-                    <tr class="text-left heading primary-background-light">
-                        <th colspan="2">Page Tags</th>
-                    </tr>
-                </thead>
-                <tbody>
-                    @foreach($page->tags as $tag)
-                        <tr class="tag">
-                            <td @if(!$tag->value) colspan="2" @endif><a href="{{ baseUrl('/search/all?term=%5B' . urlencode($tag->name) .'%5D') }}">{{ $tag->name }}</a></td>
-                            @if($tag->value) <td class="tag-value"><a href="{{ baseUrl('/search/all?term=%5B' . urlencode($tag->name) .'%3D' . urlencode($tag->value) . '%5D') }}">{{$tag->value}}</a></td> @endif
-                        </tr>
-                    @endforeach
-                </tbody>
-            </table>
-        </div>
-    @endif
-
     <div style="clear:left;"></div>
 
-    {!! $page->html !!}
+    @if (isset($diff) && $diff)
+        {!! $diff !!}
+    @else
+        {!! $page->html !!}
+    @endif
 </div>
\ No newline at end of file
index 0cbf4df02a32ec16f7ee99f00e4730236a034ec1..5c9fd5eea8707fad42b2ea5b982f3b78266a5f1c 100644 (file)
@@ -14,7 +14,7 @@
         table {
             max-width: 800px !important;
             font-size: 0.8em;
-            width: auto !important;
+            width: 100% !important;
         }
 
         table td {
index 926affffc3824d969518ae57383ed19a53726b00..720e34fea4efd76c4187e4f5b37b29e9b65e3a57 100644 (file)
 
             <table class="table">
                 <tr>
-                    <th width="25%">Name</th>
-                    <th colspan="2" width="10%">Created By</th>
+                    <th width="23%">Name</th>
+                    <th colspan="2" width="8%">Created By</th>
                     <th width="15%">Revision Date</th>
                     <th width="25%">Changelog</th>
-                    <th width="15%">Actions</th>
+                    <th width="20%">Actions</th>
                 </tr>
                 @foreach($page->revisions as $index => $revision)
                     <tr>
                         <td> @if($revision->createdBy) {{ $revision->createdBy->name }} @else Deleted User @endif</td>
                         <td><small>{{ $revision->created_at->format('jS F, Y H:i:s') }} <br> ({{ $revision->created_at->diffForHumans() }})</small></td>
                         <td>{{ $revision->summary }}</td>
-                        @if ($index !== 0)
-                            <td>
+                        <td>
+                            <a href="{{ $revision->getUrl('changes') }}" target="_blank">Changes</a>
+                            <span class="text-muted">&nbsp;|&nbsp;</span>
+
+                            @if ($index === 0)
+                                <a target="_blank" href="{{ $page->getUrl() }}"><i>Current Version</i></a>
+                            @else
                                 <a href="{{ $revision->getUrl() }}" target="_blank">Preview</a>
                                 <span class="text-muted">&nbsp;|&nbsp;</span>
-                                <a href="{{ $revision->getUrl() }}/restore">Restore</a>
-                            </td>
-                        @else
-                            <td><a target="_blank" href="{{ $page->getUrl() }}"><i>Current Version</i></a></td>
-                        @endif
+                                <a href="{{ $revision->getUrl('restore') }}" target="_blank">Restore</a>
+                            @endif
+                        </td>
                     </tr>
                 @endforeach
             </table>
index af85075a204981b84e2644d5f93ea6eec1a3046b..50c6f5d2c2eb4da7bdf90d9649a756551546971c 100644 (file)
                     </div>
                 @endif
 
+
+
                 @include('pages/sidebar-tree-list', ['book' => $book, 'sidebarTree' => $sidebarTree, 'pageNav' => $pageNav])
             </div>
 
index 5fcec8731ee098620366f836c346551a35530c2c..5309cb7748aa452f0651a7a03ae0332100f78cdf 100644 (file)
@@ -1,6 +1,31 @@
 
 <div class="book-tree" ng-non-bindable>
 
+    @if(isset($page) && $page->tags->count() > 0)
+        <div class="tag-display">
+            <h6 class="text-muted">Page Tags</h6>
+            <table>
+                <tbody>
+                @foreach($page->tags as $tag)
+                    <tr class="tag">
+                        <td @if(!$tag->value) colspan="2" @endif><a href="{{ baseUrl('/search/all?term=%5B' . urlencode($tag->name) .'%5D') }}">{{ $tag->name }}</a></td>
+                        @if($tag->value) <td class="tag-value"><a href="{{ baseUrl('/search/all?term=%5B' . urlencode($tag->name) .'%3D' . urlencode($tag->value) . '%5D') }}">{{$tag->value}}</a></td> @endif
+                    </tr>
+                @endforeach
+                </tbody>
+            </table>
+        </div>
+    @endif
+
+    @if (isset($page) && $page->attachments->count() > 0)
+        <h6 class="text-muted">Attachments</h6>
+        @foreach($page->attachments as $attachment)
+            <div class="attachment">
+                <a href="{{ $attachment->getUrl() }}" @if($attachment->external) target="_blank" @endif><i class="zmdi zmdi-{{ $attachment->external ? 'open-in-new' : 'file' }}"></i> {{ $attachment->name }}</a>
+            </div>
+        @endforeach
+    @endif
+
     @if (isset($pageNav) && $pageNav)
         <h6 class="text-muted">Page Navigation</h6>
         <div class="sidebar-page-nav menu">
@@ -10,8 +35,6 @@
                 </li>
             @endforeach
         </div>
-
-
     @endif
 
     <h6 class="text-muted">Book Navigation</h6>
index bf7dde1d4a34de2344ee1a32d49ba6b5101e166a..885cc2729c9c1a2386fe65e69a89c37a14be4ee8 100644 (file)
@@ -14,7 +14,7 @@
     .nav-tabs a.selected, .nav-tabs .tab-item.selected {
         border-bottom-color: {{ setting('app-color') }};
     }
-    p.primary:hover, p .primary:hover, span.primary:hover, .text-primary:hover, a, a:hover, a:focus, .text-button, .text-button:hover, .text-button:focus {
+    .text-primary, p.primary, p .primary, span.primary:hover, .text-primary:hover, a, a:hover, a:focus, .text-button, .text-button:hover, .text-button:focus {
         color: {{ setting('app-color') }};
     }
 </style>
\ No newline at end of file
index 542d5c8679add17afe29e4070f456616fc21cbf8..16aebe2bb74fcede0efea22af753c41ec920b46b 100644 (file)
     <!-- Scripts -->
     <script src="{{ baseUrl("/libs/jquery/jquery.min.js?version=2.1.4") }}"></script>
     @include('partials/custom-styles')
+
+    <!-- Custom user content -->
+    @if(setting('app-custom-head'))
+        {!! setting('app-custom-head') !!}
+    @endif
 </head>
 <body class="@yield('body-class')" ng-app="bookStack">
 
index 757729763a17542cce69d13eabb6726ce7dad77e..ac25eb3b55e8a40bc33f885fb86cf5fcef16947c 100644 (file)
@@ -79,7 +79,7 @@
                 <div class="form-group">
                     <label for="setting-registration-role">{{ trans('settings.reg_default_role') }}</label>
                     <select id="setting-registration-role" name="setting-registration-role" @if($errors->has('setting-registration-role')) class="neg" @endif>
-                        @foreach(\BookStack\Role::visible() as $role)
+                        @foreach(\BookStack\Role::all() as $role)
                             <option value="{{$role->id}}" data-role-name="{{ $role->name }}"
                                     @if(setting('registration-role', \BookStack\Role::first()->id) == $role->id) selected @endif
                                     >
index 5e653f8de0525975e1f375243fed25c9bf9d0fe9..78e9e15333091fc0e2bbf99fd5443766edc8376b 100644 (file)
                             <label>@include('settings/roles/checkbox', ['permission' => 'image-delete-all']) All</label>
                         </td>
                     </tr>
+                    <tr>
+                        <td>Attachments</td>
+                        <td>@include('settings/roles/checkbox', ['permission' => 'attachment-create-all'])</td>
+                        <td style="line-height:1.2;"><small class="faded">Controlled by the asset they are uploaded to</small></td>
+                        <td>
+                            <label>@include('settings/roles/checkbox', ['permission' => 'attachment-update-own']) Own</label>
+                            <label>@include('settings/roles/checkbox', ['permission' => 'attachment-update-all']) All</label>
+                        </td>
+                        <td>
+                            <label>@include('settings/roles/checkbox', ['permission' => 'attachment-delete-own']) Own</label>
+                            <label>@include('settings/roles/checkbox', ['permission' => 'attachment-delete-all']) All</label>
+                        </td>
+                    </tr>
                 </table>
             </div>
         </div>
index d06ec09bc3a3fcde92179766b0eed4b647185120..6cbbdb7f7ec879a776e3ec0087f13618c8a836c0 100644 (file)
@@ -15,7 +15,9 @@
                 </div>
                 <div class="col-sm-4">
                     <p></p>
-                    <a href="{{ baseUrl("/settings/users/{$user->id}/delete") }}" class="neg button float right">Delete User</a>
+                    @if($authMethod !== 'system')
+                        <a href="{{ baseUrl("/settings/users/{$user->id}/delete") }}" class="neg button float right">Delete User</a>
+                    @endif
                 </div>
             </div>
             <div class="row">
diff --git a/resources/views/users/forms/system.blade.php b/resources/views/users/forms/system.blade.php
new file mode 100644 (file)
index 0000000..3ee5f64
--- /dev/null
@@ -0,0 +1,25 @@
+@if($user->system_name == 'public')
+    <p>This user represents any guest users that visit your instance. It cannot be used for logins but is assigned&nbsp;automatically.</p>
+@endif
+
+<div class="form-group">
+    <label for="name">Name</label>
+    @include('form.text', ['name' => 'name'])
+</div>
+
+<div class="form-group">
+    <label for="email">Email</label>
+    @include('form.text', ['name' => 'email'])
+</div>
+
+@if(userCan('users-manage'))
+    <div class="form-group">
+        <label for="role">User Role</label>
+        @include('form/role-checkboxes', ['name' => 'roles', 'roles' => $roles])
+    </div>
+@endif
+
+<div class="form-group">
+    <a href="{{ baseUrl("/settings/users") }}" class="button muted">Cancel</a>
+    <button class="button pos" type="submit">Save</button>
+</div>
index 58ceb5f3b6930208654024a81c203d0c30e5ef2a..d179c28a52d2671a8a36b9ff437e1ed4e3d843a5 100644 (file)
@@ -27,6 +27,7 @@ Route::group(['middleware' => 'auth'], function () {
 
         // Pages
         Route::get('/{bookSlug}/page/create', 'PageController@create');
+        Route::post('/{bookSlug}/page/create/guest', 'PageController@createAsGuest');
         Route::get('/{bookSlug}/draft/{pageId}', 'PageController@editDraft');
         Route::post('/{bookSlug}/draft/{pageId}', 'PageController@store');
         Route::get('/{bookSlug}/page/{pageSlug}', 'PageController@show');
@@ -47,10 +48,12 @@ Route::group(['middleware' => 'auth'], function () {
         // Revisions
         Route::get('/{bookSlug}/page/{pageSlug}/revisions', 'PageController@showRevisions');
         Route::get('/{bookSlug}/page/{pageSlug}/revisions/{revId}', 'PageController@showRevision');
+        Route::get('/{bookSlug}/page/{pageSlug}/revisions/{revId}/changes', 'PageController@showRevisionChanges');
         Route::get('/{bookSlug}/page/{pageSlug}/revisions/{revId}/restore', 'PageController@restoreRevision');
 
         // Chapters
         Route::get('/{bookSlug}/chapter/{chapterSlug}/create-page', 'PageController@create');
+        Route::post('/{bookSlug}/chapter/{chapterSlug}/page/create/guest', 'PageController@createAsGuest');
         Route::get('/{bookSlug}/chapter/create', 'ChapterController@create');
         Route::post('/{bookSlug}/chapter/create', 'ChapterController@store');
         Route::get('/{bookSlug}/chapter/{chapterSlug}', 'ChapterController@show');
@@ -84,6 +87,16 @@ Route::group(['middleware' => 'auth'], function () {
         Route::delete('/{imageId}', 'ImageController@destroy');
     });
 
+    // Attachments routes
+    Route::get('/attachments/{id}', 'AttachmentController@get');
+    Route::post('/attachments/upload', 'AttachmentController@upload');
+    Route::post('/attachments/upload/{id}', 'AttachmentController@uploadUpdate');
+    Route::post('/attachments/link', 'AttachmentController@attachLink');
+    Route::put('/attachments/{id}', 'AttachmentController@update');
+    Route::get('/attachments/get/page/{pageId}', 'AttachmentController@listForPage');
+    Route::put('/attachments/sort/page/{pageId}', 'AttachmentController@sortForPage');
+    Route::delete('/attachments/{id}', 'AttachmentController@delete');
+
     // AJAX routes
     Route::put('/ajax/page/{id}/save-draft', 'PageController@saveDraft');
     Route::get('/ajax/page/{id}', 'PageController@getPageAjax');
@@ -140,7 +153,7 @@ Route::group(['middleware' => 'auth'], function () {
 });
 
 // Social auth routes
-Route::get('/login/service/{socialDriver}', 'Auth\RegisterController@getSocialLogin');
+Route::get('/login/service/{socialDriver}', 'Auth\LoginController@getSocialLogin');
 Route::get('/login/service/{socialDriver}/callback', 'Auth\RegisterController@socialCallback');
 Route::get('/login/service/{socialDriver}/detach', 'Auth\RegisterController@detachSocialAccount');
 Route::get('/register/service/{socialDriver}', 'Auth\RegisterController@socialRegister');
old mode 100644 (file)
new mode 100755 (executable)
similarity index 100%
rename from public/build/.gitignore
rename to storage/uploads/files/.gitignore
diff --git a/tests/AttachmentTest.php b/tests/AttachmentTest.php
new file mode 100644 (file)
index 0000000..df625bc
--- /dev/null
@@ -0,0 +1,201 @@
+<?php
+
+class AttachmentTest extends TestCase
+{
+    /**
+     * Get a test file that can be uploaded
+     * @param $fileName
+     * @return \Illuminate\Http\UploadedFile
+     */
+    protected function getTestFile($fileName)
+    {
+        return new \Illuminate\Http\UploadedFile(base_path('tests/test-data/test-file.txt'), $fileName, 'text/plain', 55, null, true);
+    }
+
+    /**
+     * Uploads a file with the given name.
+     * @param $name
+     * @param int $uploadedTo
+     * @return string
+     */
+    protected function uploadFile($name, $uploadedTo = 0)
+    {
+        $file = $this->getTestFile($name);
+        return $this->call('POST', '/attachments/upload', ['uploaded_to' => $uploadedTo], [], ['file' => $file], []);
+    }
+
+    /**
+     * Get the expected upload path for a file.
+     * @param $fileName
+     * @return string
+     */
+    protected function getUploadPath($fileName)
+    {
+        return 'uploads/files/' . Date('Y-m-M') . '/' . $fileName;
+    }
+
+    /**
+     * Delete all uploaded files.
+     * To assist with cleanup.
+     */
+    protected function deleteUploads()
+    {
+        $fileService = $this->app->make(\BookStack\Services\AttachmentService::class);
+        foreach (\BookStack\Attachment::all() as $file) {
+            $fileService->deleteFile($file);
+        }
+    }
+
+    public function test_file_upload()
+    {
+        $page = \BookStack\Page::first();
+        $this->asAdmin();
+        $admin = $this->getAdmin();
+        $fileName = 'upload_test_file.txt';
+
+        $expectedResp = [
+            'name' => $fileName,
+            'uploaded_to'=> $page->id,
+            'extension' => 'txt',
+            'order' => 1,
+            'created_by' => $admin->id,
+            'updated_by' => $admin->id,
+            'path' => $this->getUploadPath($fileName)
+        ];
+
+        $this->uploadFile($fileName, $page->id);
+        $this->assertResponseOk();
+        $this->seeJsonContains($expectedResp);
+        $this->seeInDatabase('attachments', $expectedResp);
+
+        $this->deleteUploads();
+    }
+
+    public function test_file_display_and_access()
+    {
+        $page = \BookStack\Page::first();
+        $this->asAdmin();
+        $admin = $this->getAdmin();
+        $fileName = 'upload_test_file.txt';
+
+        $this->uploadFile($fileName, $page->id);
+        $this->assertResponseOk();
+        $this->visit($page->getUrl())
+            ->seeLink($fileName)
+            ->click($fileName)
+            ->see('Hi, This is a test file for testing the upload process.');
+
+        $this->deleteUploads();
+    }
+
+    public function test_attaching_link_to_page()
+    {
+        $page = \BookStack\Page::first();
+        $admin = $this->getAdmin();
+        $this->asAdmin();
+
+        $this->call('POST', 'attachments/link', [
+            'link' => 'https://example.com',
+            'name' => 'Example Attachment Link',
+            'uploaded_to' => $page->id,
+        ]);
+
+        $expectedResp = [
+            'path' => 'https://example.com',
+            'name' => 'Example Attachment Link',
+            'uploaded_to' => $page->id,
+            'created_by' => $admin->id,
+            'updated_by' => $admin->id,
+            'external' => true,
+            'order' => 1,
+            'extension' => ''
+        ];
+
+        $this->assertResponseOk();
+        $this->seeJsonContains($expectedResp);
+        $this->seeInDatabase('attachments', $expectedResp);
+
+        $this->visit($page->getUrl())->seeLink('Example Attachment Link')
+            ->click('Example Attachment Link')->seePageIs('https://example.com');
+
+        $this->deleteUploads();
+    }
+
+    public function test_attachment_updating()
+    {
+        $page = \BookStack\Page::first();
+        $this->asAdmin();
+
+        $this->call('POST', 'attachments/link', [
+            'link' => 'https://example.com',
+            'name' => 'Example Attachment Link',
+            'uploaded_to' => $page->id,
+        ]);
+
+        $attachmentId = \BookStack\Attachment::first()->id;
+
+        $this->call('PUT', 'attachments/' . $attachmentId, [
+            'uploaded_to' => $page->id,
+            'name' => 'My new attachment name',
+            'link' => 'https://test.example.com'
+        ]);
+
+        $expectedResp = [
+            'path' => 'https://test.example.com',
+            'name' => 'My new attachment name',
+            'uploaded_to' => $page->id
+        ];
+
+        $this->assertResponseOk();
+        $this->seeJsonContains($expectedResp);
+        $this->seeInDatabase('attachments', $expectedResp);
+
+        $this->deleteUploads();
+    }
+
+    public function test_file_deletion()
+    {
+        $page = \BookStack\Page::first();
+        $this->asAdmin();
+        $fileName = 'deletion_test.txt';
+        $this->uploadFile($fileName, $page->id);
+
+        $filePath = base_path('storage/' . $this->getUploadPath($fileName));
+
+        $this->assertTrue(file_exists($filePath), 'File at path ' . $filePath . ' does not exist');
+
+        $attachmentId = \BookStack\Attachment::first()->id;
+        $this->call('DELETE', 'attachments/' . $attachmentId);
+
+        $this->dontSeeInDatabase('attachments', [
+            'name' => $fileName
+        ]);
+        $this->assertFalse(file_exists($filePath), 'File at path ' . $filePath . ' was not deleted as expected');
+
+        $this->deleteUploads();
+    }
+
+    public function test_attachment_deletion_on_page_deletion()
+    {
+        $page = \BookStack\Page::first();
+        $this->asAdmin();
+        $fileName = 'deletion_test.txt';
+        $this->uploadFile($fileName, $page->id);
+
+        $filePath = base_path('storage/' . $this->getUploadPath($fileName));
+
+        $this->assertTrue(file_exists($filePath), 'File at path ' . $filePath . ' does not exist');
+        $this->seeInDatabase('attachments', [
+            'name' => $fileName
+        ]);
+
+        $this->call('DELETE', $page->getUrl());
+
+        $this->dontSeeInDatabase('attachments', [
+            'name' => $fileName
+        ]);
+        $this->assertFalse(file_exists($filePath), 'File at path ' . $filePath . ' was not deleted as expected');
+
+        $this->deleteUploads();
+    }
+}
index 0affff799919c1e894f0c15dc9fe8254b7b1676c..0d2e4ac170a660e3ed0925f65df404e915a1c398 100644 (file)
@@ -146,7 +146,7 @@ class AuthTest extends TestCase
 
     public function test_user_updating()
     {
-        $user = \BookStack\User::all()->last();
+        $user = $this->getNormalUser();
         $password = $user->password;
         $this->asAdmin()
             ->visit('/settings/users')
@@ -162,7 +162,7 @@ class AuthTest extends TestCase
 
     public function test_user_password_update()
     {
-        $user = \BookStack\User::all()->last();
+        $user = $this->getNormalUser();
         $userProfilePage = '/settings/users/' . $user->id;
         $this->asAdmin()
             ->visit($userProfilePage)
@@ -218,6 +218,37 @@ class AuthTest extends TestCase
             ->seePageIs('/login');
     }
 
+    public function test_reset_password_flow()
+    {
+        $this->visit('/login')->click('Forgot Password?')
+            ->seePageIs('/password/email')
+            ->type('[email protected]', 'email')
+            ->press('Send Reset Link')
+            ->see('A password reset link has been sent to [email protected]');
+
+        $this->seeInDatabase('password_resets', [
+            'email' => '[email protected]'
+        ]);
+
+        $reset = DB::table('password_resets')->where('email', '=', '[email protected]')->first();
+        $this->visit('/password/reset/' . $reset->token)
+            ->see('Reset Password')
+            ->submitForm('Reset Password', [
+                'email' => '[email protected]',
+                'password' => 'randompass',
+                'password_confirmation' => 'randompass'
+            ])->seePageIs('/')
+            ->see('Your password has been successfully reset');
+    }
+
+    public function test_reset_password_page_shows_sign_links()
+    {
+        $this->setSettings(['registration-enabled' => 'true']);
+        $this->visit('/password/email')
+            ->seeLink('Sign in')
+            ->seeLink('Sign up');
+    }
+
     /**
      * Perform a login
      * @param string $email
index 76fbc662ab89fab61bc50b6e8c79aa8e8d421b68..9573321fba98e5c3bb6fbf06630ddafbfb5a0221 100644 (file)
@@ -108,7 +108,7 @@ class LdapTest extends \TestCase
 
     public function test_user_edit_form()
     {
-        $editUser = User::all()->last();
+        $editUser = $this->getNormalUser();
         $this->asAdmin()->visit('/settings/users/' . $editUser->id)
             ->see('Edit User')
             ->dontSee('Password')
@@ -126,7 +126,7 @@ class LdapTest extends \TestCase
 
     public function test_non_admins_cannot_change_auth_id()
     {
-        $testUser = User::all()->last();
+        $testUser = $this->getNormalUser();
         $this->actingAs($testUser)->visit('/settings/users/' . $testUser->id)
             ->dontSee('External Authentication');
     }
index 8adfd35a3e3c59037c2cb8e8e1f7574bf46adc66..60b5ceebd4a8fe783d3eda3f7e9d7cd009b83fa5 100644 (file)
@@ -91,6 +91,45 @@ class EntitySearchTest extends TestCase
             ->see('Book Search Results')->see('.entity-list', $book->name);
     }
 
+    public function test_searching_hypen_doesnt_break()
+    {
+        $this->visit('/search/all?term=cat+-')
+            ->seeStatusCode(200);
+    }
+
+    public function test_tag_search()
+    {
+        $newTags = [
+            new \BookStack\Tag([
+                'name' => 'animal',
+                'value' => 'cat'
+            ]),
+            new \BookStack\Tag([
+                'name' => 'color',
+                'value' => 'red'
+            ])
+        ];
+
+        $pageA = \BookStack\Page::first();
+        $pageA->tags()->saveMany($newTags);
+
+        $pageB = \BookStack\Page::all()->last();
+        $pageB->tags()->create(['name' => 'animal', 'value' => 'dog']);
+
+        $this->asAdmin()->visit('/search/all?term=%5Banimal%5D')
+            ->seeLink($pageA->name)
+            ->seeLink($pageB->name);
+
+        $this->visit('/search/all?term=%5Bcolor%5D')
+            ->seeLink($pageA->name)
+            ->dontSeeLink($pageB->name);
+
+        $this->visit('/search/all?term=%5Banimal%3Dcat%5D')
+            ->seeLink($pageA->name)
+            ->dontSeeLink($pageB->name);
+
+    }
+
     public function test_ajax_entity_search()
     {
         $page = \BookStack\Page::all()->last();
index d9acd4b71b8efbdf573bd7f0ccba48e3c5cd2f9a..031517cdb019d1d9380cf4161fc4dd44346d4399 100644 (file)
@@ -10,7 +10,7 @@ class ImageTest extends TestCase
      */
     protected function getTestImage($fileName)
     {
-        return new \Illuminate\Http\UploadedFile(base_path('tests/test-image.jpg'), $fileName, 'image/jpeg', 5238);
+        return new \Illuminate\Http\UploadedFile(base_path('tests/test-data/test-image.jpg'), $fileName, 'image/jpeg', 5238);
     }
 
     /**
@@ -57,7 +57,7 @@ class ImageTest extends TestCase
         $relPath = $this->uploadImage($imageName, $page->id);
         $this->assertResponseOk();
 
-        $this->assertTrue(file_exists(public_path($relPath)), 'Uploaded image exists');
+        $this->assertTrue(file_exists(public_path($relPath)), 'Uploaded image not found at path: '. public_path($relPath));
 
         $this->deleteImage($relPath);
 
@@ -70,7 +70,6 @@ class ImageTest extends TestCase
             'updated_by' => $admin->id,
             'name' => $imageName
         ]);
-        
 
     }
 
index b64f40dc6af325bc5ebfdbeacf8e24b60cc8c9e6..7a0515fd939795ea5732326cfc016a770a37d009 100644 (file)
@@ -544,27 +544,38 @@ class RolesTest extends TestCase
             ->dontSeeInElement('.book-content', $otherPage->name);
     }
 
-    public function test_public_role_not_visible_in_user_edit_screen()
+    public function test_public_role_visible_in_user_edit_screen()
     {
         $user = \BookStack\User::first();
         $this->asAdmin()->visit('/settings/users/' . $user->id)
             ->seeElement('#roles-admin')
-            ->dontSeeElement('#roles-public');
+            ->seeElement('#roles-public');
     }
 
-    public function test_public_role_not_visible_in_role_listing()
+    public function test_public_role_visible_in_role_listing()
     {
         $this->asAdmin()->visit('/settings/roles')
             ->see('Admin')
-            ->dontSee('Public');
+            ->see('Public');
     }
 
-    public function test_public_role_not_visible_in_default_role_setting()
+    public function test_public_role_visible_in_default_role_setting()
     {
         $this->asAdmin()->visit('/settings')
             ->seeElement('[data-role-name="admin"]')
-            ->dontSeeElement('[data-role-name="public"]');
+            ->seeElement('[data-role-name="public"]');
 
     }
 
+    public function test_public_role_not_deleteable()
+    {
+        $this->asAdmin()->visit('/settings/roles')
+            ->click('Public')
+            ->see('Edit Role')
+            ->click('Delete Role')
+            ->press('Confirm')
+            ->see('Delete Role')
+            ->see('Cannot be deleted');
+    }
+
 }
diff --git a/tests/PublicActionTest.php b/tests/PublicActionTest.php
new file mode 100644 (file)
index 0000000..6851464
--- /dev/null
@@ -0,0 +1,83 @@
+<?php
+
+class PublicActionTest extends TestCase
+{
+
+    public function test_app_not_public()
+    {
+        $this->setSettings(['app-public' => 'false']);
+        $book = \BookStack\Book::orderBy('name', 'asc')->first();
+        $this->visit('/books')->seePageIs('/login');
+        $this->visit($book->getUrl())->seePageIs('/login');
+
+        $page = \BookStack\Page::first();
+        $this->visit($page->getUrl())->seePageIs('/login');
+    }
+
+    public function test_books_viewable()
+    {
+        $this->setSettings(['app-public' => 'true']);
+        $books = \BookStack\Book::orderBy('name', 'asc')->take(10)->get();
+        $bookToVisit = $books[1];
+
+        // Check books index page is showing
+        $this->visit('/books')
+            ->seeStatusCode(200)
+            ->see($books[0]->name)
+            // Check individual book page is showing and it's child contents are visible.
+            ->click($bookToVisit->name)
+            ->seePageIs($bookToVisit->getUrl())
+            ->see($bookToVisit->name)
+            ->see($bookToVisit->chapters()->first()->name);
+    }
+
+    public function test_chapters_viewable()
+    {
+        $this->setSettings(['app-public' => 'true']);
+        $chapterToVisit = \BookStack\Chapter::first();
+        $pageToVisit = $chapterToVisit->pages()->first();
+
+        // Check chapters index page is showing
+        $this->visit($chapterToVisit->getUrl())
+            ->seeStatusCode(200)
+            ->see($chapterToVisit->name)
+            // Check individual chapter page is showing and it's child contents are visible.
+            ->see($pageToVisit->name)
+            ->click($pageToVisit->name)
+            ->see($chapterToVisit->book->name)
+            ->see($chapterToVisit->name)
+            ->seePageIs($pageToVisit->getUrl());
+    }
+
+    public function test_public_page_creation()
+    {
+        $this->setSettings(['app-public' => 'true']);
+        $publicRole = \BookStack\Role::getSystemRole('public');
+        // Grant all permissions to public
+        $publicRole->permissions()->detach();
+        foreach (\BookStack\RolePermission::all() as $perm) {
+            $publicRole->attachPermission($perm);
+        }
+        $this->app[\BookStack\Services\PermissionService::class]->buildJointPermissionForRole($publicRole);
+
+        $chapter = \BookStack\Chapter::first();
+        $this->visit($chapter->book->getUrl());
+        $this->visit($chapter->getUrl())
+            ->click('New Page')
+            ->see('Create Page')
+            ->seePageIs($chapter->getUrl('/create-page'));
+
+        $this->submitForm('Continue', [
+            'name' => 'My guest page'
+        ])->seePageIs($chapter->book->getUrl('/page/my-guest-page/edit'));
+
+        $user = \BookStack\User::getDefault();
+        $this->seeInDatabase('pages', [
+            'name' => 'My guest page',
+            'chapter_id' => $chapter->id,
+            'created_by' => $user->id,
+            'updated_by' => $user->id
+        ]);
+    }
+
+}
\ No newline at end of file
diff --git a/tests/PublicViewTest.php b/tests/PublicViewTest.php
deleted file mode 100644 (file)
index 58e39df..0000000
+++ /dev/null
@@ -1,41 +0,0 @@
-<?php
-
-class PublicViewTest extends TestCase
-{
-
-    public function test_books_viewable()
-    {
-        $this->setSettings(['app-public' => 'true']);
-        $books = \BookStack\Book::orderBy('name', 'asc')->take(10)->get();
-        $bookToVisit = $books[1];
-
-        // Check books index page is showing
-        $this->visit('/books')
-            ->seeStatusCode(200)
-            ->see($books[0]->name)
-            // Check individual book page is showing and it's child contents are visible.
-            ->click($bookToVisit->name)
-            ->seePageIs($bookToVisit->getUrl())
-            ->see($bookToVisit->name)
-            ->see($bookToVisit->chapters()->first()->name);
-    }
-
-    public function test_chapters_viewable()
-    {
-        $this->setSettings(['app-public' => 'true']);
-        $chapterToVisit = \BookStack\Chapter::first();
-        $pageToVisit = $chapterToVisit->pages()->first();
-
-        // Check chapters index page is showing
-        $this->visit($chapterToVisit->getUrl())
-            ->seeStatusCode(200)
-            ->see($chapterToVisit->name)
-            // Check individual chapter page is showing and it's child contents are visible.
-            ->see($pageToVisit->name)
-            ->click($pageToVisit->name)
-            ->see($chapterToVisit->book->name)
-            ->see($chapterToVisit->name)
-            ->seePageIs($pageToVisit->getUrl());
-    }
-
-}
\ No newline at end of file
index 6a8c2d732b65dc123de08569c6f4c2da7c2ac3bc..d3620eae0b9d1d985d8baaa988ca69c49c52a5f8 100644 (file)
@@ -66,6 +66,14 @@ class TestCase extends Illuminate\Foundation\Testing\TestCase
         return $this->actingAs($this->editor);
     }
 
+    /**
+     * Get a user that's not a system user such as the guest user.
+     */
+    public function getNormalUser()
+    {
+        return \BookStack\User::where('system_name', '=', null)->get()->last();
+    }
+
     /**
      * Quickly sets an array of settings.
      * @param $settingsArray
index 40ae004e981681b66e6fbea20b891c8a8e5fb248..9543adc1d3bc3f6752bffa1eadd27eb26387f064 100644 (file)
@@ -76,5 +76,23 @@ class UserProfileTest extends TestCase
             ->seePageIs('/user/' . $newUser->id)
             ->see($newUser->name);
     }
+
+    public function test_guest_profile_shows_limited_form()
+    {
+        $this->asAdmin()
+            ->visit('/settings/users')
+            ->click('Guest')
+            ->dontSeeElement('#password');
+    }
+
+    public function test_guest_profile_cannot_be_deleted()
+    {
+        $guestUser = \BookStack\User::getDefault();
+        $this->asAdmin()->visit('/settings/users/' . $guestUser->id . '/delete')
+            ->see('Delete User')->see('Guest')
+            ->press('Confirm')
+            ->seePageIs('/settings/users/' . $guestUser->id)
+            ->see('cannot delete the guest user');
+    }
     
 }
diff --git a/tests/test-data/test-file.txt b/tests/test-data/test-file.txt
new file mode 100644 (file)
index 0000000..4c1f41a
--- /dev/null
@@ -0,0 +1 @@
+Hi, This is a test file for testing the upload process.
\ No newline at end of file
diff --git a/version b/version
new file mode 100644 (file)
index 0000000..8287f28
--- /dev/null
+++ b/version
@@ -0,0 +1 @@
+v0.13-dev