]> BookStack Code Mirror - bookstack/commitdiff
Merge branch 'v0.30.x'
authorDan Brown <redacted>
Fri, 18 Dec 2020 14:16:13 +0000 (14:16 +0000)
committerDan Brown <redacted>
Fri, 18 Dec 2020 14:16:13 +0000 (14:16 +0000)
204 files changed:
.env.example.complete
app/Actions/Activity.php
app/Actions/ActivityService.php
app/Actions/ActivityType.php [new file with mode: 0644]
app/Actions/CommentRepo.php
app/Actions/Tag.php
app/Actions/TagRepo.php
app/Actions/ViewService.php
app/Api/ApiDocsGenerator.php
app/Api/ApiToken.php
app/Auth/Access/Guards/ExternalBaseSessionGuard.php
app/Auth/Access/Guards/Saml2SessionGuard.php
app/Auth/Access/Ldap.php
app/Auth/Access/RegistrationService.php
app/Auth/Access/Saml2Service.php
app/Auth/Access/SocialAuthService.php
app/Auth/Permissions/JointPermission.php
app/Auth/Permissions/PermissionService.php
app/Auth/Permissions/PermissionsRepo.php
app/Auth/Role.php
app/Auth/SocialAccount.php
app/Auth/User.php
app/Auth/UserRepo.php
app/Config/app.php
app/Console/Commands/ClearRevisions.php
app/Console/Commands/CopyShelfPermissions.php
app/Console/Commands/CreateAdmin.php
app/Console/Commands/RegenerateSearch.php
app/Entities/BreadcrumbsViewComposer.php
app/Entities/Chapter.php [deleted file]
app/Entities/EntityProvider.php
app/Entities/Managers/TrashCan.php [deleted file]
app/Entities/Models/Book.php [moved from app/Entities/Book.php with 77% similarity]
app/Entities/Models/BookChild.php [moved from app/Entities/BookChild.php with 87% similarity]
app/Entities/Models/Bookshelf.php [moved from app/Entities/Bookshelf.php with 78% similarity]
app/Entities/Models/Chapter.php [new file with mode: 0644]
app/Entities/Models/Deletion.php [new file with mode: 0644]
app/Entities/Models/Entity.php [moved from app/Entities/Entity.php with 74% similarity]
app/Entities/Models/HasCoverImage.php [moved from app/Entities/HasCoverImage.php with 90% similarity]
app/Entities/Models/Page.php [moved from app/Entities/Page.php with 70% similarity]
app/Entities/Models/PageRevision.php [moved from app/Entities/PageRevision.php with 96% similarity]
app/Entities/Models/SearchTerm.php [moved from app/Entities/SearchTerm.php with 89% similarity]
app/Entities/Repos/BaseRepo.php
app/Entities/Repos/BookRepo.php
app/Entities/Repos/BookshelfRepo.php
app/Entities/Repos/ChapterRepo.php
app/Entities/Repos/PageRepo.php
app/Entities/Tools/BookContents.php [moved from app/Entities/Managers/BookContents.php with 96% similarity]
app/Entities/Tools/ExportFormatter.php [moved from app/Entities/ExportService.php with 96% similarity]
app/Entities/Tools/PageContent.php [moved from app/Entities/Managers/PageContent.php with 93% similarity]
app/Entities/Tools/PageEditActivity.php [moved from app/Entities/Managers/PageEditActivity.php with 95% similarity]
app/Entities/Tools/SearchIndex.php [new file with mode: 0644]
app/Entities/Tools/SearchOptions.php [moved from app/Entities/SearchOptions.php with 98% similarity]
app/Entities/Tools/SearchRunner.php [moved from app/Entities/SearchService.php with 72% similarity]
app/Entities/Tools/ShelfContext.php [moved from app/Entities/Managers/EntityContext.php with 55% similarity]
app/Entities/Tools/SiblingFetcher.php [new file with mode: 0644]
app/Entities/Tools/SlugGenerator.php [moved from app/Entities/SlugGenerator.php with 52% similarity]
app/Entities/Tools/TrashCan.php [new file with mode: 0644]
app/Http/Controllers/Api/ApiController.php
app/Http/Controllers/Api/ApiDocsController.php
app/Http/Controllers/Api/BookApiController.php
app/Http/Controllers/Api/BookExportApiController.php
app/Http/Controllers/Api/BookshelfApiController.php
app/Http/Controllers/Api/ChapterApiController.php
app/Http/Controllers/Api/ChapterExportApiController.php
app/Http/Controllers/Api/PageApiController.php [new file with mode: 0644]
app/Http/Controllers/Api/PageExportApiController.php [new file with mode: 0644]
app/Http/Controllers/AttachmentController.php
app/Http/Controllers/AuditLogController.php
app/Http/Controllers/Auth/ConfirmEmailController.php
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/Auth/Saml2Controller.php
app/Http/Controllers/Auth/UserInviteController.php
app/Http/Controllers/BookController.php
app/Http/Controllers/BookExportController.php
app/Http/Controllers/BookSortController.php
app/Http/Controllers/BookshelfController.php
app/Http/Controllers/ChapterController.php
app/Http/Controllers/ChapterExportController.php
app/Http/Controllers/CommentController.php
app/Http/Controllers/Controller.php
app/Http/Controllers/HomeController.php
app/Http/Controllers/Images/DrawioImageController.php
app/Http/Controllers/Images/GalleryImageController.php
app/Http/Controllers/Images/ImageController.php
app/Http/Controllers/MaintenanceController.php
app/Http/Controllers/PageController.php
app/Http/Controllers/PageExportController.php
app/Http/Controllers/PageRevisionController.php
app/Http/Controllers/PageTemplateController.php
app/Http/Controllers/RecycleBinController.php [new file with mode: 0644]
app/Http/Controllers/RoleController.php [moved from app/Http/Controllers/PermissionController.php with 89% similarity]
app/Http/Controllers/SearchController.php
app/Http/Controllers/SettingController.php
app/Http/Controllers/TagController.php
app/Http/Controllers/UserApiTokenController.php
app/Http/Controllers/UserController.php
app/Interfaces/Loggable.php [new file with mode: 0644]
app/Ownable.php
app/Providers/AppServiceProvider.php
app/Providers/CustomValidationServiceProvider.php [new file with mode: 0644]
app/Uploads/Attachment.php
app/Uploads/Image.php
app/Uploads/ImageRepo.php
app/Uploads/ImageService.php
app/Uploads/UserAvatars.php [new file with mode: 0644]
app/helpers.php
artisan
bootstrap/init.php [deleted file]
composer.json
composer.lock
database/factories/ModelFactory.php
database/migrations/2018_08_04_115700_create_bookshelves_table.php
database/migrations/2020_09_27_210059_add_entity_soft_deletes.php [new file with mode: 0644]
database/migrations/2020_09_27_210528_create_deletions_table.php [new file with mode: 0644]
database/migrations/2020_11_07_232321_simplify_activities_table.php [new file with mode: 0644]
database/seeds/DummyContentSeeder.php
database/seeds/LargeContentSeeder.php
dev/api/requests/pages-create.json [new file with mode: 0644]
dev/api/requests/pages-update.json [new file with mode: 0644]
dev/api/responses/books-read.json
dev/api/responses/chapters-read.json
dev/api/responses/pages-create.json [new file with mode: 0644]
dev/api/responses/pages-list.json [new file with mode: 0644]
dev/api/responses/pages-read.json [new file with mode: 0644]
dev/api/responses/pages-update.json [new file with mode: 0644]
dev/api/responses/shelves-read.json
dev/docker/entrypoint.app.sh
phpunit.xml
public/index.php
readme.md
resources/js/components/markdown-editor.js
resources/js/components/wysiwyg-editor.js
resources/lang/en/activities.php
resources/lang/en/entities.php
resources/lang/en/settings.php
resources/sass/_blocks.scss
resources/sass/_layout.scss
resources/sass/styles.scss
resources/views/books/sort-box.blade.php
resources/views/common/header.blade.php
resources/views/components/page-picker.blade.php
resources/views/pages/markdown-editor.blade.php
resources/views/pages/wysiwyg-editor.blade.php
resources/views/partials/activity-item.blade.php
resources/views/partials/book-tree.blade.php
resources/views/partials/breadcrumbs.blade.php
resources/views/partials/entity-display-item.blade.php [new file with mode: 0644]
resources/views/partials/table-user.blade.php [new file with mode: 0644]
resources/views/settings/audit.blade.php
resources/views/settings/maintenance.blade.php
resources/views/settings/recycle-bin/deletable-entity-list.blade.php [new file with mode: 0644]
resources/views/settings/recycle-bin/destroy.blade.php [new file with mode: 0644]
resources/views/settings/recycle-bin/index.blade.php [new file with mode: 0644]
resources/views/settings/recycle-bin/restore.blade.php [new file with mode: 0644]
resources/views/users/index.blade.php
routes/api.php
routes/web.php
tests/ActivityTrackingTest.php
tests/Api/ApiListingTest.php
tests/Api/BooksApiTest.php
tests/Api/ChaptersApiTest.php
tests/Api/PagesApiTest.php [new file with mode: 0644]
tests/Api/ShelvesApiTest.php
tests/AuditLogTest.php
tests/Auth/AuthTest.php
tests/BrowserKitTest.php
tests/CommandsTest.php
tests/Entity/BookShelfTest.php
tests/Entity/BookTest.php [new file with mode: 0644]
tests/Entity/ChapterTest.php [new file with mode: 0644]
tests/Entity/CommentSettingTest.php
tests/Entity/CommentTest.php
tests/Entity/EntitySearchTest.php
tests/Entity/EntityTest.php
tests/Entity/ExportTest.php
tests/Entity/MarkdownTest.php
tests/Entity/PageContentTest.php
tests/Entity/PageDraftTest.php
tests/Entity/PageRevisionTest.php
tests/Entity/PageTemplateTest.php
tests/Entity/PageTest.php [new file with mode: 0644]
tests/Entity/SearchOptionsTest.php
tests/Entity/SortTest.php
tests/Entity/TagTest.php
tests/ErrorTest.php
tests/HomepageTest.php
tests/Permissions/RestrictionsTest.php
tests/Permissions/RolesTest.php
tests/PublicActionTest.php
tests/RecycleBinTest.php [new file with mode: 0644]
tests/SharedTestHelpers.php
tests/TestCase.php
tests/TestResponse.php
tests/Uploads/AttachmentTest.php
tests/Uploads/DrawioTest.php
tests/Uploads/ImageTest.php
tests/Uploads/UsesImages.php
tests/User/UserApiTokenTest.php
tests/User/UserPreferencesTest.php
tests/User/UserProfileTest.php

index 45b1e13215d475daed4e23734a294602a6119b1d..19643a49f6d83aa6a576fc7d171f52c38a483f2f 100644 (file)
@@ -255,6 +255,14 @@ APP_VIEWS_BOOKSHELVES=grid
 # If set to 'false' a limit will not be enforced.
 REVISION_LIMIT=50
 
+# Recycle Bin Lifetime
+# The number of days that content will remain in the recycle bin before
+# being considered for auto-removal. It is not a guarantee that content will
+# be removed after this time.
+# Set to 0 for no recycle bin functionality.
+# Set to -1 for unlimited recycle bin lifetime.
+RECYCLE_BIN_LIFETIME=30
+
 # Allow <script> tags in page content
 # Note, if set to 'true' the page editor may still escape scripts.
 ALLOW_CONTENT_SCRIPTS=false
index 035a9cc750ef16618ee81b8f48e0981c2442f8da..9d256c9b2918b2cdf0d7f543662bd7a7fb920f87 100644 (file)
@@ -3,18 +3,19 @@
 namespace BookStack\Actions;
 
 use BookStack\Auth\User;
-use BookStack\Entities\Entity;
+use BookStack\Entities\Models\Entity;
 use BookStack\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Support\Str;
 
 /**
- * @property string $key
+ * @property string $type
  * @property User $user
  * @property Entity $entity
- * @property string $extra
+ * @property string $detail
  * @property string $entity_type
  * @property int $entity_id
  * @property int $user_id
- * @property int $book_id
  */
 class Activity extends Model
 {
@@ -32,20 +33,28 @@ class Activity extends Model
 
     /**
      * Get the user this activity relates to.
-     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
      */
-    public function user()
+    public function user(): BelongsTo
     {
         return $this->belongsTo(User::class);
     }
 
     /**
-     * Returns text from the language files, Looks up by using the
-     * activity key.
+     * Returns text from the language files, Looks up by using the activity key.
      */
-    public function getText()
+    public function getText(): string
     {
-        return trans('activities.' . $this->key);
+        return trans('activities.' . $this->type);
+    }
+
+    /**
+     * Check if this activity is intended to be for an entity.
+     */
+    public function isForEntity(): bool
+    {
+        return Str::startsWith($this->type, [
+            'page_', 'chapter_', 'book_', 'bookshelf_'
+        ]);
     }
 
     /**
@@ -53,6 +62,6 @@ class Activity extends Model
      */
     public function isSimilarTo(Activity $activityB): bool
     {
-        return [$this->key, $this->entity_type, $this->entity_id] === [$activityB->key, $activityB->entity_type, $activityB->entity_id];
+        return [$this->type, $this->entity_type, $this->entity_id] === [$activityB->type, $activityB->entity_type, $activityB->entity_id];
     }
 }
index e6b004f01fb0bc15d3b892ba6f56c49676e31cdc..b2a35fd2a5115c7ba869a35ddbd34e69287b8e6d 100644 (file)
@@ -2,57 +2,59 @@
 
 use BookStack\Auth\Permissions\PermissionService;
 use BookStack\Auth\User;
-use BookStack\Entities\Entity;
-use Illuminate\Support\Collection;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Models\Page;
+use BookStack\Interfaces\Loggable;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Database\Eloquent\Relations\Relation;
 use Illuminate\Support\Facades\Log;
 
 class ActivityService
 {
     protected $activity;
-    protected $user;
     protected $permissionService;
 
-    /**
-     * ActivityService constructor.
-     */
     public function __construct(Activity $activity, PermissionService $permissionService)
     {
         $this->activity = $activity;
         $this->permissionService = $permissionService;
-        $this->user = user();
     }
 
     /**
-     * Add activity data to database.
+     * Add activity data to database for an entity.
      */
-    public function add(Entity $entity, string $activityKey, ?int $bookId = null)
+    public function addForEntity(Entity $entity, string $type)
     {
-        $activity = $this->newActivityForUser($activityKey, $bookId);
+        $activity = $this->newActivityForUser($type);
         $entity->activity()->save($activity);
-        $this->setNotification($activityKey);
+        $this->setNotification($type);
     }
 
     /**
-     * Adds a activity history with a message, without binding to a entity.
+     * Add a generic activity event to the database.
+     * @param string|Loggable $detail
      */
-    public function addMessage(string $activityKey, string $message, ?int $bookId = null)
+    public function add(string $type, $detail = '')
     {
-        $this->newActivityForUser($activityKey, $bookId)->forceFill([
-            'extra' => $message
-        ])->save();
+        if ($detail instanceof Loggable) {
+            $detail = $detail->logDescriptor();
+        }
 
-        $this->setNotification($activityKey);
+        $activity = $this->newActivityForUser($type);
+        $activity->detail = $detail;
+        $activity->save();
+        $this->setNotification($type);
     }
 
     /**
      * Get a new activity instance for the current user.
      */
-    protected function newActivityForUser(string $key, ?int $bookId = null): Activity
+    protected function newActivityForUser(string $type): Activity
     {
         return $this->activity->newInstance()->forceFill([
-            'key'     => strtolower($key),
-            'user_id' => $this->user->id,
-            'book_id' => $bookId ?? 0,
+            'type'     => strtolower($type),
+            'user_id' => user()->id,
         ]);
     }
 
@@ -61,15 +63,13 @@ class ActivityService
      * and instead uses the 'extra' field with the entities name.
      * Used when an entity is deleted.
      */
-    public function removeEntity(Entity $entity): Collection
+    public function removeEntity(Entity $entity)
     {
-        $activities = $entity->activity()->get();
         $entity->activity()->update([
-            'extra'       => $entity->name,
-            'entity_id'   => 0,
-            'entity_type' => '',
+            'detail'       => $entity->name,
+            'entity_id'   => null,
+            'entity_type' => null,
         ]);
-        return $activities;
     }
 
     /**
@@ -94,17 +94,30 @@ class ActivityService
      */
     public function entityActivity(Entity $entity, int $count = 20, int $page = 1): array
     {
+        /** @var [string => int[]] $queryIds */
+        $queryIds = [$entity->getMorphClass() => [$entity->id]];
+
         if ($entity->isA('book')) {
-            $query = $this->activity->newQuery()->where('book_id', '=', $entity->id);
-        } else {
-            $query = $this->activity->newQuery()->where('entity_type', '=', $entity->getMorphClass())
-                ->where('entity_id', '=', $entity->id);
+            $queryIds[(new Chapter)->getMorphClass()] = $entity->chapters()->visible()->pluck('id');
+        }
+        if ($entity->isA('book') || $entity->isA('chapter')) {
+            $queryIds[(new Page)->getMorphClass()] = $entity->pages()->visible()->pluck('id');
         }
 
-        $activity = $this->permissionService
-            ->filterRestrictedEntityRelations($query, 'activities', 'entity_id', 'entity_type')
-            ->orderBy('created_at', 'desc')
-            ->with(['entity', 'user.avatar'])
+        $query = $this->activity->newQuery();
+        $query->where(function (Builder $query) use ($queryIds) {
+            foreach ($queryIds as $morphClass => $idArr) {
+                $query->orWhere(function (Builder $innerQuery) use ($morphClass, $idArr) {
+                    $innerQuery->where('entity_type', '=', $morphClass)
+                        ->whereIn('entity_id', $idArr);
+                });
+            }
+        });
+
+        $activity = $query->orderBy('created_at', 'desc')
+            ->with(['entity' => function (Relation $query) {
+                $query->withTrashed();
+            }, 'user.avatar'])
             ->skip($count * ($page - 1))
             ->take($count)
             ->get();
@@ -152,9 +165,9 @@ class ActivityService
     /**
      * Flashes a notification message to the session if an appropriate message is available.
      */
-    protected function setNotification(string $activityKey)
+    protected function setNotification(string $type)
     {
-        $notificationTextKey = 'activities.' . $activityKey . '_notification';
+        $notificationTextKey = 'activities.' . $type . '_notification';
         if (trans()->has($notificationTextKey)) {
             $message = trans($notificationTextKey);
             session()->flash('success', $message);
diff --git a/app/Actions/ActivityType.php b/app/Actions/ActivityType.php
new file mode 100644 (file)
index 0000000..216f612
--- /dev/null
@@ -0,0 +1,51 @@
+<?php namespace BookStack\Actions;
+
+class ActivityType
+{
+    const PAGE_CREATE = 'page_create';
+    const PAGE_UPDATE = 'page_update';
+    const PAGE_DELETE = 'page_delete';
+    const PAGE_RESTORE = 'page_restore';
+    const PAGE_MOVE = 'page_move';
+
+    const CHAPTER_CREATE = 'chapter_create';
+    const CHAPTER_UPDATE = 'chapter_update';
+    const CHAPTER_DELETE = 'chapter_delete';
+    const CHAPTER_MOVE = 'chapter_move';
+
+    const BOOK_CREATE = 'book_create';
+    const BOOK_UPDATE = 'book_update';
+    const BOOK_DELETE = 'book_delete';
+    const BOOK_SORT = 'book_sort';
+
+    const BOOKSHELF_CREATE = 'bookshelf_create';
+    const BOOKSHELF_UPDATE = 'bookshelf_update';
+    const BOOKSHELF_DELETE = 'bookshelf_delete';
+
+    const COMMENTED_ON = 'commented_on';
+    const PERMISSIONS_UPDATE = 'permissions_update';
+
+    const SETTINGS_UPDATE = 'settings_update';
+    const MAINTENANCE_ACTION_RUN = 'maintenance_action_run';
+
+    const RECYCLE_BIN_EMPTY = 'recycle_bin_empty';
+    const RECYCLE_BIN_RESTORE = 'recycle_bin_restore';
+    const RECYCLE_BIN_DESTROY = 'recycle_bin_destroy';
+
+    const USER_CREATE = 'user_create';
+    const USER_UPDATE = 'user_update';
+    const USER_DELETE = 'user_delete';
+
+    const API_TOKEN_CREATE = 'api_token_create';
+    const API_TOKEN_UPDATE = 'api_token_update';
+    const API_TOKEN_DELETE = 'api_token_delete';
+
+    const ROLE_CREATE = 'role_create';
+    const ROLE_UPDATE = 'role_update';
+    const ROLE_DELETE = 'role_delete';
+
+    const AUTH_PASSWORD_RESET = 'auth_password_reset_request';
+    const AUTH_PASSWORD_RESET_UPDATE = 'auth_password_reset_update';
+    const AUTH_LOGIN = 'auth_login';
+    const AUTH_REGISTER = 'auth_register';
+}
\ No newline at end of file
index 4dfe3ddb64f86f3252418b3b83d04ef6367d39b6..13a83e7fdd247064983c64928811b49db2ba4ca1 100644 (file)
@@ -1,7 +1,8 @@
 <?php namespace BookStack\Actions;
 
-use BookStack\Entities\Entity;
+use BookStack\Entities\Models\Entity;
 use League\CommonMark\CommonMarkConverter;
+use BookStack\Facades\Activity as ActivityService;
 
 /**
  * Class CommentRepo
@@ -44,6 +45,7 @@ class CommentRepo
         $comment->parent_id = $parent_id;
 
         $entity->comments()->save($comment);
+        ActivityService::addForEntity($entity, ActivityType::COMMENTED_ON);
         return $comment;
     }
 
index 80a91150868e9cd87be62685891237c392606328..5968ffe6d5ea9d875cf4fa574ac63d3d4c8d62a1 100644 (file)
@@ -2,14 +2,10 @@
 
 use BookStack\Model;
 
-/**
- * Class Attribute
- * @package BookStack
- */
 class Tag extends Model
 {
     protected $fillable = ['name', 'value', 'order'];
-    protected $hidden = ['id', 'entity_id', 'entity_type'];
+    protected $hidden = ['id', 'entity_id', 'entity_type', 'created_at', 'updated_at'];
 
     /**
      * Get the entity that this tag belongs to
index 0297d8bc6997b790085a485b1761085cc946ce59..f58589ccd589c4d019d055156f8596974da447c4 100644 (file)
@@ -1,7 +1,7 @@
 <?php namespace BookStack\Actions;
 
 use BookStack\Auth\Permissions\PermissionService;
-use BookStack\Entities\Entity;
+use BookStack\Entities\Models\Entity;
 use DB;
 use Illuminate\Support\Collection;
 
index 324bfaa4ef9fb14c722148529c4bb895692a12e8..ec57cdb764b6e701a109c20a7ad9668048db2f94 100644 (file)
@@ -1,8 +1,8 @@
 <?php namespace BookStack\Actions;
 
 use BookStack\Auth\Permissions\PermissionService;
-use BookStack\Entities\Book;
-use BookStack\Entities\Entity;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Entity;
 use BookStack\Entities\EntityProvider;
 use DB;
 use Illuminate\Support\Collection;
@@ -28,7 +28,7 @@ class ViewService
 
     /**
      * Add a view to the given entity.
-     * @param \BookStack\Entities\Entity $entity
+     * @param \BookStack\Entities\Models\Entity $entity
      * @return int
      */
     public function add(Entity $entity)
@@ -79,29 +79,26 @@ class ViewService
 
     /**
      * Get all recently viewed entities for the current user.
-     * @param int $count
-     * @param int $page
-     * @param Entity|bool $filterModel
-     * @return mixed
      */
-    public function getUserRecentlyViewed($count = 10, $page = 0, $filterModel = false)
+    public function getUserRecentlyViewed(int $count = 10, int $page = 1)
     {
         $user = user();
         if ($user === null || $user->isDefault()) {
             return collect();
         }
 
-        $query = $this->permissionService
-            ->filterRestrictedEntityRelations($this->view, 'views', 'viewable_id', 'viewable_type');
-
-        if ($filterModel) {
-            $query = $query->where('viewable_type', '=', $filterModel->getMorphClass());
+        $all = collect();
+        /** @var Entity $instance */
+        foreach ($this->entityProvider->all() as $name => $instance) {
+            $items = $instance::visible()->withLastView()
+                ->orderBy('last_viewed_at', 'desc')
+                ->skip($count * ($page - 1))
+                ->take($count)
+                ->get();
+            $all = $all->concat($items);
         }
-        $query = $query->where('user_id', '=', $user->id);
 
-        $viewables = $query->with('viewable')->orderBy('updated_at', 'desc')
-            ->skip($count * $page)->take($count)->get()->pluck('viewable');
-        return $viewables;
+        return $all->sortByDesc('last_viewed_at')->slice(0, $count);
     }
 
     /**
index ddba24bdb65d6ec8dc1474e3d50996c546623228..2953647bb33b21d59f565ce990cffecfc7a8beea 100644 (file)
@@ -1,7 +1,9 @@
 <?php namespace BookStack\Api;
 
 use BookStack\Http\Controllers\Api\ApiController;
+use Illuminate\Contracts\Container\BindingResolutionException;
 use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\Route;
 use Illuminate\Support\Str;
 use ReflectionClass;
@@ -14,10 +16,27 @@ class ApiDocsGenerator
     protected $reflectionClasses = [];
     protected $controllerClasses = [];
 
+    /**
+     * Load the docs form the cache if existing
+     * otherwise generate and store in the cache.
+     */
+    public static function generateConsideringCache(): Collection
+    {
+        $appVersion = trim(file_get_contents(base_path('version')));
+        $cacheKey = 'api-docs::' . $appVersion;
+        if (Cache::has($cacheKey) && config('app.env') === 'production') {
+            $docs = Cache::get($cacheKey);
+        } else {
+            $docs = (new static())->generate();
+            Cache::put($cacheKey, $docs, 60 * 24);
+        }
+        return $docs;
+    }
+
     /**
      * Generate API documentation.
      */
-    public function generate(): Collection
+    protected function generate(): Collection
     {
         $apiRoutes = $this->getFlatApiRoutes();
         $apiRoutes = $this->loadDetailsFromControllers($apiRoutes);
@@ -58,7 +77,7 @@ class ApiDocsGenerator
 
     /**
      * Load body params and their rules by inspecting the given class and method name.
-     * @throws \Illuminate\Contracts\Container\BindingResolutionException
+     * @throws BindingResolutionException
      */
     protected function getBodyParamsFromClass(string $className, string $methodName): ?array
     {
index 523c3b8b80ec2a883e590b73cbf701d2d7e67829..defaa7e954af69354fc3f3c637720f392fbc005c 100644 (file)
@@ -1,11 +1,21 @@
 <?php namespace BookStack\Api;
 
 use BookStack\Auth\User;
+use BookStack\Interfaces\Loggable;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Support\Carbon;
 
-class ApiToken extends Model
+/**
+ * Class ApiToken
+ * @property int $id
+ * @property string $token_id
+ * @property string $secret
+ * @property string $name
+ * @property Carbon $expires_at
+ * @property User $user
+ */
+class ApiToken extends Model implements Loggable
 {
     protected $fillable = ['name', 'expires_at'];
     protected $casts = [
@@ -28,4 +38,12 @@ class ApiToken extends Model
     {
         return Carbon::now()->addYears(100)->format('Y-m-d');
     }
+
+    /**
+     * @inheritdoc
+     */
+    public function logDescriptor(): string
+    {
+        return "({$this->id}) {$this->name}; User: {$this->user->logDescriptor()}";
+    }
 }
index f3d05366d9544ee5dde9c1a17304199b83f834c7..9a0c691c8bbb272301c45bccbf6b7c3739554f84 100644 (file)
@@ -15,8 +15,6 @@ use Illuminate\Contracts\Session\Session;
  * guard with 'remember' functionality removed. Basic auth and event emission
  * has also been removed to keep this simple. Designed to be extended by external
  * Auth Guards.
- *
- * @package Illuminate\Auth
  */
 class ExternalBaseSessionGuard implements StatefulGuard
 {
index 4023913ed77bb47caac93d5dc0d84a6673be4f90..68683bb4368b61ac23769fdce2eeb1ed7e876497 100644 (file)
@@ -9,8 +9,6 @@ namespace BookStack\Auth\Access\Guards;
  * into the default laravel 'Guard' auth flow. Instead most of the logic is done
  * via the Saml2 controller & Saml2Service. This class provides a safer, thin
  * version of SessionGuard.
- *
- * @package BookStack\Auth\Access\Guards
  */
 class Saml2SessionGuard extends ExternalBaseSessionGuard
 {
index 843a2f204920e9bd5154efe477c5212f73efa057..6b7bd9b9bf2a4d4699a74609ab507d33cc72f98d 100644 (file)
@@ -4,7 +4,6 @@
  * Class Ldap
  * An object-orientated thin abstraction wrapper for common PHP LDAP functions.
  * Allows the standard LDAP functions to be mocked for testing.
- * @package BookStack\Services
  */
 class Ldap
 {
index ecc92c117d46ccb84de50a8c2defc2c75322a3a7..2aff6c37d5140c84644f75077cceea2cd25d43d1 100644 (file)
@@ -1,9 +1,11 @@
 <?php namespace BookStack\Auth\Access;
 
+use BookStack\Actions\ActivityType;
 use BookStack\Auth\SocialAccount;
 use BookStack\Auth\User;
 use BookStack\Auth\UserRepo;
 use BookStack\Exceptions\UserRegistrationException;
+use BookStack\Facades\Activity;
 use Exception;
 
 class RegistrationService
@@ -68,6 +70,8 @@ class RegistrationService
             $newUser->socialAccounts()->save($socialAccount);
         }
 
+        Activity::add(ActivityType::AUTH_REGISTER, $socialAccount ?? $newUser);
+
         // Start email confirmation flow if required
         if ($this->emailConfirmationService->confirmationRequired() && !$emailConfirmed) {
             $newUser->save();
index 89ddd0011ecb037c8831b4a79260a18030ee7abe..0316ff976e4623e222ac69cdcf956f8efab55334 100644 (file)
@@ -1,9 +1,11 @@
 <?php namespace BookStack\Auth\Access;
 
+use BookStack\Actions\ActivityType;
 use BookStack\Auth\User;
 use BookStack\Exceptions\JsonDebugException;
 use BookStack\Exceptions\SamlException;
 use BookStack\Exceptions\UserRegistrationException;
+use BookStack\Facades\Activity;
 use Exception;
 use Illuminate\Support\Str;
 use OneLogin\Saml2\Auth;
@@ -372,6 +374,7 @@ class Saml2Service extends ExternalAuthService
         }
 
         auth()->login($user);
+        Activity::add(ActivityType::AUTH_LOGIN, "saml2; {$user->logDescriptor()}");
         return $user;
     }
 }
index 657aae3f327d530557b37c4ff2ce0f6f7126114a..b0383a938522e0ba67cad2213a29895f5d82cba2 100644 (file)
@@ -1,10 +1,12 @@
 <?php namespace BookStack\Auth\Access;
 
+use BookStack\Actions\ActivityType;
 use BookStack\Auth\SocialAccount;
 use BookStack\Auth\UserRepo;
 use BookStack\Exceptions\SocialDriverNotConfigured;
 use BookStack\Exceptions\SocialSignInAccountNotUsed;
 use BookStack\Exceptions\UserRegistrationException;
+use BookStack\Facades\Activity;
 use Illuminate\Support\Str;
 use Laravel\Socialite\Contracts\Factory as Socialite;
 use Laravel\Socialite\Contracts\Provider;
@@ -98,6 +100,7 @@ class SocialAuthService
         // Simply log the user into the application.
         if (!$isLoggedIn && $socialAccount !== null) {
             auth()->login($socialAccount->user);
+            Activity::add(ActivityType::AUTH_LOGIN, $socialAccount);
             return redirect()->intended('/');
         }
 
index 8d1776bd8a45ad59d74d417a82b370ccf20b1fa0..6f7fa582b83a6610b57813bc009fd44582761ab3 100644 (file)
@@ -1,7 +1,7 @@
 <?php namespace BookStack\Auth\Permissions;
 
 use BookStack\Auth\Role;
-use BookStack\Entities\Entity;
+use BookStack\Entities\Models\Entity;
 use BookStack\Model;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Database\Eloquent\Relations\MorphOne;
index 97cc1ca241e84209f235136550378f3cb8f43f81..5f4648d588ab16d553b76c83a4a72d28d456dbcc 100644 (file)
@@ -2,12 +2,9 @@
 
 use BookStack\Auth\Permissions;
 use BookStack\Auth\Role;
-use BookStack\Entities\Book;
-use BookStack\Entities\Bookshelf;
-use BookStack\Entities\Chapter;
-use BookStack\Entities\Entity;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Entity;
 use BookStack\Entities\EntityProvider;
-use BookStack\Entities\Page;
 use BookStack\Ownable;
 use Illuminate\Database\Connection;
 use Illuminate\Database\Eloquent\Builder;
@@ -51,11 +48,6 @@ class PermissionService
 
     /**
      * PermissionService constructor.
-     * @param JointPermission $jointPermission
-     * @param EntityPermission $entityPermission
-     * @param Role $role
-     * @param Connection $db
-     * @param EntityProvider $entityProvider
      */
     public function __construct(
         JointPermission $jointPermission,
@@ -82,7 +74,7 @@ class PermissionService
 
     /**
      * Prepare the local entity cache and ensure it's empty
-     * @param \BookStack\Entities\Entity[] $entities
+     * @param \BookStack\Entities\Models\Entity[] $entities
      */
     protected function readyEntityCache($entities = [])
     {
@@ -119,7 +111,7 @@ class PermissionService
     /**
      * Get a chapter via ID, Checks local cache
      * @param $chapterId
-     * @return \BookStack\Entities\Book
+     * @return \BookStack\Entities\Models\Book
      */
     protected function getChapter($chapterId)
     {
@@ -176,7 +168,7 @@ class PermissionService
         });
 
         // Chunk through all bookshelves
-        $this->entityProvider->bookshelf->newQuery()->select(['id', 'restricted', 'created_by'])
+        $this->entityProvider->bookshelf->newQuery()->withTrashed()->select(['id', 'restricted', 'created_by'])
             ->chunk(50, function ($shelves) use ($roles) {
                 $this->buildJointPermissionsForShelves($shelves, $roles);
             });
@@ -188,11 +180,11 @@ class PermissionService
      */
     protected function bookFetchQuery()
     {
-        return $this->entityProvider->book->newQuery()
+        return $this->entityProvider->book->withTrashed()->newQuery()
             ->select(['id', 'restricted', 'created_by'])->with(['chapters' => function ($query) {
-                $query->select(['id', 'restricted', 'created_by', 'book_id']);
+                $query->withTrashed()->select(['id', 'restricted', 'created_by', 'book_id']);
             }, 'pages'  => function ($query) {
-                $query->select(['id', 'restricted', 'created_by', 'book_id', 'chapter_id']);
+                $query->withTrashed()->select(['id', 'restricted', 'created_by', 'book_id', 'chapter_id']);
             }]);
     }
 
@@ -238,7 +230,7 @@ class PermissionService
 
     /**
      * Rebuild the entity jointPermissions for a particular entity.
-     * @param \BookStack\Entities\Entity $entity
+     * @param \BookStack\Entities\Models\Entity $entity
      * @throws \Throwable
      */
     public function buildJointPermissionsForEntity(Entity $entity)
@@ -333,7 +325,7 @@ class PermissionService
 
     /**
      * Delete all of the entity jointPermissions for a list of entities.
-     * @param \BookStack\Entities\Entity[] $entities
+     * @param \BookStack\Entities\Models\Entity[] $entities
      * @throws \Throwable
      */
     protected function deleteManyJointPermissionsForEntities($entities)
@@ -414,7 +406,7 @@ class PermissionService
 
     /**
      * Get the actions related to an entity.
-     * @param \BookStack\Entities\Entity $entity
+     * @param \BookStack\Entities\Models\Entity $entity
      * @return array
      */
     protected function getActions(Entity $entity)
@@ -500,7 +492,7 @@ class PermissionService
     /**
      * Create an array of data with the information of an entity jointPermissions.
      * Used to build data for bulk insertion.
-     * @param \BookStack\Entities\Entity $entity
+     * @param \BookStack\Entities\Models\Entity $entity
      * @param Role $role
      * @param $action
      * @param $permissionAll
@@ -591,7 +583,7 @@ class PermissionService
     /**
      * Check if an entity has restrictions set on itself or its
      * parent tree.
-     * @param \BookStack\Entities\Entity $entity
+     * @param \BookStack\Entities\Models\Entity $entity
      * @param $action
      * @return bool|mixed
      */
@@ -672,7 +664,7 @@ class PermissionService
     /**
      * Add restrictions for a generic entity
      * @param string $entityType
-     * @param Builder|\BookStack\Entities\Entity $query
+     * @param Builder|\BookStack\Entities\Models\Entity $query
      * @param string $action
      * @return Builder
      */
index ce61093cc5ed26d4f81ebb01671e4a1e91eac6e1..f54612a4339a3423557a994a3fa876636215799b 100644 (file)
@@ -1,10 +1,11 @@
 <?php namespace BookStack\Auth\Permissions;
 
+use BookStack\Actions\ActivityType;
 use BookStack\Auth\Role;
 use BookStack\Exceptions\PermissionsException;
+use BookStack\Facades\Activity;
 use Exception;
 use Illuminate\Database\Eloquent\Collection;
-use Illuminate\Support\Str;
 
 class PermissionsRepo
 {
@@ -60,6 +61,7 @@ class PermissionsRepo
         $permissions = isset($roleData['permissions']) ? array_keys($roleData['permissions']) : [];
         $this->assignRolePermissions($role, $permissions);
         $this->permissionService->buildJointPermissionForRole($role);
+        Activity::add(ActivityType::ROLE_CREATE, $role);
         return $role;
     }
 
@@ -88,12 +90,13 @@ class PermissionsRepo
         $role->fill($roleData);
         $role->save();
         $this->permissionService->buildJointPermissionForRole($role);
+        Activity::add(ActivityType::ROLE_UPDATE, $role);
     }
 
     /**
      * Assign an list of permission names to an role.
      */
-    public function assignRolePermissions(Role $role, array $permissionNameArray = [])
+    protected function assignRolePermissions(Role $role, array $permissionNameArray = [])
     {
         $permissions = [];
         $permissionNameArray = array_values($permissionNameArray);
@@ -137,6 +140,7 @@ class PermissionsRepo
         }
 
         $this->permissionService->deleteJointPermissionsForRole($role);
+        Activity::add(ActivityType::ROLE_DELETE, $role);
         $role->delete();
     }
 }
index 13ec6df16b8488c23120d9a129e617fcad99182b..629cd6a955d8abf7961b67aa1c598d1d62d30658 100644 (file)
@@ -2,8 +2,10 @@
 
 use BookStack\Auth\Permissions\JointPermission;
 use BookStack\Auth\Permissions\RolePermission;
+use BookStack\Interfaces\Loggable;
 use BookStack\Model;
 use Illuminate\Database\Eloquent\Collection;
+use Illuminate\Database\Eloquent\Relations\BelongsToMany;
 use Illuminate\Database\Eloquent\Relations\HasMany;
 
 /**
@@ -14,7 +16,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
  * @property string $external_auth_id
  * @property string $system_name
  */
-class Role extends Model
+class Role extends Model implements Loggable
 {
 
     protected $fillable = ['display_name', 'description', 'external_auth_id'];
@@ -22,7 +24,7 @@ class Role extends Model
     /**
      * The roles that belong to the role.
      */
-    public function users()
+    public function users(): BelongsToMany
     {
         return $this->belongsToMany(User::class)->orderBy('name', 'asc');
     }
@@ -38,7 +40,7 @@ class Role extends Model
     /**
      * The RolePermissions that belong to the role.
      */
-    public function permissions()
+    public function permissions(): BelongsToMany
     {
         return $this->belongsToMany(RolePermission::class, 'permission_role', 'role_id', 'permission_id');
     }
@@ -104,4 +106,12 @@ class Role extends Model
     {
         return static::query()->where('system_name', '!=', 'admin')->get();
     }
+
+    /**
+     * @inheritdoc
+     */
+    public function logDescriptor(): string
+    {
+        return "({$this->id}) {$this->display_name}";
+    }
 }
index 804dbe6292973c16b7dc068ad00dd353c33fa48d..116cdc8546957a4071ad54bbc52aba5b8b6ede6c 100644 (file)
@@ -1,8 +1,14 @@
 <?php namespace BookStack\Auth;
 
+use BookStack\Interfaces\Loggable;
 use BookStack\Model;
 
-class SocialAccount extends Model
+/**
+ * Class SocialAccount
+ * @property string $driver
+ * @property User $user
+ */
+class SocialAccount extends Model implements Loggable
 {
 
     protected $fillable = ['user_id', 'driver', 'driver_id', 'timestamps'];
@@ -11,4 +17,12 @@ class SocialAccount extends Model
     {
         return $this->belongsTo(User::class);
     }
+
+    /**
+     * @inheritDoc
+     */
+    public function logDescriptor(): string
+    {
+        return "{$this->driver}; {$this->user->logDescriptor()}";
+    }
 }
index f65ef5316f67bfe3c68f40a6e2f7925ac7aa8f27..32179a1fbb8d1e97300d4a5445975014705a4631 100644 (file)
@@ -1,6 +1,8 @@
 <?php namespace BookStack\Auth;
 
+use BookStack\Actions\Activity;
 use BookStack\Api\ApiToken;
+use BookStack\Interfaces\Loggable;
 use BookStack\Model;
 use BookStack\Notifications\ResetPassword;
 use BookStack\Uploads\Image;
@@ -11,11 +13,11 @@ use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
 use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;
 use Illuminate\Database\Eloquent\Relations\BelongsToMany;
 use Illuminate\Database\Eloquent\Relations\HasMany;
+use Illuminate\Database\Eloquent\Relations\HasOne;
 use Illuminate\Notifications\Notifiable;
 
 /**
  * Class User
- * @package BookStack\Auth
  * @property string $id
  * @property string $name
  * @property string $email
@@ -27,7 +29,7 @@ use Illuminate\Notifications\Notifiable;
  * @property string $external_auth_id
  * @property string $system_name
  */
-class User extends Model implements AuthenticatableContract, CanResetPasswordContract
+class User extends Model implements AuthenticatableContract, CanResetPasswordContract, Loggable
 {
     use Authenticatable, CanResetPassword, Notifiable;
 
@@ -229,6 +231,14 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
         return $this->hasMany(ApiToken::class);
     }
 
+    /**
+     * Get the latest activity instance for this user.
+     */
+    public function latestActivity(): HasOne
+    {
+        return $this->hasOne(Activity::class)->latest();
+    }
+
     /**
      * Get the url for editing this user.
      */
@@ -274,4 +284,12 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
     {
         $this->notify(new ResetPassword($token));
     }
+
+    /**
+     * @inheritdoc
+     */
+    public function logDescriptor(): string
+    {
+        return "({$this->id}) {$this->name}";
+    }
 }
index fdb8c0923882cabe08e03f4db68e6b89609e1468..6b7de325998a10e8dbd4a530a475d91d0c1f3458 100644 (file)
@@ -1,31 +1,31 @@
 <?php namespace BookStack\Auth;
 
 use Activity;
-use BookStack\Entities\Book;
-use BookStack\Entities\Bookshelf;
-use BookStack\Entities\Chapter;
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Bookshelf;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
 use BookStack\Exceptions\NotFoundException;
 use BookStack\Exceptions\UserUpdateException;
 use BookStack\Uploads\Image;
+use BookStack\Uploads\UserAvatars;
 use Exception;
 use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Database\Eloquent\Collection;
+use Illuminate\Pagination\LengthAwarePaginator;
 use Images;
 use Log;
 
 class UserRepo
 {
-
-    protected $user;
-    protected $role;
+    protected $userAvatar;
 
     /**
      * UserRepo constructor.
      */
-    public function __construct(User $user, Role $role)
+    public function __construct(UserAvatars $userAvatar)
     {
-        $this->user = $user;
-        $this->role = $role;
+        $this->userAvatar = $userAvatar;
     }
 
     /**
@@ -33,36 +33,40 @@ class UserRepo
      */
     public function getByEmail(string $email): ?User
     {
-        return $this->user->where('email', '=', $email)->first();
+        return User::query()->where('email', '=', $email)->first();
     }
 
     /**
-     * @param int $id
-     * @return User
+     * Get a user by their ID.
      */
-    public function getById($id)
+    public function getById(int $id): User
     {
-        return $this->user->newQuery()->findOrFail($id);
+        return User::query()->findOrFail($id);
     }
 
     /**
      * Get all the users with their permissions.
-     * @return Builder|static
      */
-    public function getAllUsers()
+    public function getAllUsers(): Collection
     {
-        return $this->user->with('roles', 'avatar')->orderBy('name', 'asc')->get();
+        return User::query()->with('roles', 'avatar')->orderBy('name', 'asc')->get();
     }
 
     /**
      * Get all the users with their permissions in a paginated format.
-     * @param int $count
-     * @param $sortData
-     * @return Builder|static
      */
-    public function getAllUsersPaginatedAndSorted($count, $sortData)
+    public function getAllUsersPaginatedAndSorted(int $count, array $sortData): LengthAwarePaginator
     {
-        $query = $this->user->with('roles', 'avatar')->orderBy($sortData['sort'], $sortData['order']);
+        $sort = $sortData['sort'];
+        if ($sort === 'latest_activity') {
+            $sort = \BookStack\Actions\Activity::query()->select('created_at')
+                ->whereColumn('activities.user_id', 'users.id')
+                ->latest()
+                ->take(1);
+        }
+
+        $query = User::query()->with(['roles', 'avatar', 'latestActivity'])
+            ->orderBy($sort, $sortData['order']);
 
         if ($sortData['search']) {
             $term = '%' . $sortData['search'] . '%';
@@ -89,14 +93,12 @@ class UserRepo
 
     /**
      * Assign a user to a system-level role.
-     * @param User $user
-     * @param $systemRoleName
      * @throws NotFoundException
      */
-    public function attachSystemRole(User $user, $systemRoleName)
+    public function attachSystemRole(User $user, string $systemRoleName)
     {
-        $role = $this->role->newQuery()->where('system_name', '=', $systemRoleName)->first();
-        if ($role === null) {
+        $role = Role::getSystemRole($systemRoleName);
+        if (is_null($role)) {
             throw new NotFoundException("Role '{$systemRoleName}' not found");
         }
         $user->attachRole($role);
@@ -104,26 +106,23 @@ class UserRepo
 
     /**
      * Checks if the give user is the only admin.
-     * @param User $user
-     * @return bool
      */
-    public function isOnlyAdmin(User $user)
+    public function isOnlyAdmin(User $user): bool
     {
         if (!$user->hasSystemRole('admin')) {
             return false;
         }
 
-        $adminRole = $this->role->getSystemRole('admin');
-        if ($adminRole->users->count() > 1) {
+        $adminRole = Role::getSystemRole('admin');
+        if ($adminRole->users()->count() > 1) {
             return false;
         }
+
         return true;
     }
 
     /**
      * Set the assigned user roles via an array of role IDs.
-     * @param User $user
-     * @param array $roles
      * @throws UserUpdateException
      */
     public function setUserRoles(User $user, array $roles)
@@ -138,14 +137,11 @@ class UserRepo
     /**
      * Check if the given user is the last admin and their new roles no longer
      * contains the admin role.
-     * @param User $user
-     * @param array $newRoles
-     * @return bool
      */
     protected function demotingLastAdmin(User $user, array $newRoles) : bool
     {
         if ($this->isOnlyAdmin($user)) {
-            $adminRole = $this->role->getSystemRole('admin');
+            $adminRole = Role::getSystemRole('admin');
             if (!in_array(strval($adminRole->id), $newRoles)) {
                 return true;
             }
@@ -159,18 +155,18 @@ class UserRepo
      */
     public function create(array $data, bool $emailConfirmed = false): User
     {
-        return $this->user->forceCreate([
+        $details = [
             'name'     => $data['name'],
             'email'    => $data['email'],
             'password' => bcrypt($data['password']),
             'email_confirmed' => $emailConfirmed,
             'external_auth_id' => $data['external_auth_id'] ?? '',
-        ]);
+        ];
+        return User::query()->forceCreate($details);
     }
 
     /**
      * Remove the given user from storage, Delete all related content.
-     * @param User $user
      * @throws Exception
      */
     public function destroy(User $user)
@@ -180,7 +176,10 @@ class UserRepo
         $user->delete();
         
         // Delete user profile images
-        $profileImages = Image::where('type', '=', 'user')->where('uploaded_to', '=', $user->id)->get();
+        $profileImages = Image::query()->where('type', '=', 'user')
+            ->where('uploaded_to', '=', $user->id)
+            ->get();
+
         foreach ($profileImages as $image) {
             Images::destroy($image);
         }
@@ -188,12 +187,8 @@ class UserRepo
 
     /**
      * Get the latest activity for a user.
-     * @param User $user
-     * @param int $count
-     * @param int $page
-     * @return array
      */
-    public function getActivity(User $user, $count = 20, $page = 0)
+    public function getActivity(User $user, int $count = 20, int $page = 0): array
     {
         return Activity::userActivity($user, $count, $page);
     }
@@ -234,33 +229,22 @@ class UserRepo
 
     /**
      * Get the roles in the system that are assignable to a user.
-     * @return mixed
      */
-    public function getAllRoles()
+    public function getAllRoles(): Collection
     {
-        return $this->role->newQuery()->orderBy('display_name', 'asc')->get();
+        return Role::query()->orderBy('display_name', 'asc')->get();
     }
 
     /**
      * Get an avatar image for a user and set it as their avatar.
      * Returns early if avatars disabled or not set in config.
-     * @param User $user
-     * @return bool
      */
-    public function downloadAndAssignUserAvatar(User $user)
+    public function downloadAndAssignUserAvatar(User $user): void
     {
-        if (!Images::avatarFetchEnabled()) {
-            return false;
-        }
-
         try {
-            $avatar = Images::saveUserAvatar($user);
-            $user->avatar()->associate($avatar);
-            $user->save();
-            return true;
+            $this->userAvatar->fetchAndAssignToUser($user);
         } catch (Exception $e) {
             Log::error('Failed to save user avatar image');
-            return false;
         }
     }
 }
index 8a1d175284851708caa41dff5e44ef6b4cb8a1a1..7297048b4a33130df700274791a9b8833a15dc9c 100755 (executable)
@@ -31,6 +31,13 @@ return [
     // If set to false then a limit will not be enforced.
     'revision_limit' => env('REVISION_LIMIT', 50),
 
+    // The number of days that content will remain in the recycle bin before
+    // being considered for auto-removal. It is not a guarantee that content will
+    // be removed after this time.
+    // Set to 0 for no recycle bin functionality.
+    // Set to -1 for unlimited recycle bin lifetime.
+    'recycle_bin_lifetime' => env('RECYCLE_BIN_LIFETIME', 30),
+
     // Allow <script> tags to entered within page content.
     // <script> tags are escaped by default.
     // Even when overridden the WYSIWYG editor may still escape script content.
@@ -117,6 +124,7 @@ return [
         BookStack\Providers\EventServiceProvider::class,
         BookStack\Providers\RouteServiceProvider::class,
         BookStack\Providers\CustomFacadeProvider::class,
+        BookStack\Providers\CustomValidationServiceProvider::class,
     ],
 
     /*
index 15f1fcc0a7138f057d3894c727cb62774a0ef37b..681a7564b282e3e6d279c1ffa2b9ddfbce96488e 100644 (file)
@@ -2,7 +2,7 @@
 
 namespace BookStack\Console\Commands;
 
-use BookStack\Entities\PageRevision;
+use BookStack\Entities\Models\PageRevision;
 use Illuminate\Console\Command;
 
 class ClearRevisions extends Command
index 6b5d35a476798a67d2bf16a42d2225b67363d632..d220c59f9ea7cedbf0a77c6e6ccb3bab27d7496e 100644 (file)
@@ -2,7 +2,7 @@
 
 namespace BookStack\Console\Commands;
 
-use BookStack\Entities\Bookshelf;
+use BookStack\Entities\Models\Bookshelf;
 use BookStack\Entities\Repos\BookshelfRepo;
 use Illuminate\Console\Command;
 
index e67da871763f8b9ef379c8687961ec2612d33bc3..3d1a3dca08db4045e528bfcab92c13984cbdeff9 100644 (file)
@@ -28,8 +28,6 @@ class CreateAdmin extends Command
 
     /**
      * Create a new command instance.
-     *
-     * @param UserRepo $userRepo
      */
     public function __construct(UserRepo $userRepo)
     {
index dc57f2cea764b3a8517de9453ae4ee05fd86d9a6..3dc3ec0af0e98b33bd3f1d741540dd13e1d319c5 100644 (file)
@@ -2,7 +2,7 @@
 
 namespace BookStack\Console\Commands;
 
-use BookStack\Entities\SearchService;
+use BookStack\Entities\Tools\SearchIndex;
 use DB;
 use Illuminate\Console\Command;
 
@@ -22,17 +22,15 @@ class RegenerateSearch extends Command
      */
     protected $description = 'Re-index all content for searching';
 
-    protected $searchService;
+    protected $searchIndex;
 
     /**
      * Create a new command instance.
-     *
-     * @param SearchService $searchService
      */
-    public function __construct(SearchService $searchService)
+    public function __construct(SearchIndex $searchIndex)
     {
         parent::__construct();
-        $this->searchService = $searchService;
+        $this->searchIndex = $searchIndex;
     }
 
     /**
@@ -45,10 +43,9 @@ class RegenerateSearch extends Command
         $connection = DB::getDefaultConnection();
         if ($this->option('database') !== null) {
             DB::setDefaultConnection($this->option('database'));
-            $this->searchService->setConnection(DB::connection($this->option('database')));
         }
 
-        $this->searchService->indexAllEntities();
+        $this->searchIndex->indexAllEntities();
         DB::setDefaultConnection($connection);
         $this->comment('Search index regenerated');
     }
index 43d63d026021dd07edcb1165841018ee213b13a3..cf7cf296c94fdbd8cdcb3b7dffd59767cb8000ad 100644 (file)
@@ -1,6 +1,7 @@
 <?php namespace BookStack\Entities;
 
-use BookStack\Entities\Managers\EntityContext;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Tools\ShelfContext;
 use Illuminate\View\View;
 
 class BreadcrumbsViewComposer
@@ -10,9 +11,9 @@ class BreadcrumbsViewComposer
 
     /**
      * BreadcrumbsViewComposer constructor.
-     * @param EntityContext $entityContextManager
+     * @param ShelfContext $entityContextManager
      */
-    public function __construct(EntityContext $entityContextManager)
+    public function __construct(ShelfContext $entityContextManager)
     {
         $this->entityContextManager = $entityContextManager;
     }
diff --git a/app/Entities/Chapter.php b/app/Entities/Chapter.php
deleted file mode 100644 (file)
index 5f5509e..0000000
+++ /dev/null
@@ -1,64 +0,0 @@
-<?php namespace BookStack\Entities;
-
-use Illuminate\Support\Collection;
-
-/**
- * Class Chapter
- * @property Collection<Page> $pages
- */
-class Chapter extends BookChild
-{
-    public $searchFactor = 1.3;
-
-    protected $fillable = ['name', 'description', 'priority', 'book_id'];
-    protected $hidden = ['restricted', 'pivot'];
-
-    /**
-     * Get the pages that this chapter contains.
-     * @param string $dir
-     * @return mixed
-     */
-    public function pages($dir = 'ASC')
-    {
-        return $this->hasMany(Page::class)->orderBy('priority', $dir);
-    }
-
-    /**
-     * Get the url of this chapter.
-     * @param string|bool $path
-     * @return string
-     */
-    public function getUrl($path = false)
-    {
-        $bookSlug = $this->getAttribute('bookSlug') ? $this->getAttribute('bookSlug') : $this->book->slug;
-        $fullPath = '/books/' . urlencode($bookSlug) . '/chapter/' . urlencode($this->slug);
-
-        if ($path !== false) {
-            $fullPath .= '/' . trim($path, '/');
-        }
-
-        return url($fullPath);
-    }
-
-    /**
-     * Get an excerpt of this chapter's description to the specified length or less.
-     * @param int $length
-     * @return string
-     */
-    public function getExcerpt(int $length = 100)
-    {
-        $description = $this->text ?? $this->description;
-        return mb_strlen($description) > $length ? mb_substr($description, 0, $length-3) . '...' : $description;
-    }
-
-    /**
-     * Get the visible pages in this chapter.
-     */
-    public function getVisiblePages(): Collection
-    {
-        return $this->pages()->visible()
-        ->orderBy('draft', 'desc')
-        ->orderBy('priority', 'asc')
-        ->get();
-    }
-}
index 6bf923b3112aa8e7387fd6eedeb601a601511dad..ef1935a0f0fa9891aec03f7b7010317feedf8b0c 100644 (file)
@@ -1,13 +1,18 @@
 <?php namespace BookStack\Entities;
 
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Bookshelf;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Models\Page;
+use BookStack\Entities\Models\PageRevision;
+
 /**
  * Class EntityProvider
  *
  * Provides access to the core entity models.
  * Wrapped up in this provider since they are often used together
  * so this is a neater alternative to injecting all in individually.
- *
- * @package BookStack\Entities
  */
 class EntityProvider
 {
@@ -37,26 +42,20 @@ class EntityProvider
      */
     public $pageRevision;
 
-    /**
-     * EntityProvider constructor.
-     */
-    public function __construct(
-        Bookshelf $bookshelf,
-        Book $book,
-        Chapter $chapter,
-        Page $page,
-        PageRevision $pageRevision
-    ) {
-        $this->bookshelf = $bookshelf;
-        $this->book = $book;
-        $this->chapter = $chapter;
-        $this->page = $page;
-        $this->pageRevision = $pageRevision;
+
+    public function __construct()
+    {
+        $this->bookshelf = new Bookshelf();
+        $this->book = new Book();
+        $this->chapter = new Chapter();
+        $this->page = new Page();
+        $this->pageRevision = new PageRevision();
     }
 
     /**
      * Fetch all core entity types as an associated array
      * with their basic names as the keys.
+     * @return [string => Entity]
      */
     public function all(): array
     {
diff --git a/app/Entities/Managers/TrashCan.php b/app/Entities/Managers/TrashCan.php
deleted file mode 100644 (file)
index 1a32294..0000000
+++ /dev/null
@@ -1,109 +0,0 @@
-<?php namespace BookStack\Entities\Managers;
-
-use BookStack\Entities\Book;
-use BookStack\Entities\Bookshelf;
-use BookStack\Entities\Chapter;
-use BookStack\Entities\Entity;
-use BookStack\Entities\HasCoverImage;
-use BookStack\Entities\Page;
-use BookStack\Exceptions\NotifyException;
-use BookStack\Facades\Activity;
-use BookStack\Uploads\AttachmentService;
-use BookStack\Uploads\ImageService;
-use Exception;
-use Illuminate\Contracts\Container\BindingResolutionException;
-
-class TrashCan
-{
-
-    /**
-     * Remove a bookshelf from the system.
-     * @throws Exception
-     */
-    public function destroyShelf(Bookshelf $shelf)
-    {
-        $this->destroyCommonRelations($shelf);
-        $shelf->delete();
-    }
-
-    /**
-     * Remove a book from the system.
-     * @throws NotifyException
-     * @throws BindingResolutionException
-     */
-    public function destroyBook(Book $book)
-    {
-        foreach ($book->pages as $page) {
-            $this->destroyPage($page);
-        }
-
-        foreach ($book->chapters as $chapter) {
-            $this->destroyChapter($chapter);
-        }
-
-        $this->destroyCommonRelations($book);
-        $book->delete();
-    }
-
-    /**
-     * Remove a page from the system.
-     * @throws NotifyException
-     */
-    public function destroyPage(Page $page)
-    {
-        // Check if set as custom homepage & remove setting if not used or throw error if active
-        $customHome = setting('app-homepage', '0:');
-        if (intval($page->id) === intval(explode(':', $customHome)[0])) {
-            if (setting('app-homepage-type') === 'page') {
-                throw new NotifyException(trans('errors.page_custom_home_deletion'), $page->getUrl());
-            }
-            setting()->remove('app-homepage');
-        }
-
-        $this->destroyCommonRelations($page);
-
-        // Delete Attached Files
-        $attachmentService = app(AttachmentService::class);
-        foreach ($page->attachments as $attachment) {
-            $attachmentService->deleteFile($attachment);
-        }
-
-        $page->delete();
-    }
-
-    /**
-     * Remove a chapter from the system.
-     * @throws Exception
-     */
-    public function destroyChapter(Chapter $chapter)
-    {
-        if (count($chapter->pages) > 0) {
-            foreach ($chapter->pages as $page) {
-                $page->chapter_id = 0;
-                $page->save();
-            }
-        }
-
-        $this->destroyCommonRelations($chapter);
-        $chapter->delete();
-    }
-
-    /**
-     * Update entity relations to remove or update outstanding connections.
-     */
-    protected function destroyCommonRelations(Entity $entity)
-    {
-        Activity::removeEntity($entity);
-        $entity->views()->delete();
-        $entity->permissions()->delete();
-        $entity->tags()->delete();
-        $entity->comments()->delete();
-        $entity->jointPermissions()->delete();
-        $entity->searchTerms()->delete();
-
-        if ($entity instanceof HasCoverImage && $entity->cover) {
-            $imageService = app()->make(ImageService::class);
-            $imageService->destroy($entity->cover);
-        }
-    }
-}
similarity index 77%
rename from app/Entities/Book.php
rename to app/Entities/Models/Book.php
index af8344b88f5cb440b9abeb6913e3e55ffeda68f6..6c56767655c894b22c548e7ff552a07f36940ea6 100644 (file)
@@ -1,4 +1,4 @@
-<?php namespace BookStack\Entities;
+<?php namespace BookStack\Entities\Models;
 
 use BookStack\Uploads\Image;
 use Exception;
@@ -12,26 +12,20 @@ use Illuminate\Support\Collection;
  * @property string $description
  * @property int $image_id
  * @property Image|null $cover
- * @package BookStack\Entities
  */
 class Book extends Entity implements HasCoverImage
 {
     public $searchFactor = 2;
 
     protected $fillable = ['name', 'description'];
-    protected $hidden = ['restricted', 'pivot', 'image_id'];
+    protected $hidden = ['restricted', 'pivot', 'image_id', 'deleted_at'];
 
     /**
      * Get the url for this book.
-     * @param string|bool $path
-     * @return string
      */
-    public function getUrl($path = false)
+    public function getUrl(string $path = ''): string
     {
-        if ($path !== false) {
-            return url('/books/' . urlencode($this->slug) . '/' . trim($path, '/'));
-        }
-        return url('/books/' . urlencode($this->slug));
+        return url('/books/' . implode('/', [urlencode($this->slug), trim($path, '/')]));
     }
 
     /**
@@ -117,15 +111,4 @@ class Book extends Entity implements HasCoverImage
         $chapters = $this->chapters()->visible()->get();
         return $pages->concat($chapters)->sortBy('priority')->sortByDesc('draft');
     }
-
-    /**
-     * Get an excerpt of this book's description to the specified length or less.
-     * @param int $length
-     * @return string
-     */
-    public function getExcerpt(int $length = 100)
-    {
-        $description = $this->description;
-        return mb_strlen($description) > $length ? mb_substr($description, 0, $length-3) . '...' : $description;
-    }
 }
similarity index 87%
rename from app/Entities/BookChild.php
rename to app/Entities/Models/BookChild.php
index 6eac4375ddce6c271a06669a8eaa108b774d55e2..91f62dc403c64fdde0079b223e4574bd84199251 100644 (file)
@@ -1,5 +1,8 @@
-<?php namespace BookStack\Entities;
+<?php namespace BookStack\Entities\Models;
 
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Models\Book;
 use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 
@@ -10,7 +13,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
  * @property Book $book
  * @method Builder whereSlugs(string $bookSlug, string $childSlug)
  */
-class BookChild extends Entity
+abstract class BookChild extends Entity
 {
 
     /**
@@ -45,9 +48,6 @@ class BookChild extends Entity
         $this->save();
         $this->refresh();
 
-        // Update related activity
-        $this->activity()->update(['book_id' => $newBookId]);
-
         // Update all child pages if a chapter
         if ($this instanceof Chapter) {
             foreach ($this->pages as $page) {
similarity index 78%
rename from app/Entities/Bookshelf.php
rename to app/Entities/Models/Bookshelf.php
index 474ba51cd8204bf27dfc95ad421029cdaa8e7375..8ffd06d2e2f1b9d22dee6cabb23318976c85ddac 100644 (file)
@@ -1,4 +1,4 @@
-<?php namespace BookStack\Entities;
+<?php namespace BookStack\Entities\Models;
 
 use BookStack\Uploads\Image;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -12,7 +12,7 @@ class Bookshelf extends Entity implements HasCoverImage
 
     protected $fillable = ['name', 'description', 'image_id'];
 
-    protected $hidden = ['restricted', 'image_id'];
+    protected $hidden = ['restricted', 'image_id', 'deleted_at'];
 
     /**
      * Get the books in this shelf.
@@ -36,15 +36,10 @@ class Bookshelf extends Entity implements HasCoverImage
 
     /**
      * Get the url for this bookshelf.
-     * @param string|bool $path
-     * @return string
      */
-    public function getUrl($path = false)
+    public function getUrl(string $path = ''): string
     {
-        if ($path !== false) {
-            return url('/shelves/' . urlencode($this->slug) . '/' . trim($path, '/'));
-        }
-        return url('/shelves/' . urlencode($this->slug));
+        return url('/shelves/' . implode('/', [urlencode($this->slug), trim($path, '/')]));
     }
 
     /**
@@ -85,17 +80,6 @@ class Bookshelf extends Entity implements HasCoverImage
         return 'cover_shelf';
     }
 
-    /**
-     * Get an excerpt of this book's description to the specified length or less.
-     * @param int $length
-     * @return string
-     */
-    public function getExcerpt(int $length = 100)
-    {
-        $description = $this->description;
-        return mb_strlen($description) > $length ? mb_substr($description, 0, $length-3) . '...' : $description;
-    }
-
     /**
      * Check if this shelf contains the given book.
      * @param Book $book
diff --git a/app/Entities/Models/Chapter.php b/app/Entities/Models/Chapter.php
new file mode 100644 (file)
index 0000000..257b19e
--- /dev/null
@@ -0,0 +1,52 @@
+<?php namespace BookStack\Entities\Models;
+
+use Illuminate\Support\Collection;
+
+/**
+ * Class Chapter
+ * @property Collection<Page> $pages
+ */
+class Chapter extends BookChild
+{
+    public $searchFactor = 1.3;
+
+    protected $fillable = ['name', 'description', 'priority', 'book_id'];
+    protected $hidden = ['restricted', 'pivot', 'deleted_at'];
+
+    /**
+     * Get the pages that this chapter contains.
+     * @param string $dir
+     * @return mixed
+     */
+    public function pages($dir = 'ASC')
+    {
+        return $this->hasMany(Page::class)->orderBy('priority', $dir);
+    }
+
+    /**
+     * Get the url of this chapter.
+     */
+    public function getUrl($path = ''): string
+    {
+        $parts = [
+            'books',
+            urlencode($this->getAttribute('bookSlug') ?? $this->book->slug),
+            'chapter',
+            urlencode($this->slug),
+            trim($path, '/'),
+        ];
+
+        return url('/' . implode('/', $parts));
+    }
+
+    /**
+     * Get the visible pages in this chapter.
+     */
+    public function getVisiblePages(): Collection
+    {
+        return $this->pages()->visible()
+        ->orderBy('draft', 'desc')
+        ->orderBy('priority', 'asc')
+        ->get();
+    }
+}
diff --git a/app/Entities/Models/Deletion.php b/app/Entities/Models/Deletion.php
new file mode 100644 (file)
index 0000000..1be0ba4
--- /dev/null
@@ -0,0 +1,48 @@
+<?php namespace BookStack\Entities\Models;
+
+use BookStack\Auth\User;
+use BookStack\Entities\Models\Entity;
+use BookStack\Interfaces\Loggable;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\Relations\MorphTo;
+
+class Deletion extends Model implements Loggable
+{
+
+    /**
+     * Get the related deletable record.
+     */
+    public function deletable(): MorphTo
+    {
+        return $this->morphTo('deletable')->withTrashed();
+    }
+
+    /**
+     * The the user that performed the deletion.
+     */
+    public function deleter(): BelongsTo
+    {
+        return $this->belongsTo(User::class, 'deleted_by');
+    }
+
+    /**
+     * Create a new deletion record for the provided entity.
+     */
+    public static function createForEntity(Entity $entity): Deletion
+    {
+        $record = (new self())->forceFill([
+            'deleted_by' => user()->id,
+            'deletable_type' => $entity->getMorphClass(),
+            'deletable_id' => $entity->id,
+        ]);
+        $record->save();
+        return $record;
+    }
+
+    public function logDescriptor(): string
+    {
+        $deletable = $this->deletable()->first();
+        return "Deletion ({$this->id}) for {$deletable->getType()} ({$deletable->id}) {$deletable->name}";
+    }
+}
similarity index 74%
rename from app/Entities/Entity.php
rename to app/Entities/Models/Entity.php
index cc7df46d4f653e201f9ba17c72a09c0b45046577..e681a4e22f1f2691f9e05b75f2b1ffd56bfd7098 100644 (file)
@@ -1,4 +1,4 @@
-<?php namespace BookStack\Entities;
+<?php namespace BookStack\Entities\Models;
 
 use BookStack\Actions\Activity;
 use BookStack\Actions\Comment;
@@ -6,12 +6,15 @@ use BookStack\Actions\Tag;
 use BookStack\Actions\View;
 use BookStack\Auth\Permissions\EntityPermission;
 use BookStack\Auth\Permissions\JointPermission;
+use BookStack\Entities\Tools\SearchIndex;
+use BookStack\Entities\Tools\SlugGenerator;
 use BookStack\Facades\Permissions;
 use BookStack\Ownable;
 use Carbon\Carbon;
 use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Database\Eloquent\Collection;
 use Illuminate\Database\Eloquent\Relations\MorphMany;
+use Illuminate\Database\Eloquent\SoftDeletes;
 
 /**
  * Class Entity
@@ -31,11 +34,10 @@ use Illuminate\Database\Eloquent\Relations\MorphMany;
  * @method static Entity|Builder hasPermission(string $permission)
  * @method static Builder withLastView()
  * @method static Builder withViewCount()
- *
- * @package BookStack\Entities
  */
-class Entity extends Ownable
+abstract class Entity extends Ownable
 {
+    use SoftDeletes;
 
     /**
      * @var string - Name of property where the main text content is found
@@ -50,7 +52,7 @@ class Entity extends Ownable
     /**
      * Get the entities that are visible to the current user.
      */
-    public function scopeVisible(Builder $query)
+    public function scopeVisible(Builder $query): Builder
     {
         return $this->scopeHasPermission($query, 'view');
     }
@@ -92,24 +94,18 @@ class Entity extends Ownable
     /**
      * Compares this entity to another given entity.
      * Matches by comparing class and id.
-     * @param $entity
-     * @return bool
      */
-    public function matches($entity)
+    public function matches(Entity $entity): bool
     {
         return [get_class($this), $this->id] === [get_class($entity), $entity->id];
     }
 
     /**
-     * Checks if an entity matches or contains another given entity.
-     * @param Entity $entity
-     * @return bool
+     * Checks if the current entity matches or contains the given.
      */
-    public function matchesOrContains(Entity $entity)
+    public function matchesOrContains(Entity $entity): bool
     {
-        $matches = [get_class($this), $this->id] === [get_class($entity), $entity->id];
-
-        if ($matches) {
+        if ($this->matches($entity)) {
             return true;
         }
 
@@ -126,9 +122,8 @@ class Entity extends Ownable
 
     /**
      * Gets the activity objects for this entity.
-     * @return MorphMany
      */
-    public function activity()
+    public function activity(): MorphMany
     {
         return $this->morphMany(Activity::class, 'entity')
             ->orderBy('created_at', 'desc');
@@ -137,26 +132,23 @@ class Entity extends Ownable
     /**
      * Get View objects for this entity.
      */
-    public function views()
+    public function views(): MorphMany
     {
         return $this->morphMany(View::class, 'viewable');
     }
 
     /**
      * Get the Tag models that have been user assigned to this entity.
-     * @return MorphMany
      */
-    public function tags()
+    public function tags(): MorphMany
     {
         return $this->morphMany(Tag::class, 'entity')->orderBy('order', 'asc');
     }
 
     /**
      * Get the comments for an entity
-     * @param bool $orderByCreated
-     * @return MorphMany
      */
-    public function comments($orderByCreated = true)
+    public function comments(bool $orderByCreated = true): MorphMany
     {
         $query = $this->morphMany(Comment::class, 'entity');
         return $orderByCreated ? $query->orderBy('created_at', 'asc') : $query;
@@ -164,9 +156,8 @@ class Entity extends Ownable
 
     /**
      * Get the related search terms.
-     * @return MorphMany
      */
-    public function searchTerms()
+    public function searchTerms(): MorphMany
     {
         return $this->morphMany(SearchTerm::class, 'entity');
     }
@@ -174,18 +165,15 @@ class Entity extends Ownable
     /**
      * Get this entities restrictions.
      */
-    public function permissions()
+    public function permissions(): MorphMany
     {
         return $this->morphMany(EntityPermission::class, 'restrictable');
     }
 
     /**
      * Check if this entity has a specific restriction set against it.
-     * @param $role_id
-     * @param $action
-     * @return bool
      */
-    public function hasRestriction($role_id, $action)
+    public function hasRestriction(int $role_id, string $action): bool
     {
         return $this->permissions()->where('role_id', '=', $role_id)
             ->where('action', '=', $action)->count() > 0;
@@ -193,45 +181,36 @@ class Entity extends Ownable
 
     /**
      * Get the entity jointPermissions this is connected to.
-     * @return MorphMany
      */
-    public function jointPermissions()
+    public function jointPermissions(): MorphMany
     {
         return $this->morphMany(JointPermission::class, 'entity');
     }
 
     /**
-     * Check if this instance or class is a certain type of entity.
-     * Examples of $type are 'page', 'book', 'chapter'
+     * Get the related delete records for this entity.
      */
-    public static function isA(string $type): bool
+    public function deletions(): MorphMany
     {
-        return static::getType() === strtolower($type);
+        return $this->morphMany(Deletion::class, 'deletable');
     }
 
     /**
-     * Get entity type.
-     * @return mixed
+     * Check if this instance or class is a certain type of entity.
+     * Examples of $type are 'page', 'book', 'chapter'
      */
-    public static function getType()
+    public static function isA(string $type): bool
     {
-        return strtolower(static::getClassName());
+        return static::getType() === strtolower($type);
     }
 
     /**
-     * Get an instance of an entity of the given type.
-     * @param $type
-     * @return Entity
+     * Get the entity type as a simple lowercase word.
      */
-    public static function getEntityInstance($type)
+    public static function getType(): string
     {
-        $types = ['Page', 'Book', 'Chapter', 'Bookshelf'];
-        $className = str_replace([' ', '-', '_'], '', ucwords($type));
-        if (!in_array($className, $types)) {
-            return null;
-        }
-
-        return app('BookStack\\Entities\\' . $className);
+        $className = array_slice(explode('\\', static::class), -1, 1)[0];
+        return strtolower($className);
     }
 
     /**
@@ -247,35 +226,45 @@ class Entity extends Ownable
 
     /**
      * Get the body text of this entity.
-     * @return mixed
      */
-    public function getText()
+    public function getText(): string
     {
-        return $this->{$this->textField};
+        return $this->{$this->textField} ?? '';
     }
 
     /**
      * Get an excerpt of this entity's descriptive content to the specified length.
-     * @param int $length
-     * @return mixed
      */
-    public function getExcerpt(int $length = 100)
+    public function getExcerpt(int $length = 100): string
     {
         $text = $this->getText();
+
         if (mb_strlen($text) > $length) {
             $text = mb_substr($text, 0, $length-3) . '...';
         }
+
         return trim($text);
     }
 
     /**
      * Get the url of this entity
-     * @param $path
-     * @return string
      */
-    public function getUrl($path = '/')
+    abstract public function getUrl(string $path = '/'): string;
+
+    /**
+     * Get the parent entity if existing.
+     * This is the "static" parent and does not include dynamic
+     * relations such as shelves to books.
+     */
+    public function getParent(): ?Entity
     {
-        return $path;
+        if ($this->isA('page')) {
+            return $this->chapter_id ? $this->chapter()->withTrashed()->first() : $this->book()->withTrashed()->first();
+        }
+        if ($this->isA('chapter')) {
+            return $this->book()->withTrashed()->first();
+        }
+        return null;
     }
 
     /**
@@ -292,8 +281,7 @@ class Entity extends Ownable
      */
     public function indexForSearch()
     {
-        $searchService = app()->make(SearchService::class);
-        $searchService->indexEntity(clone $this);
+        app(SearchIndex::class)->indexEntity(clone $this);
     }
 
     /**
@@ -301,8 +289,7 @@ class Entity extends Ownable
      */
     public function refreshSlug(): string
     {
-        $generator = new SlugGenerator($this);
-        $this->slug = $generator->generate();
+        $this->slug = (new SlugGenerator)->generate($this);
         return $this->slug;
     }
 }
similarity index 90%
rename from app/Entities/HasCoverImage.php
rename to app/Entities/Models/HasCoverImage.php
index 31277f4b69c59bb659c2842277015ffbccca843c..f3a486d1877f32a2ef3fcf9f2145868d94337005 100644 (file)
@@ -1,7 +1,7 @@
 <?php
 
 
-namespace BookStack\Entities;
+namespace BookStack\Entities\Models;
 
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 
similarity index 70%
rename from app/Entities/Page.php
rename to app/Entities/Models/Page.php
index 32ba2981d807e7a2b2b7a35c7984e523a82448ca..52c64f0480af78fc64a30bae48c8f05b1d108407 100644 (file)
@@ -1,5 +1,6 @@
-<?php namespace BookStack\Entities;
+<?php namespace BookStack\Entities\Models;
 
+use BookStack\Entities\Tools\PageContent;
 use BookStack\Uploads\Attachment;
 use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Database\Eloquent\Collection;
@@ -27,12 +28,17 @@ class Page extends BookChild
 
     public $textField = 'text';
 
-    protected $hidden = ['html', 'markdown', 'text', 'restricted', 'pivot'];
+    protected $hidden = ['html', 'markdown', 'text', 'restricted', 'pivot', 'deleted_at'];
+
+    protected $casts = [
+        'draft' => 'boolean',
+        'template' => 'boolean',
+    ];
 
     /**
      * Get the entities that are visible to the current user.
      */
-    public function scopeVisible(Builder $query)
+    public function scopeVisible(Builder $query): Builder
     {
         $query = Permissions::enforceDraftVisiblityOnQuery($query);
         return parent::scopeVisible($query);
@@ -49,14 +55,6 @@ class Page extends BookChild
         return $array;
     }
 
-    /**
-     * Get the parent item
-     */
-    public function parent(): Entity
-    {
-        return $this->chapter_id ? $this->chapter : $this->book;
-    }
-
     /**
      * Get the chapter that this page is in, If applicable.
      * @return BelongsTo
@@ -94,22 +92,19 @@ class Page extends BookChild
     }
 
     /**
-     * Get the url for this page.
-     * @param string|bool $path
-     * @return string
+     * Get the url of this page.
      */
-    public function getUrl($path = false)
+    public function getUrl($path = ''): string
     {
-        $bookSlug = $this->getAttribute('bookSlug') ? $this->getAttribute('bookSlug') : $this->book->slug;
-        $midText = $this->draft ? '/draft/' : '/page/';
-        $idComponent = $this->draft ? $this->id : urlencode($this->slug);
-
-        $url = '/books/' . urlencode($bookSlug) . $midText . $idComponent;
-        if ($path !== false) {
-            $url .= '/' . trim($path, '/');
-        }
-
-        return url($url);
+        $parts = [
+            'books',
+            urlencode($this->getAttribute('bookSlug') ?? $this->book->slug),
+            $this->draft ? 'draft' : 'page',
+            $this->draft ? $this->id : urlencode($this->slug),
+            trim($path, '/'),
+        ];
+
+        return url('/' . implode('/', $parts));
     }
 
     /**
@@ -120,4 +115,15 @@ class Page extends BookChild
     {
         return $this->revisions()->first();
     }
+
+    /**
+     * Get this page for JSON display.
+     */
+    public function forJsonDisplay(): Page
+    {
+        $refreshed = $this->refresh()->unsetRelations()->load(['tags', 'createdBy', 'updatedBy']);
+        $refreshed->setHidden(array_diff($refreshed->getHidden(), ['html', 'markdown']));
+        $refreshed->html = (new PageContent($refreshed))->render();
+        return $refreshed;
+    }
 }
similarity index 96%
rename from app/Entities/PageRevision.php
rename to app/Entities/Models/PageRevision.php
index 13dc713ba43be37453ca525f06e91f143793476e..76a3b15ffd44ea64e176eb1020773dc0ca41ec70 100644 (file)
@@ -1,6 +1,7 @@
-<?php namespace BookStack\Entities;
+<?php namespace BookStack\Entities\Models;
 
 use BookStack\Auth\User;
+use BookStack\Entities\Models\Page;
 use BookStack\Model;
 use Carbon\Carbon;
 
similarity index 89%
rename from app/Entities/SearchTerm.php
rename to app/Entities/Models/SearchTerm.php
index 886c4dbc1fe4a4041859357ebe293b8ecb79177d..f55cb8407b34c9ce5b4a1bf672178f320c840db5 100644 (file)
@@ -1,4 +1,4 @@
-<?php namespace BookStack\Entities;
+<?php namespace BookStack\Entities\Models;
 
 use BookStack\Model;
 
index 7c25e49813e18bf7f34b42ce68df72c780f141fa..ff4fc635b73cd51f0450dc4e8e278443afad3f0e 100644 (file)
@@ -2,11 +2,12 @@
 
 namespace BookStack\Entities\Repos;
 
+use BookStack\Actions\ActivityType;
 use BookStack\Actions\TagRepo;
-use BookStack\Entities\Book;
-use BookStack\Entities\Entity;
-use BookStack\Entities\HasCoverImage;
+use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Models\HasCoverImage;
 use BookStack\Exceptions\ImageUploadException;
+use BookStack\Facades\Activity;
 use BookStack\Uploads\ImageRepo;
 use Illuminate\Http\UploadedFile;
 use Illuminate\Support\Collection;
@@ -18,10 +19,6 @@ class BaseRepo
     protected $imageRepo;
 
 
-    /**
-     * BaseRepo constructor.
-     * @param $tagRepo
-     */
     public function __construct(TagRepo $tagRepo, ImageRepo $imageRepo)
     {
         $this->tagRepo = $tagRepo;
@@ -115,5 +112,6 @@ class BaseRepo
 
         $entity->save();
         $entity->rebuildPermissions();
+        Activity::addForEntity($entity, ActivityType::PERMISSIONS_UPDATE);
     }
 }
index 70db0fa65750bde4266c97040939d7a0b55c098a..d6dbe0b7368b7731a5aa72cf6c909a378aa031f6 100644 (file)
@@ -1,14 +1,14 @@
 <?php namespace BookStack\Entities\Repos;
 
+use BookStack\Actions\ActivityType;
 use BookStack\Actions\TagRepo;
-use BookStack\Entities\Book;
-use BookStack\Entities\Managers\TrashCan;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Tools\TrashCan;
 use BookStack\Exceptions\ImageUploadException;
 use BookStack\Exceptions\NotFoundException;
-use BookStack\Exceptions\NotifyException;
+use BookStack\Facades\Activity;
 use BookStack\Uploads\ImageRepo;
 use Exception;
-use Illuminate\Contracts\Container\BindingResolutionException;
 use Illuminate\Contracts\Pagination\LengthAwarePaginator;
 use Illuminate\Http\UploadedFile;
 use Illuminate\Support\Collection;
@@ -22,7 +22,6 @@ class BookRepo
 
     /**
      * BookRepo constructor.
-     * @param $tagRepo
      */
     public function __construct(BaseRepo $baseRepo, TagRepo $tagRepo, ImageRepo $imageRepo)
     {
@@ -91,6 +90,7 @@ class BookRepo
     {
         $book = new Book();
         $this->baseRepo->create($book, $input);
+        Activity::addForEntity($book, ActivityType::BOOK_CREATE);
         return $book;
     }
 
@@ -100,6 +100,7 @@ class BookRepo
     public function update(Book $book, array $input): Book
     {
         $this->baseRepo->update($book, $input);
+        Activity::addForEntity($book, ActivityType::BOOK_UPDATE);
         return $book;
     }
 
@@ -123,12 +124,14 @@ class BookRepo
 
     /**
      * Remove a book from the system.
-     * @throws NotifyException
-     * @throws BindingResolutionException
+     * @throws Exception
      */
     public function destroy(Book $book)
     {
         $trashCan = new TrashCan();
-        $trashCan->destroyBook($book);
+        $trashCan->softDestroyBook($book);
+        Activity::addForEntity($book, ActivityType::BOOK_DELETE);
+
+        $trashCan->autoClearOld();
     }
 }
index ba687c6f6e754f3a49959ad932294620cb3e74c8..075582cbfaa0e3b52df1812bf6b11f434e73c34b 100644 (file)
@@ -1,10 +1,12 @@
 <?php namespace BookStack\Entities\Repos;
 
-use BookStack\Entities\Book;
-use BookStack\Entities\Bookshelf;
-use BookStack\Entities\Managers\TrashCan;
+use BookStack\Actions\ActivityType;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Bookshelf;
+use BookStack\Entities\Tools\TrashCan;
 use BookStack\Exceptions\ImageUploadException;
 use BookStack\Exceptions\NotFoundException;
+use BookStack\Facades\Activity;
 use Exception;
 use Illuminate\Contracts\Pagination\LengthAwarePaginator;
 use Illuminate\Http\UploadedFile;
@@ -16,7 +18,6 @@ class BookshelfRepo
 
     /**
      * BookshelfRepo constructor.
-     * @param $baseRepo
      */
     public function __construct(BaseRepo $baseRepo)
     {
@@ -87,11 +88,12 @@ class BookshelfRepo
         $shelf = new Bookshelf();
         $this->baseRepo->create($shelf, $input);
         $this->updateBooks($shelf, $bookIds);
+        Activity::addForEntity($shelf, ActivityType::BOOKSHELF_CREATE);
         return $shelf;
     }
 
     /**
-     * Create a new shelf in the system.
+     * Update an existing shelf in the system using the given input.
      */
     public function update(Bookshelf $shelf, array $input, ?array $bookIds): Bookshelf
     {
@@ -101,6 +103,7 @@ class BookshelfRepo
             $this->updateBooks($shelf, $bookIds);
         }
 
+        Activity::addForEntity($shelf, ActivityType::BOOKSHELF_UPDATE);
         return $shelf;
     }
 
@@ -174,6 +177,8 @@ class BookshelfRepo
     public function destroy(Bookshelf $shelf)
     {
         $trashCan = new TrashCan();
-        $trashCan->destroyShelf($shelf);
+        $trashCan->softDestroyShelf($shelf);
+        Activity::addForEntity($shelf, ActivityType::BOOKSHELF_DELETE);
+        $trashCan->autoClearOld();
     }
 }
index c6f3a2d2f0fc093c37b6e541081c20c977468c75..281cc2cab174268e31580e55b1db7b95ffa29a32 100644 (file)
@@ -1,15 +1,14 @@
 <?php namespace BookStack\Entities\Repos;
 
-use BookStack\Entities\Book;
-use BookStack\Entities\Chapter;
-use BookStack\Entities\Managers\BookContents;
-use BookStack\Entities\Managers\TrashCan;
+use BookStack\Actions\ActivityType;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Tools\BookContents;
+use BookStack\Entities\Tools\TrashCan;
 use BookStack\Exceptions\MoveOperationException;
 use BookStack\Exceptions\NotFoundException;
-use BookStack\Exceptions\NotifyException;
+use BookStack\Facades\Activity;
 use Exception;
-use Illuminate\Contracts\Container\BindingResolutionException;
-use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Support\Collection;
 
 class ChapterRepo
@@ -19,7 +18,6 @@ class ChapterRepo
 
     /**
      * ChapterRepo constructor.
-     * @param $baseRepo
      */
     public function __construct(BaseRepo $baseRepo)
     {
@@ -50,6 +48,7 @@ class ChapterRepo
         $chapter->book_id = $parentBook->id;
         $chapter->priority = (new BookContents($parentBook))->getLastPriority() + 1;
         $this->baseRepo->create($chapter, $input);
+        Activity::addForEntity($chapter, ActivityType::CHAPTER_CREATE);
         return $chapter;
     }
 
@@ -59,6 +58,7 @@ class ChapterRepo
     public function update(Chapter $chapter, array $input): Chapter
     {
         $this->baseRepo->update($chapter, $input);
+        Activity::addForEntity($chapter, ActivityType::CHAPTER_UPDATE);
         return $chapter;
     }
 
@@ -77,7 +77,9 @@ class ChapterRepo
     public function destroy(Chapter $chapter)
     {
         $trashCan = new TrashCan();
-        $trashCan->destroyChapter($chapter);
+        $trashCan->softDestroyChapter($chapter);
+        Activity::addForEntity($chapter, ActivityType::CHAPTER_DELETE);
+        $trashCan->autoClearOld();
     }
 
     /**
@@ -96,6 +98,7 @@ class ChapterRepo
             throw new MoveOperationException('Chapters can only be moved into books');
         }
 
+        /** @var Book $parent */
         $parent = Book::visible()->where('id', '=', $entityId)->first();
         if ($parent === null) {
             throw new MoveOperationException('Book to move chapter into not found');
@@ -103,6 +106,8 @@ class ChapterRepo
 
         $chapter->changeBook($parent->id);
         $chapter->rebuildPermissions();
+        Activity::addForEntity($chapter, ActivityType::CHAPTER_MOVE);
+
         return $parent;
     }
 }
index e5f13463c388f781dd215338afb4af37cefaa7c5..153ef857594bd7ee91f2fb5cf2db7bee80b50b2c 100644 (file)
@@ -1,17 +1,19 @@
 <?php namespace BookStack\Entities\Repos;
 
-use BookStack\Entities\Book;
-use BookStack\Entities\Chapter;
-use BookStack\Entities\Entity;
-use BookStack\Entities\Managers\BookContents;
-use BookStack\Entities\Managers\PageContent;
-use BookStack\Entities\Managers\TrashCan;
-use BookStack\Entities\Page;
-use BookStack\Entities\PageRevision;
+use BookStack\Actions\ActivityType;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Tools\BookContents;
+use BookStack\Entities\Tools\PageContent;
+use BookStack\Entities\Tools\TrashCan;
+use BookStack\Entities\Models\Page;
+use BookStack\Entities\Models\PageRevision;
 use BookStack\Exceptions\MoveOperationException;
 use BookStack\Exceptions\NotFoundException;
-use BookStack\Exceptions\NotifyException;
 use BookStack\Exceptions\PermissionsException;
+use BookStack\Facades\Activity;
+use Exception;
 use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Pagination\LengthAwarePaginator;
 use Illuminate\Support\Collection;
@@ -33,9 +35,9 @@ class PageRepo
      * Get a page by ID.
      * @throws NotFoundException
      */
-    public function getById(int $id): Page
+    public function getById(int $id, array $relations = ['book']): Page
     {
-        $page = Page::visible()->with(['book'])->find($id);
+        $page = Page::visible()->with($relations)->find($id);
 
         if (!$page) {
             throw new NotFoundException(trans('errors.page_not_found'));
@@ -150,12 +152,8 @@ class PageRepo
     public function publishDraft(Page $draft, array $input): Page
     {
         $this->baseRepo->update($draft, $input);
-        if (isset($input['template']) && userCan('templates-manage')) {
-            $draft->template = ($input['template'] === 'true');
-        }
+        $this->updateTemplateStatusAndContentFromInput($draft, $input);
 
-        $pageContent = new PageContent($draft);
-        $pageContent->setNewHTML($input['html']);
         $draft->draft = false;
         $draft->revision_count = 1;
         $draft->priority = $this->getNewPriority($draft);
@@ -164,7 +162,10 @@ class PageRepo
 
         $this->savePageRevision($draft, trans('entities.pages_initial_revision'));
         $draft->indexForSearch();
-        return $draft->refresh();
+        $draft->refresh();
+
+        Activity::addForEntity($draft, ActivityType::PAGE_CREATE);
+        return $draft;
     }
 
     /**
@@ -176,12 +177,7 @@ class PageRepo
         $oldHtml = $page->html;
         $oldName = $page->name;
 
-        if (isset($input['template']) && userCan('templates-manage')) {
-            $page->template = ($input['template'] === 'true');
-        }
-
-        $pageContent = new PageContent($page);
-        $pageContent->setNewHTML($input['html']);
+        $this->updateTemplateStatusAndContentFromInput($page, $input);
         $this->baseRepo->update($page, $input);
 
         // Update with new details
@@ -202,9 +198,24 @@ class PageRepo
             $this->savePageRevision($page, $summary);
         }
 
+        Activity::addForEntity($page, ActivityType::PAGE_UPDATE);
         return $page;
     }
 
+    protected function updateTemplateStatusAndContentFromInput(Page $page, array $input)
+    {
+        if (isset($input['template']) && userCan('templates-manage')) {
+            $page->template = ($input['template'] === 'true');
+        }
+
+        $pageContent = new PageContent($page);
+        if (isset($input['html'])) {
+            $pageContent->setNewHTML($input['html']);
+        } else {
+            $pageContent->setNewMarkdown($input['markdown']);
+        }
+    }
+
     /**
      * Saves a page revision into the system.
      */
@@ -237,11 +248,10 @@ class PageRepo
     {
         // If the page itself is a draft simply update that
         if ($page->draft) {
-            $page->fill($input);
             if (isset($input['html'])) {
-                $content = new PageContent($page);
-                $content->setNewHTML($input['html']);
+                (new PageContent($page))->setNewHTML($input['html']);
             }
+            $page->fill($input);
             $page->save();
             return $page;
         }
@@ -259,12 +269,14 @@ class PageRepo
 
     /**
      * Destroy a page from the system.
-     * @throws NotifyException
+     * @throws Exception
      */
     public function destroy(Page $page)
     {
         $trashCan = new TrashCan();
-        $trashCan->destroyPage($page);
+        $trashCan->softDestroyPage($page);
+        Activity::addForEntity($page, ActivityType::PAGE_DELETE);
+        $trashCan->autoClearOld();
     }
 
     /**
@@ -284,6 +296,7 @@ class PageRepo
         $page->save();
 
         $page->indexForSearch();
+        Activity::addForEntity($page, ActivityType::PAGE_RESTORE);
         return $page;
     }
 
@@ -294,7 +307,7 @@ class PageRepo
      * @throws MoveOperationException
      * @throws PermissionsException
      */
-    public function move(Page $page, string $parentIdentifier): Book
+    public function move(Page $page, string $parentIdentifier): Entity
     {
         $parent = $this->findParentByIdentifier($parentIdentifier);
         if ($parent === null) {
@@ -309,7 +322,8 @@ class PageRepo
         $page->changeBook($parent instanceof Book ? $parent->id : $parent->book->id);
         $page->rebuildPermissions();
 
-        return ($parent instanceof Book ? $parent : $parent->book);
+        Activity::addForEntity($page, ActivityType::PAGE_MOVE);
+        return $parent;
     }
 
     /**
@@ -320,7 +334,7 @@ class PageRepo
      */
     public function copy(Page $page, string $parentIdentifier = null, string $newName = null): Page
     {
-        $parent = $parentIdentifier ? $this->findParentByIdentifier($parentIdentifier) : $page->parent();
+        $parent = $parentIdentifier ? $this->findParentByIdentifier($parentIdentifier) : $page->getParent();
         if ($parent === null) {
             throw new MoveOperationException('Book or chapter to move page into not found');
         }
@@ -439,8 +453,9 @@ class PageRepo
      */
     protected function getNewPriority(Page $page): int
     {
-        if ($page->parent() instanceof Chapter) {
-            $lastPage = $page->parent()->pages('desc')->first();
+        $parent = $page->getParent();
+        if ($parent instanceof Chapter) {
+            $lastPage = $parent->pages('desc')->first();
             return $lastPage ? $lastPage->priority + 1 : 0;
         }
 
similarity index 96%
rename from app/Entities/Managers/BookContents.php
rename to app/Entities/Tools/BookContents.php
index b9844da5774baacccaf74b8543ec8b2054ca4ff5..71c8f8393a22dff67f95a4ec931ce236d4641b49 100644 (file)
@@ -1,10 +1,10 @@
-<?php namespace BookStack\Entities\Managers;
+<?php namespace BookStack\Entities\Tools;
 
-use BookStack\Entities\Book;
-use BookStack\Entities\BookChild;
-use BookStack\Entities\Chapter;
-use BookStack\Entities\Entity;
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\BookChild;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Models\Page;
 use BookStack\Exceptions\SortOperationException;
 use Illuminate\Support\Collection;
 
@@ -18,7 +18,6 @@ class BookContents
 
     /**
      * BookContents constructor.
-     * @param $book
      */
     public function __construct(Book $book)
     {
similarity index 96%
rename from app/Entities/ExportService.php
rename to app/Entities/Tools/ExportFormatter.php
index 508670c850828265bf7a8dd2b7d94082d7335fcf..eb8f6862f23fe76b703c8f0a2514b2f5d61acae9 100644 (file)
@@ -1,14 +1,15 @@
-<?php namespace BookStack\Entities;
+<?php namespace BookStack\Entities\Tools;
 
-use BookStack\Entities\Managers\BookContents;
-use BookStack\Entities\Managers\PageContent;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
 use BookStack\Uploads\ImageService;
 use DomPDF;
 use Exception;
 use SnappyPDF;
 use Throwable;
 
-class ExportService
+class ExportFormatter
 {
 
     protected $imageService;
@@ -142,7 +143,7 @@ class ExportService
     protected function containHtml(string $htmlContent): string
     {
         $imageTagsOutput = [];
-        preg_match_all("/\<img.*src\=(\'|\")(.*?)(\'|\").*?\>/i", $htmlContent, $imageTagsOutput);
+        preg_match_all("/\<img.*?src\=(\'|\")(.*?)(\'|\").*?\>/i", $htmlContent, $imageTagsOutput);
 
         // Replace image src with base64 encoded image strings
         if (isset($imageTagsOutput[0]) && count($imageTagsOutput[0]) > 0) {
similarity index 93%
rename from app/Entities/Managers/PageContent.php
rename to app/Entities/Tools/PageContent.php
index 7338a36b393631289a8f30ce09888d403d52763e..f60971b8bbe02b4539419fe1103426ef1017fda7 100644 (file)
@@ -1,9 +1,10 @@
-<?php namespace BookStack\Entities\Managers;
+<?php namespace BookStack\Entities\Tools;
 
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Page;
 use DOMDocument;
 use DOMNodeList;
 use DOMXPath;
+use League\CommonMark\CommonMarkConverter;
 
 class PageContent
 {
@@ -25,6 +26,27 @@ class PageContent
     {
         $this->page->html = $this->formatHtml($html);
         $this->page->text = $this->toPlainText();
+        $this->page->markdown = '';
+    }
+
+    /**
+     * Update the content of the page with new provided Markdown content.
+     */
+    public function setNewMarkdown(string $markdown)
+    {
+        $this->page->markdown = $markdown;
+        $html = $this->markdownToHtml($markdown);
+        $this->page->html = $this->formatHtml($html);
+        $this->page->text = $this->toPlainText();
+    }
+
+    /**
+     * Convert the given Markdown content to a HTML string.
+     */
+    protected function markdownToHtml(string $markdown): string
+    {
+        $converter = new CommonMarkConverter();
+        return $converter->convertToHtml($markdown);
     }
 
     /**
similarity index 95%
rename from app/Entities/Managers/PageEditActivity.php
rename to app/Entities/Tools/PageEditActivity.php
index cebbf8720f12a0ac7d66b8cc9e67dae3a5719db7..79de5c827987e8d1f9a4041d70d2598839c2c87c 100644 (file)
@@ -1,7 +1,7 @@
-<?php namespace BookStack\Entities\Managers;
+<?php namespace BookStack\Entities\Tools;
 
-use BookStack\Entities\Page;
-use BookStack\Entities\PageRevision;
+use BookStack\Entities\Models\Page;
+use BookStack\Entities\Models\PageRevision;
 use Carbon\Carbon;
 use Illuminate\Database\Eloquent\Builder;
 
diff --git a/app/Entities/Tools/SearchIndex.php b/app/Entities/Tools/SearchIndex.php
new file mode 100644 (file)
index 0000000..81a5022
--- /dev/null
@@ -0,0 +1,120 @@
+<?php namespace BookStack\Entities\Tools;
+
+use BookStack\Entities\EntityProvider;
+use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Models\SearchTerm;
+use Illuminate\Support\Collection;
+
+class SearchIndex
+{
+    /**
+     * @var SearchTerm
+     */
+    protected $searchTerm;
+
+    /**
+     * @var EntityProvider
+     */
+    protected $entityProvider;
+
+
+    public function __construct(SearchTerm $searchTerm, EntityProvider $entityProvider)
+    {
+        $this->searchTerm = $searchTerm;
+        $this->entityProvider = $entityProvider;
+    }
+
+
+    /**
+     * Index the given entity.
+     */
+    public function indexEntity(Entity $entity)
+    {
+        $this->deleteEntityTerms($entity);
+        $nameTerms = $this->generateTermArrayFromText($entity->name, 5 * $entity->searchFactor);
+        $bodyTerms = $this->generateTermArrayFromText($entity->getText(), 1 * $entity->searchFactor);
+        $terms = array_merge($nameTerms, $bodyTerms);
+        foreach ($terms as $index => $term) {
+            $terms[$index]['entity_type'] = $entity->getMorphClass();
+            $terms[$index]['entity_id'] = $entity->id;
+        }
+        $this->searchTerm->newQuery()->insert($terms);
+    }
+
+    /**
+     * Index multiple Entities at once
+     * @param Entity[] $entities
+     */
+    protected function indexEntities(array $entities)
+    {
+        $terms = [];
+        foreach ($entities as $entity) {
+            $nameTerms = $this->generateTermArrayFromText($entity->name, 5 * $entity->searchFactor);
+            $bodyTerms = $this->generateTermArrayFromText($entity->getText(), 1 * $entity->searchFactor);
+            foreach (array_merge($nameTerms, $bodyTerms) as $term) {
+                $term['entity_id'] = $entity->id;
+                $term['entity_type'] = $entity->getMorphClass();
+                $terms[] = $term;
+            }
+        }
+
+        $chunkedTerms = array_chunk($terms, 500);
+        foreach ($chunkedTerms as $termChunk) {
+            $this->searchTerm->newQuery()->insert($termChunk);
+        }
+    }
+
+    /**
+     * Delete and re-index the terms for all entities in the system.
+     */
+    public function indexAllEntities()
+    {
+        $this->searchTerm->newQuery()->truncate();
+
+        foreach ($this->entityProvider->all() as $entityModel) {
+            $selectFields = ['id', 'name', $entityModel->textField];
+            $entityModel->newQuery()
+                ->withTrashed()
+                ->select($selectFields)
+                ->chunk(1000, function (Collection $entities) {
+                    $this->indexEntities($entities->all());
+                });
+        }
+    }
+
+    /**
+     * Delete related Entity search terms.
+     */
+    public function deleteEntityTerms(Entity $entity)
+    {
+        $entity->searchTerms()->delete();
+    }
+
+    /**
+     * Create a scored term array from the given text.
+     */
+    protected function generateTermArrayFromText(string $text, int $scoreAdjustment = 1): array
+    {
+        $tokenMap = []; // {TextToken => OccurrenceCount}
+        $splitChars = " \n\t.,!?:;()[]{}<>`'\"";
+        $token = strtok($text, $splitChars);
+
+        while ($token !== false) {
+            if (!isset($tokenMap[$token])) {
+                $tokenMap[$token] = 0;
+            }
+            $tokenMap[$token]++;
+            $token = strtok($splitChars);
+        }
+
+        $terms = [];
+        foreach ($tokenMap as $token => $count) {
+            $terms[] = [
+                'term' => $token,
+                'score' => $count * $scoreAdjustment
+            ];
+        }
+
+        return $terms;
+    }
+}
similarity index 98%
rename from app/Entities/SearchOptions.php
rename to app/Entities/Tools/SearchOptions.php
index a121bd7939cbc6eb4e6ccd18e96e21dcf1e7f992..60e3a9b7876d971edd29c681643ec58a0fbab09d 100644 (file)
@@ -1,4 +1,4 @@
-<?php namespace BookStack\Entities;
+<?php namespace BookStack\Entities\Tools;
 
 use Illuminate\Http\Request;
 
similarity index 72%
rename from app/Entities/SearchService.php
rename to app/Entities/Tools/SearchRunner.php
index 11b731cd0153591e2cfd7b6b71f88504f088cd92..acfe8d9565fdf1ea2884d337e31e0402270f6cb9 100644 (file)
@@ -1,6 +1,8 @@
-<?php namespace BookStack\Entities;
+<?php namespace BookStack\Entities\Tools;
 
 use BookStack\Auth\Permissions\PermissionService;
+use BookStack\Entities\EntityProvider;
+use BookStack\Entities\Models\Entity;
 use Illuminate\Database\Connection;
 use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
 use Illuminate\Database\Query\Builder;
@@ -8,12 +10,8 @@ use Illuminate\Database\Query\JoinClause;
 use Illuminate\Support\Collection;
 use Illuminate\Support\Str;
 
-class SearchService
+class SearchRunner
 {
-    /**
-     * @var SearchTerm
-     */
-    protected $searchTerm;
 
     /**
      * @var EntityProvider
@@ -37,25 +35,14 @@ class SearchService
      */
     protected $queryOperators = ['<=', '>=', '=', '<', '>', 'like', '!='];
 
-    /**
-     * SearchService constructor.
-     */
-    public function __construct(SearchTerm $searchTerm, EntityProvider $entityProvider, Connection $db, PermissionService $permissionService)
+
+    public function __construct(EntityProvider $entityProvider, Connection $db, PermissionService $permissionService)
     {
-        $this->searchTerm = $searchTerm;
         $this->entityProvider = $entityProvider;
         $this->db = $db;
         $this->permissionService = $permissionService;
     }
 
-    /**
-     * Set the database connection
-     */
-    public function setConnection(Connection $connection)
-    {
-        $this->db = $connection;
-    }
-
     /**
      * Search all entities in the system.
      * The provided count is for each entity to search,
@@ -115,11 +102,12 @@ class SearchService
             $search = $this->buildEntitySearchQuery($opts, $entityType)->where('book_id', '=', $bookId)->take(20)->get();
             $results = $results->merge($search);
         }
+
         return $results->sortByDesc('score')->take(20);
     }
 
     /**
-     * Search a book for entities
+     * Search a chapter for entities
      */
     public function searchChapter(int $chapterId, string $searchString): Collection
     {
@@ -134,7 +122,7 @@ class SearchService
      * matching instead of the items themselves.
      * @return \Illuminate\Database\Eloquent\Collection|int|static[]
      */
-    public function searchEntityTable(SearchOptions $searchOpts, string $entityType = 'page', int $page = 1, int $count = 20, string $action = 'view', bool $getCount = false)
+    protected function searchEntityTable(SearchOptions $searchOpts, string $entityType = 'page', int $page = 1, int $count = 20, string $action = 'view', bool $getCount = false)
     {
         $query = $this->buildEntitySearchQuery($searchOpts, $entityType, $action);
         if ($getCount) {
@@ -155,28 +143,25 @@ class SearchService
 
         // Handle normal search terms
         if (count($searchOpts->searches) > 0) {
-            $subQuery = $this->db->table('search_terms')->select('entity_id', 'entity_type', \DB::raw('SUM(score) as score'));
+            $rawScoreSum = $this->db->raw('SUM(score) as score');
+            $subQuery = $this->db->table('search_terms')->select('entity_id', 'entity_type', $rawScoreSum);
             $subQuery->where('entity_type', '=', $entity->getMorphClass());
             $subQuery->where(function (Builder $query) use ($searchOpts) {
                 foreach ($searchOpts->searches as $inputTerm) {
                     $query->orWhere('term', 'like', $inputTerm .'%');
                 }
             })->groupBy('entity_type', 'entity_id');
-            $entitySelect->join(\DB::raw('(' . $subQuery->toSql() . ') as s'), function (JoinClause $join) {
+            $entitySelect->join($this->db->raw('(' . $subQuery->toSql() . ') as s'), function (JoinClause $join) {
                 $join->on('id', '=', 'entity_id');
             })->selectRaw($entity->getTable().'.*, s.score')->orderBy('score', 'desc');
             $entitySelect->mergeBindings($subQuery);
         }
 
         // Handle exact term matching
-        if (count($searchOpts->exacts) > 0) {
-            $entitySelect->where(function (EloquentBuilder $query) use ($searchOpts, $entity) {
-                foreach ($searchOpts->exacts as $inputTerm) {
-                    $query->where(function (EloquentBuilder $query) use ($inputTerm, $entity) {
-                        $query->where('name', 'like', '%'.$inputTerm .'%')
-                            ->orWhere($entity->textField, 'like', '%'.$inputTerm .'%');
-                    });
-                }
+        foreach ($searchOpts->exacts as $inputTerm) {
+            $entitySelect->where(function (EloquentBuilder $query) use ($inputTerm, $entity) {
+                $query->where('name', 'like', '%'.$inputTerm .'%')
+                    ->orWhere($entity->textField, 'like', '%'.$inputTerm .'%');
             });
         }
 
@@ -239,102 +224,6 @@ class SearchService
         return $query;
     }
 
-    /**
-     * Index the given entity.
-     */
-    public function indexEntity(Entity $entity)
-    {
-        $this->deleteEntityTerms($entity);
-        $nameTerms = $this->generateTermArrayFromText($entity->name, 5 * $entity->searchFactor);
-        $bodyTerms = $this->generateTermArrayFromText($entity->getText(), 1 * $entity->searchFactor);
-        $terms = array_merge($nameTerms, $bodyTerms);
-        foreach ($terms as $index => $term) {
-            $terms[$index]['entity_type'] = $entity->getMorphClass();
-            $terms[$index]['entity_id'] = $entity->id;
-        }
-        $this->searchTerm->newQuery()->insert($terms);
-    }
-
-    /**
-     * Index multiple Entities at once
-     * @param \BookStack\Entities\Entity[] $entities
-     */
-    protected function indexEntities($entities)
-    {
-        $terms = [];
-        foreach ($entities as $entity) {
-            $nameTerms = $this->generateTermArrayFromText($entity->name, 5 * $entity->searchFactor);
-            $bodyTerms = $this->generateTermArrayFromText($entity->getText(), 1 * $entity->searchFactor);
-            foreach (array_merge($nameTerms, $bodyTerms) as $term) {
-                $term['entity_id'] = $entity->id;
-                $term['entity_type'] = $entity->getMorphClass();
-                $terms[] = $term;
-            }
-        }
-
-        $chunkedTerms = array_chunk($terms, 500);
-        foreach ($chunkedTerms as $termChunk) {
-            $this->searchTerm->newQuery()->insert($termChunk);
-        }
-    }
-
-    /**
-     * Delete and re-index the terms for all entities in the system.
-     */
-    public function indexAllEntities()
-    {
-        $this->searchTerm->truncate();
-
-        foreach ($this->entityProvider->all() as $entityModel) {
-            $selectFields = ['id', 'name', $entityModel->textField];
-            $entityModel->newQuery()->select($selectFields)->chunk(1000, function ($entities) {
-                $this->indexEntities($entities);
-            });
-        }
-    }
-
-    /**
-     * Delete related Entity search terms.
-     * @param Entity $entity
-     */
-    public function deleteEntityTerms(Entity $entity)
-    {
-        $entity->searchTerms()->delete();
-    }
-
-    /**
-     * Create a scored term array from the given text.
-     * @param $text
-     * @param float|int $scoreAdjustment
-     * @return array
-     */
-    protected function generateTermArrayFromText($text, $scoreAdjustment = 1)
-    {
-        $tokenMap = []; // {TextToken => OccurrenceCount}
-        $splitChars = " \n\t.,!?:;()[]{}<>`'\"";
-        $token = strtok($text, $splitChars);
-
-        while ($token !== false) {
-            if (!isset($tokenMap[$token])) {
-                $tokenMap[$token] = 0;
-            }
-            $tokenMap[$token]++;
-            $token = strtok($splitChars);
-        }
-
-        $terms = [];
-        foreach ($tokenMap as $token => $count) {
-            $terms[] = [
-                'term' => $token,
-                'score' => $count * $scoreAdjustment
-            ];
-        }
-        return $terms;
-    }
-
-
-
-
     /**
      * Custom entity search filters
      */
similarity index 55%
rename from app/Entities/Managers/EntityContext.php
rename to app/Entities/Tools/ShelfContext.php
index 551cd1a100c142f26cea88d8e86aaa282c768fa0..f3849bbb4741e8c075c122bca3274f242f6d0e28 100644 (file)
@@ -1,29 +1,18 @@
-<?php namespace BookStack\Entities\Managers;
+<?php namespace BookStack\Entities\Tools;
 
-use BookStack\Entities\Book;
-use BookStack\Entities\Bookshelf;
-use Illuminate\Session\Store;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Bookshelf;
 
-class EntityContext
+class ShelfContext
 {
-    protected $session;
-
     protected $KEY_SHELF_CONTEXT_ID = 'context_bookshelf_id';
 
-    /**
-     * EntityContextManager constructor.
-     */
-    public function __construct(Store $session)
-    {
-        $this->session = $session;
-    }
-
     /**
      * Get the current bookshelf context for the given book.
      */
     public function getContextualShelfForBook(Book $book): ?Bookshelf
     {
-        $contextBookshelfId = $this->session->get($this->KEY_SHELF_CONTEXT_ID, null);
+        $contextBookshelfId = session()->get($this->KEY_SHELF_CONTEXT_ID, null);
 
         if (!is_int($contextBookshelfId)) {
             return null;
@@ -37,11 +26,10 @@ class EntityContext
 
     /**
      * Store the current contextual shelf ID.
-     * @param int $shelfId
      */
     public function setShelfContext(int $shelfId)
     {
-        $this->session->put($this->KEY_SHELF_CONTEXT_ID, $shelfId);
+        session()->put($this->KEY_SHELF_CONTEXT_ID, $shelfId);
     }
 
     /**
@@ -49,6 +37,6 @@ class EntityContext
      */
     public function clearShelfContext()
     {
-        $this->session->forget($this->KEY_SHELF_CONTEXT_ID);
+        session()->forget($this->KEY_SHELF_CONTEXT_ID);
     }
 }
diff --git a/app/Entities/Tools/SiblingFetcher.php b/app/Entities/Tools/SiblingFetcher.php
new file mode 100644 (file)
index 0000000..6964fa2
--- /dev/null
@@ -0,0 +1,47 @@
+<?php namespace BookStack\Entities\Tools;
+
+use BookStack\Entities\EntityProvider;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Bookshelf;
+use Illuminate\Support\Collection;
+
+class SiblingFetcher
+{
+
+    /**
+     * Search among the siblings of the entity of given type and id.
+     */
+    public function fetch(string $entityType, int $entityId): Collection
+    {
+        $entity = (new EntityProvider)->get($entityType)->visible()->findOrFail($entityId);
+        $entities = [];
+
+        // Page in chapter
+        if ($entity->isA('page') && $entity->chapter) {
+            $entities = $entity->chapter->getVisiblePages();
+        }
+
+        // Page in book or chapter
+        if (($entity->isA('page') && !$entity->chapter) || $entity->isA('chapter')) {
+            $entities = $entity->book->getDirectChildren();
+        }
+
+        // Book
+        // Gets just the books in a shelf if shelf is in context
+        if ($entity->isA('book')) {
+            $contextShelf = (new ShelfContext)->getContextualShelfForBook($entity);
+            if ($contextShelf) {
+                $entities = $contextShelf->visibleBooks()->get();
+            } else {
+                $entities = Book::visible()->get();
+            }
+        }
+
+        // Shelve
+        if ($entity->isA('bookshelf')) {
+            $entities = Bookshelf::visible()->get();
+        }
+
+        return $entities;
+    }
+}
similarity index 52%
rename from app/Entities/SlugGenerator.php
rename to app/Entities/Tools/SlugGenerator.php
index e8bc556abefa102153b64953a6dc76c89784e206..7075bc72c14ce50e78119592be480f70c6756266 100644 (file)
@@ -1,29 +1,19 @@
-<?php namespace BookStack\Entities;
+<?php namespace BookStack\Entities\Tools;
 
+use BookStack\Entities\Models\Entity;
 use Illuminate\Support\Str;
 
 class SlugGenerator
 {
 
-    protected $entity;
-
-    /**
-     * SlugGenerator constructor.
-     * @param $entity
-     */
-    public function __construct(Entity $entity)
-    {
-        $this->entity = $entity;
-    }
-
     /**
      * Generate a fresh slug for the given entity.
      * The slug will generated so it does not conflict within the same parent item.
      */
-    public function generate(): string
+    public function generate(Entity $entity): string
     {
-        $slug = $this->formatNameAsSlug($this->entity->name);
-        while ($this->slugInUse($slug)) {
+        $slug = $this->formatNameAsSlug($entity->name);
+        while ($this->slugInUse($slug, $entity)) {
             $slug .= '-' . substr(md5(rand(1, 500)), 0, 3);
         }
         return $slug;
@@ -45,16 +35,16 @@ class SlugGenerator
      * Check if a slug is already in-use for this
      * type of model within the same parent.
      */
-    protected function slugInUse(string $slug): bool
+    protected function slugInUse(string $slug, Entity $entity): bool
     {
-        $query = $this->entity->newQuery()->where('slug', '=', $slug);
+        $query = $entity->newQuery()->where('slug', '=', $slug);
 
-        if ($this->entity instanceof BookChild) {
-            $query->where('book_id', '=', $this->entity->book_id);
+        if ($entity instanceof BookChild) {
+            $query->where('book_id', '=', $entity->book_id);
         }
 
-        if ($this->entity->id) {
-            $query->where('id', '!=', $this->entity->id);
+        if ($entity->id) {
+            $query->where('id', '!=', $entity->id);
         }
 
         return $query->count() > 0;
diff --git a/app/Entities/Tools/TrashCan.php b/app/Entities/Tools/TrashCan.php
new file mode 100644 (file)
index 0000000..d2447ec
--- /dev/null
@@ -0,0 +1,325 @@
+<?php namespace BookStack\Entities\Tools;
+
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Bookshelf;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Deletion;
+use BookStack\Entities\Models\Entity;
+use BookStack\Entities\EntityProvider;
+use BookStack\Entities\Models\HasCoverImage;
+use BookStack\Entities\Models\Page;
+use BookStack\Exceptions\NotifyException;
+use BookStack\Facades\Activity;
+use BookStack\Uploads\AttachmentService;
+use BookStack\Uploads\ImageService;
+use Exception;
+use Illuminate\Support\Carbon;
+
+class TrashCan
+{
+
+    /**
+     * Send a shelf to the recycle bin.
+     */
+    public function softDestroyShelf(Bookshelf $shelf)
+    {
+        Deletion::createForEntity($shelf);
+        $shelf->delete();
+    }
+
+    /**
+     * Send a book to the recycle bin.
+     * @throws Exception
+     */
+    public function softDestroyBook(Book $book)
+    {
+        Deletion::createForEntity($book);
+
+        foreach ($book->pages as $page) {
+            $this->softDestroyPage($page, false);
+        }
+
+        foreach ($book->chapters as $chapter) {
+            $this->softDestroyChapter($chapter, false);
+        }
+
+        $book->delete();
+    }
+
+    /**
+     * Send a chapter to the recycle bin.
+     * @throws Exception
+     */
+    public function softDestroyChapter(Chapter $chapter, bool $recordDelete = true)
+    {
+        if ($recordDelete) {
+            Deletion::createForEntity($chapter);
+        }
+
+        if (count($chapter->pages) > 0) {
+            foreach ($chapter->pages as $page) {
+                $this->softDestroyPage($page, false);
+            }
+        }
+
+        $chapter->delete();
+    }
+
+    /**
+     * Send a page to the recycle bin.
+     * @throws Exception
+     */
+    public function softDestroyPage(Page $page, bool $recordDelete = true)
+    {
+        if ($recordDelete) {
+            Deletion::createForEntity($page);
+        }
+
+        // Check if set as custom homepage & remove setting if not used or throw error if active
+        $customHome = setting('app-homepage', '0:');
+        if (intval($page->id) === intval(explode(':', $customHome)[0])) {
+            if (setting('app-homepage-type') === 'page') {
+                throw new NotifyException(trans('errors.page_custom_home_deletion'), $page->getUrl());
+            }
+            setting()->remove('app-homepage');
+        }
+
+        $page->delete();
+    }
+
+    /**
+     * Remove a bookshelf from the system.
+     * @throws Exception
+     */
+    protected function destroyShelf(Bookshelf $shelf): int
+    {
+        $this->destroyCommonRelations($shelf);
+        $shelf->forceDelete();
+        return 1;
+    }
+
+    /**
+     * Remove a book from the system.
+     * Destroys any child chapters and pages.
+     * @throws Exception
+     */
+    protected function destroyBook(Book $book): int
+    {
+        $count = 0;
+        $pages = $book->pages()->withTrashed()->get();
+        foreach ($pages as $page) {
+            $this->destroyPage($page);
+            $count++;
+        }
+
+        $chapters = $book->chapters()->withTrashed()->get();
+        foreach ($chapters as $chapter) {
+            $this->destroyChapter($chapter);
+            $count++;
+        }
+
+        $this->destroyCommonRelations($book);
+        $book->forceDelete();
+        return $count + 1;
+    }
+
+    /**
+     * Remove a chapter from the system.
+     * Destroys all pages within.
+     * @throws Exception
+     */
+    protected function destroyChapter(Chapter $chapter): int
+    {
+        $count = 0;
+        $pages = $chapter->pages()->withTrashed()->get();
+        if (count($pages)) {
+            foreach ($pages as $page) {
+                $this->destroyPage($page);
+                $count++;
+            }
+        }
+
+        $this->destroyCommonRelations($chapter);
+        $chapter->forceDelete();
+        return $count + 1;
+    }
+
+    /**
+     * Remove a page from the system.
+     * @throws Exception
+     */
+    protected function destroyPage(Page $page): int
+    {
+        $this->destroyCommonRelations($page);
+
+        // Delete Attached Files
+        $attachmentService = app(AttachmentService::class);
+        foreach ($page->attachments as $attachment) {
+            $attachmentService->deleteFile($attachment);
+        }
+
+        $page->forceDelete();
+        return 1;
+    }
+
+    /**
+     * Get the total counts of those that have been trashed
+     * but not yet fully deleted (In recycle bin).
+     */
+    public function getTrashedCounts(): array
+    {
+        $counts = [];
+
+        /** @var Entity $instance */
+        foreach ((new EntityProvider)->all() as $key => $instance) {
+            $counts[$key] = $instance->newQuery()->onlyTrashed()->count();
+        }
+
+        return $counts;
+    }
+
+    /**
+     * Destroy all items that have pending deletions.
+     * @throws Exception
+     */
+    public function empty(): int
+    {
+        $deletions = Deletion::all();
+        $deleteCount = 0;
+        foreach ($deletions as $deletion) {
+            $deleteCount += $this->destroyFromDeletion($deletion);
+        }
+        return $deleteCount;
+    }
+
+    /**
+     * Destroy an element from the given deletion model.
+     * @throws Exception
+     */
+    public function destroyFromDeletion(Deletion $deletion): int
+    {
+        // We directly load the deletable element here just to ensure it still
+        // exists in the event it has already been destroyed during this request.
+        $entity = $deletion->deletable()->first();
+        $count = 0;
+        if ($entity) {
+            $count = $this->destroyEntity($deletion->deletable);
+        }
+        $deletion->delete();
+        return $count;
+    }
+
+    /**
+     * Restore the content within the given deletion.
+     * @throws Exception
+     */
+    public function restoreFromDeletion(Deletion $deletion): int
+    {
+        $shouldRestore = true;
+        $restoreCount = 0;
+        $parent = $deletion->deletable->getParent();
+
+        if ($parent && $parent->trashed()) {
+            $shouldRestore = false;
+        }
+
+        if ($shouldRestore) {
+            $restoreCount = $this->restoreEntity($deletion->deletable);
+        }
+
+        $deletion->delete();
+        return $restoreCount;
+    }
+
+    /**
+     * Automatically clear old content from the recycle bin
+     * depending on the configured lifetime.
+     * Returns the total number of deleted elements.
+     * @throws Exception
+     */
+    public function autoClearOld(): int
+    {
+        $lifetime = intval(config('app.recycle_bin_lifetime'));
+        if ($lifetime < 0) {
+            return 0;
+        }
+
+        $clearBeforeDate = Carbon::now()->addSeconds(10)->subDays($lifetime);
+        $deleteCount = 0;
+
+        $deletionsToRemove = Deletion::query()->where('created_at', '<', $clearBeforeDate)->get();
+        foreach ($deletionsToRemove as $deletion) {
+            $deleteCount += $this->destroyFromDeletion($deletion);
+        }
+
+        return $deleteCount;
+    }
+
+    /**
+     * Restore an entity so it is essentially un-deleted.
+     * Deletions on restored child elements will be removed during this restoration.
+     */
+    protected function restoreEntity(Entity $entity): int
+    {
+        $count = 1;
+        $entity->restore();
+
+        $restoreAction = function ($entity) use (&$count) {
+            if ($entity->deletions_count > 0) {
+                $entity->deletions()->delete();
+            }
+
+            $entity->restore();
+            $count++;
+        };
+
+        if ($entity->isA('chapter') || $entity->isA('book')) {
+            $entity->pages()->withTrashed()->withCount('deletions')->get()->each($restoreAction);
+        }
+
+        if ($entity->isA('book')) {
+            $entity->chapters()->withTrashed()->withCount('deletions')->get()->each($restoreAction);
+        }
+
+        return $count;
+    }
+
+    /**
+     * Destroy the given entity.
+     */
+    protected function destroyEntity(Entity $entity): int
+    {
+        if ($entity->isA('page')) {
+            return $this->destroyPage($entity);
+        }
+        if ($entity->isA('chapter')) {
+            return $this->destroyChapter($entity);
+        }
+        if ($entity->isA('book')) {
+            return $this->destroyBook($entity);
+        }
+        if ($entity->isA('shelf')) {
+            return $this->destroyShelf($entity);
+        }
+    }
+
+    /**
+     * Update entity relations to remove or update outstanding connections.
+     */
+    protected function destroyCommonRelations(Entity $entity)
+    {
+        Activity::removeEntity($entity);
+        $entity->views()->delete();
+        $entity->permissions()->delete();
+        $entity->tags()->delete();
+        $entity->comments()->delete();
+        $entity->jointPermissions()->delete();
+        $entity->searchTerms()->delete();
+        $entity->deletions()->delete();
+
+        if ($entity instanceof HasCoverImage && $entity->cover) {
+            $imageService = app()->make(ImageService::class);
+            $imageService->destroy($entity->cover);
+        }
+    }
+}
index 65a5bb99f6ca3bfc6f99e4e0137d4e6029d91ff3..0a3d8945356cb9414fc592fb3c4f7668c272e4b7 100644 (file)
@@ -5,7 +5,7 @@ use BookStack\Http\Controllers\Controller;
 use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Http\JsonResponse;
 
-class ApiController extends Controller
+abstract class ApiController extends Controller
 {
 
     protected $rules = [];
index 84ddd521567ca13ebdada94d4722dee2e56ba307..80e86e101038f475da2e9f19a6821f7cab27b462 100644 (file)
@@ -1,8 +1,6 @@
 <?php namespace BookStack\Http\Controllers\Api;
 
 use BookStack\Api\ApiDocsGenerator;
-use Cache;
-use Illuminate\Support\Collection;
 
 class ApiDocsController extends ApiController
 {
@@ -12,7 +10,8 @@ class ApiDocsController extends ApiController
      */
     public function display()
     {
-        $docs = $this->getDocs();
+        $docs = ApiDocsGenerator::generateConsideringCache();
+        $this->setPageTitle(trans('settings.users_api_tokens_docs'));
         return view('api-docs.index', [
             'docs' => $docs,
         ]);
@@ -21,27 +20,10 @@ class ApiDocsController extends ApiController
     /**
      * Show a JSON view of the API docs data.
      */
-    public function json() {
-        $docs = $this->getDocs();
-        return response()->json($docs);
-    }
-
-    /**
-     * Get the base docs data.
-     * Checks and uses the system cache for quick re-fetching.
-     */
-    protected function getDocs(): Collection
+    public function json()
     {
-        $appVersion = trim(file_get_contents(base_path('version')));
-        $cacheKey = 'api-docs::' . $appVersion;
-        if (Cache::has($cacheKey) && config('app.env') === 'production') {
-            $docs = Cache::get($cacheKey);
-        } else {
-            $docs = (new ApiDocsGenerator())->generate();
-            Cache::put($cacheKey, $docs, 60*24);
-        }
-
-        return $docs;
+        $docs = ApiDocsGenerator::generateConsideringCache();
+        return response()->json($docs);
     }
 
 }
index 8333eba3a1d3779431dbbffa39e4e9abd49837d2..1b25b9645288e9ccc3afc80b2a631423d509c98a 100644 (file)
@@ -1,9 +1,8 @@
 <?php namespace BookStack\Http\Controllers\Api;
 
-use BookStack\Entities\Book;
+use BookStack\Entities\Models\Book;
 use BookStack\Entities\Repos\BookRepo;
 use BookStack\Exceptions\NotifyException;
-use BookStack\Facades\Activity;
 use Illuminate\Contracts\Container\BindingResolutionException;
 use Illuminate\Http\Request;
 use Illuminate\Validation\ValidationException;
@@ -26,9 +25,6 @@ class BookApiController extends ApiController
         ],
     ];
 
-    /**
-     * BooksApiController constructor.
-     */
     public function __construct(BookRepo $bookRepo)
     {
         $this->bookRepo = $bookRepo;
@@ -55,8 +51,6 @@ class BookApiController extends ApiController
         $requestData = $this->validate($request, $this->rules['create']);
 
         $book = $this->bookRepo->create($requestData);
-        Activity::add($book, 'book_create', $book->id);
-
         return response()->json($book);
     }
 
@@ -80,15 +74,14 @@ class BookApiController extends ApiController
 
         $requestData = $this->validate($request, $this->rules['update']);
         $book = $this->bookRepo->update($book, $requestData);
-        Activity::add($book, 'book_update', $book->id);
 
         return response()->json($book);
     }
 
     /**
-     * Delete a single book from the system.
-     * @throws NotifyException
-     * @throws BindingResolutionException
+     * Delete a single book.
+     * This will typically send the book to the recycle bin.
+     * @throws \Exception
      */
     public function delete(string $id)
     {
@@ -96,8 +89,6 @@ class BookApiController extends ApiController
         $this->checkOwnablePermission('book-delete', $book);
 
         $this->bookRepo->destroy($book);
-        Activity::addMessage('book_delete', $book->name);
-
         return response('', 204);
     }
 }
\ No newline at end of file
index 31fe5250fd7e3f14d7d60b8ff51a5d4bc4c241fd..3d813c4d4225fabdaa686d9b15881871f85303ad 100644 (file)
@@ -1,23 +1,16 @@
 <?php namespace BookStack\Http\Controllers\Api;
 
-use BookStack\Entities\Book;
-use BookStack\Entities\ExportService;
-use BookStack\Entities\Repos\BookRepo;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Tools\ExportFormatter;
 use Throwable;
 
 class BookExportApiController extends ApiController
 {
-    protected $bookRepo;
-    protected $exportService;
+    protected $exportFormatter;
 
-    /**
-     * BookExportController constructor.
-     */
-    public function __construct(BookRepo $bookRepo, ExportService $exportService)
+    public function __construct(ExportFormatter $exportFormatter)
     {
-        $this->bookRepo = $bookRepo;
-        $this->exportService = $exportService;
-        parent::__construct();
+        $this->exportFormatter = $exportFormatter;
     }
 
     /**
@@ -27,7 +20,7 @@ class BookExportApiController extends ApiController
     public function exportPdf(int $id)
     {
         $book = Book::visible()->findOrFail($id);
-        $pdfContent = $this->exportService->bookToPdf($book);
+        $pdfContent = $this->exportFormatter->bookToPdf($book);
         return $this->downloadResponse($pdfContent, $book->slug . '.pdf');
     }
 
@@ -38,7 +31,7 @@ class BookExportApiController extends ApiController
     public function exportHtml(int $id)
     {
         $book = Book::visible()->findOrFail($id);
-        $htmlContent = $this->exportService->bookToContainedHtml($book);
+        $htmlContent = $this->exportFormatter->bookToContainedHtml($book);
         return $this->downloadResponse($htmlContent, $book->slug . '.html');
     }
 
@@ -48,7 +41,7 @@ class BookExportApiController extends ApiController
     public function exportPlainText(int $id)
     {
         $book = Book::visible()->findOrFail($id);
-        $textContent = $this->exportService->bookToPlainText($book);
+        $textContent = $this->exportFormatter->bookToPlainText($book);
         return $this->downloadResponse($textContent, $book->slug . '.txt');
     }
 }
index 14b5e053b9ec42b8fc9c18dc0c40119be5dcbabf..c4851b003f6cc012d12658362e40f3d1608336f4 100644 (file)
@@ -1,8 +1,7 @@
 <?php namespace BookStack\Http\Controllers\Api;
 
-use BookStack\Facades\Activity;
 use BookStack\Entities\Repos\BookshelfRepo;
-use BookStack\Entities\Bookshelf;
+use BookStack\Entities\Models\Bookshelf;
 use Exception;
 use Illuminate\Database\Eloquent\Relations\BelongsToMany;
 use Illuminate\Http\Request;
@@ -31,7 +30,6 @@ class BookshelfApiController extends ApiController
 
     /**
      * BookshelfApiController constructor.
-     * @param BookshelfRepo $bookshelfRepo
      */
     public function __construct(BookshelfRepo $bookshelfRepo)
     {
@@ -63,7 +61,6 @@ class BookshelfApiController extends ApiController
         $bookIds = $request->get('books', []);
         $shelf = $this->bookshelfRepo->create($requestData, $bookIds);
 
-        Activity::add($shelf, 'bookshelf_create', $shelf->id);
         return response()->json($shelf);
     }
 
@@ -94,19 +91,17 @@ class BookshelfApiController extends ApiController
         $this->checkOwnablePermission('bookshelf-update', $shelf);
 
         $requestData = $this->validate($request, $this->rules['update']);
-
         $bookIds = $request->get('books', null);
 
         $shelf = $this->bookshelfRepo->update($shelf, $requestData, $bookIds);
-        Activity::add($shelf, 'bookshelf_update', $shelf->id);
-
         return response()->json($shelf);
     }
 
 
 
     /**
-     * Delete a single shelf from the system.
+     * Delete a single shelf.
+     * This will typically send the shelf to the recycle bin.
      * @throws Exception
      */
     public function delete(string $id)
@@ -115,8 +110,6 @@ class BookshelfApiController extends ApiController
         $this->checkOwnablePermission('bookshelf-delete', $shelf);
 
         $this->bookshelfRepo->destroy($shelf);
-        Activity::addMessage('bookshelf_delete', $shelf->name);
-
         return response('', 204);
     }
 }
\ No newline at end of file
index 50aa8834ec13ea7cc23bbf158a740f7dc16e8fb0..e69aecc2ddcd9df9141e30f30edd2bcd821bd590 100644 (file)
@@ -1,7 +1,8 @@
 <?php namespace BookStack\Http\Controllers\Api;
 
-use BookStack\Entities\Book;
-use BookStack\Entities\Chapter;
+use BookStack\Actions\ActivityType;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
 use BookStack\Entities\Repos\ChapterRepo;
 use BookStack\Facades\Activity;
 use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -58,8 +59,6 @@ class ChapterApiController extends ApiController
         $this->checkOwnablePermission('chapter-create', $book);
 
         $chapter = $this->chapterRepo->create($request->all(), $book);
-        Activity::add($chapter, 'chapter_create', $book->id);
-
         return response()->json($chapter->load(['tags']));
     }
 
@@ -83,13 +82,12 @@ class ChapterApiController extends ApiController
         $this->checkOwnablePermission('chapter-update', $chapter);
 
         $updatedChapter = $this->chapterRepo->update($chapter, $request->all());
-        Activity::add($chapter, 'chapter_update', $chapter->book->id);
-
         return response()->json($updatedChapter->load(['tags']));
     }
 
     /**
-     * Delete a chapter from the system.
+     * Delete a chapter.
+     * This will typically send the chapter to the recycle bin.
      */
     public function delete(string $id)
     {
@@ -97,8 +95,6 @@ class ChapterApiController extends ApiController
         $this->checkOwnablePermission('chapter-delete', $chapter);
 
         $this->chapterRepo->destroy($chapter);
-        Activity::addMessage('chapter_delete', $chapter->name, $chapter->book->id);
-
         return response('', 204);
     }
 }
index f19f29e9d2d752ee23182abbb0def5758e816714..afdfe555dd56f1bee4bc0a40139476b8a2d80c4f 100644 (file)
@@ -1,23 +1,20 @@
 <?php namespace BookStack\Http\Controllers\Api;
 
-use BookStack\Entities\Chapter;
-use BookStack\Entities\ExportService;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Tools\ExportFormatter;
 use BookStack\Entities\Repos\BookRepo;
 use Throwable;
 
 class ChapterExportApiController extends ApiController
 {
-    protected $chapterRepo;
-    protected $exportService;
+    protected $exportFormatter;
 
     /**
      * ChapterExportController constructor.
      */
-    public function __construct(BookRepo $chapterRepo, ExportService $exportService)
+    public function __construct(ExportFormatter $exportFormatter)
     {
-        $this->chapterRepo = $chapterRepo;
-        $this->exportService = $exportService;
-        parent::__construct();
+        $this->exportFormatter = $exportFormatter;
     }
 
     /**
@@ -27,7 +24,7 @@ class ChapterExportApiController extends ApiController
     public function exportPdf(int $id)
     {
         $chapter = Chapter::visible()->findOrFail($id);
-        $pdfContent = $this->exportService->chapterToPdf($chapter);
+        $pdfContent = $this->exportFormatter->chapterToPdf($chapter);
         return $this->downloadResponse($pdfContent, $chapter->slug . '.pdf');
     }
 
@@ -38,7 +35,7 @@ class ChapterExportApiController extends ApiController
     public function exportHtml(int $id)
     {
         $chapter = Chapter::visible()->findOrFail($id);
-        $htmlContent = $this->exportService->chapterToContainedHtml($chapter);
+        $htmlContent = $this->exportFormatter->chapterToContainedHtml($chapter);
         return $this->downloadResponse($htmlContent, $chapter->slug . '.html');
     }
 
@@ -48,7 +45,7 @@ class ChapterExportApiController extends ApiController
     public function exportPlainText(int $id)
     {
         $chapter = Chapter::visible()->findOrFail($id);
-        $textContent = $this->exportService->chapterToPlainText($chapter);
+        $textContent = $this->exportFormatter->chapterToPlainText($chapter);
         return $this->downloadResponse($textContent, $chapter->slug . '.txt');
     }
 }
diff --git a/app/Http/Controllers/Api/PageApiController.php b/app/Http/Controllers/Api/PageApiController.php
new file mode 100644 (file)
index 0000000..0b3323c
--- /dev/null
@@ -0,0 +1,140 @@
+<?php
+
+namespace BookStack\Http\Controllers\Api;
+
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
+use BookStack\Entities\Repos\PageRepo;
+use BookStack\Exceptions\PermissionsException;
+use Exception;
+use Illuminate\Http\Request;
+
+class PageApiController extends ApiController
+{
+    protected $pageRepo;
+
+    protected $rules = [
+        'create' => [
+            'book_id' => 'required_without:chapter_id|integer',
+            'chapter_id' => 'required_without:book_id|integer',
+            'name' => 'required|string|max:255',
+            'html' => 'required_without:markdown|string',
+            'markdown' => 'required_without:html|string',
+            'tags' => 'array',
+        ],
+        'update' => [
+            'book_id' => 'required|integer',
+            'chapter_id' => 'required|integer',
+            'name' => 'string|min:1|max:255',
+            'html' => 'string',
+            'markdown' => 'string',
+            'tags' => 'array',
+        ],
+    ];
+
+    public function __construct(PageRepo $pageRepo)
+    {
+        $this->pageRepo = $pageRepo;
+    }
+
+    /**
+     * Get a listing of pages visible to the user.
+     */
+    public function list()
+    {
+        $pages = Page::visible();
+        return $this->apiListingResponse($pages, [
+            'id', 'book_id', 'chapter_id', 'name', 'slug', 'priority',
+            'draft', 'template',
+            'created_at', 'updated_at', 'created_by', 'updated_by',
+        ]);
+    }
+
+    /**
+     * Create a new page in the system.
+     *
+     * The ID of a parent book or chapter is required to indicate
+     * where this page should be located.
+     *
+     * Any HTML content provided should be kept to a single-block depth of plain HTML
+     * elements to remain compatible with the BookStack front-end and editors.
+     */
+    public function create(Request $request)
+    {
+        $this->validate($request, $this->rules['create']);
+
+        if ($request->has('chapter_id')) {
+            $parent = Chapter::visible()->findOrFail($request->get('chapter_id'));
+        } else {
+            $parent = Book::visible()->findOrFail($request->get('book_id'));
+        }
+        $this->checkOwnablePermission('page-create', $parent);
+
+        $draft = $this->pageRepo->getNewDraftPage($parent);
+        $this->pageRepo->publishDraft($draft, $request->only(array_keys($this->rules['create'])));
+
+        return response()->json($draft->forJsonDisplay());
+    }
+
+    /**
+     * View the details of a single page.
+     *
+     * Pages will always have HTML content. They may have markdown content
+     * if the markdown editor was used to last update the page.
+     */
+    public function read(string $id)
+    {
+        $page = $this->pageRepo->getById($id, []);
+        return response()->json($page->forJsonDisplay());
+    }
+
+    /**
+     * Update the details of a single page.
+     *
+     * See the 'create' action for details on the provided HTML/Markdown.
+     * Providing a 'book_id' or 'chapter_id' property will essentially move
+     * the page into that parent element if you have permissions to do so.
+     */
+    public function update(Request $request, string $id)
+    {
+        $page = $this->pageRepo->getById($id, []);
+        $this->checkOwnablePermission('page-update', $page);
+
+        $parent = null;
+        if ($request->has('chapter_id')) {
+            $parent = Chapter::visible()->findOrFail($request->get('chapter_id'));
+        } else if ($request->has('book_id')) {
+            $parent = Book::visible()->findOrFail($request->get('book_id'));
+        }
+
+        if ($parent && !$parent->matches($page->getParent())) {
+            $this->checkOwnablePermission('page-delete', $page);
+            try {
+                $this->pageRepo->move($page, $parent->getType() . ':' . $parent->id);
+            } catch (Exception $exception) {
+                if ($exception instanceof  PermissionsException) {
+                    $this->showPermissionError();
+                }
+
+                return $this->jsonError(trans('errors.selected_book_chapter_not_found'));
+            }
+        }
+
+        $updatedPage = $this->pageRepo->update($page, $request->all());
+        return response()->json($updatedPage->forJsonDisplay());
+    }
+
+    /**
+     * Delete a page.
+     * This will typically send the page to the recycle bin.
+     */
+    public function delete(string $id)
+    {
+        $page = $this->pageRepo->getById($id, []);
+        $this->checkOwnablePermission('page-delete', $page);
+
+        $this->pageRepo->destroy($page);
+        return response('', 204);
+    }
+}
diff --git a/app/Http/Controllers/Api/PageExportApiController.php b/app/Http/Controllers/Api/PageExportApiController.php
new file mode 100644 (file)
index 0000000..7563092
--- /dev/null
@@ -0,0 +1,47 @@
+<?php namespace BookStack\Http\Controllers\Api;
+
+use BookStack\Entities\Models\Page;
+use BookStack\Entities\Tools\ExportFormatter;
+use Throwable;
+
+class PageExportApiController extends ApiController
+{
+    protected $exportFormatter;
+
+    public function __construct(ExportFormatter $exportFormatter)
+    {
+        $this->exportFormatter = $exportFormatter;
+    }
+
+    /**
+     * Export a page as a PDF file.
+     * @throws Throwable
+     */
+    public function exportPdf(int $id)
+    {
+        $page = Page::visible()->findOrFail($id);
+        $pdfContent = $this->exportFormatter->pageToPdf($page);
+        return $this->downloadResponse($pdfContent, $page->slug . '.pdf');
+    }
+
+    /**
+     * Export a page as a contained HTML file.
+     * @throws Throwable
+     */
+    public function exportHtml(int $id)
+    {
+        $page = Page::visible()->findOrFail($id);
+        $htmlContent = $this->exportFormatter->pageToContainedHtml($page);
+        return $this->downloadResponse($htmlContent, $page->slug . '.html');
+    }
+
+    /**
+     * Export a page as a plain text file.
+     */
+    public function exportPlainText(int $id)
+    {
+        $page = Page::visible()->findOrFail($id);
+        $textContent = $this->exportFormatter->pageToPlainText($page);
+        return $this->downloadResponse($textContent, $page->slug . '.txt');
+    }
+}
index f52143292de060b4b0eaf883c73e9d6e7adb6789..04e89ac5d1a0db18407398aabd3689951822eee8 100644 (file)
@@ -25,7 +25,6 @@ class AttachmentController extends Controller
         $this->attachmentService = $attachmentService;
         $this->attachment = $attachment;
         $this->pageRepo = $pageRepo;
-        parent::__construct();
     }
 
 
index a3ef01baa472ec923de70068abcc84ee2fe09439..eb6eecc944ec0ba29567b2147b5d88af4f39d437 100644 (file)
@@ -23,11 +23,16 @@ class AuditLogController extends Controller
         ];
 
         $query = Activity::query()
-            ->with(['entity', 'user'])
+            ->with([
+                'entity' => function ($query) {
+                    $query->withTrashed();
+                },
+                'user'
+            ])
             ->orderBy($listDetails['sort'], $listDetails['order']);
 
         if ($listDetails['event']) {
-            $query->where('key', '=', $listDetails['event']);
+            $query->where('type', '=', $listDetails['event']);
         }
 
         if ($listDetails['date_from']) {
@@ -40,12 +45,12 @@ class AuditLogController extends Controller
         $activities = $query->paginate(100);
         $activities->appends($listDetails);
 
-        $keys = DB::table('activities')->select('key')->distinct()->pluck('key');
+        $types = DB::table('activities')->select('type')->distinct()->pluck('type');
         $this->setPageTitle(trans('settings.audit'));
         return view('settings.audit', [
             'activities' => $activities,
             'listDetails' => $listDetails,
-            'activityKeys' => $keys,
+            'activityTypes' => $types,
         ]);
     }
 }
index 099558eb77fdce133b307aaac327277bc02510f8..bffeb5f61b2f2f7528b87e6ad506f4b6dded53d2 100644 (file)
@@ -21,15 +21,11 @@ class ConfirmEmailController extends Controller
 
     /**
      * Create a new controller instance.
-     *
-     * @param EmailConfirmationService $emailConfirmationService
-     * @param UserRepo $userRepo
      */
     public function __construct(EmailConfirmationService $emailConfirmationService, UserRepo $userRepo)
     {
         $this->emailConfirmationService = $emailConfirmationService;
         $this->userRepo = $userRepo;
-        parent::__construct();
     }
 
 
index fadac641ecdb810b916560611029a1b517d3d6fe..5a033c6aad57d89fe7067b1c65016c200e327439 100644 (file)
@@ -2,6 +2,7 @@
 
 namespace BookStack\Http\Controllers\Auth;
 
+use BookStack\Actions\ActivityType;
 use BookStack\Http\Controllers\Controller;
 use Illuminate\Foundation\Auth\SendsPasswordResetEmails;
 use Illuminate\Http\Request;
@@ -31,7 +32,6 @@ class ForgotPasswordController extends Controller
     {
         $this->middleware('guest');
         $this->middleware('guard:standard');
-        parent::__construct();
     }
 
 
@@ -52,6 +52,10 @@ class ForgotPasswordController extends Controller
             $request->only('email')
         );
 
+        if ($response === Password::RESET_LINK_SENT) {
+            $this->logActivity(ActivityType::AUTH_PASSWORD_RESET, $request->get('email'));
+        }
+
         if ($response === Password::RESET_LINK_SENT || $response === Password::INVALID_USER) {
             $message = trans('auth.reset_password_sent', ['email' => $request->get('email')]);
             $this->showSuccessNotification($message);
index 8084ce1a5dcfa220af09c73b21f711bdcc363dce..1252e6217a8b66f1bda3b405af28085a177308de 100644 (file)
@@ -3,10 +3,10 @@
 namespace BookStack\Http\Controllers\Auth;
 
 use Activity;
+use BookStack\Actions\ActivityType;
 use BookStack\Auth\Access\SocialAuthService;
 use BookStack\Exceptions\LoginAttemptEmailNeededException;
 use BookStack\Exceptions\LoginAttemptException;
-use BookStack\Exceptions\UserRegistrationException;
 use BookStack\Http\Controllers\Controller;
 use Illuminate\Foundation\Auth\AuthenticatesUsers;
 use Illuminate\Http\Request;
@@ -46,7 +46,6 @@ class LoginController extends Controller
         $this->socialAuthService = $socialAuthService;
         $this->redirectPath = url('/');
         $this->redirectAfterLogout = url('/login');
-        parent::__construct();
     }
 
     public function username()
@@ -151,6 +150,7 @@ class LoginController extends Controller
             }
         }
 
+        $this->logActivity(ActivityType::AUTH_LOGIN, $user);
         return redirect()->intended($this->redirectPath());
     }
 
index 0bdeef9e6855c1337c34ff934bece9ab5d42d45d..e3d22264d5301a73c2d2bee41daa1c71209512a9 100644 (file)
@@ -51,7 +51,6 @@ class RegisterController extends Controller
 
         $this->redirectTo = url('/');
         $this->redirectPath = url('/');
-        parent::__construct();
     }
 
     /**
index efdf0015924f6d831a0233a737e7209ff246b7e0..59e9ab79baa7cb146ed7582e1fc50cd88a7d9e31 100644 (file)
@@ -2,6 +2,7 @@
 
 namespace BookStack\Http\Controllers\Auth;
 
+use BookStack\Actions\ActivityType;
 use BookStack\Http\Controllers\Controller;
 use Illuminate\Foundation\Auth\ResetsPasswords;
 use Illuminate\Http\Request;
@@ -33,7 +34,6 @@ class ResetPasswordController extends Controller
     {
         $this->middleware('guest');
         $this->middleware('guard:standard');
-        parent::__construct();
     }
 
     /**
@@ -47,6 +47,7 @@ class ResetPasswordController extends Controller
     {
         $message = trans('auth.reset_password_success');
         $this->showSuccessNotification($message);
+        $this->logActivity(ActivityType::AUTH_PASSWORD_RESET_UPDATE, user());
         return redirect($this->redirectPath())
             ->with('status', trans($response));
     }
index 7ffcc572bcd06dc43f003df6edb9f8c05d84720e..8a3bf065ed566b55062a184c76c7144797417060 100644 (file)
@@ -15,7 +15,6 @@ class Saml2Controller extends Controller
      */
     public function __construct(Saml2Service $samlService)
     {
-        parent::__construct();
         $this->samlService = $samlService;
         $this->middleware('guard:saml2');
     }
index c61b1c42b688e58b8c6defd8c007f8db7a099009..926458fa613ddc1073305ea5405750c7ce01e085 100644 (file)
@@ -27,8 +27,6 @@ class UserInviteController extends Controller
 
         $this->inviteService = $inviteService;
         $this->userRepo = $userRepo;
-
-        parent::__construct();
     }
 
     /**
index 1643c62f980cd151dabd3fedf0031084bd336c78..b63fe911fe083f378dc58495334eedad23e36d2b 100644 (file)
@@ -1,12 +1,12 @@
 <?php namespace BookStack\Http\Controllers;
 
 use Activity;
-use BookStack\Entities\Managers\BookContents;
-use BookStack\Entities\Bookshelf;
-use BookStack\Entities\Managers\EntityContext;
+use BookStack\Actions\ActivityType;
+use BookStack\Entities\Tools\BookContents;
+use BookStack\Entities\Models\Bookshelf;
+use BookStack\Entities\Tools\ShelfContext;
 use BookStack\Entities\Repos\BookRepo;
 use BookStack\Exceptions\ImageUploadException;
-use BookStack\Exceptions\NotifyException;
 use Illuminate\Http\Request;
 use Illuminate\Validation\ValidationException;
 use Throwable;
@@ -18,14 +18,10 @@ class BookController extends Controller
     protected $bookRepo;
     protected $entityContextManager;
 
-    /**
-     * BookController constructor.
-     */
-    public function __construct(EntityContext $entityContextManager, BookRepo $bookRepo)
+    public function __construct(ShelfContext $entityContextManager, BookRepo $bookRepo)
     {
         $this->bookRepo = $bookRepo;
         $this->entityContextManager = $entityContextManager;
-        parent::__construct();
     }
 
     /**
@@ -97,11 +93,10 @@ class BookController extends Controller
 
         $book = $this->bookRepo->create($request->all());
         $this->bookRepo->updateCoverImage($book, $request->file('image', null));
-        Activity::add($book, 'book_create', $book->id);
 
         if ($bookshelf) {
             $bookshelf->appendBook($book);
-            Activity::add($bookshelf, 'bookshelf_update');
+            Activity::addForEntity($bookshelf, ActivityType::BOOKSHELF_UPDATE);
         }
 
         return redirect($book->getUrl());
@@ -162,8 +157,6 @@ class BookController extends Controller
         $resetCover = $request->has('image_reset');
         $this->bookRepo->updateCoverImage($book, $request->file('image', null), $resetCover);
 
-        Activity::add($book, 'book_update', $book->id);
-
         return redirect($book->getUrl());
     }
 
@@ -181,14 +174,12 @@ class BookController extends Controller
     /**
      * Remove the specified book from the system.
      * @throws Throwable
-     * @throws NotifyException
      */
     public function destroy(string $bookSlug)
     {
         $book = $this->bookRepo->getBySlug($bookSlug);
         $this->checkOwnablePermission('book-delete', $book);
 
-        Activity::addMessage('book_delete', $book->name);
         $this->bookRepo->destroy($book);
 
         return redirect('/books');
index cfa3d6a3a3d162e9eb5d3bf19afa5eb4a4f6b7b8..1c1f124422f962020d31e4f35595b2e65d0f80cf 100644 (file)
@@ -2,7 +2,7 @@
 
 namespace BookStack\Http\Controllers;
 
-use BookStack\Entities\ExportService;
+use BookStack\Entities\Tools\ExportFormatter;
 use BookStack\Entities\Repos\BookRepo;
 use Throwable;
 
@@ -10,16 +10,15 @@ class BookExportController extends Controller
 {
 
     protected $bookRepo;
-    protected $exportService;
+    protected $exportFormatter;
 
     /**
      * BookExportController constructor.
      */
-    public function __construct(BookRepo $bookRepo, ExportService $exportService)
+    public function __construct(BookRepo $bookRepo, ExportFormatter $exportFormatter)
     {
         $this->bookRepo = $bookRepo;
-        $this->exportService = $exportService;
-        parent::__construct();
+        $this->exportFormatter = $exportFormatter;
     }
 
     /**
@@ -29,7 +28,7 @@ class BookExportController extends Controller
     public function pdf(string $bookSlug)
     {
         $book = $this->bookRepo->getBySlug($bookSlug);
-        $pdfContent = $this->exportService->bookToPdf($book);
+        $pdfContent = $this->exportFormatter->bookToPdf($book);
         return $this->downloadResponse($pdfContent, $bookSlug . '.pdf');
     }
 
@@ -40,7 +39,7 @@ class BookExportController extends Controller
     public function html(string $bookSlug)
     {
         $book = $this->bookRepo->getBySlug($bookSlug);
-        $htmlContent = $this->exportService->bookToContainedHtml($book);
+        $htmlContent = $this->exportFormatter->bookToContainedHtml($book);
         return $this->downloadResponse($htmlContent, $bookSlug . '.html');
     }
 
@@ -50,7 +49,7 @@ class BookExportController extends Controller
     public function plainText(string $bookSlug)
     {
         $book = $this->bookRepo->getBySlug($bookSlug);
-        $textContent = $this->exportService->bookToPlainText($book);
+        $textContent = $this->exportFormatter->bookToPlainText($book);
         return $this->downloadResponse($textContent, $bookSlug . '.txt');
     }
 }
index f5fb6f255537c2d16017f7365974863cc402260f..6d3199cbee990fe90b484c2b3a3bc6d4c73a54b8 100644 (file)
@@ -2,8 +2,9 @@
 
 namespace BookStack\Http\Controllers;
 
-use BookStack\Entities\Book;
-use BookStack\Entities\Managers\BookContents;
+use BookStack\Actions\ActivityType;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Tools\BookContents;
 use BookStack\Entities\Repos\BookRepo;
 use BookStack\Exceptions\SortOperationException;
 use BookStack\Facades\Activity;
@@ -14,14 +15,9 @@ class BookSortController extends Controller
 
     protected $bookRepo;
 
-    /**
-     * BookSortController constructor.
-     * @param $bookRepo
-     */
     public function __construct(BookRepo $bookRepo)
     {
         $this->bookRepo = $bookRepo;
-        parent::__construct();
     }
 
     /**
@@ -74,7 +70,7 @@ class BookSortController extends Controller
 
         // Rebuild permissions and add activity for involved books.
         $booksInvolved->each(function (Book $book) {
-            Activity::add($book, 'book_sort', $book->id);
+            Activity::addForEntity($book, ActivityType::BOOK_SORT);
         });
 
         return redirect($book->getUrl());
index f2cc11c7ba16126eb6f7fef610c8662525d190bf..50dc97bab9ea102587e112cb3fb222bab5f934e8 100644 (file)
@@ -1,8 +1,8 @@
 <?php namespace BookStack\Http\Controllers;
 
 use Activity;
-use BookStack\Entities\Book;
-use BookStack\Entities\Managers\EntityContext;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Tools\ShelfContext;
 use BookStack\Entities\Repos\BookshelfRepo;
 use BookStack\Exceptions\ImageUploadException;
 use BookStack\Exceptions\NotFoundException;
@@ -22,12 +22,11 @@ class BookshelfController extends Controller
     /**
      * BookController constructor.
      */
-    public function __construct(BookshelfRepo $bookshelfRepo, EntityContext $entityContextManager, ImageRepo $imageRepo)
+    public function __construct(BookshelfRepo $bookshelfRepo, ShelfContext $entityContextManager, ImageRepo $imageRepo)
     {
         $this->bookshelfRepo = $bookshelfRepo;
         $this->entityContextManager = $entityContextManager;
         $this->imageRepo = $imageRepo;
-        parent::__construct();
     }
 
     /**
@@ -92,7 +91,6 @@ class BookshelfController extends Controller
         $shelf = $this->bookshelfRepo->create($request->all(), $bookIds);
         $this->bookshelfRepo->updateCoverImage($shelf, $request->file('image', null));
 
-        Activity::add($shelf, 'bookshelf_create');
         return redirect($shelf->getUrl());
     }
 
@@ -156,7 +154,6 @@ class BookshelfController extends Controller
         $shelf = $this->bookshelfRepo->update($shelf, $request->all(), $bookIds);
         $resetCover = $request->has('image_reset');
         $this->bookshelfRepo->updateCoverImage($shelf, $request->file('image', null), $resetCover);
-        Activity::add($shelf, 'bookshelf_update');
 
         return redirect($shelf->getUrl());
     }
@@ -182,7 +179,6 @@ class BookshelfController extends Controller
         $shelf = $this->bookshelfRepo->getBySlug($slug);
         $this->checkOwnablePermission('bookshelf-delete', $shelf);
 
-        Activity::addMessage('bookshelf_delete', $shelf->name);
         $this->bookshelfRepo->destroy($shelf);
 
         return redirect('/shelves');
index 1355979107eb0181d272e3610511688d5772b7b7..0059f202b9df7702d1b1baecd485056fd909affa 100644 (file)
@@ -1,8 +1,7 @@
 <?php namespace BookStack\Http\Controllers;
 
-use Activity;
-use BookStack\Entities\Book;
-use BookStack\Entities\Managers\BookContents;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Tools\BookContents;
 use BookStack\Entities\Repos\ChapterRepo;
 use BookStack\Exceptions\MoveOperationException;
 use BookStack\Exceptions\NotFoundException;
@@ -22,7 +21,6 @@ class ChapterController extends Controller
     public function __construct(ChapterRepo $chapterRepo)
     {
         $this->chapterRepo = $chapterRepo;
-        parent::__construct();
     }
 
     /**
@@ -51,7 +49,6 @@ class ChapterController extends Controller
         $this->checkOwnablePermission('chapter-create', $book);
 
         $chapter = $this->chapterRepo->create($request->all(), $book);
-        Activity::add($chapter, 'chapter_create', $book->id);
 
         return redirect($chapter->getUrl());
     }
@@ -100,7 +97,6 @@ class ChapterController extends Controller
         $this->checkOwnablePermission('chapter-update', $chapter);
 
         $this->chapterRepo->update($chapter, $request->all());
-        Activity::add($chapter, 'chapter_update', $chapter->book->id);
 
         return redirect($chapter->getUrl());
     }
@@ -128,7 +124,6 @@ class ChapterController extends Controller
         $chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
         $this->checkOwnablePermission('chapter-delete', $chapter);
 
-        Activity::addMessage('chapter_delete', $chapter->name, $chapter->book->id);
         $this->chapterRepo->destroy($chapter);
 
         return redirect($chapter->book->getUrl());
@@ -173,8 +168,6 @@ class ChapterController extends Controller
             return redirect()->back();
         }
 
-        Activity::add($chapter, 'chapter_move', $newBook->id);
-
         $this->showSuccessNotification(trans('entities.chapter_move_success', ['bookName' => $newBook->name]));
         return redirect($chapter->getUrl());
     }
index 0c86f854828b70dad5418a9b475c7262aef16612..52d087442ab287eb2d533365ed6cfd8cce5da642 100644 (file)
@@ -1,6 +1,6 @@
 <?php namespace BookStack\Http\Controllers;
 
-use BookStack\Entities\ExportService;
+use BookStack\Entities\Tools\ExportFormatter;
 use BookStack\Entities\Repos\ChapterRepo;
 use BookStack\Exceptions\NotFoundException;
 use Throwable;
@@ -9,16 +9,15 @@ class ChapterExportController extends Controller
 {
 
     protected $chapterRepo;
-    protected $exportService;
+    protected $exportFormatter;
 
     /**
      * ChapterExportController constructor.
      */
-    public function __construct(ChapterRepo $chapterRepo, ExportService $exportService)
+    public function __construct(ChapterRepo $chapterRepo, ExportFormatter $exportFormatter)
     {
         $this->chapterRepo = $chapterRepo;
-        $this->exportService = $exportService;
-        parent::__construct();
+        $this->exportFormatter = $exportFormatter;
     }
 
     /**
@@ -29,7 +28,7 @@ class ChapterExportController extends Controller
     public function pdf(string $bookSlug, string $chapterSlug)
     {
         $chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
-        $pdfContent = $this->exportService->chapterToPdf($chapter);
+        $pdfContent = $this->exportFormatter->chapterToPdf($chapter);
         return $this->downloadResponse($pdfContent, $chapterSlug . '.pdf');
     }
 
@@ -41,7 +40,7 @@ class ChapterExportController extends Controller
     public function html(string $bookSlug, string $chapterSlug)
     {
         $chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
-        $containedHtml = $this->exportService->chapterToContainedHtml($chapter);
+        $containedHtml = $this->exportFormatter->chapterToContainedHtml($chapter);
         return $this->downloadResponse($containedHtml, $chapterSlug . '.html');
     }
 
@@ -52,7 +51,7 @@ class ChapterExportController extends Controller
     public function plainText(string $bookSlug, string $chapterSlug)
     {
         $chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
-        $chapterText = $this->exportService->chapterToPlainText($chapter);
+        $chapterText = $this->exportFormatter->chapterToPlainText($chapter);
         return $this->downloadResponse($chapterText, $chapterSlug . '.txt');
     }
 }
index 4eb56a4b0cd0720cfcc110e5c22662b958231f4b..bf1a76f518f3ce70f2792bd1138bc309ca961d4d 100644 (file)
@@ -1,8 +1,9 @@
 <?php namespace BookStack\Http\Controllers;
 
 use Activity;
+use BookStack\Actions\ActivityType;
 use BookStack\Actions\CommentRepo;
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Page;
 use Illuminate\Http\Request;
 use Illuminate\Validation\ValidationException;
 
@@ -13,7 +14,6 @@ class CommentController extends Controller
     public function __construct(CommentRepo $commentRepo)
     {
         $this->commentRepo = $commentRepo;
-        parent::__construct();
     }
 
     /**
@@ -40,7 +40,6 @@ class CommentController extends Controller
         // Create a new comment.
         $this->checkPermission('comment-create-all');
         $comment = $this->commentRepo->create($page, $request->get('text'), $request->get('parent_id'));
-        Activity::add($page, 'commented_on', $page->book->id);
         return view('comments.comment', ['comment' => $comment]);
     }
 
index 6a1dfcb0140062d0fcabcffe9174f226f1cacc48..758c85dda4772b094a1abd50947cebc2d3e20238 100644 (file)
@@ -2,26 +2,20 @@
 
 namespace BookStack\Http\Controllers;
 
+use BookStack\Facades\Activity;
+use BookStack\Interfaces\Loggable;
 use BookStack\Ownable;
 use Illuminate\Foundation\Bus\DispatchesJobs;
 use Illuminate\Foundation\Validation\ValidatesRequests;
 use Illuminate\Http\Exceptions\HttpResponseException;
-use Illuminate\Http\Request;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Response;
 use Illuminate\Routing\Controller as BaseController;
-use Illuminate\Validation\ValidationException;
 
 abstract class Controller extends BaseController
 {
     use DispatchesJobs, ValidatesRequests;
 
-    /**
-     * Controller constructor.
-     */
-    public function __construct()
-    {
-        //
-    }
-
     /**
      * Check if the current user is signed in.
      */
@@ -43,9 +37,8 @@ abstract class Controller extends BaseController
 
     /**
      * Adds the page title into the view.
-     * @param $title
      */
-    public function setPageTitle($title)
+    public function setPageTitle(string $title)
     {
         view()->share('pageTitle', $title);
     }
@@ -67,79 +60,59 @@ abstract class Controller extends BaseController
     }
 
     /**
-     * Checks for a permission.
-     * @param string $permissionName
-     * @return bool|\Illuminate\Http\RedirectResponse
+     * Checks that the current user has the given permission otherwise throw an exception.
      */
-    protected function checkPermission($permissionName)
+    protected function checkPermission(string $permission): void
     {
-        if (!user() || !user()->can($permissionName)) {
+        if (!user() || !user()->can($permission)) {
             $this->showPermissionError();
         }
-        return true;
     }
 
     /**
-     * Check the current user's permissions against an ownable item.
-     * @param $permission
-     * @param Ownable $ownable
-     * @return bool
+     * Check the current user's permissions against an ownable item otherwise throw an exception.
      */
-    protected function checkOwnablePermission($permission, Ownable $ownable)
+    protected function checkOwnablePermission(string $permission, Ownable $ownable): void
     {
-        if (userCan($permission, $ownable)) {
-            return true;
+        if (!userCan($permission, $ownable)) {
+            $this->showPermissionError();
         }
-        return $this->showPermissionError();
     }
 
     /**
-     * Check if a user has a permission or bypass if the callback is true.
-     * @param $permissionName
-     * @param $callback
-     * @return bool
+     * Check if a user has a permission or bypass the permission
+     * check if the given callback resolves true.
      */
-    protected function checkPermissionOr($permissionName, $callback)
+    protected function checkPermissionOr(string $permission, callable $callback): void
     {
-        $callbackResult = $callback();
-        if ($callbackResult === false) {
-            $this->checkPermission($permissionName);
+        if ($callback() !== true) {
+            $this->checkPermission($permission);
         }
-        return true;
     }
 
     /**
      * Check if the current user has a permission or bypass if the provided user
      * id matches the current user.
-     * @param string $permissionName
-     * @param int $userId
-     * @return bool
      */
-    protected function checkPermissionOrCurrentUser(string $permissionName, int $userId)
+    protected function checkPermissionOrCurrentUser(string $permission, int $userId): void
     {
-        return $this->checkPermissionOr($permissionName, function () use ($userId) {
+        $this->checkPermissionOr($permission, function () use ($userId) {
             return $userId === user()->id;
         });
     }
 
     /**
      * Send back a json error message.
-     * @param string $messageText
-     * @param int $statusCode
-     * @return mixed
      */
-    protected function jsonError($messageText = "", $statusCode = 500)
+    protected function jsonError(string $messageText = "", int $statusCode = 500): JsonResponse
     {
         return response()->json(['message' => $messageText, 'status' => 'error'], $statusCode);
     }
 
     /**
      * Create a response that forces a download in the browser.
-     * @param string $content
-     * @param string $fileName
-     * @return \Illuminate\Http\Response
      */
-    protected function downloadResponse(string $content, string $fileName)
+    protected function downloadResponse(string $content, string $fileName): Response
     {
         return response()->make($content, 200, [
             'Content-Type'        => 'application/octet-stream',
@@ -149,31 +122,37 @@ abstract class Controller extends BaseController
 
     /**
      * Show a positive, successful notification to the user on next view load.
-     * @param string $message
      */
-    protected function showSuccessNotification(string $message)
+    protected function showSuccessNotification(string $message): void
     {
         session()->flash('success', $message);
     }
 
     /**
      * Show a warning notification to the user on next view load.
-     * @param string $message
      */
-    protected function showWarningNotification(string $message)
+    protected function showWarningNotification(string $message): void
     {
         session()->flash('warning', $message);
     }
 
     /**
      * Show an error notification to the user on next view load.
-     * @param string $message
      */
-    protected function showErrorNotification(string $message)
+    protected function showErrorNotification(string $message): void
     {
         session()->flash('error', $message);
     }
 
+    /**
+     * Log an activity in the system.
+     * @param string|Loggable
+     */
+    protected function logActivity(string $type, $detail = ''): void
+    {
+        Activity::add($type, $detail);
+    }
+
     /**
      * Get the validation rules for image files.
      */
index 60d2664d03a81107b9427f1258a8a82664551c90..c6856d33963c4fb72a88357819935b9d15e10c0c 100644 (file)
@@ -1,9 +1,9 @@
 <?php namespace BookStack\Http\Controllers;
 
 use Activity;
-use BookStack\Entities\Book;
-use BookStack\Entities\Managers\PageContent;
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Tools\PageContent;
+use BookStack\Entities\Models\Page;
 use BookStack\Entities\Repos\BookRepo;
 use BookStack\Entities\Repos\BookshelfRepo;
 use Illuminate\Http\Response;
@@ -14,7 +14,6 @@ class HomeController extends Controller
 
     /**
      * Display the homepage.
-     * @return Response
      */
     public function index()
     {
@@ -22,14 +21,17 @@ class HomeController extends Controller
         $draftPages = [];
 
         if ($this->isSignedIn()) {
-            $draftPages = Page::visible()->where('draft', '=', true)
+            $draftPages = Page::visible()
+                ->where('draft', '=', true)
                 ->where('created_by', '=', user()->id)
-                ->orderBy('updated_at', 'desc')->take(6)->get();
+                ->orderBy('updated_at', 'desc')
+                ->take(6)
+                ->get();
         }
 
         $recentFactor = count($draftPages) > 0 ? 0.5 : 1;
         $recents = $this->isSignedIn() ?
-              Views::getUserRecentlyViewed(12*$recentFactor, 0)
+              Views::getUserRecentlyViewed(12*$recentFactor, 1)
             : Book::visible()->orderBy('created_at', 'desc')->take(12 * $recentFactor)->get();
         $recentlyUpdatedPages = Page::visible()->where('draft', false)
             ->orderBy('updated_at', 'desc')->take(12)->get();
index 29b1e9027ea128ff9189cc086e97a336c5381441..462ab68f6f32f66ee44ef4439d2448ff62e2c12e 100644 (file)
@@ -15,7 +15,6 @@ class DrawioImageController extends Controller
     public function __construct(ImageRepo $imageRepo)
     {
         $this->imageRepo = $imageRepo;
-        parent::__construct();
     }
 
     /**
index 61907c0039bc7f72d8ec7cdd34804cf5f33536b1..c3ad0b7b261fe6d5946de46eacf5f263715e7470 100644 (file)
@@ -18,7 +18,6 @@ class GalleryImageController extends Controller
     public function __construct(ImageRepo $imageRepo)
     {
         $this->imageRepo = $imageRepo;
-        parent::__construct();
     }
 
     /**
index 7d06facffe14b963a53d645eb47d56d8a655b290..ecc36bf67e24ad531f83326ed32d22bf4f97f63d 100644 (file)
@@ -1,14 +1,11 @@
 <?php namespace BookStack\Http\Controllers\Images;
 
-use BookStack\Entities\Page;
 use BookStack\Exceptions\ImageUploadException;
 use BookStack\Http\Controllers\Controller;
-use BookStack\Repos\PageRepo;
 use BookStack\Uploads\Image;
 use BookStack\Uploads\ImageRepo;
 use Exception;
 use Illuminate\Filesystem\Filesystem as File;
-use Illuminate\Http\JsonResponse;
 use Illuminate\Http\Request;
 use Illuminate\Validation\ValidationException;
 
@@ -26,7 +23,6 @@ class ImageController extends Controller
         $this->image = $image;
         $this->file = $file;
         $this->imageRepo = $imageRepo;
-        parent::__construct();
     }
 
     /**
index 664a896b25e714ca36ab80c45ee724d0374dbf82..3354a148cfd1f08a628b0f30c0f15d7a4f15b21b 100644 (file)
@@ -2,6 +2,8 @@
 
 namespace BookStack\Http\Controllers;
 
+use BookStack\Actions\ActivityType;
+use BookStack\Entities\Tools\TrashCan;
 use BookStack\Notifications\TestEmail;
 use BookStack\Uploads\ImageService;
 use Illuminate\Http\Request;
@@ -19,7 +21,13 @@ class MaintenanceController extends Controller
         // Get application version
         $version = trim(file_get_contents(base_path('version')));
 
-        return view('settings.maintenance', ['version' => $version]);
+        // Recycle bin details
+        $recycleStats = (new TrashCan())->getTrashedCounts();
+
+        return view('settings.maintenance', [
+            'version' => $version,
+            'recycleStats' => $recycleStats,
+        ]);
     }
 
     /**
@@ -28,6 +36,7 @@ class MaintenanceController extends Controller
     public function cleanupImages(Request $request, ImageService $imageService)
     {
         $this->checkPermission('settings-manage');
+        $this->logActivity(ActivityType::MAINTENANCE_ACTION_RUN, 'cleanup-images');
 
         $checkRevisions = !($request->get('ignore_revisions', 'false') === 'true');
         $dryRun = !($request->has('confirm'));
@@ -54,6 +63,7 @@ class MaintenanceController extends Controller
     public function sendTestEmail()
     {
         $this->checkPermission('settings-manage');
+        $this->logActivity(ActivityType::MAINTENANCE_ACTION_RUN, 'send-test-email');
 
         try {
             user()->notify(new TestEmail());
index 57d70fb3247f8177b4879e9f25eecf54dbbfd76d..67d28a78a45ec388f15444de7621d3d3d29c2105 100644 (file)
@@ -1,10 +1,9 @@
 <?php namespace BookStack\Http\Controllers;
 
-use Activity;
-use BookStack\Entities\Managers\BookContents;
-use BookStack\Entities\Managers\PageContent;
-use BookStack\Entities\Managers\PageEditActivity;
-use BookStack\Entities\Page;
+use BookStack\Entities\Tools\BookContents;
+use BookStack\Entities\Tools\PageContent;
+use BookStack\Entities\Tools\PageEditActivity;
+use BookStack\Entities\Models\Page;
 use BookStack\Entities\Repos\PageRepo;
 use BookStack\Exceptions\NotFoundException;
 use BookStack\Exceptions\NotifyException;
@@ -26,7 +25,6 @@ class PageController extends Controller
     public function __construct(PageRepo $pageRepo)
     {
         $this->pageRepo = $pageRepo;
-        parent::__construct();
     }
 
     /**
@@ -78,7 +76,7 @@ class PageController extends Controller
     public function editDraft(string $bookSlug, int $pageId)
     {
         $draft = $this->pageRepo->getById($pageId);
-        $this->checkOwnablePermission('page-create', $draft->parent());
+        $this->checkOwnablePermission('page-create', $draft->getParent());
         $this->setPageTitle(trans('entities.pages_edit_draft'));
 
         $draftsEnabled = $this->isSignedIn();
@@ -104,10 +102,9 @@ class PageController extends Controller
             'name' => 'required|string|max:255'
         ]);
         $draftPage = $this->pageRepo->getById($pageId);
-        $this->checkOwnablePermission('page-create', $draftPage->parent());
+        $this->checkOwnablePermission('page-create', $draftPage->getParent());
 
         $page = $this->pageRepo->publishDraft($draftPage, $request->all());
-        Activity::add($page, 'page_create', $draftPage->book->id);
 
         return redirect($page->getUrl());
     }
@@ -224,7 +221,6 @@ class PageController extends Controller
         $this->checkOwnablePermission('page-update', $page);
 
         $this->pageRepo->update($page, $request->all());
-        Activity::add($page, 'page_update', $page->book->id);
 
         return redirect($page->getUrl());
     }
@@ -304,13 +300,10 @@ class PageController extends Controller
     {
         $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
         $this->checkOwnablePermission('page-delete', $page);
+        $parent = $page->getParent();
 
-        $book = $page->book;
-        $parent = $page->chapter ?? $book;
         $this->pageRepo->destroy($page);
-        Activity::addMessage('page_delete', $page->name, $book->id);
 
-        $this->showSuccessNotification(trans('entities.pages_delete_success'));
         return redirect($parent->getUrl());
     }
 
@@ -394,7 +387,6 @@ class PageController extends Controller
             return redirect()->back();
         }
 
-        Activity::add($page, 'page_move', $page->book->id);
         $this->showSuccessNotification(trans('entities.pages_move_success', ['parentName' => $parent->name]));
         return redirect($page->getUrl());
     }
@@ -439,8 +431,6 @@ class PageController extends Controller
             return redirect()->back();
         }
 
-        Activity::add($pageCopy, 'page_create', $pageCopy->book->id);
-
         $this->showSuccessNotification(trans('entities.pages_copy_success'));
         return redirect($pageCopy->getUrl());
     }
index 3b02ea224716c4f01bc1ceffdd043d5e88702ba4..e5e027fe72cd2f5cec19418d9ea81901238e2eb7 100644 (file)
@@ -2,8 +2,8 @@
 
 namespace BookStack\Http\Controllers;
 
-use BookStack\Entities\ExportService;
-use BookStack\Entities\Managers\PageContent;
+use BookStack\Entities\Tools\ExportFormatter;
+use BookStack\Entities\Tools\PageContent;
 use BookStack\Entities\Repos\PageRepo;
 use BookStack\Exceptions\NotFoundException;
 use Throwable;
@@ -12,18 +12,15 @@ class PageExportController extends Controller
 {
 
     protected $pageRepo;
-    protected $exportService;
+    protected $exportFormatter;
 
     /**
      * PageExportController constructor.
-     * @param PageRepo $pageRepo
-     * @param ExportService $exportService
      */
-    public function __construct(PageRepo $pageRepo, ExportService $exportService)
+    public function __construct(PageRepo $pageRepo, ExportFormatter $exportFormatter)
     {
         $this->pageRepo = $pageRepo;
-        $this->exportService = $exportService;
-        parent::__construct();
+        $this->exportFormatter = $exportFormatter;
     }
 
     /**
@@ -36,7 +33,7 @@ class PageExportController extends Controller
     {
         $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
         $page->html = (new PageContent($page))->render();
-        $pdfContent = $this->exportService->pageToPdf($page);
+        $pdfContent = $this->exportFormatter->pageToPdf($page);
         return $this->downloadResponse($pdfContent, $pageSlug . '.pdf');
     }
 
@@ -49,7 +46,7 @@ class PageExportController extends Controller
     {
         $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
         $page->html = (new PageContent($page))->render();
-        $containedHtml = $this->exportService->pageToContainedHtml($page);
+        $containedHtml = $this->exportFormatter->pageToContainedHtml($page);
         return $this->downloadResponse($containedHtml, $pageSlug . '.html');
     }
 
@@ -60,7 +57,7 @@ class PageExportController extends Controller
     public function plainText(string $bookSlug, string $pageSlug)
     {
         $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
-        $pageText = $this->exportService->pageToPlainText($page);
+        $pageText = $this->exportFormatter->pageToPlainText($page);
         return $this->downloadResponse($pageText, $pageSlug . '.txt');
     }
 }
index 797f5db8f43ff1ca9f8f561e029b888cec7a662d..4c43330164b743133490dbdf8e764cdfc2836383 100644 (file)
@@ -1,10 +1,9 @@
 <?php namespace BookStack\Http\Controllers;
 
-use BookStack\Entities\Managers\PageContent;
+use BookStack\Entities\Tools\PageContent;
 use BookStack\Entities\Repos\PageRepo;
 use BookStack\Exceptions\NotFoundException;
-use BookStack\Facades\Activity;
-use GatherContent\Htmldiff\Htmldiff;
+use Ssddanbrown\HtmlDiff\Diff;
 
 class PageRevisionController extends Controller
 {
@@ -17,7 +16,6 @@ class PageRevisionController extends Controller
     public function __construct(PageRepo $pageRepo)
     {
         $this->pageRepo = $pageRepo;
-        parent::__construct();
     }
 
     /**
@@ -74,7 +72,7 @@ class PageRevisionController extends Controller
 
         $prev = $revision->getPrevious();
         $prevContent = $prev->html ?? '';
-        $diff = (new Htmldiff)->diff($prevContent, $revision->html);
+        $diff = Diff::excecute($prevContent, $revision->html);
 
         $page->fill($revision->toArray());
         // TODO - Refactor PageContent so we don't need to juggle this
@@ -101,7 +99,6 @@ class PageRevisionController extends Controller
 
         $page = $this->pageRepo->restoreRevision($page, $revisionId);
 
-        Activity::add($page, 'page_restore', $page->book->id);
         return redirect($page->getUrl());
     }
 
index eaa1a8ae26ae18f28473c6796062dfb5065dba97..2307bc0d52c09b5bd6ee1411f795ac83c11f8e0a 100644 (file)
@@ -16,7 +16,6 @@ class PageTemplateController extends Controller
     public function __construct(PageRepo $pageRepo)
     {
         $this->pageRepo = $pageRepo;
-        parent::__construct();
     }
 
     /**
diff --git a/app/Http/Controllers/RecycleBinController.php b/app/Http/Controllers/RecycleBinController.php
new file mode 100644 (file)
index 0000000..a644a28
--- /dev/null
@@ -0,0 +1,107 @@
+<?php namespace BookStack\Http\Controllers;
+
+use BookStack\Actions\ActivityType;
+use BookStack\Entities\Models\Deletion;
+use BookStack\Entities\Tools\TrashCan;
+
+class RecycleBinController extends Controller
+{
+
+    protected $recycleBinBaseUrl = '/settings/recycle-bin';
+
+    /**
+     * On each request to a method of this controller check permissions
+     * using a middleware closure.
+     */
+    public function __construct()
+    {
+        $this->middleware(function ($request, $next) {
+            $this->checkPermission('settings-manage');
+            $this->checkPermission('restrictions-manage-all');
+            return $next($request);
+        });
+    }
+
+
+    /**
+     * Show the top-level listing for the recycle bin.
+     */
+    public function index()
+    {
+        $deletions = Deletion::query()->with(['deletable', 'deleter'])->paginate(10);
+
+        $this->setPageTitle(trans('settings.recycle_bin'));
+        return view('settings.recycle-bin.index', [
+            'deletions' => $deletions,
+        ]);
+    }
+
+    /**
+     * Show the page to confirm a restore of the deletion of the given id.
+     */
+    public function showRestore(string $id)
+    {
+        /** @var Deletion $deletion */
+        $deletion = Deletion::query()->findOrFail($id);
+
+        return view('settings.recycle-bin.restore', [
+            'deletion' => $deletion,
+        ]);
+    }
+
+    /**
+     * Restore the element attached to the given deletion.
+     * @throws \Exception
+     */
+    public function restore(string $id)
+    {
+        /** @var Deletion $deletion */
+        $deletion = Deletion::query()->findOrFail($id);
+        $this->logActivity(ActivityType::RECYCLE_BIN_RESTORE, $deletion);
+        $restoreCount = (new TrashCan())->restoreFromDeletion($deletion);
+
+        $this->showSuccessNotification(trans('settings.recycle_bin_restore_notification', ['count' => $restoreCount]));
+        return redirect($this->recycleBinBaseUrl);
+    }
+
+    /**
+     * Show the page to confirm a Permanent deletion of the element attached to the deletion of the given id.
+     */
+    public function showDestroy(string $id)
+    {
+        /** @var Deletion $deletion */
+        $deletion = Deletion::query()->findOrFail($id);
+
+        return view('settings.recycle-bin.destroy', [
+            'deletion' => $deletion,
+        ]);
+    }
+
+    /**
+     * Permanently delete the content associated with the given deletion.
+     * @throws \Exception
+     */
+    public function destroy(string $id)
+    {
+        /** @var Deletion $deletion */
+        $deletion = Deletion::query()->findOrFail($id);
+        $this->logActivity(ActivityType::RECYCLE_BIN_DESTROY, $deletion);
+        $deleteCount = (new TrashCan())->destroyFromDeletion($deletion);
+
+        $this->showSuccessNotification(trans('settings.recycle_bin_destroy_notification', ['count' => $deleteCount]));
+        return redirect($this->recycleBinBaseUrl);
+    }
+
+    /**
+     * Empty out the recycle bin.
+     * @throws \Exception
+     */
+    public function empty()
+    {
+        $deleteCount = (new TrashCan())->empty();
+
+        $this->logActivity(ActivityType::RECYCLE_BIN_EMPTY);
+        $this->showSuccessNotification(trans('settings.recycle_bin_destroy_notification', ['count' => $deleteCount]));
+        return redirect($this->recycleBinBaseUrl);
+    }
+}
similarity index 89%
rename from app/Http/Controllers/PermissionController.php
rename to app/Http/Controllers/RoleController.php
index 1200d44ab69092ddcd3e498a2abf9ac961386f7a..e16a724a48c49fa419506a997ace27310af607b1 100644 (file)
@@ -6,7 +6,7 @@ use Exception;
 use Illuminate\Http\Request;
 use Illuminate\Validation\ValidationException;
 
-class PermissionController extends Controller
+class RoleController extends Controller
 {
 
     protected $permissionsRepo;
@@ -17,13 +17,12 @@ class PermissionController extends Controller
     public function __construct(PermissionsRepo $permissionsRepo)
     {
         $this->permissionsRepo = $permissionsRepo;
-        parent::__construct();
     }
 
     /**
      * Show a listing of the roles in the system.
      */
-    public function listRoles()
+    public function list()
     {
         $this->checkPermission('user-roles-manage');
         $roles = $this->permissionsRepo->getAllRoles();
@@ -33,7 +32,7 @@ class PermissionController extends Controller
     /**
      * Show the form to create a new role
      */
-    public function createRole()
+    public function create()
     {
         $this->checkPermission('user-roles-manage');
         return view('settings.roles.create');
@@ -42,7 +41,7 @@ class PermissionController extends Controller
     /**
      * Store a new role in the system.
      */
-    public function storeRole(Request $request)
+    public function store(Request $request)
     {
         $this->checkPermission('user-roles-manage');
         $this->validate($request, [
@@ -59,7 +58,7 @@ class PermissionController extends Controller
      * Show the form for editing a user role.
      * @throws PermissionsException
      */
-    public function editRole(string $id)
+    public function edit(string $id)
     {
         $this->checkPermission('user-roles-manage');
         $role = $this->permissionsRepo->getRoleById($id);
@@ -73,7 +72,7 @@ class PermissionController extends Controller
      * Updates a user role.
      * @throws ValidationException
      */
-    public function updateRole(Request $request, string $id)
+    public function update(Request $request, string $id)
     {
         $this->checkPermission('user-roles-manage');
         $this->validate($request, [
@@ -90,7 +89,7 @@ class PermissionController extends Controller
      * Show the view to delete a role.
      * Offers the chance to migrate users.
      */
-    public function showDeleteRole(string $id)
+    public function showDelete(string $id)
     {
         $this->checkPermission('user-roles-manage');
         $role = $this->permissionsRepo->getRoleById($id);
@@ -105,7 +104,7 @@ class PermissionController extends Controller
      * Migrate from a previous role if set.
      * @throws Exception
      */
-    public function deleteRole(Request $request, string $id)
+    public function delete(Request $request, string $id)
     {
         $this->checkPermission('user-roles-manage');
 
index 8105843b576acb9072651c878190a5489968f7b4..21ebea378c06c11f897046575a3fe76a3d0185f9 100644 (file)
@@ -1,32 +1,29 @@
 <?php namespace BookStack\Http\Controllers;
 
 use BookStack\Actions\ViewService;
-use BookStack\Entities\Book;
-use BookStack\Entities\Bookshelf;
-use BookStack\Entities\Entity;
-use BookStack\Entities\Managers\EntityContext;
-use BookStack\Entities\SearchService;
-use BookStack\Entities\SearchOptions;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Bookshelf;
+use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Tools\SearchRunner;
+use BookStack\Entities\Tools\ShelfContext;
+use BookStack\Entities\Tools\SearchOptions;
+use BookStack\Entities\Tools\SiblingFetcher;
 use Illuminate\Http\Request;
 
 class SearchController extends Controller
 {
     protected $viewService;
-    protected $searchService;
+    protected $searchRunner;
     protected $entityContextManager;
 
-    /**
-     * SearchController constructor.
-     */
     public function __construct(
         ViewService $viewService,
-        SearchService $searchService,
-        EntityContext $entityContextManager
+        SearchRunner $searchRunner,
+        ShelfContext $entityContextManager
     ) {
         $this->viewService = $viewService;
-        $this->searchService = $searchService;
+        $this->searchRunner = $searchRunner;
         $this->entityContextManager = $entityContextManager;
-        parent::__construct();
     }
 
     /**
@@ -41,7 +38,7 @@ class SearchController extends Controller
         $page = intval($request->get('page', '0')) ?: 1;
         $nextPageLink = url('/search?term=' . urlencode($fullSearchString) . '&page=' . ($page+1));
 
-        $results = $this->searchService->searchEntities($searchOpts, 'all', $page, 20);
+        $results = $this->searchRunner->searchEntities($searchOpts, 'all', $page, 20);
 
         return view('search.all', [
             'entities'   => $results['results'],
@@ -53,14 +50,13 @@ class SearchController extends Controller
         ]);
     }
 
-
     /**
      * Searches all entities within a book.
      */
     public function searchBook(Request $request, int $bookId)
     {
         $term = $request->get('term', '');
-        $results = $this->searchService->searchBook($bookId, $term);
+        $results = $this->searchRunner->searchBook($bookId, $term);
         return view('partials.entity-list', ['entities' => $results]);
     }
 
@@ -70,7 +66,7 @@ class SearchController extends Controller
     public function searchChapter(Request $request, int $chapterId)
     {
         $term = $request->get('term', '');
-        $results = $this->searchService->searchChapter($chapterId, $term);
+        $results = $this->searchRunner->searchChapter($chapterId, $term);
         return view('partials.entity-list', ['entities' => $results]);
     }
 
@@ -87,7 +83,7 @@ class SearchController extends Controller
         // Search for entities otherwise show most popular
         if ($searchTerm !== false) {
             $searchTerm .= ' {type:'. implode('|', $entityTypes) .'}';
-            $entities = $this->searchService->searchEntities(SearchOptions::fromString($searchTerm), 'all', 1, 20, $permission)['results'];
+            $entities = $this->searchRunner->searchEntities(SearchOptions::fromString($searchTerm), 'all', 1, 20, $permission)['results'];
         } else {
             $entities = $this->viewService->getPopular(20, 0, $entityTypes, $permission);
         }
@@ -103,39 +99,7 @@ class SearchController extends Controller
         $type = $request->get('entity_type', null);
         $id = $request->get('entity_id', null);
 
-        $entity = Entity::getEntityInstance($type)->newQuery()->visible()->find($id);
-        if (!$entity) {
-            return $this->jsonError(trans('errors.entity_not_found'), 404);
-        }
-
-        $entities = [];
-
-        // Page in chapter
-        if ($entity->isA('page') && $entity->chapter) {
-            $entities = $entity->chapter->getVisiblePages();
-        }
-
-        // Page in book or chapter
-        if (($entity->isA('page') && !$entity->chapter) || $entity->isA('chapter')) {
-            $entities = $entity->book->getDirectChildren();
-        }
-
-        // Book
-        // Gets just the books in a shelf if shelf is in context
-        if ($entity->isA('book')) {
-            $contextShelf = $this->entityContextManager->getContextualShelfForBook($entity);
-            if ($contextShelf) {
-                $entities = $contextShelf->visibleBooks()->get();
-            } else {
-                $entities = Book::visible()->get();
-            }
-        }
-
-        // Shelve
-        if ($entity->isA('bookshelf')) {
-            $entities = Bookshelf::visible()->get();
-        }
-
+        $entities = (new SiblingFetcher)->fetch($type, $id);
         return view('partials.entity-list-basic', ['entities' => $entities, 'style' => 'compact']);
     }
 }
index 50d91d3881e39362908bfe30aaa3a8168042355b..f02f541bc9ba5b1a9ad14270d900ae053b23900d 100644 (file)
@@ -1,5 +1,6 @@
 <?php namespace BookStack\Http\Controllers;
 
+use BookStack\Actions\ActivityType;
 use BookStack\Auth\User;
 use BookStack\Uploads\ImageRepo;
 use Illuminate\Http\Request;
@@ -14,7 +15,6 @@ class SettingController extends Controller
     public function __construct(ImageRepo $imageRepo)
     {
         $this->imageRepo = $imageRepo;
-        parent::__construct();
     }
 
     /**
@@ -47,10 +47,10 @@ class SettingController extends Controller
 
         // Cycles through posted settings and update them
         foreach ($request->all() as $name => $value) {
+            $key = str_replace('setting-', '', trim($name));
             if (strpos($name, 'setting-') !== 0) {
                 continue;
             }
-            $key = str_replace('setting-', '', trim($name));
             setting()->put($key, $value);
         }
 
@@ -68,8 +68,10 @@ class SettingController extends Controller
             setting()->remove('app-logo');
         }
 
+        $section = $request->get('section', '');
+        $this->logActivity(ActivityType::SETTINGS_UPDATE, $section);
         $this->showSuccessNotification(trans('settings.settings_save_success'));
-        $redirectLocation = '/settings#' . $request->get('section', '');
+        $redirectLocation = '/settings#' . $section;
         return redirect(rtrim($redirectLocation, '#'));
     }
 }
index 8c6d6748fa5b79d41090e56f8fd1b1c73dab57c4..ce84bf4101e4c23f6437915a35c3d014a7c306a5 100644 (file)
@@ -14,7 +14,6 @@ class TagController extends Controller
     public function __construct(TagRepo $tagRepo)
     {
         $this->tagRepo = $tagRepo;
-        parent::__construct();
     }
 
     /**
index 55675233c38af9552175d69b0eccb561e0034080..ab0e9069e7c2db27fd310308d39c2be6bc14d537 100644 (file)
@@ -1,9 +1,9 @@
 <?php namespace BookStack\Http\Controllers;
 
+use BookStack\Actions\ActivityType;
 use BookStack\Api\ApiToken;
 use BookStack\Auth\User;
 use Illuminate\Http\Request;
-use Illuminate\Support\Carbon;
 use Illuminate\Support\Facades\Hash;
 use Illuminate\Support\Str;
 
@@ -57,6 +57,8 @@ class UserApiTokenController extends Controller
 
         session()->flash('api-token-secret:' . $token->id, $secret);
         $this->showSuccessNotification(trans('settings.user_api_token_create_success'));
+        $this->logActivity(ActivityType::API_TOKEN_CREATE, $token);
+
         return redirect($user->getEditUrl('/api-tokens/' . $token->id));
     }
 
@@ -93,6 +95,7 @@ class UserApiTokenController extends Controller
         ])->save();
 
         $this->showSuccessNotification(trans('settings.user_api_token_update_success'));
+        $this->logActivity(ActivityType::API_TOKEN_UPDATE, $token);
         return redirect($user->getEditUrl('/api-tokens/' . $token->id));
     }
 
@@ -117,6 +120,8 @@ class UserApiTokenController extends Controller
         $token->delete();
 
         $this->showSuccessNotification(trans('settings.user_api_token_delete_success'));
+        $this->logActivity(ActivityType::API_TOKEN_DELETE, $token);
+
         return redirect($user->getEditUrl('#api_tokens'));
     }
 
index 651dedc0d855d5f54ecc2aba6b2def4e2feced1c..8d688ed8479309550f5dc83e188a27fdfc639f0d 100644 (file)
@@ -1,5 +1,6 @@
 <?php namespace BookStack\Http\Controllers;
 
+use BookStack\Actions\ActivityType;
 use BookStack\Auth\Access\SocialAuthService;
 use BookStack\Auth\Access\UserInviteService;
 use BookStack\Auth\User;
@@ -26,7 +27,6 @@ class UserController extends Controller
         $this->userRepo = $userRepo;
         $this->inviteService = $inviteService;
         $this->imageRepo = $imageRepo;
-        parent::__construct();
     }
 
     /**
@@ -102,6 +102,7 @@ class UserController extends Controller
 
         $this->userRepo->downloadAndAssignUserAvatar($user);
 
+        $this->logActivity(ActivityType::USER_CREATE, $user);
         return redirect('/settings/users');
     }
 
@@ -187,13 +188,14 @@ class UserController extends Controller
             $user->image_id = $image->id;
         }
 
-        // Delete the profile image if set to
+        // Delete the profile image if reset option is in request
         if ($request->has('profile_image_reset')) {
             $this->imageRepo->destroyImage($user->avatar);
         }
 
         $user->save();
         $this->showSuccessNotification(trans('settings.users_edit_success'));
+        $this->logActivity(ActivityType::USER_UPDATE, $user);
 
         $redirectUrl = userCan('users-manage') ? '/settings/users' : ('/settings/users/' . $user->id);
         return redirect($redirectUrl);
@@ -234,6 +236,7 @@ class UserController extends Controller
 
         $this->userRepo->destroy($user);
         $this->showSuccessNotification(trans('settings.users_delete_success'));
+        $this->logActivity(ActivityType::USER_DELETE, $user);
 
         return redirect('/settings/users');
     }
diff --git a/app/Interfaces/Loggable.php b/app/Interfaces/Loggable.php
new file mode 100644 (file)
index 0000000..33e1d7c
--- /dev/null
@@ -0,0 +1,11 @@
+<?php
+
+namespace BookStack\Interfaces;
+
+interface Loggable
+{
+    /**
+     * Get the string descriptor for this item.
+     */
+    public function logDescriptor(): string;
+}
\ No newline at end of file
index bf24fad5d8676c5198783aa4b547a19d76ca13bd..b118bc7429839137416b738d1ef1feef16476e89 100644 (file)
@@ -26,12 +26,4 @@ abstract class Ownable extends Model
         return $this->belongsTo(User::class, 'updated_by');
     }
 
-    /**
-     * Gets the class name.
-     * @return string
-     */
-    public static function getClassName()
-    {
-        return strtolower(array_slice(explode('\\', static::class), -1, 1)[0]);
-    }
 }
index f418153997286e5636e754279be147e753171ec7..1c6180a1f4b3c3329bbb28632b4bca076cd19b8e 100644 (file)
@@ -1,11 +1,11 @@
 <?php namespace BookStack\Providers;
 
 use Blade;
-use BookStack\Entities\Book;
-use BookStack\Entities\Bookshelf;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Bookshelf;
 use BookStack\Entities\BreadcrumbsViewComposer;
-use BookStack\Entities\Chapter;
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
 use BookStack\Settings\Setting;
 use BookStack\Settings\SettingService;
 use Illuminate\Database\Eloquent\Relations\Relation;
@@ -13,7 +13,6 @@ use Illuminate\Support\Facades\View;
 use Illuminate\Support\ServiceProvider;
 use Schema;
 use URL;
-use Validator;
 
 class AppServiceProvider extends ServiceProvider
 {
@@ -32,37 +31,11 @@ class AppServiceProvider extends ServiceProvider
             URL::forceScheme($isHttps ? 'https' : 'http');
         }
 
-        // Custom validation methods
-        Validator::extend('image_extension', function ($attribute, $value, $parameters, $validator) {
-            $validImageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'webp'];
-            return in_array(strtolower($value->getClientOriginalExtension()), $validImageExtensions);
-        });
-
-        Validator::extend('no_double_extension', function ($attribute, $value, $parameters, $validator) {
-            $uploadName = $value->getClientOriginalName();
-            return substr_count($uploadName, '.') < 2;
-        });
-
-        Validator::extend('safe_url', function ($attribute, $value, $parameters, $validator) {
-            $cleanLinkName = strtolower(trim($value));
-            $isJs = strpos($cleanLinkName, 'javascript:') === 0;
-            $isData = strpos($cleanLinkName, 'data:') === 0;
-            return !$isJs && !$isData;
-        });
-
         // Custom blade view directives
         Blade::directive('icon', function ($expression) {
             return "<?php echo icon($expression); ?>";
         });
 
-        Blade::directive('exposeTranslations', function ($expression) {
-            return "<?php \$__env->startPush('translations'); ?>" .
-                "<?php foreach({$expression} as \$key): ?>" .
-                '<meta name="translation" key="<?php echo e($key); ?>" value="<?php echo e(trans($key)); ?>">' . "\n" .
-                "<?php endforeach; ?>" .
-                '<?php $__env->stopPush(); ?>';
-        });
-
         // Allow longer string lengths after upgrade to utf8mb4
         Schema::defaultStringLength(191);
 
diff --git a/app/Providers/CustomValidationServiceProvider.php b/app/Providers/CustomValidationServiceProvider.php
new file mode 100644 (file)
index 0000000..4a5272b
--- /dev/null
@@ -0,0 +1,33 @@
+<?php
+
+namespace BookStack\Providers;
+
+use Illuminate\Support\Facades\Validator;
+use Illuminate\Support\ServiceProvider;
+
+class CustomValidationServiceProvider extends ServiceProvider
+{
+
+    /**
+     * Register our custom validation rules when the application boots.
+     */
+    public function boot(): void
+    {
+        Validator::extend('image_extension', function ($attribute, $value, $parameters, $validator) {
+            $validImageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'webp'];
+            return in_array(strtolower($value->getClientOriginalExtension()), $validImageExtensions);
+        });
+
+        Validator::extend('no_double_extension', function ($attribute, $value, $parameters, $validator) {
+            $uploadName = $value->getClientOriginalName();
+            return substr_count($uploadName, '.') < 2;
+        });
+
+        Validator::extend('safe_url', function ($attribute, $value, $parameters, $validator) {
+            $cleanLinkName = strtolower(trim($value));
+            $isJs = strpos($cleanLinkName, 'javascript:') === 0;
+            $isData = strpos($cleanLinkName, 'data:') === 0;
+            return !$isJs && !$isData;
+        });
+    }
+}
index 66c032be587dca8c0a41c606df510c6e74d14479..77c7925dba28837986e3ea24c2baf2e7bc964ff9 100644 (file)
@@ -1,6 +1,6 @@
 <?php namespace BookStack\Uploads;
 
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Page;
 use BookStack\Ownable;
 
 /**
index c76979d7cab0c5bee668b3e6a993d781842aa77c..029fd317501fd91a9706ea955c04359d12bce98e 100644 (file)
@@ -1,6 +1,6 @@
 <?php namespace BookStack\Uploads;
 
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Page;
 use BookStack\Ownable;
 use Images;
 
index fb2a892286567fa762b25d769ea9f948f1b46dac..b4d743b73447a2cc99dade9b367f78e489020279 100644 (file)
@@ -1,7 +1,7 @@
 <?php namespace BookStack\Uploads;
 
 use BookStack\Auth\Permissions\PermissionService;
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Page;
 use BookStack\Exceptions\ImageUploadException;
 use Exception;
 use Illuminate\Database\Eloquent\Builder;
index 1e5ad8aa198131e0816e196eafca818fe0eba17b..5c16827cb75f1e3c94fb6ddabc95ab64df34389d 100644 (file)
@@ -1,7 +1,5 @@
 <?php namespace BookStack\Uploads;
 
-use BookStack\Auth\User;
-use BookStack\Exceptions\HttpFetchException;
 use BookStack\Exceptions\ImageUploadException;
 use DB;
 use ErrorException;
@@ -17,24 +15,21 @@ use Symfony\Component\HttpFoundation\File\UploadedFile;
 
 class ImageService
 {
-
     protected $imageTool;
     protected $cache;
     protected $storageUrl;
     protected $image;
-    protected $http;
     protected $fileSystem;
 
     /**
      * ImageService constructor.
      */
-    public function __construct(Image $image, ImageManager $imageTool, FileSystem $fileSystem, Cache $cache, HttpFetcher $http)
+    public function __construct(Image $image, ImageManager $imageTool, FileSystem $fileSystem, Cache $cache)
     {
         $this->image = $image;
         $this->imageTool = $imageTool;
         $this->fileSystem = $fileSystem;
         $this->cache = $cache;
-        $this->http = $http;
     }
 
     /**
@@ -77,14 +72,9 @@ class ImageService
 
     /**
      * Save a new image from a uri-encoded base64 string of data.
-     * @param string $base64Uri
-     * @param string $name
-     * @param string $type
-     * @param int $uploadedTo
-     * @return Image
      * @throws ImageUploadException
      */
-    public function saveNewFromBase64Uri(string $base64Uri, string $name, string $type, $uploadedTo = 0)
+    public function saveNewFromBase64Uri(string $base64Uri, string $name, string $type, int $uploadedTo = 0): Image
     {
         $splitData = explode(';base64,', $base64Uri);
         if (count($splitData) < 2) {
@@ -94,30 +84,11 @@ class ImageService
         return $this->saveNew($name, $data, $type, $uploadedTo);
     }
 
-    /**
-     * Gets an image from url and saves it to the database.
-     * @param             $url
-     * @param string $type
-     * @param bool|string $imageName
-     * @return mixed
-     * @throws Exception
-     */
-    private function saveNewFromUrl($url, $type, $imageName = false)
-    {
-        $imageName = $imageName ? $imageName : basename($url);
-        try {
-            $imageData = $this->http->fetch($url);
-        } catch (HttpFetchException $exception) {
-            throw new Exception(trans('errors.cannot_get_image_from_url', ['url' => $url]));
-        }
-        return $this->saveNew($imageName, $imageData, $type);
-    }
-
     /**
      * Save a new image into storage.
      * @throws ImageUploadException
      */
-    private function saveNew(string $imageName, string $imageData, string $type, int $uploadedTo = 0): Image
+    public function saveNew(string $imageName, string $imageData, string $type, int $uploadedTo = 0): Image
     {
         $storage = $this->getStorage($type);
         $secureUploads = setting('app-secure-images');
@@ -327,56 +298,6 @@ class ImageService
         return (count($files) === 0 && count($folders) === 0);
     }
 
-    /**
-     * Save an avatar image from an external service.
-     * @throws Exception
-     */
-    public function saveUserAvatar(User $user, int $size = 500): Image
-    {
-        $avatarUrl = $this->getAvatarUrl();
-        $email = strtolower(trim($user->email));
-
-        $replacements = [
-            '${hash}' => md5($email),
-            '${size}' => $size,
-            '${email}' => urlencode($email),
-        ];
-
-        $userAvatarUrl = strtr($avatarUrl, $replacements);
-        $imageName = str_replace(' ', '-', $user->name . '-avatar.png');
-        $image = $this->saveNewFromUrl($userAvatarUrl, 'user', $imageName);
-        $image->created_by = $user->id;
-        $image->updated_by = $user->id;
-        $image->uploaded_to = $user->id;
-        $image->save();
-
-        return $image;
-    }
-
-    /**
-     * Check if fetching external avatars is enabled.
-     */
-    public function avatarFetchEnabled(): bool
-    {
-        $fetchUrl = $this->getAvatarUrl();
-        return is_string($fetchUrl) && strpos($fetchUrl, 'http') === 0;
-    }
-
-    /**
-     * Get the URL to fetch avatars from.
-     * @return string|mixed
-     */
-    protected function getAvatarUrl()
-    {
-        $url = trim(config('services.avatar_url'));
-
-        if (empty($url) && !config('services.disable_services')) {
-            $url = 'https://www.gravatar.com/avatar/${hash}?s=${size}&d=identicon';
-        }
-
-        return $url;
-    }
-
     /**
      * Delete gallery and drawings that are not within HTML content of pages or page revisions.
      * Checks based off of only the image name.
diff --git a/app/Uploads/UserAvatars.php b/app/Uploads/UserAvatars.php
new file mode 100644 (file)
index 0000000..92b06bc
--- /dev/null
@@ -0,0 +1,100 @@
+<?php namespace BookStack\Uploads;
+
+use BookStack\Auth\User;
+use BookStack\Exceptions\HttpFetchException;
+use Exception;
+
+class UserAvatars
+{
+    protected $imageService;
+    protected $http;
+
+    public function __construct(ImageService $imageService, HttpFetcher $http)
+    {
+        $this->imageService = $imageService;
+        $this->http = $http;
+    }
+
+    /**
+     * Fetch and assign an avatar image to the given user.
+     */
+    public function fetchAndAssignToUser(User $user): void
+    {
+        if (!$this->avatarFetchEnabled()) {
+            return;
+        }
+
+        try {
+            $avatar = $this->saveAvatarImage($user);
+            $user->avatar()->associate($avatar);
+            $user->save();
+        } catch (Exception $e) {
+            Log::error('Failed to save user avatar image');
+        }
+    }
+
+    /**
+     * Save an avatar image from an external service.
+     * @throws Exception
+     */
+    protected function saveAvatarImage(User $user, int $size = 500): Image
+    {
+        $avatarUrl = $this->getAvatarUrl();
+        $email = strtolower(trim($user->email));
+
+        $replacements = [
+            '${hash}' => md5($email),
+            '${size}' => $size,
+            '${email}' => urlencode($email),
+        ];
+
+        $userAvatarUrl = strtr($avatarUrl, $replacements);
+        $imageName = str_replace(' ', '-', $user->id . '-avatar.png');
+        $imageData = $this->getAvatarImageData($userAvatarUrl);
+
+        $image = $this->imageService->saveNew($imageName, $imageData, 'user', $user->id);
+        $image->created_by = $user->id;
+        $image->updated_by = $user->id;
+        $image->save();
+
+        return $image;
+    }
+
+    /**
+     * Gets an image from url and returns it as a string of image data.
+     * @throws Exception
+     */
+    protected function getAvatarImageData(string $url): string
+    {
+        try {
+            $imageData = $this->http->fetch($url);
+        } catch (HttpFetchException $exception) {
+            throw new Exception(trans('errors.cannot_get_image_from_url', ['url' => $url]));
+        }
+        return $imageData;
+    }
+
+    /**
+     * Check if fetching external avatars is enabled.
+     */
+    protected function avatarFetchEnabled(): bool
+    {
+        $fetchUrl = $this->getAvatarUrl();
+        return is_string($fetchUrl) && strpos($fetchUrl, 'http') === 0;
+    }
+
+    /**
+     * Get the URL to fetch avatars from.
+     */
+    protected function getAvatarUrl(): string
+    {
+        $url = trim(config('services.avatar_url'));
+
+        if (empty($url) && !config('services.disable_services')) {
+            $url = 'https://www.gravatar.com/avatar/${hash}?s=${size}&d=identicon';
+        }
+
+        return $url;
+    }
+
+}
\ No newline at end of file
index 83017c37dddda3c81043c7f29b1aad080e8e346e..935d4d8daee4a2d4600dc0394e85c28c7184c48e 100644 (file)
@@ -7,9 +7,6 @@ use BookStack\Settings\SettingService;
 
 /**
  * Get the path to a versioned file.
- *
- * @param  string $file
- * @return string
  * @throws Exception
  */
 function versioned_asset(string $file = ''): string
@@ -33,7 +30,6 @@ function versioned_asset(string $file = ''): string
 /**
  * Helper method to get the current User.
  * Defaults to public 'Guest' user if not logged in.
- * @return User
  */
 function user(): User
 {
@@ -57,9 +53,8 @@ function hasAppAccess(): bool
 }
 
 /**
- * Check if the current user has a permission.
- * If an ownable element is passed in the jointPermissions are checked against
- * that particular item.
+ * Check if the current user has a permission. If an ownable element
+ * is passed in the jointPermissions are checked against that particular item.
  */
 function userCan(string $permission, Ownable $ownable = null): bool
 {
@@ -75,9 +70,6 @@ function userCan(string $permission, Ownable $ownable = null): bool
 /**
  * Check if the current user has the given permission
  * on any item in the system.
- * @param string $permission
- * @param string|null $entityClass
- * @return bool
  */
 function userCanOnAny(string $permission, string $entityClass = null): bool
 {
@@ -87,27 +79,26 @@ function userCanOnAny(string $permission, string $entityClass = null): bool
 
 /**
  * Helper to access system settings.
- * @param string $key
- * @param $default
  * @return bool|string|SettingService
  */
 function setting(string $key = null, $default = false)
 {
     $settingService = resolve(SettingService::class);
+
     if (is_null($key)) {
         return $settingService;
     }
+
     return $settingService->get($key, $default);
 }
 
 /**
  * Get a path to a theme resource.
- * @param string $path
- * @return string
  */
 function theme_path(string $path = ''): string
 {
     $theme = config('view.theme');
+
     if (!$theme) {
         return '';
     }
@@ -121,9 +112,6 @@ function theme_path(string $path = ''): string
  * to the 'resources/assets/icons' folder.
  *
  * Returns an empty string if icon file not found.
- * @param $name
- * @param array $attrs
- * @return mixed
  */
 function icon(string $name, array $attrs = []): string
 {
@@ -139,6 +127,7 @@ function icon(string $name, array $attrs = []): string
 
     $iconPath = resource_path('icons/' . $name . '.svg');
     $themeIconPath = theme_path('icons/' . $name . '.svg');
+
     if ($themeIconPath && file_exists($themeIconPath)) {
         $iconPath = $themeIconPath;
     } else if (!file_exists($iconPath)) {
diff --git a/artisan b/artisan
index dad16dcdefdee1989b99de8cddffffe06d10a381..d5c6aaf98542479db38a44dce76de952dbc34de6 100755 (executable)
--- a/artisan
+++ b/artisan
@@ -5,15 +5,17 @@ define('LARAVEL_START', microtime(true));
 
 /*
 |--------------------------------------------------------------------------
-| Initialize The App
+| Register The Auto Loader
 |--------------------------------------------------------------------------
 |
-| We need to get things going before we start up the app.
-| The init file loads everything in, in the correct order.
+| Composer provides a convenient, automatically generated class loader
+| for our application. We just need to utilize it! We'll require it
+| into the script here so that we do not have to worry about the
+| loading of any our classes "manually". Feels great to relax.
 |
 */
 
-require __DIR__.'/bootstrap/init.php';
+require __DIR__.'/vendor/autoload.php';
 
 $app = require_once __DIR__.'/bootstrap/app.php';
 
diff --git a/bootstrap/init.php b/bootstrap/init.php
deleted file mode 100644 (file)
index 7d9e43f..0000000
+++ /dev/null
@@ -1,25 +0,0 @@
-<?php
-
-/*
-|--------------------------------------------------------------------------
-| Load Our Own Helpers
-|--------------------------------------------------------------------------
-|
-| This custom function loads any helpers, before the Laravel Framework
-| is built so we can override any helpers as we please.
-|
-*/
-require __DIR__.'/../app/helpers.php';
-
-/*
-|--------------------------------------------------------------------------
-| Register The Composer Auto Loader
-|--------------------------------------------------------------------------
-|
-| Composer provides a convenient, automatically generated class loader
-| for our application. We just need to utilize it! We'll require it
-| into the script here so that we do not have to worry about the
-| loading of any our classes "manually". Feels great to relax.
-|
-*/
-require __DIR__.'/../vendor/autoload.php';
\ No newline at end of file
index 59fc909d6b1e9f2298a510db246246c06bd46ebd..17bc12e4ae1ec65980814cefc9008e9483461619 100644 (file)
         "ext-gd": "*",
         "ext-json": "*",
         "ext-mbstring": "*",
-        "ext-tidy": "*",
         "ext-xml": "*",
         "barryvdh/laravel-dompdf": "^0.8.6",
         "barryvdh/laravel-snappy": "^0.4.7",
         "doctrine/dbal": "^2.9",
         "facade/ignition": "^1.4",
         "fideloper/proxy": "^4.0",
-        "gathercontent/htmldiff": "^0.2.1",
         "intervention/image": "^2.5",
         "laravel/framework": "^6.18",
         "laravel/socialite": "^4.3.2",
@@ -32,7 +30,8 @@
         "socialiteproviders/microsoft-azure": "^3.0",
         "socialiteproviders/okta": "^1.0",
         "socialiteproviders/slack": "^3.0",
-        "socialiteproviders/twitch": "^5.0"
+        "socialiteproviders/twitch": "^5.0",
+        "ssddanbrown/htmldiff": "^1.0"
     },
     "require-dev": {
         "barryvdh/laravel-debugbar": "^3.2.8",
         ],
         "psr-4": {
             "BookStack\\": "app/"
-        }
+        },
+               "files": [
+                       "app/helpers.php"
+               ]
     },
     "autoload-dev": {
         "psr-4": {
index b22874455b95bc9db5a0fd453ae38938dcaa6d8b..dcfc3beb443d4aaea4ad4c0d2c04770a0d05e3d5 100644 (file)
@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "34390536dd685e0bc49b179babaa06ec",
+    "content-hash": "a61fd1c287bd93fa496202c6583c7bb1",
     "packages": [
         {
             "name": "aws/aws-sdk-php",
             ],
             "time": "2020-09-07T12:33:10+00:00"
         },
-        {
-            "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-19T10:25:02+00:00"
-        },
         {
             "name": "doctrine/cache",
             "version": "1.10.2",
             ],
             "time": "2020-06-14T09:00:00+00:00"
         },
-        {
-            "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-15T15:39:46+00:00"
-        },
         {
             "name": "guzzlehttp/guzzle",
             "version": "6.5.5",
             "description": "Twitch OAuth2 Provider for Laravel Socialite",
             "time": "2020-05-06T22:51:30+00:00"
         },
+        {
+            "name": "ssddanbrown/htmldiff",
+            "version": "v1.0.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/ssddanbrown/HtmlDiff.git",
+                "reference": "d1978c7d1c685800997f982a0ae9cff1e45df70c"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/ssddanbrown/HtmlDiff/zipball/d1978c7d1c685800997f982a0ae9cff1e45df70c",
+                "reference": "d1978c7d1c685800997f982a0ae9cff1e45df70c",
+                "shasum": ""
+            },
+            "require": {
+                "ext-mbstring": "*",
+                "php": ">=7.2"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^8.5|^9.4.3"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Ssddanbrown\\HtmlDiff\\": "src"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Dan Brown",
+                    "email": "[email protected]",
+                    "role": "Developer"
+                }
+            ],
+            "description": "HTML Content Diff Generator",
+            "homepage": "https://github.com/ssddanbrown/htmldiff",
+            "support": {
+                "issues": "https://github.com/ssddanbrown/HtmlDiff/issues",
+                "source": "https://github.com/ssddanbrown/HtmlDiff/tree/v1.0.0"
+            },
+            "time": "2020-11-29T18:38:45+00:00"
+        },
         {
             "name": "swiftmailer/swiftmailer",
             "version": "v6.2.3",
         "ext-gd": "*",
         "ext-json": "*",
         "ext-mbstring": "*",
-        "ext-tidy": "*",
         "ext-xml": "*"
     },
     "platform-dev": [],
     "platform-overrides": {
         "php": "7.2.0"
     },
-    "plugin-api-version": "1.1.0"
+    "plugin-api-version": "2.0.0"
 }
index ddf3c295d22e09023fad7e62eaefc5c198f454a1..405e5fcf4490a062408dd79569fd41099847ead3 100644 (file)
@@ -21,7 +21,7 @@ $factory->define(\BookStack\Auth\User::class, function ($faker) {
     ];
 });
 
-$factory->define(\BookStack\Entities\Bookshelf::class, function ($faker) {
+$factory->define(\BookStack\Entities\Models\Bookshelf::class, function ($faker) {
     return [
         'name' => $faker->sentence,
         'slug' => Str::random(10),
@@ -29,7 +29,7 @@ $factory->define(\BookStack\Entities\Bookshelf::class, function ($faker) {
     ];
 });
 
-$factory->define(\BookStack\Entities\Book::class, function ($faker) {
+$factory->define(\BookStack\Entities\Models\Book::class, function ($faker) {
     return [
         'name' => $faker->sentence,
         'slug' => Str::random(10),
@@ -37,7 +37,7 @@ $factory->define(\BookStack\Entities\Book::class, function ($faker) {
     ];
 });
 
-$factory->define(\BookStack\Entities\Chapter::class, function ($faker) {
+$factory->define(\BookStack\Entities\Models\Chapter::class, function ($faker) {
     return [
         'name' => $faker->sentence,
         'slug' => Str::random(10),
@@ -45,7 +45,7 @@ $factory->define(\BookStack\Entities\Chapter::class, function ($faker) {
     ];
 });
 
-$factory->define(\BookStack\Entities\Page::class, function ($faker) {
+$factory->define(\BookStack\Entities\Models\Page::class, function ($faker) {
     $html = '<p>' . implode('</p>', $faker->paragraphs(5)) . '</p>';
     return [
         'name' => $faker->sentence,
index eab3216bbdfa6cc02e2b180ff094683a73f5cfdd..9efba0071c3689c6fe5ce13cdc8faa59ae0f7142 100644 (file)
@@ -119,11 +119,11 @@ class CreateBookshelvesTable extends Migration
         Schema::dropIfExists('bookshelves');
 
         // Drop related polymorphic items
-        DB::table('activities')->where('entity_type', '=', 'BookStack\Entities\Bookshelf')->delete();
-        DB::table('views')->where('viewable_type', '=', 'BookStack\Entities\Bookshelf')->delete();
-        DB::table('entity_permissions')->where('restrictable_type', '=', 'BookStack\Entities\Bookshelf')->delete();
-        DB::table('tags')->where('entity_type', '=', 'BookStack\Entities\Bookshelf')->delete();
-        DB::table('search_terms')->where('entity_type', '=', 'BookStack\Entities\Bookshelf')->delete();
-        DB::table('comments')->where('entity_type', '=', 'BookStack\Entities\Bookshelf')->delete();
+        DB::table('activities')->where('entity_type', '=', 'BookStack\Entities\Models\Bookshelf')->delete();
+        DB::table('views')->where('viewable_type', '=', 'BookStack\Entities\Models\Bookshelf')->delete();
+        DB::table('entity_permissions')->where('restrictable_type', '=', 'BookStack\Entities\Models\Bookshelf')->delete();
+        DB::table('tags')->where('entity_type', '=', 'BookStack\Entities\Models\Bookshelf')->delete();
+        DB::table('search_terms')->where('entity_type', '=', 'BookStack\Entities\Models\Bookshelf')->delete();
+        DB::table('comments')->where('entity_type', '=', 'BookStack\Entities\Models\Bookshelf')->delete();
     }
 }
diff --git a/database/migrations/2020_09_27_210059_add_entity_soft_deletes.php b/database/migrations/2020_09_27_210059_add_entity_soft_deletes.php
new file mode 100644 (file)
index 0000000..d2b63e8
--- /dev/null
@@ -0,0 +1,50 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class AddEntitySoftDeletes extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::table('bookshelves', function(Blueprint  $table) {
+            $table->softDeletes();
+        });
+        Schema::table('books', function(Blueprint  $table) {
+            $table->softDeletes();
+        });
+        Schema::table('chapters', function(Blueprint  $table) {
+            $table->softDeletes();
+        });
+        Schema::table('pages', function(Blueprint  $table) {
+            $table->softDeletes();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::table('bookshelves', function(Blueprint  $table) {
+            $table->dropSoftDeletes();
+        });
+        Schema::table('books', function(Blueprint  $table) {
+            $table->dropSoftDeletes();
+        });
+        Schema::table('chapters', function(Blueprint  $table) {
+            $table->dropSoftDeletes();
+        });
+        Schema::table('pages', function(Blueprint  $table) {
+            $table->dropSoftDeletes();
+        });
+    }
+}
diff --git a/database/migrations/2020_09_27_210528_create_deletions_table.php b/database/migrations/2020_09_27_210528_create_deletions_table.php
new file mode 100644 (file)
index 0000000..c38a935
--- /dev/null
@@ -0,0 +1,38 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class CreateDeletionsTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('deletions', function (Blueprint $table) {
+            $table->increments('id');
+            $table->integer('deleted_by');
+            $table->string('deletable_type', 100);
+            $table->integer('deletable_id');
+            $table->timestamps();
+
+            $table->index('deleted_by');
+            $table->index('deletable_type');
+            $table->index('deletable_id');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('deletions');
+    }
+}
diff --git a/database/migrations/2020_11_07_232321_simplify_activities_table.php b/database/migrations/2020_11_07_232321_simplify_activities_table.php
new file mode 100644 (file)
index 0000000..828dbc6
--- /dev/null
@@ -0,0 +1,58 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Support\Facades\DB;
+
+class SimplifyActivitiesTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::table('activities', function (Blueprint $table) {
+            $table->renameColumn('key', 'type');
+            $table->renameColumn('extra', 'detail');
+            $table->dropColumn('book_id');
+            $table->integer('entity_id')->nullable()->change();
+            $table->string('entity_type', 191)->nullable()->change();
+        });
+
+        DB::table('activities')
+            ->where('entity_id', '=', 0)
+            ->update([
+                'entity_id' => null,
+                'entity_type' => null,
+            ]);
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        DB::table('activities')
+            ->whereNull('entity_id')
+            ->update([
+                'entity_id' => 0,
+                'entity_type' => '',
+            ]);
+
+        Schema::table('activities', function (Blueprint $table) {
+            $table->renameColumn('type', 'key');
+            $table->renameColumn('detail', 'extra');
+            $table->integer('book_id');
+
+            $table->integer('entity_id')->change();
+            $table->string('entity_type', 191)->change();
+
+            $table->index('book_id');
+        });
+    }
+}
index 6d902a19632a7e7f7983d6710550854654faaf4e..55e1f10752fe02f2e7e5ad836a5571ea5eb168d6 100644 (file)
@@ -5,10 +5,10 @@ use BookStack\Auth\Permissions\PermissionService;
 use BookStack\Auth\Permissions\RolePermission;
 use BookStack\Auth\Role;
 use BookStack\Auth\User;
-use BookStack\Entities\Bookshelf;
-use BookStack\Entities\Chapter;
-use BookStack\Entities\Page;
-use BookStack\Entities\SearchService;
+use BookStack\Entities\Models\Bookshelf;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
+use BookStack\Entities\Tools\SearchIndex;
 use Illuminate\Database\Seeder;
 use Illuminate\Support\Str;
 
@@ -33,7 +33,7 @@ class DummyContentSeeder extends Seeder
 
         $byData = ['created_by' => $editorUser->id, 'updated_by' => $editorUser->id];
 
-        factory(\BookStack\Entities\Book::class, 5)->create($byData)
+        factory(\BookStack\Entities\Models\Book::class, 5)->create($byData)
             ->each(function($book) use ($editorUser, $byData) {
                 $chapters = factory(Chapter::class, 3)->create($byData)
                     ->each(function($chapter) use ($editorUser, $book, $byData){
@@ -45,7 +45,7 @@ class DummyContentSeeder extends Seeder
                 $book->pages()->saveMany($pages);
             });
 
-        $largeBook = factory(\BookStack\Entities\Book::class)->create(array_merge($byData, ['name' => 'Large book' . Str::random(10)]));
+        $largeBook = factory(\BookStack\Entities\Models\Book::class)->create(array_merge($byData, ['name' => 'Large book' . Str::random(10)]));
         $pages = factory(Page::class, 200)->make($byData);
         $chapters = factory(Chapter::class, 50)->make($byData);
         $largeBook->pages()->saveMany($pages);
@@ -67,6 +67,6 @@ class DummyContentSeeder extends Seeder
         $token->save();
 
         app(PermissionService::class)->buildJointPermissions();
-        app(SearchService::class)->indexAllEntities();
+        app(SearchIndex::class)->indexAllEntities();
     }
 }
index 4db10395adf037a48aac650a19b2cc02748f3f84..535626b8f794e8c74fb3d28a8b0bfe6ce7612975 100644 (file)
@@ -3,9 +3,9 @@
 use BookStack\Auth\Permissions\PermissionService;
 use BookStack\Auth\Role;
 use BookStack\Auth\User;
-use BookStack\Entities\Chapter;
-use BookStack\Entities\Page;
-use BookStack\Entities\SearchService;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
+use BookStack\Entities\Tools\SearchIndex;
 use Illuminate\Database\Seeder;
 use Illuminate\Support\Str;
 
@@ -23,12 +23,12 @@ class LargeContentSeeder extends Seeder
         $editorRole = Role::getRole('editor');
         $editorUser->attachRole($editorRole);
 
-        $largeBook = factory(\BookStack\Entities\Book::class)->create(['name' => 'Large book' . Str::random(10), 'created_by' => $editorUser->id, 'updated_by' => $editorUser->id]);
+        $largeBook = factory(\BookStack\Entities\Models\Book::class)->create(['name' => 'Large book' . Str::random(10), 'created_by' => $editorUser->id, 'updated_by' => $editorUser->id]);
         $pages = factory(Page::class, 200)->make(['created_by' => $editorUser->id, 'updated_by' => $editorUser->id]);
         $chapters = factory(Chapter::class, 50)->make(['created_by' => $editorUser->id, 'updated_by' => $editorUser->id]);
         $largeBook->pages()->saveMany($pages);
         $largeBook->chapters()->saveMany($chapters);
         app(PermissionService::class)->buildJointPermissions();
-        app(SearchService::class)->indexAllEntities();
+        app(SearchIndex::class)->indexAllEntities();
     }
 }
diff --git a/dev/api/requests/pages-create.json b/dev/api/requests/pages-create.json
new file mode 100644 (file)
index 0000000..1f53b42
--- /dev/null
@@ -0,0 +1,9 @@
+{
+       "book_id": 1,
+       "name": "My API Page",
+       "html": "<p>my new API page</p>",
+       "tags": [
+               {"name": "Category", "value": "Not Bad Content"},
+               {"name": "Rating", "value": "Average"}
+       ]
+}
\ No newline at end of file
diff --git a/dev/api/requests/pages-update.json b/dev/api/requests/pages-update.json
new file mode 100644 (file)
index 0000000..b9bfeb6
--- /dev/null
@@ -0,0 +1,9 @@
+{
+       "chapter_id": 1,
+       "name": "My updated API Page",
+       "html": "<p>my new API page - Updated</p>",
+       "tags": [
+               {"name": "Category", "value": "API Examples"},
+               {"name": "Rating", "value": "Alright"}
+       ]
+}
\ No newline at end of file
index 2e43f5f87fc810163bc8323f53e304c6cbb070db..815a71c3573d139806145654a5547ef21dccd410 100644 (file)
   "tags": [
     {
       "id": 13,
-      "entity_id": 16,
-      "entity_type": "BookStack\\Book",
       "name": "Category",
       "value": "Guide",
-      "order": 0,
-      "created_at": "2020-01-12 14:11:51",
-      "updated_at": "2020-01-12 14:11:51"
+      "order": 0
     }
   ],
   "cover": {
index 2eddad8955070a36b15df967df96a3b7e7aa7dd7..0d16f4b6a30ffadc438e0c3dc9e7d0eafc6b5c98 100644 (file)
@@ -19,9 +19,7 @@
     {
       "name": "Category",
       "value": "Guide",
-      "order": 0,
-      "created_at": "2020-05-22 22:51:51",
-      "updated_at": "2020-05-22 22:51:51"
+      "order": 0
     }
   ],
   "pages": [
@@ -36,9 +34,9 @@
       "updated_at": "2019-08-26 14:32:59",
       "created_by": 1,
       "updated_by": 1,
-      "draft": 0,
+      "draft": false,
       "revision_count": 2,
-      "template": 0
+      "template": false
     },
     {
       "id": 7,
@@ -51,9 +49,9 @@
       "updated_at": "2019-06-06 12:03:04",
       "created_by": 3,
       "updated_by": 3,
-      "draft": 0,
+      "draft": false,
       "revision_count": 1,
-      "template": 0
+      "template": false
     }
   ]
 }
\ No newline at end of file
diff --git a/dev/api/responses/pages-create.json b/dev/api/responses/pages-create.json
new file mode 100644 (file)
index 0000000..1f6c970
--- /dev/null
@@ -0,0 +1,35 @@
+{
+       "id": 358,
+       "book_id": 1,
+       "chapter_id": 0,
+       "name": "My API Page",
+       "slug": "my-api-page",
+       "html": "<p id=\"bkmrk-my-new-api-page\">my new API page</p>",
+       "priority": 14,
+       "created_at": "2020-11-28 15:01:39",
+       "updated_at": "2020-11-28 15:01:39",
+       "created_by": {
+               "id": 1,
+               "name": "Admin"
+       },
+       "updated_by": {
+               "id": 1,
+               "name": "Admin"
+       },
+       "draft": false,
+       "markdown": "",
+       "revision_count": 1,
+       "template": false,
+       "tags": [
+               {
+                       "name": "Category",
+                       "value": "Not Bad Content",
+                       "order": 0
+               },
+               {
+                       "name": "Rating",
+                       "value": "Average",
+                       "order": 1
+               }
+       ]
+}
\ No newline at end of file
diff --git a/dev/api/responses/pages-list.json b/dev/api/responses/pages-list.json
new file mode 100644 (file)
index 0000000..9739345
--- /dev/null
@@ -0,0 +1,47 @@
+{
+       "data": [
+               {
+                       "id": 1,
+                       "book_id": 1,
+                       "chapter_id": 1,
+                       "name": "How to create page content",
+                       "slug": "how-to-create-page-content",
+                       "priority": 0,
+                       "draft": false,
+                       "template": false,
+                       "created_at": "2019-05-05 21:49:58",
+                       "updated_at": "2020-07-04 15:50:58",
+                       "created_by": 1,
+                       "updated_by": 1
+               },
+               {
+                       "id": 2,
+                       "book_id": 1,
+                       "chapter_id": 1,
+                       "name": "How to use images",
+                       "slug": "how-to-use-images",
+                       "priority": 2,
+                       "draft": false,
+                       "template": false,
+                       "created_at": "2019-05-05 21:53:30",
+                       "updated_at": "2019-06-06 12:03:04",
+                       "created_by": 1,
+                       "updated_by": 1
+               },
+               {
+                       "id": 3,
+                       "book_id": 1,
+                       "chapter_id": 1,
+                       "name": "Drawings via draw.io",
+                       "slug": "drawings-via-drawio",
+                       "priority": 3,
+                       "draft": false,
+                       "template": false,
+                       "created_at": "2019-05-05 21:53:49",
+                       "updated_at": "2019-12-18 21:56:52",
+                       "created_by": 1,
+                       "updated_by": 1
+               }
+       ],
+       "total": 322
+}
\ No newline at end of file
diff --git a/dev/api/responses/pages-read.json b/dev/api/responses/pages-read.json
new file mode 100644 (file)
index 0000000..c8acb52
--- /dev/null
@@ -0,0 +1,35 @@
+{
+       "id": 306,
+       "book_id": 1,
+       "chapter_id": 0,
+       "name": "A page written in markdown",
+       "slug": "a-page-written-in-markdown",
+       "html": "<h1 id=\"bkmrk-how-this-is-built\">How this is built</h1>\r\n<p id=\"bkmrk-this-page-is-written\">This page is written in markdown. BookStack stores the page data in HTML.</p>\r\n<p id=\"bkmrk-here%27s-a-cute-pictur\">Here's a cute picture of my cat:</p>\r\n<p id=\"bkmrk-\"><a href=\"http://example.com/uploads/images/gallery/2020-04/yXSrubes.jpg\"><img src=\"http://example.com/uploads/images/gallery/2020-04/scaled-1680-/yXSrubes.jpg\" alt=\"yXSrubes.jpg\"></a></p>",
+       "priority": 13,
+       "created_at": "2020-02-02 21:40:38",
+       "updated_at": "2020-11-28 14:43:20",
+       "created_by": {
+               "id": 1,
+               "name": "Admin"
+       },
+       "updated_by": {
+               "id": 1,
+               "name": "Admin"
+       },
+       "draft": false,
+       "markdown": "# How this is built\r\n\r\nThis page is written in markdown. BookStack stores the page data in HTML.\r\n\r\nHere's a cute picture of my cat:\r\n\r\n[![yXSrubes.jpg](http://example.com/uploads/images/gallery/2020-04/scaled-1680-/yXSrubes.jpg)](http://example.com/uploads/images/gallery/2020-04/yXSrubes.jpg)",
+       "revision_count": 5,
+       "template": false,
+       "tags": [
+               {
+                       "name": "Category",
+                       "value": "Top Content",
+                       "order": 0
+               },
+               {
+                       "name": "Animal",
+                       "value": "Cat",
+                       "order": 1
+               }
+       ]
+}
\ No newline at end of file
diff --git a/dev/api/responses/pages-update.json b/dev/api/responses/pages-update.json
new file mode 100644 (file)
index 0000000..23f8d22
--- /dev/null
@@ -0,0 +1,35 @@
+{
+       "id": 361,
+       "book_id": 1,
+       "chapter_id": 1,
+       "name": "My updated API Page",
+       "slug": "my-updated-api-page",
+       "html": "<p id=\"bkmrk-my-new-api-page---up\">my new API page - Updated</p>",
+       "priority": 16,
+       "created_at": "2020-11-28 15:10:54",
+       "updated_at": "2020-11-28 15:13:03",
+       "created_by": {
+               "id": 1,
+               "name": "Admin"
+       },
+       "updated_by": {
+               "id": 1,
+               "name": "Admin"
+       },
+       "draft": false,
+       "markdown": "",
+       "revision_count": 5,
+       "template": false,
+       "tags": [
+               {
+                       "name": "Category",
+                       "value": "API Examples",
+                       "order": 0
+               },
+               {
+                       "name": "Rating",
+                       "value": "Alright",
+                       "order": 0
+               }
+       ]
+}
\ No newline at end of file
index 634fbb5a53c6fde235e72c2516b111a73f645451..b0487debe7f0a98796beab3b9b5892c4d1f0aab8 100644 (file)
   "tags": [
     {
       "id": 16,
-      "entity_id": 14,
-      "entity_type": "BookStack\\Bookshelf",
       "name": "Category",
       "value": "Guide",
-      "order": 0,
-      "created_at": "2020-04-10 13:31:04",
-      "updated_at": "2020-04-10 13:31:04"
+      "order": 0
     }
   ],
   "cover": {
index ff44f0c8d3f5d7b3af67a55818022805d8d113cf..e91d34a713377a7e579a6594000c01fb5c98ba57 100755 (executable)
@@ -7,8 +7,9 @@ env
 if [[ -n "$1" ]]; then
     exec "$@"
 else
+    composer install
     wait-for-it db:3306 -t 45
     php artisan migrate --database=mysql
     chown -R www-data:www-data storage
     exec apache2-foreground
-fi
\ No newline at end of file
+fi
index 70f1c1f9c3ed341b39af5baa54e79af2de7a70ab..ad7c6f43a5d551eec767dadda32f047d64ded014 100644 (file)
@@ -1,7 +1,7 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <phpunit backupGlobals="false"
          backupStaticAttributes="false"
-         bootstrap="bootstrap/init.php"
+         bootstrap="vendor/autoload.php"
          colors="true"
          convertErrorsToExceptions="true"
          convertNoticesToExceptions="true"
index 8205764728cdb1dc6bd8bdfb78f20ebec5525ac3..9d890e90a4ef4cd9ade7b25b434248c63766d3d0 100644 (file)
@@ -11,15 +11,17 @@ define('LARAVEL_START', microtime(true));
 
 /*
 |--------------------------------------------------------------------------
-| Initialize The App
+| Register The Auto Loader
 |--------------------------------------------------------------------------
 |
-| We need to get things going before we start up the app.
-| The init file loads everything in, in the correct order.
+| Composer provides a convenient, automatically generated class loader for
+| our application. We just need to utilize it! We'll simply require it
+| into the script here so that we don't have to worry about manual
+| loading any of our classes later on. It feels great to relax.
 |
 */
 
-require __DIR__.'/../bootstrap/init.php';
+require __DIR__.'/../vendor/autoload.php';
 
 /*
 |--------------------------------------------------------------------------
index 7c2872a0a4517b086f4f7a07be5402d1ff2cbcda..bf6dfac2ddc584439e357be29e6d9dadabcb3da1 100644 (file)
--- a/readme.md
+++ b/readme.md
@@ -93,12 +93,11 @@ To get started, make sure you meet the following requirements:
 
 If all the conditions are met, you can proceed with the following steps:
 
-1. Install PHP/Composer dependencies with **`docker-compose run app composer install`** (first time can take a while because the image has to be built).
-2. **Copy `.env.example` to `.env`** and change `APP_KEY` to a random 32 char string.
-3. Make sure **port 8080 is unused** *or else* change `DEV_PORT` to a free port on your host.
-4. **Run `chgrp -R docker storage`**. The development container will chown the `storage` directory to the `www-data` user inside the container so BookStack can write to it. You need to change the group to your host's `docker` group here to not lose access to the `storage` directory.
-5. **Run `docker-compose up`** and wait until all database migrations have been done.
-6. You can now login with `[email protected]` and `password` as password on `localhost:8080` (or another port if specified).
+1. **Copy `.env.example` to `.env`**, change `APP_KEY` to a random 32 char string and set `APP_ENV` to `local`.
+2. Make sure **port 8080 is unused** *or else* change `DEV_PORT` to a free port on your host.
+3. **Run `chgrp -R docker storage`**. The development container will chown the `storage` directory to the `www-data` user inside the container so BookStack can write to it. You need to change the group to your host's `docker` group here to not lose access to the `storage` directory.
+4. **Run `docker-compose up`** and wait until the image is built and all database migrations have been done.
+5. You can now login with `[email protected]` and `password` as password on `localhost:8080` (or another port if specified).
 
 If needed, You'll be able to run any artisan commands via docker-compose like so:
 
@@ -170,4 +169,4 @@ These are the great open-source projects used to help build BookStack:
 * [WKHTMLtoPDF](http://wkhtmltopdf.org/index.html)
 * [diagrams.net](https://github.com/jgraph/drawio)
 * [Laravel Stats](https://github.com/stefanzweifel/laravel-stats)
-* [OneLogin's SAML PHP Toolkit](https://github.com/onelogin/php-saml)
\ No newline at end of file
+* [OneLogin's SAML PHP Toolkit](https://github.com/onelogin/php-saml)
index 19d26d4a987f561f2aa0a3d007283f0655463d83..bd107f2bf7a00f53ed3404a27c95bcf3c1c7d1c3 100644 (file)
@@ -13,6 +13,7 @@ class MarkdownEditor {
 
         this.pageId = this.$opts.pageId;
         this.textDirection = this.$opts.textDirection;
+        this.imageUploadErrorText = this.$opts.imageUploadErrorText;
 
         this.markdown = new MarkdownIt({html: true});
         this.markdown.use(mdTasksLists, {label: true});
@@ -373,7 +374,7 @@ class MarkdownEditor {
                 const newContent = `[![${selectedText}](${resp.data.thumbs.display})](${resp.data.url})`;
                 replaceContent(placeHolderText, newContent);
             }).catch(err => {
-                window.$events.emit('error', trans('errors.image_upload_error'));
+                window.$events.emit('error', context.imageUploadErrorText);
                 replaceContent(placeHolderText, selectedText);
                 console.log(err);
             });
@@ -492,7 +493,7 @@ class MarkdownEditor {
                 this.cm.focus();
                 DrawIO.close();
             }).catch(err => {
-                window.$events.emit('error', trans('errors.image_upload_error'));
+                window.$events.emit('error', this.imageUploadErrorText);
                 console.log(err);
             });
         });
index a32e78161649e6337ee45bb6d9ab76f8bbf5fc9c..bae70ad14e97c20beef6bab0990a2ba58ee0aa56 100644 (file)
@@ -38,7 +38,7 @@ function editorPaste(event, editor, wysiwygComponent) {
                 editor.dom.replace(newEl, id);
             }).catch(err => {
                 editor.dom.remove(id);
-                window.$events.emit('error', trans('errors.image_upload_error'));
+                window.$events.emit('error', wysiwygComponent.imageUploadErrorText);
                 console.log(err);
             });
         }, 10);
@@ -236,7 +236,7 @@ function codePlugin() {
     });
 }
 
-function drawIoPlugin(drawioUrl, isDarkMode, pageId) {
+function drawIoPlugin(drawioUrl, isDarkMode, pageId, wysiwygComponent) {
 
     let pageEditor = null;
     let currentNode = null;
@@ -280,7 +280,7 @@ function drawIoPlugin(drawioUrl, isDarkMode, pageId) {
                 pageEditor.dom.setAttrib(imgElem, 'src', img.url);
                 pageEditor.dom.setAttrib(currentNode, 'drawio-diagram', img.id);
             } catch (err) {
-                window.$events.emit('error', trans('errors.image_upload_error'));
+                window.$events.emit('error', wysiwygComponent.imageUploadErrorText);
                 console.log(err);
             }
             return;
@@ -295,7 +295,7 @@ function drawIoPlugin(drawioUrl, isDarkMode, pageId) {
                 pageEditor.dom.get(id).parentNode.setAttribute('drawio-diagram', img.id);
             } catch (err) {
                 pageEditor.dom.remove(id);
-                window.$events.emit('error', trans('errors.image_upload_error'));
+                window.$events.emit('error', wysiwygComponent.imageUploadErrorText);
                 console.log(err);
             }
         }, 5);
@@ -414,12 +414,12 @@ function listenForBookStackEditorEvents(editor) {
 
 class WysiwygEditor {
 
-
     setup() {
         this.elem = this.$el;
 
         this.pageId = this.$opts.pageId;
         this.textDirection = this.$opts.textDirection;
+        this.imageUploadErrorText = this.$opts.imageUploadErrorText;
         this.isDarkMode = document.documentElement.classList.contains('dark-mode');
 
         this.plugins = "image table textcolor paste link autolink fullscreen code customhr autosave lists codeeditor media";
@@ -437,7 +437,7 @@ class WysiwygEditor {
         const drawioUrlElem = document.querySelector('[drawio-url]');
         if (drawioUrlElem) {
             const url = drawioUrlElem.getAttribute('drawio-url');
-            drawIoPlugin(url, this.isDarkMode, this.pageId);
+            drawIoPlugin(url, this.isDarkMode, this.pageId, this);
             this.plugins += ' drawio';
         }
 
index 4cac54b2a706efa35cb8873ebc247c20420e65b5..fe937b061930262a060465699446647adab763d9 100644 (file)
@@ -45,4 +45,5 @@ return [
 
     // Other
     'commented_on'                => 'commented on',
+    'permissions_update'          => 'updated permissions',
 ];
index f64867a56c31736a1730d58c51f3fe0c088364d1..485ecb7bc55f300ad8a8bfa2c56d973b82f489d9 100644 (file)
@@ -146,7 +146,7 @@ return [
     'chapters_create' => 'Create New Chapter',
     'chapters_delete' => 'Delete Chapter',
     'chapters_delete_named' => 'Delete Chapter :chapterName',
-    'chapters_delete_explain' => 'This will delete the chapter with the name \':chapterName\'. All pages will be removed and added directly to the parent book.',
+    'chapters_delete_explain' => 'This will delete the chapter with the name \':chapterName\'. All pages that exist within this chapter will also be deleted.',
     'chapters_delete_confirm' => 'Are you sure you want to delete this chapter?',
     'chapters_edit' => 'Edit Chapter',
     'chapters_edit_named' => 'Edit Chapter :chapterName',
index e280396a25fb136154aabf2552a364e59fa99476..dce3454263bf160598f9442944a9de012b6fded0 100755 (executable)
@@ -80,6 +80,27 @@ return [
     'maint_send_test_email_mail_subject' => 'Test Email',
     'maint_send_test_email_mail_greeting' => 'Email delivery seems to work!',
     'maint_send_test_email_mail_text' => 'Congratulations! As you received this email notification, your email settings seem to be configured properly.',
+    'maint_recycle_bin_desc' => 'Deleted shelves, books, chapters & pages are sent to the recycle bin so they can be restored or permanently deleted. Older items in the recycle bin may be automatically removed after a while depending on system configuration.',
+    'maint_recycle_bin_open' => 'Open Recycle Bin',
+
+    // Recycle Bin
+    'recycle_bin' => 'Recycle Bin',
+    'recycle_bin_desc' => 'Here you can restore items that have been deleted or choose to permanently remove them from the system. This list is unfiltered unlike similar activity lists in the system where permission filters are applied.',
+    'recycle_bin_deleted_item' => 'Deleted Item',
+    'recycle_bin_deleted_by' => 'Deleted By',
+    'recycle_bin_deleted_at' => 'Deletion Time',
+    'recycle_bin_permanently_delete' => 'Permanently Delete',
+    'recycle_bin_restore' => 'Restore',
+    'recycle_bin_contents_empty' => 'The recycle bin is currently empty',
+    'recycle_bin_empty' => 'Empty Recycle Bin',
+    'recycle_bin_empty_confirm' => 'This will permanently destroy all items in the recycle bin including content contained within each item. Are you sure you want to empty the recycle bin?',
+    'recycle_bin_destroy_confirm' => 'This action will permanently delete this item, along with any child elements listed below, from the system and you will not be able to restore this content. Are you sure you want to permanently delete this item?',
+    'recycle_bin_destroy_list' => 'Items to be Destroyed',
+    'recycle_bin_restore_list' => 'Items to be Restored',
+    'recycle_bin_restore_confirm' => 'This action will restore the deleted item, including any child elements, to their original location. If the original location has since been deleted, and is now in the recycle bin, the parent item will also need to be restored.',
+    'recycle_bin_restore_deleted_parent' => 'The parent of this item has also been deleted. These will remain deleted until that parent is also restored.',
+    'recycle_bin_destroy_notification' => 'Deleted :count total items from the recycle bin.',
+    'recycle_bin_restore_notification' => 'Restored :count total items from the recycle bin.',
 
     // Audit Log
     'audit' => 'Audit Log',
@@ -90,7 +111,7 @@ return [
     'audit_deleted_item_name' => 'Name: :name',
     'audit_table_user' => 'User',
     'audit_table_event' => 'Event',
-    'audit_table_item' => 'Related Item',
+    'audit_table_related' => 'Related Item or Detail',
     'audit_table_date' => 'Activity Date',
     'audit_date_from' => 'Date Range From',
     'audit_date_to' => 'Date Range To',
@@ -136,6 +157,7 @@ return [
     'user_profile' => 'User Profile',
     'users_add_new' => 'Add New User',
     'users_search' => 'Search Users',
+    'users_latest_activity' => 'Latest Activity',
     'users_details' => 'User Details',
     'users_details_desc' => 'Set a display name and an email address for this user. The email address will be used for logging into the application.',
     'users_details_desc_no_email' => 'Set a display name for this user so others can recognise them.',
index 5b219b9ae33ae77ad24e73d4ddd676421316608a..75adf12aacde34a99b795c7278320f6ebdfee62b 100644 (file)
 .sticky-sidebar {
   position: sticky;
   top: $-m;
+  max-height: calc(100vh - #{$-m});
+  overflow-y: auto;
 }
index 519cb27ad7787f31199276fbe821d87334a76603..c4e412f0e67a982492b61363c05dab4bf27f0471 100644 (file)
@@ -150,22 +150,25 @@ body.flexbox {
 .justify-flex-end {
   justify-content: flex-end;
 }
+.justify-center {
+  justify-content: center;
+}
 
 
 /**
  * Display and float utilities
  */
 .block {
-  display: block;
+  display: block !important;
   position: relative;
 }
 
 .inline {
-  display: inline;
+  display: inline !important;
 }
 
 .block.inline {
-  display: inline-block;
+  display: inline-block !important;
 }
 
 .hidden {
index 376541b5dcba5d67b476f269b1c60ab8aab61131..78d94f977f8d0043679557148fc00af97a8cd276 100644 (file)
@@ -290,12 +290,12 @@ $btt-size: 40px;
   }
 }
 
-table a.audit-log-user {
+table.table .table-user-item {
   display: grid;
   grid-template-columns: 42px 1fr;
   align-items: center;
 }
-table a.icon-list-item {
+table.table .table-entity-item {
   display: grid;
   grid-template-columns: 36px 1fr;
   align-items: center;
index c5d461629826221145ccffd5769088233be01ab9..f043735bbf4c9c0df001d5f40fc565756b467550 100644 (file)
@@ -13,8 +13,8 @@
     <ul class="sortable-page-list sort-list">
 
         @foreach($bookChildren as $bookChild)
-            <li class="text-{{ $bookChild->getClassName() }}"
-                data-id="{{$bookChild->id}}" data-type="{{ $bookChild->getClassName() }}"
+            <li class="text-{{ $bookChild->getType() }}"
+                data-id="{{$bookChild->id}}" data-type="{{ $bookChild->getType() }}"
                 data-name="{{ $bookChild->name }}" data-created="{{ $bookChild->created_at->timestamp }}"
                 data-updated="{{ $bookChild->updated_at->timestamp }}">
                 <div class="entity-list-item">
index 827abcac601d8d6e4276880085a1a27aadb7a960..80e79410a04ff3aeee9c4324ca3978e6b4eabf35 100644 (file)
@@ -29,7 +29,7 @@
                 <div class="links text-center">
                     @if (hasAppAccess())
                         <a class="hide-over-l" href="{{ url('/search') }}">@icon('search'){{ trans('common.search') }}</a>
-                        @if(userCanOnAny('view', \BookStack\Entities\Bookshelf::class) || userCan('bookshelf-view-all') || userCan('bookshelf-view-own'))
+                        @if(userCanOnAny('view', \BookStack\Entities\Models\Bookshelf::class) || userCan('bookshelf-view-all') || userCan('bookshelf-view-own'))
                             <a href="{{ url('/shelves') }}">@icon('bookshelf'){{ trans('entities.shelves') }}</a>
                         @endif
                         <a href="{{ url('/books') }}">@icon('books'){{ trans('entities.books') }}</a>
index e24ea49f1c82a7a374f8c8cf0c51392a40cac943..c59615d92a30a38fbb0aa66feba92d5a56c0ad1f 100644 (file)
@@ -3,7 +3,7 @@
 <div page-picker>
     <div class="input-base">
         <span @if($value) style="display: none" @endif page-picker-default class="text-muted italic">{{ $placeholder }}</span>
-        <a @if(!$value) style="display: none" @endif href="{{ url('/link/' . $value) }}" target="_blank" class="text-page" page-picker-display>#{{$value}}, {{$value ? \BookStack\Entities\Page::find($value)->name : '' }}</a>
+        <a @if(!$value) style="display: none" @endif href="{{ url('/link/' . $value) }}" target="_blank" class="text-page" page-picker-display>#{{$value}}, {{$value ? \BookStack\Entities\Models\Page::find($value)->name : '' }}</a>
     </div>
     <br>
     <input type="hidden" value="{{$value}}" name="{{$name}}" id="{{$name}}">
index 6c1437ec1ac37b81da7e2b78ddeeceee4aad8a22..a9d1f1174bbbabf9f3266f09a6e59a4694beed33 100644 (file)
@@ -1,10 +1,8 @@
 <div id="markdown-editor" component="markdown-editor"
      option:markdown-editor:page-id="{{ $model->id ?? 0 }}"
      option:markdown-editor:text-direction="{{ config('app.rtl') ? 'rtl' : 'ltr' }}"
+     option:markdown-editor:image-upload-error-text="{{ trans('errors.image_upload_error') }}"
      class="flex-fill flex code-fill">
-    @exposeTranslations([
-        'errors.image_upload_error',
-    ])
 
     <div class="markdown-editor-wrap active">
         <div class="editor-toolbar">
index 3bc85caa9d57a749128fad5f3cb9de02f57035d9..d8b8b1c353c73f53c10b639635345fe78c86ee14 100644 (file)
@@ -1,12 +1,9 @@
 <div component="wysiwyg-editor"
      option:wysiwyg-editor:page-id="{{ $model->id ?? 0 }}"
      option:wysiwyg-editor:text-direction="{{ config('app.rtl') ? 'rtl' : 'ltr' }}"
+     option:wysiwyg-editor:image-upload-error-text="{{ trans('errors.image_upload_error') }}"
      class="flex-fill flex">
 
-    @exposeTranslations([
-        'errors.image_upload_error',
-    ])
-
     <textarea id="html-editor"  name="html" rows="5"
           @if($errors->has('html')) class="text-neg" @endif>@if(isset($model) || old('html')){{ old('html') ? old('html') : $model->html }}@endif</textarea>
 </div>
index 4fd8dde1b58550fbf1f0f6c59c84ead73f88c11e..eebfb591a4af40713816963b5c978d5a6b3ba171 100644 (file)
 
     {{ $activity->getText() }}
 
-    @if($activity->entity)
+    @if($activity->entity && is_null($activity->entity->deleted_at))
         <a href="{{ $activity->entity->getUrl() }}">{{ $activity->entity->name }}</a>
     @endif
 
+    @if($activity->entity && !is_null($activity->entity->deleted_at))
+        "{{ $activity->entity->name }}"
+    @endif
+
     @if($activity->extra) "{{ $activity->extra }}" @endif
 
     <br>
index 6e308bb09f06447f588b62e512edd5d3d588dc99..15b5832897d01756c7fc59b22a89805bdf7a8bbe 100644 (file)
@@ -12,7 +12,7 @@
         @endif
 
         @foreach($sidebarTree as $bookChild)
-            <li class="list-item-{{ $bookChild->getClassName() }} {{ $bookChild->getClassName() }} {{ $bookChild->isA('page') && $bookChild->draft ? 'draft' : '' }}">
+            <li class="list-item-{{ $bookChild->getType() }} {{ $bookChild->getType() }} {{ $bookChild->isA('page') && $bookChild->draft ? 'draft' : '' }}">
                 @include('partials.entity-list-item-basic', ['entity' => $bookChild, 'classes' => $current->matches($bookChild)? 'selected' : ''])
 
                 @if($bookChild->isA('chapter') && count($bookChild->visible_pages) > 0)
index 58ccd51257e0338686e746762781d36b318b5d70..065aa842026e91ca481dae18a5a606a5bcfe341d 100644 (file)
@@ -2,7 +2,7 @@
     <?php $breadcrumbCount = 0; ?>
 
     {{-- Show top level books item --}}
-    @if (count($crumbs) > 0 && ($crumbs[0] ?? null) instanceof  \BookStack\Entities\Book)
+    @if (count($crumbs) > 0 && ($crumbs[0] ?? null) instanceof  \BookStack\Entities\Models\Book)
         <a href="{{  url('/books')  }}" class="text-book icon-list-item outline-hover">
             <span>@icon('books')</span>
             <span>{{ trans('entities.books') }}</span>
@@ -11,7 +11,7 @@
     @endif
 
     {{-- Show top level shelves item --}}
-    @if (count($crumbs) > 0 && ($crumbs[0] ?? null) instanceof  \BookStack\Entities\Bookshelf)
+    @if (count($crumbs) > 0 && ($crumbs[0] ?? null) instanceof  \BookStack\Entities\Models\Bookshelf)
         <a href="{{  url('/shelves')  }}" class="text-bookshelf icon-list-item outline-hover">
             <span>@icon('bookshelf')</span>
             <span>{{ trans('entities.shelves') }}</span>
@@ -20,7 +20,7 @@
     @endif
 
     @foreach($crumbs as $key => $crumb)
-        <?php $isEntity = ($crumb instanceof \BookStack\Entities\Entity); ?>
+        <?php $isEntity = ($crumb instanceof \BookStack\Entities\Models\Entity); ?>
 
         @if (is_null($crumb))
             <?php continue; ?>
diff --git a/resources/views/partials/entity-display-item.blade.php b/resources/views/partials/entity-display-item.blade.php
new file mode 100644 (file)
index 0000000..d6633ed
--- /dev/null
@@ -0,0 +1,7 @@
+<?php $type = $entity->getType(); ?>
+<div class="{{$type}} {{$type === 'page' && $entity->draft ? 'draft' : ''}} {{$classes ?? ''}} entity-list-item no-hover">
+    <span role="presentation" class="icon text-{{$type}} {{$type === 'page' && $entity->draft ? 'draft' : ''}}">@icon($type)</span>
+    <div class="content">
+        <div class="entity-list-item-name break-text">{{ $entity->name }}</div>
+    </div>
+</div>
\ No newline at end of file
diff --git a/resources/views/partials/table-user.blade.php b/resources/views/partials/table-user.blade.php
new file mode 100644 (file)
index 0000000..a8f2777
--- /dev/null
@@ -0,0 +1,12 @@
+{{--
+$user - User mode to display, Can be null.
+$user_id - Id of user to show. Must be provided.
+--}}
+@if($user)
+    <a href="{{ $user->getEditUrl() }}" class="table-user-item">
+        <div><img class="avatar block" src="{{ $user->getAvatar(40)}}" alt="{{ $user->name }}"></div>
+        <div>{{ $user->name }}</div>
+    </a>
+@else
+    [ID: {{ $user_id }}] {{ trans('common.deleted_user') }}
+@endif
\ No newline at end of file
index 9b97f060da7ca9ce70b32c10819156cff5c6e823..1996e1c2144e59119d00b0f34cbfaf39d3b44626 100644 (file)
@@ -19,8 +19,8 @@
                 <button refs="dropdown@toggle" aria-haspopup="true" aria-expanded="false" aria-label="{{ trans('common.sort_options') }}" class="input-base text-left">{{ $listDetails['event'] ?: trans('settings.audit_event_filter_no_filter') }}</button>
                 <ul refs="dropdown@menu" class="dropdown-menu">
                     <li @if($listDetails['event'] === '') class="active" @endif><a href="{{ sortUrl('/settings/audit', $listDetails, ['event' => '']) }}">{{ trans('settings.audit_event_filter_no_filter') }}</a></li>
-                    @foreach($activityKeys as $key)
-                        <li @if($key === $listDetails['event']) class="active" @endif><a href="{{ sortUrl('/settings/audit', $listDetails, ['event' => $key]) }}">{{ $key }}</a></li>
+                    @foreach($activityTypes as $type)
+                        <li @if($type === $listDetails['event']) class="active" @endif><a href="{{ sortUrl('/settings/audit', $listDetails, ['event' => $type]) }}">{{ $type }}</a></li>
                     @endforeach
                 </ul>
             </div>
                 <th>
                     <a href="{{ sortUrl('/settings/audit', $listDetails, ['sort' => 'key']) }}">{{ trans('settings.audit_table_event') }}</a>
                 </th>
-                <th>{{ trans('settings.audit_table_item') }}</th>
+                <th>{{ trans('settings.audit_table_related') }}</th>
                 <th>
                     <a href="{{ sortUrl('/settings/audit', $listDetails, ['sort' => 'created_at']) }}">{{ trans('settings.audit_table_date') }}</a></th>
             </tr>
             @foreach($activities as $activity)
                 <tr>
                     <td>
-                        @if($activity->user)
-                            <a href="{{ $activity->user->getEditUrl() }}" class="audit-log-user">
-                                <div><img class="avatar block" src="{{ $activity->user->getAvatar(40)}}" alt="{{ $activity->user->name }}"></div>
-                                <div>{{ $activity->user->name }}</div>
-                            </a>
-                        @else
-                            [ID: {{ $activity->user_id }}] {{ trans('common.deleted_user') }}
-                        @endif
+                        @include('partials.table-user', ['user' => $activity->user, 'user_id' => $activity->user_id])
                     </td>
-                    <td>{{ $activity->key }}</td>
+                    <td>{{ $activity->type }}</td>
                     <td>
                         @if($activity->entity)
-                            <a href="{{ $activity->entity->getUrl() }}" class="icon-list-item">
+                            <a href="{{ $activity->entity->getUrl() }}" class="table-entity-item">
                                 <span role="presentation" class="icon text-{{$activity->entity->getType()}}">@icon($activity->entity->getType())</span>
                                 <div class="text-{{ $activity->entity->getType() }}">
                                     {{ $activity->entity->name }}
                                 </div>
                             </a>
-                        @elseif($activity->extra)
+                        @elseif($activity->detail && $activity->isForEntity())
                             <div class="px-m">
                                 {{ trans('settings.audit_deleted_item') }} <br>
-                                {{ trans('settings.audit_deleted_item_name', ['name' => $activity->extra]) }}
+                                {{ trans('settings.audit_deleted_item_name', ['name' => $activity->detail]) }}
                             </div>
+                        @elseif($activity->detail)
+                            <div class="px-m">{{ $activity->detail }}</div>
                         @endif
                     </td>
                     <td>{{ $activity->created_at }}</td>
index 35686ca3307e0f27a268987cc2fbad708d411071..13a8930a12106876efd81e7bc65bf6177b702dac 100644 (file)
@@ -5,6 +5,24 @@
 
     @include('settings.navbar-with-version', ['selected' => 'maintenance'])
 
+    <div class="card content-wrap auto-height pb-xl">
+        <h2 class="list-heading">{{ trans('settings.recycle_bin') }}</h2>
+        <div class="grid half gap-xl">
+            <div>
+                <p class="small text-muted">{{ trans('settings.maint_recycle_bin_desc') }}</p>
+            </div>
+            <div>
+                <div class="grid half no-gap mb-m">
+                    <p class="mb-xs text-bookshelf">@icon('bookshelf'){{ trans('entities.shelves') }}: {{ $recycleStats['bookshelf'] }}</p>
+                    <p class="mb-xs text-book">@icon('book'){{ trans('entities.books') }}: {{ $recycleStats['book'] }}</p>
+                    <p class="mb-xs text-chapter">@icon('chapter'){{ trans('entities.chapters') }}: {{ $recycleStats['chapter'] }}</p>
+                    <p class="mb-xs text-page">@icon('page'){{ trans('entities.pages') }}: {{ $recycleStats['page'] }}</p>
+                </div>
+                <a href="{{ url('/settings/recycle-bin') }}" class="button outline">{{ trans('settings.maint_recycle_bin_open') }}</a>
+            </div>
+        </div>
+    </div>
+
     <div id="image-cleanup" class="card content-wrap auto-height">
         <h2 class="list-heading">{{ trans('settings.maint_image_cleanup') }}</h2>
         <div class="grid half gap-xl">
@@ -15,7 +33,7 @@
                 <form method="POST" action="{{ url('/settings/maintenance/cleanup-images') }}">
                     {!! csrf_field()  !!}
                     <input type="hidden" name="_method" value="DELETE">
-                    <div>
+                    <div class="mb-s">
                         @if(session()->has('cleanup-images-warning'))
                             <p class="text-neg">
                                 {{ session()->get('cleanup-images-warning') }}
diff --git a/resources/views/settings/recycle-bin/deletable-entity-list.blade.php b/resources/views/settings/recycle-bin/deletable-entity-list.blade.php
new file mode 100644 (file)
index 0000000..07ad94f
--- /dev/null
@@ -0,0 +1,11 @@
+@include('partials.entity-display-item', ['entity' => $entity])
+@if($entity->isA('book'))
+    @foreach($entity->chapters()->withTrashed()->get() as $chapter)
+        @include('partials.entity-display-item', ['entity' => $chapter])
+    @endforeach
+@endif
+@if($entity->isA('book') || $entity->isA('chapter'))
+    @foreach($entity->pages()->withTrashed()->get() as $page)
+        @include('partials.entity-display-item', ['entity' => $page])
+    @endforeach
+@endif
\ No newline at end of file
diff --git a/resources/views/settings/recycle-bin/destroy.blade.php b/resources/views/settings/recycle-bin/destroy.blade.php
new file mode 100644 (file)
index 0000000..d027199
--- /dev/null
@@ -0,0 +1,31 @@
+@extends('simple-layout')
+
+@section('body')
+    <div class="container small">
+
+        <div class="grid left-focus v-center no-row-gap">
+            <div class="py-m">
+                @include('settings.navbar', ['selected' => 'maintenance'])
+            </div>
+        </div>
+
+        <div class="card content-wrap auto-height">
+            <h2 class="list-heading">{{ trans('settings.recycle_bin_permanently_delete') }}</h2>
+            <p class="text-muted">{{ trans('settings.recycle_bin_destroy_confirm') }}</p>
+            <form action="{{ url('/settings/recycle-bin/' . $deletion->id) }}" method="post">
+                {!! method_field('DELETE') !!}
+                {!! csrf_field() !!}
+                <a href="{{ url('/settings/recycle-bin') }}" class="button outline">{{ trans('common.cancel') }}</a>
+                <button type="submit" class="button">{{ trans('common.delete_confirm') }}</button>
+            </form>
+
+            @if($deletion->deletable instanceof \BookStack\Entities\Models\Entity)
+                <hr class="mt-m">
+                <h5>{{ trans('settings.recycle_bin_destroy_list') }}</h5>
+                @include('settings.recycle-bin.deletable-entity-list', ['entity' => $deletion->deletable])
+            @endif
+
+        </div>
+
+    </div>
+@stop
diff --git a/resources/views/settings/recycle-bin/index.blade.php b/resources/views/settings/recycle-bin/index.blade.php
new file mode 100644 (file)
index 0000000..657b45a
--- /dev/null
@@ -0,0 +1,103 @@
+@extends('simple-layout')
+
+@section('body')
+    <div class="container">
+
+        <div class="grid left-focus v-center no-row-gap">
+            <div class="py-m">
+                @include('settings.navbar', ['selected' => 'maintenance'])
+            </div>
+        </div>
+
+        <div class="card content-wrap auto-height">
+            <h2 class="list-heading">{{ trans('settings.recycle_bin') }}</h2>
+
+            <div class="grid half left-focus">
+                <div>
+                    <p class="text-muted">{{ trans('settings.recycle_bin_desc') }}</p>
+                </div>
+                <div class="text-right">
+                    <div component="dropdown" class="dropdown-container">
+                        <button refs="dropdown@toggle"
+                                type="button"
+                                class="button outline">{{ trans('settings.recycle_bin_empty') }} </button>
+                        <div refs="dropdown@menu" class="dropdown-menu">
+                            <p class="text-neg small px-m mb-xs">{{ trans('settings.recycle_bin_empty_confirm') }}</p>
+
+                            <form action="{{ url('/settings/recycle-bin/empty') }}" method="POST">
+                                {!! csrf_field() !!}
+                                <button type="submit" class="text-primary small delete">{{ trans('common.confirm') }}</button>
+                            </form>
+                        </div>
+                    </div>
+
+                </div>
+            </div>
+
+
+            <hr class="mt-l mb-s">
+
+            {!! $deletions->links() !!}
+
+            <table class="table">
+                <tr>
+                    <th>{{ trans('settings.recycle_bin_deleted_item') }}</th>
+                    <th>{{ trans('settings.recycle_bin_deleted_by') }}</th>
+                    <th>{{ trans('settings.recycle_bin_deleted_at') }}</th>
+                    <th></th>
+                </tr>
+                @if(count($deletions) === 0)
+                    <tr>
+                        <td colspan="4">
+                            <p class="text-muted"><em>{{ trans('settings.recycle_bin_contents_empty') }}</em></p>
+                        </td>
+                    </tr>
+                @endif
+                @foreach($deletions as $deletion)
+                <tr>
+                    <td>
+                        <div class="table-entity-item">
+                            <span role="presentation" class="icon text-{{$deletion->deletable->getType()}}">@icon($deletion->deletable->getType())</span>
+                            <div class="text-{{ $deletion->deletable->getType() }}">
+                                {{ $deletion->deletable->name }}
+                            </div>
+                        </div>
+                        @if($deletion->deletable instanceof \BookStack\Entities\Models\Book || $deletion->deletable instanceof \BookStack\Entities\Models\Chapter)
+                            <div class="mb-m"></div>
+                        @endif
+                        @if($deletion->deletable instanceof \BookStack\Entities\Models\Book)
+                            <div class="pl-xl block inline">
+                                <div class="text-chapter">
+                                    @icon('chapter') {{ trans_choice('entities.x_chapters', $deletion->deletable->chapters()->withTrashed()->count()) }}
+                                </div>
+                            </div>
+                        @endif
+                        @if($deletion->deletable instanceof \BookStack\Entities\Models\Book || $deletion->deletable instanceof \BookStack\Entities\Models\Chapter)
+                        <div class="pl-xl block inline">
+                            <div class="text-page">
+                                @icon('page') {{ trans_choice('entities.x_pages', $deletion->deletable->pages()->withTrashed()->count()) }}
+                            </div>
+                        </div>
+                        @endif
+                    </td>
+                    <td>@include('partials.table-user', ['user' => $deletion->deleter, 'user_id' => $deletion->deleted_by])</td>
+                    <td width="200">{{ $deletion->created_at }}</td>
+                    <td width="150" class="text-right">
+                        <div component="dropdown" class="dropdown-container">
+                            <button type="button" refs="dropdown@toggle" class="button outline">{{ trans('common.actions') }}</button>
+                            <ul refs="dropdown@menu" class="dropdown-menu">
+                                <li><a class="block" href="{{ url('/settings/recycle-bin/'.$deletion->id.'/restore') }}">{{ trans('settings.recycle_bin_restore') }}</a></li>
+                                <li><a class="block" href="{{ url('/settings/recycle-bin/'.$deletion->id.'/destroy') }}">{{ trans('settings.recycle_bin_permanently_delete') }}</a></li>
+                            </ul>
+                        </div>
+                    </td>
+                </tr>
+                @endforeach
+            </table>
+
+            {!! $deletions->links() !!}
+
+        </div>
+
+    </div>
+@stop
diff --git a/resources/views/settings/recycle-bin/restore.blade.php b/resources/views/settings/recycle-bin/restore.blade.php
new file mode 100644 (file)
index 0000000..62a31e5
--- /dev/null
@@ -0,0 +1,33 @@
+@extends('simple-layout')
+
+@section('body')
+    <div class="container small">
+
+        <div class="grid left-focus v-center no-row-gap">
+            <div class="py-m">
+                @include('settings.navbar', ['selected' => 'maintenance'])
+            </div>
+        </div>
+
+        <div class="card content-wrap auto-height">
+            <h2 class="list-heading">{{ trans('settings.recycle_bin_restore') }}</h2>
+            <p class="text-muted">{{ trans('settings.recycle_bin_restore_confirm') }}</p>
+            <form action="{{ url('/settings/recycle-bin/' . $deletion->id . '/restore') }}" method="post">
+                {!! csrf_field() !!}
+                <a href="{{ url('/settings/recycle-bin') }}" class="button outline">{{ trans('common.cancel') }}</a>
+                <button type="submit" class="button">{{ trans('settings.recycle_bin_restore') }}</button>
+            </form>
+
+            @if($deletion->deletable instanceof \BookStack\Entities\Models\Entity)
+                <hr class="mt-m">
+                <h5>{{ trans('settings.recycle_bin_restore_list') }}</h5>
+                @if($deletion->deletable->getParent() && $deletion->deletable->getParent()->trashed())
+                    <p class="text-neg">{{ trans('settings.recycle_bin_restore_deleted_parent') }}</p>
+                @endif
+                @include('settings.recycle-bin.deletable-entity-list', ['entity' => $deletion->deletable])
+            @endif
+
+        </div>
+
+    </div>
+@stop
index da373c1618b563fddcc9768644b7d1ac608e29cf..4b5bad0fd5bb3db369d47dfe2250ed00f5e40c58 100644 (file)
@@ -27,7 +27,6 @@
                 </div>
             </div>
 
-            {{--TODO - Add last login--}}
             <table class="table">
                 <tr>
                     <th></th>
@@ -37,6 +36,9 @@
                         <a href="{{ sortUrl('/settings/users', $listDetails, ['sort' => 'email']) }}">{{ trans('auth.email') }}</a>
                     </th>
                     <th>{{ trans('settings.role_user_roles') }}</th>
+                    <th class="text-right">
+                        <a href="{{ sortUrl('/settings/users', $listDetails, ['sort' => 'latest_activity']) }}">{{ trans('settings.users_latest_activity') }}</a>
+                    </th>
                 </tr>
                 @foreach($users as $user)
                     <tr>
                                 <small><a href="{{ url("/settings/roles/{$role->id}") }}">{{$role->display_name}}</a>@if($index !== count($user->roles) -1),@endif</small>
                             @endforeach
                         </td>
+                        <td class="text-right text-muted">
+                            @if($user->latestActivity)
+                                <small title="{{ $user->latestActivity->created_at->format('Y-m-d H:i:s') }}">{{ $user->latestActivity->created_at->diffForHumans() }}</small>
+                            @endif
+                        </td>
                     </tr>
                 @endforeach
             </table>
index 1b90d9b8fd12d591cf87b6e34fba2191b6711ff9..44643d6d4cc7182a436a7f5ed0d6605d83df637f 100644 (file)
@@ -29,6 +29,16 @@ Route::get('chapters/{id}/export/html', 'ChapterExportApiController@exportHtml')
 Route::get('chapters/{id}/export/pdf', 'ChapterExportApiController@exportPdf');
 Route::get('chapters/{id}/export/plaintext', 'ChapterExportApiController@exportPlainText');
 
+Route::get('pages', 'PageApiController@list');
+Route::post('pages', 'PageApiController@create');
+Route::get('pages/{id}', 'PageApiController@read');
+Route::put('pages/{id}', 'PageApiController@update');
+Route::delete('pages/{id}', 'PageApiController@delete');
+
+Route::get('pages/{id}/export/html', 'PageExportApiController@exportHtml');
+Route::get('pages/{id}/export/pdf', 'PageExportApiController@exportPdf');
+Route::get('pages/{id}/export/plaintext', 'PageExportApiController@exportPlainText');
+
 Route::get('shelves', 'BookshelfApiController@list');
 Route::post('shelves', 'BookshelfApiController@create');
 Route::get('shelves/{id}', 'BookshelfApiController@read');
index acbcb4e8fd4eb8f8e78002adc51584ec79306f02..afefcb99e90e2aa2c06d7ee4fdea1dc8b3ed08d9 100644 (file)
@@ -166,6 +166,14 @@ Route::group(['middleware' => 'auth'], function () {
         Route::delete('/maintenance/cleanup-images', 'MaintenanceController@cleanupImages');
         Route::post('/maintenance/send-test-email', 'MaintenanceController@sendTestEmail');
 
+        // Recycle Bin
+        Route::get('/recycle-bin', 'RecycleBinController@index');
+        Route::post('/recycle-bin/empty', 'RecycleBinController@empty');
+        Route::get('/recycle-bin/{id}/destroy', 'RecycleBinController@showDestroy');
+        Route::delete('/recycle-bin/{id}', 'RecycleBinController@destroy');
+        Route::get('/recycle-bin/{id}/restore', 'RecycleBinController@showRestore');
+        Route::post('/recycle-bin/{id}/restore', 'RecycleBinController@restore');
+
         // Audit Log
         Route::get('/audit', 'AuditLogController@index');
 
@@ -193,13 +201,13 @@ Route::group(['middleware' => 'auth'], function () {
         Route::delete('/users/{userId}/api-tokens/{tokenId}', 'UserApiTokenController@destroy');
 
         // Roles
-        Route::get('/roles', 'PermissionController@listRoles');
-        Route::get('/roles/new', 'PermissionController@createRole');
-        Route::post('/roles/new', 'PermissionController@storeRole');
-        Route::get('/roles/delete/{id}', 'PermissionController@showDeleteRole');
-        Route::delete('/roles/delete/{id}', 'PermissionController@deleteRole');
-        Route::get('/roles/{id}', 'PermissionController@editRole');
-        Route::put('/roles/{id}', 'PermissionController@updateRole');
+        Route::get('/roles', 'RoleController@list');
+        Route::get('/roles/new', 'RoleController@create');
+        Route::post('/roles/new', 'RoleController@store');
+        Route::get('/roles/delete/{id}', 'RoleController@showDelete');
+        Route::delete('/roles/delete/{id}', 'RoleController@delete');
+        Route::get('/roles/{id}', 'RoleController@edit');
+        Route::put('/roles/{id}', 'RoleController@update');
     });
 
 });
index f47bc44a30d7b01d419178dc0e9f83cc414887b6..9c3fe273c1cbf034ab8a7246f943cbabbe0d1e2c 100644 (file)
@@ -1,7 +1,7 @@
 <?php namespace Tests;
 
 
-use BookStack\Entities\Book;
+use BookStack\Entities\Models\Book;
 
 class ActivityTrackingTest extends BrowserKitTest
 {
index bb4920cc3667d35d5c52ec3df904515a817cd602..c3d9bc10888d51e91bc1a584cadabf6ff27b3642 100644 (file)
@@ -1,6 +1,6 @@
 <?php namespace Tests\Api;
 
-use BookStack\Entities\Book;
+use BookStack\Entities\Models\Book;
 use Tests\TestCase;
 
 class ApiListingTest extends TestCase
index 3fd763ec625969872d9fa5a5f27f27e6002ad80e..de4db1469acade17af9d9f1c1f9481bf4be81ff3 100644 (file)
@@ -1,6 +1,6 @@
 <?php namespace Tests\Api;
 
-use BookStack\Entities\Book;
+use BookStack\Entities\Models\Book;
 use Tests\TestCase;
 
 class BooksApiTest extends TestCase
index 15a44459ee3b4750a9e59e615224574fc7866299..422631c3af9e9bb674da98333d74a94a0c63bca2 100644 (file)
@@ -1,7 +1,7 @@
 <?php namespace Tests\Api;
 
-use BookStack\Entities\Book;
-use BookStack\Entities\Chapter;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
 use Tests\TestCase;
 
 class ChaptersApiTest extends TestCase
diff --git a/tests/Api/PagesApiTest.php b/tests/Api/PagesApiTest.php
new file mode 100644 (file)
index 0000000..44fbf5e
--- /dev/null
@@ -0,0 +1,258 @@
+<?php namespace Tests\Api;
+
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
+use Tests\TestCase;
+
+class PagesApiTest extends TestCase
+{
+    use TestsApi;
+
+    protected $baseEndpoint = '/api/pages';
+
+    public function test_index_endpoint_returns_expected_page()
+    {
+        $this->actingAsApiEditor();
+        $firstPage = Page::query()->orderBy('id', 'asc')->first();
+
+        $resp = $this->getJson($this->baseEndpoint . '?count=1&sort=+id');
+        $resp->assertJson(['data' => [
+            [
+                'id' => $firstPage->id,
+                'name' => $firstPage->name,
+                'slug' => $firstPage->slug,
+                'book_id' => $firstPage->book->id,
+                'priority' => $firstPage->priority,
+            ]
+        ]]);
+    }
+
+    public function test_create_endpoint()
+    {
+        $this->actingAsApiEditor();
+        $book = Book::query()->first();
+        $details = [
+            'name' => 'My API page',
+            'book_id' => $book->id,
+            'html' => '<p>My new page content</p>',
+            'tags' => [
+                [
+                    'name' => 'tagname',
+                    'value' => 'tagvalue',
+                ]
+            ]
+        ];
+
+        $resp = $this->postJson($this->baseEndpoint, $details);
+        unset($details['html']);
+        $resp->assertStatus(200);
+        $newItem = Page::query()->orderByDesc('id')->where('name', '=', $details['name'])->first();
+        $resp->assertJson(array_merge($details, ['id' => $newItem->id, 'slug' => $newItem->slug]));
+        $this->assertDatabaseHas('tags', [
+            'entity_id' => $newItem->id,
+            'entity_type' => $newItem->getMorphClass(),
+            'name' => 'tagname',
+            'value' => 'tagvalue',
+        ]);
+        $resp->assertSeeText('My new page content');
+        $resp->assertJsonMissing(['book' => []]);
+        $this->assertActivityExists('page_create', $newItem);
+    }
+
+    public function test_page_name_needed_to_create()
+    {
+        $this->actingAsApiEditor();
+        $book = Book::query()->first();
+        $details = [
+            'book_id' => $book->id,
+            'html' => '<p>A page created via the API</p>',
+        ];
+
+        $resp = $this->postJson($this->baseEndpoint, $details);
+        $resp->assertStatus(422);
+        $resp->assertJson($this->validationResponse([
+            "name" => ["The name field is required."]
+        ]));
+    }
+
+    public function test_book_id_or_chapter_id_needed_to_create()
+    {
+        $this->actingAsApiEditor();
+        $details = [
+            'name' => 'My api page',
+            'html' => '<p>A page created via the API</p>',
+        ];
+
+        $resp = $this->postJson($this->baseEndpoint, $details);
+        $resp->assertStatus(422);
+        $resp->assertJson($this->validationResponse([
+            "book_id" => ["The book id field is required when chapter id is not present."],
+            "chapter_id" => ["The chapter id field is required when book id is not present."]
+        ]));
+
+        $chapter = Chapter::visible()->first();
+        $resp = $this->postJson($this->baseEndpoint, array_merge($details, ['chapter_id' => $chapter->id]));
+        $resp->assertStatus(200);
+
+        $book = Book::visible()->first();
+        $resp = $this->postJson($this->baseEndpoint, array_merge($details, ['book_id' => $book->id]));
+        $resp->assertStatus(200);
+    }
+
+    public function test_markdown_can_be_provided_for_create()
+    {
+        $this->actingAsApiEditor();
+        $book = Book::visible()->first();
+        $details = [
+            'book_id' => $book->id,
+            'name' => 'My api page',
+            'markdown' => "# A new API page \n[link](https://example.com)",
+        ];
+
+        $resp = $this->postJson($this->baseEndpoint, $details);
+        $resp->assertJson(['markdown' => $details['markdown']]);
+
+        $respHtml = $resp->json('html');
+        $this->assertStringContainsString('new API page</h1>', $respHtml);
+        $this->assertStringContainsString('link</a>', $respHtml);
+        $this->assertStringContainsString('href="https://example.com"', $respHtml);
+    }
+
+    public function test_read_endpoint()
+    {
+        $this->actingAsApiEditor();
+        $page = Page::visible()->first();
+
+        $resp = $this->getJson($this->baseEndpoint . "/{$page->id}");
+        $resp->assertStatus(200);
+        $resp->assertJson([
+            'id' => $page->id,
+            'slug' => $page->slug,
+            'created_by' => [
+                'name' => $page->createdBy->name,
+            ],
+            'book_id' => $page->book_id,
+            'updated_by' => [
+                'name' => $page->createdBy->name,
+            ],
+        ]);
+    }
+
+    public function test_read_endpoint_provides_rendered_html()
+    {
+        $this->actingAsApiEditor();
+        $page = Page::visible()->first();
+        $page->html = "<p>testing</p><script>alert('danger')</script><h1>Hello</h1>";
+        $page->save();
+
+        $resp = $this->getJson($this->baseEndpoint . "/{$page->id}");
+        $html = $resp->json('html');
+        $this->assertStringNotContainsString('script', $html);
+        $this->assertStringContainsString('Hello', $html);
+        $this->assertStringContainsString('testing', $html);
+    }
+
+    public function test_update_endpoint()
+    {
+        $this->actingAsApiEditor();
+        $page = Page::visible()->first();
+        $details = [
+            'name' => 'My updated API page',
+            'html' => '<p>A page created via the API</p>',
+            'tags' => [
+                [
+                    'name' => 'freshtag',
+                    'value' => 'freshtagval',
+                ]
+            ],
+        ];
+
+        $resp = $this->putJson($this->baseEndpoint . "/{$page->id}", $details);
+        $page->refresh();
+
+        $resp->assertStatus(200);
+        unset($details['html']);
+        $resp->assertJson(array_merge($details, [
+            'id' => $page->id, 'slug' => $page->slug, 'book_id' => $page->book_id
+        ]));
+        $this->assertActivityExists('page_update', $page);
+    }
+
+    public function test_providing_new_chapter_id_on_update_will_move_page()
+    {
+        $this->actingAsApiEditor();
+        $page = Page::visible()->first();
+        $chapter = Chapter::visible()->where('book_id', '!=', $page->book_id)->first();
+        $details = [
+            'name' => 'My updated API page',
+            'chapter_id' => $chapter->id,
+            'html' => '<p>A page created via the API</p>',
+        ];
+
+        $resp = $this->putJson($this->baseEndpoint . "/{$page->id}", $details);
+        $resp->assertStatus(200);
+        $resp->assertJson([
+            'chapter_id' => $chapter->id,
+            'book_id' => $chapter->book_id,
+        ]);
+    }
+
+    public function test_providing_move_via_update_requires_page_create_permission_on_new_parent()
+    {
+        $this->actingAsApiEditor();
+        $page = Page::visible()->first();
+        $chapter = Chapter::visible()->where('book_id', '!=', $page->book_id)->first();
+        $this->setEntityRestrictions($chapter, ['view'], [$this->getEditor()->roles()->first()]);
+        $details = [
+            'name' => 'My updated API page',
+            'chapter_id' => $chapter->id,
+            'html' => '<p>A page created via the API</p>',
+        ];
+
+        $resp = $this->putJson($this->baseEndpoint . "/{$page->id}", $details);
+        $resp->assertStatus(403);
+    }
+
+    public function test_delete_endpoint()
+    {
+        $this->actingAsApiEditor();
+        $page = Page::visible()->first();
+        $resp = $this->deleteJson($this->baseEndpoint . "/{$page->id}");
+
+        $resp->assertStatus(204);
+        $this->assertActivityExists('page_delete', $page);
+    }
+
+    public function test_export_html_endpoint()
+    {
+        $this->actingAsApiEditor();
+        $page = Page::visible()->first();
+
+        $resp = $this->get($this->baseEndpoint . "/{$page->id}/export/html");
+        $resp->assertStatus(200);
+        $resp->assertSee($page->name);
+        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.html"');
+    }
+
+    public function test_export_plain_text_endpoint()
+    {
+        $this->actingAsApiEditor();
+        $page = Page::visible()->first();
+
+        $resp = $this->get($this->baseEndpoint . "/{$page->id}/export/plaintext");
+        $resp->assertStatus(200);
+        $resp->assertSee($page->name);
+        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.txt"');
+    }
+
+    public function test_export_pdf_endpoint()
+    {
+        $this->actingAsApiEditor();
+        $page = Page::visible()->first();
+
+        $resp = $this->get($this->baseEndpoint . "/{$page->id}/export/pdf");
+        $resp->assertStatus(200);
+        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.pdf"');
+    }
+}
\ No newline at end of file
index 13e44d97de7d002317d2c598e107e584838b3088..4c5600d159935e17f313f6dd2fb75080378b62cc 100644 (file)
@@ -1,7 +1,7 @@
 <?php namespace Tests\Api;
 
-use BookStack\Entities\Book;
-use BookStack\Entities\Bookshelf;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Bookshelf;
 use Tests\TestCase;
 
 class ShelvesApiTest extends TestCase
index a2cdc33ffda04aa1895ce0566e123167b984b9ce..3dc6fd7c2ecfd46b19cde94ba81f04da4a8f5d9a 100644 (file)
@@ -2,13 +2,23 @@
 
 use BookStack\Actions\Activity;
 use BookStack\Actions\ActivityService;
+use BookStack\Actions\ActivityType;
 use BookStack\Auth\UserRepo;
-use BookStack\Entities\Page;
+use BookStack\Entities\Tools\TrashCan;
+use BookStack\Entities\Models\Page;
 use BookStack\Entities\Repos\PageRepo;
 use Carbon\Carbon;
 
 class AuditLogTest extends TestCase
 {
+    /** @var ActivityService  */
+    protected $activityService;
+
+    public function setUp(): void
+    {
+        parent::setUp();
+        $this->activityService = app(ActivityService::class);
+    }
 
     public function test_only_accessible_with_right_permissions()
     {
@@ -33,14 +43,14 @@ class AuditLogTest extends TestCase
         $admin = $this->getAdmin();
         $this->actingAs($admin);
         $page = Page::query()->first();
-        app(ActivityService::class)->add($page, 'page_create', $page->book->id);
+        $this->activityService->addForEntity($page, ActivityType::PAGE_CREATE);
         $activity = Activity::query()->orderBy('id', 'desc')->first();
 
         $resp = $this->get('settings/audit');
         $resp->assertSeeText($page->name);
         $resp->assertSeeText('page_create');
         $resp->assertSeeText($activity->created_at->toDateTimeString());
-        $resp->assertElementContains('.audit-log-user', $admin->name);
+        $resp->assertElementContains('.table-user-item', $admin->name);
     }
 
     public function test_shows_name_for_deleted_items()
@@ -48,9 +58,10 @@ class AuditLogTest extends TestCase
         $this->actingAs( $this->getAdmin());
         $page = Page::query()->first();
         $pageName = $page->name;
-        app(ActivityService::class)->add($page, 'page_create', $page->book->id);
+        $this->activityService->addForEntity($page, ActivityType::PAGE_CREATE);
 
         app(PageRepo::class)->destroy($page);
+        app(TrashCan::class)->empty();
 
         $resp = $this->get('settings/audit');
         $resp->assertSeeText('Deleted Item');
@@ -62,7 +73,7 @@ class AuditLogTest extends TestCase
         $viewer = $this->getViewer();
         $this->actingAs($viewer);
         $page = Page::query()->first();
-        app(ActivityService::class)->add($page, 'page_create', $page->book->id);
+        $this->activityService->addForEntity($page, ActivityType::PAGE_CREATE);
 
         $this->actingAs($this->getAdmin());
         app(UserRepo::class)->destroy($viewer);
@@ -75,7 +86,7 @@ class AuditLogTest extends TestCase
     {
         $this->actingAs($this->getAdmin());
         $page = Page::query()->first();
-        app(ActivityService::class)->add($page, 'page_create', $page->book->id);
+        $this->activityService->addForEntity($page, ActivityType::PAGE_CREATE);
 
         $resp = $this->get('settings/audit');
         $resp->assertSeeText($page->name);
@@ -88,7 +99,7 @@ class AuditLogTest extends TestCase
     {
         $this->actingAs($this->getAdmin());
         $page = Page::query()->first();
-        app(ActivityService::class)->add($page, 'page_create', $page->book->id);
+        $this->activityService->addForEntity($page, ActivityType::PAGE_CREATE);
 
         $yesterday = (Carbon::now()->subDay()->format('Y-m-d'));
         $tomorrow = (Carbon::now()->addDay()->format('Y-m-d'));
index e2b1e0cd66edcbae814bec9f055da290c8a0375d..a0de7f803505860647964c68a917c2d49833cf0d 100644 (file)
@@ -2,7 +2,7 @@
 
 use BookStack\Auth\Role;
 use BookStack\Auth\User;
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Page;
 use BookStack\Notifications\ConfirmEmail;
 use BookStack\Notifications\ResetPassword;
 use BookStack\Settings\SettingService;
index b81afe31106025352a3d5c50a23b2162b1ae6d5f..bb5aaa03139157b32593ffb3937a554bf1b5a48a 100644 (file)
@@ -1,6 +1,6 @@
 <?php namespace Tests;
 
-use BookStack\Entities\Entity;
+use BookStack\Entities\Models\Entity;
 use BookStack\Auth\Role;
 use BookStack\Auth\Permissions\PermissionService;
 use BookStack\Settings\SettingService;
@@ -71,9 +71,9 @@ abstract class BrowserKitTest extends TestCase
     protected function createEntityChainBelongingToUser($creatorUser, $updaterUser = false)
     {
         if ($updaterUser === false) $updaterUser = $creatorUser;
-        $book = factory(\BookStack\Entities\Book::class)->create(['created_by' => $creatorUser->id, 'updated_by' => $updaterUser->id]);
-        $chapter = factory(\BookStack\Entities\Chapter::class)->create(['created_by' => $creatorUser->id, 'updated_by' => $updaterUser->id, 'book_id' => $book->id]);
-        $page = factory(\BookStack\Entities\Page::class)->create(['created_by' => $creatorUser->id, 'updated_by' => $updaterUser->id, 'book_id' => $book->id, 'chapter_id' => $chapter->id]);
+        $book = factory(\BookStack\Entities\Models\Book::class)->create(['created_by' => $creatorUser->id, 'updated_by' => $updaterUser->id]);
+        $chapter = factory(\BookStack\Entities\Models\Chapter::class)->create(['created_by' => $creatorUser->id, 'updated_by' => $updaterUser->id, 'book_id' => $book->id]);
+        $page = factory(\BookStack\Entities\Models\Page::class)->create(['created_by' => $creatorUser->id, 'updated_by' => $updaterUser->id, 'book_id' => $book->id, 'chapter_id' => $chapter->id]);
         $restrictionService = $this->app[PermissionService::class];
         $restrictionService->buildJointPermissionsForEntity($book);
         return [
index bfc0ac0eb4bb1b8ab6ea9d0c1aae4b8cebcffd05..8c6ea84bf8524e2ec61d1b2284a560147ab760aa 100644 (file)
@@ -1,10 +1,11 @@
 <?php namespace Tests;
 
+use BookStack\Actions\ActivityType;
 use BookStack\Actions\Comment;
 use BookStack\Actions\CommentRepo;
 use BookStack\Auth\Permissions\JointPermission;
-use BookStack\Entities\Bookshelf;
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Bookshelf;
+use BookStack\Entities\Models\Page;
 use BookStack\Auth\User;
 use BookStack\Entities\Repos\PageRepo;
 use Symfony\Component\Console\Exception\RuntimeException;
@@ -37,10 +38,10 @@ class CommandsTest extends TestCase
     {
         $this->asEditor();
         $page = Page::first();
-        \Activity::add($page, 'page_update', $page->book->id);
+        \Activity::addForEntity($page, ActivityType::PAGE_UPDATE);
 
         $this->assertDatabaseHas('activities', [
-            'key' => 'page_update',
+            'type' => 'page_update',
             'entity_id' => $page->id,
             'user_id' => $this->getEditor()->id
         ]);
@@ -50,7 +51,7 @@ class CommandsTest extends TestCase
 
 
         $this->assertDatabaseMissing('activities', [
-            'key' => 'page_update'
+            'type' => 'page_update'
         ]);
     }
 
index cb3acfb1e8eb8724d3a927e0b4c8c1a5a11d9823..9b3290370c197a14bd1d558a9728e1c96cae89e6 100644 (file)
@@ -1,8 +1,8 @@
 <?php namespace Tests\Entity;
 
 use BookStack\Auth\User;
-use BookStack\Entities\Book;
-use BookStack\Entities\Bookshelf;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Bookshelf;
 use BookStack\Uploads\Image;
 use Illuminate\Support\Str;
 use Tests\TestCase;
@@ -222,16 +222,25 @@ class BookShelfTest extends TestCase
 
     public function test_shelf_delete()
     {
-        $shelf = Bookshelf::first();
-        $resp = $this->asEditor()->get($shelf->getUrl('/delete'));
-        $resp->assertSeeText('Delete Bookshelf');
-        $resp->assertSee("action=\"{$shelf->getUrl()}\"");
-
-        $resp = $this->delete($shelf->getUrl());
-        $resp->assertRedirect('/shelves');
-        $this->assertDatabaseMissing('bookshelves', ['id' => $shelf->id]);
-        $this->assertDatabaseMissing('bookshelves_books', ['bookshelf_id' => $shelf->id]);
-        $this->assertSessionHas('success');
+        $shelf = Bookshelf::query()->whereHas('books')->first();
+        $this->assertNull($shelf->deleted_at);
+        $bookCount = $shelf->books()->count();
+
+        $deleteViewReq = $this->asEditor()->get($shelf->getUrl('/delete'));
+        $deleteViewReq->assertSeeText('Are you sure you want to delete this bookshelf?');
+
+        $deleteReq = $this->delete($shelf->getUrl());
+        $deleteReq->assertRedirect(url('/shelves'));
+        $this->assertActivityExists('bookshelf_delete', $shelf);
+
+        $shelf->refresh();
+        $this->assertNotNull($shelf->deleted_at);
+
+        $this->assertTrue($shelf->books()->count() === $bookCount);
+        $this->assertTrue($shelf->deletions()->count() === 1);
+
+        $redirectReq = $this->get($deleteReq->baseResponse->headers->get('location'));
+        $redirectReq->assertNotificationContains('Bookshelf Successfully Deleted');
     }
 
     public function test_shelf_copy_permissions()
diff --git a/tests/Entity/BookTest.php b/tests/Entity/BookTest.php
new file mode 100644 (file)
index 0000000..6c2cf30
--- /dev/null
@@ -0,0 +1,34 @@
+<?php namespace Tests\Entity;
+
+use BookStack\Entities\Models\Book;
+use Tests\TestCase;
+
+class BookTest extends TestCase
+{
+    public function test_book_delete()
+    {
+        $book = Book::query()->whereHas('pages')->whereHas('chapters')->first();
+        $this->assertNull($book->deleted_at);
+        $pageCount = $book->pages()->count();
+        $chapterCount = $book->chapters()->count();
+
+        $deleteViewReq = $this->asEditor()->get($book->getUrl('/delete'));
+        $deleteViewReq->assertSeeText('Are you sure you want to delete this book?');
+
+        $deleteReq = $this->delete($book->getUrl());
+        $deleteReq->assertRedirect(url('/books'));
+        $this->assertActivityExists('book_delete', $book);
+
+        $book->refresh();
+        $this->assertNotNull($book->deleted_at);
+
+        $this->assertTrue($book->pages()->count() === 0);
+        $this->assertTrue($book->chapters()->count() === 0);
+        $this->assertTrue($book->pages()->withTrashed()->count() === $pageCount);
+        $this->assertTrue($book->chapters()->withTrashed()->count() === $chapterCount);
+        $this->assertTrue($book->deletions()->count() === 1);
+
+        $redirectReq = $this->get($deleteReq->baseResponse->headers->get('location'));
+        $redirectReq->assertNotificationContains('Book Successfully Deleted');
+    }
+}
\ No newline at end of file
diff --git a/tests/Entity/ChapterTest.php b/tests/Entity/ChapterTest.php
new file mode 100644 (file)
index 0000000..e9350a3
--- /dev/null
@@ -0,0 +1,31 @@
+<?php namespace Tests\Entity;
+
+use BookStack\Entities\Models\Chapter;
+use Tests\TestCase;
+
+class ChapterTest extends TestCase
+{
+    public function test_chapter_delete()
+    {
+        $chapter = Chapter::query()->whereHas('pages')->first();
+        $this->assertNull($chapter->deleted_at);
+        $pageCount = $chapter->pages()->count();
+
+        $deleteViewReq = $this->asEditor()->get($chapter->getUrl('/delete'));
+        $deleteViewReq->assertSeeText('Are you sure you want to delete this chapter?');
+
+        $deleteReq = $this->delete($chapter->getUrl());
+        $deleteReq->assertRedirect($chapter->getParent()->getUrl());
+        $this->assertActivityExists('chapter_delete', $chapter);
+
+        $chapter->refresh();
+        $this->assertNotNull($chapter->deleted_at);
+
+        $this->assertTrue($chapter->pages()->count() === 0);
+        $this->assertTrue($chapter->pages()->withTrashed()->count() === $pageCount);
+        $this->assertTrue($chapter->deletions()->count() === 1);
+
+        $redirectReq = $this->get($deleteReq->baseResponse->headers->get('location'));
+        $redirectReq->assertNotificationContains('Chapter Successfully Deleted');
+    }
+}
\ No newline at end of file
index 3c8cae68ccefefd1e28f0aca63884c28dfdf0b33..49ceede9f3edd23207b5f62906c14fe677fcd043 100644 (file)
@@ -1,6 +1,6 @@
 <?php namespace Tests\Entity;
 
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Page;
 use Tests\BrowserKitTest;
 
 class CommentSettingTest extends BrowserKitTest
index 2198b2dd2c72decb348bd6421d8f664b52c3e793..63d1a29a29ac656bf5d53ec38a00fad8102af35c 100644 (file)
@@ -1,6 +1,6 @@
 <?php namespace Tests\Entity;
 
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Page;
 use BookStack\Actions\Comment;
 use Tests\TestCase;
 
index 956e46c3713d4785cd7b8dcd11589cc6988dcfb0..2b5dc6d749cdbf40e24ae9f93c5f5d446c0fc051 100644 (file)
@@ -1,10 +1,10 @@
 <?php namespace Tests\Entity;
 
 use BookStack\Actions\Tag;
-use BookStack\Entities\Book;
-use BookStack\Entities\Bookshelf;
-use BookStack\Entities\Chapter;
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Bookshelf;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
 use Tests\TestCase;
 
 class EntitySearchTest extends TestCase
index de1e025ade6a1e832cad59834f0c0774abf8ad1e..3a363e2b87bfeaaa7424946f49acff2556554c7d 100644 (file)
@@ -1,13 +1,12 @@
 <?php namespace Tests\Entity;
 
-use BookStack\Entities\Bookshelf;
-use BookStack\Entities\Book;
-use BookStack\Entities\Chapter;
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Bookshelf;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
 use BookStack\Auth\UserRepo;
 use BookStack\Entities\Repos\PageRepo;
 use Carbon\Carbon;
-use Illuminate\Support\Facades\DB;
 use Tests\BrowserKitTest;
 
 class EntityTest extends BrowserKitTest
@@ -18,27 +17,10 @@ class EntityTest extends BrowserKitTest
         // Test Creation
         $book = $this->bookCreation();
         $chapter = $this->chapterCreation($book);
-        $page = $this->pageCreation($chapter);
+        $this->pageCreation($chapter);
 
         // Test Updating
-        $book = $this->bookUpdate($book);
-
-        // Test Deletion
-        $this->bookDelete($book);
-    }
-
-    public function bookDelete(Book $book)
-    {
-        $this->asAdmin()
-            ->visit($book->getUrl())
-            // Check link works correctly
-            ->click('Delete')
-            ->seePageIs($book->getUrl() . '/delete')
-            // Ensure the book name is show to user
-            ->see($book->name)
-            ->press('Confirm')
-            ->seePageIs('/books')
-            ->notSeeInDatabase('books', ['id' => $book->id]);
+        $this->bookUpdate($book);
     }
 
     public function bookUpdate(Book $book)
@@ -332,34 +314,4 @@ class EntityTest extends BrowserKitTest
             ->seePageIs($chapter->getUrl());
     }
 
-    public function test_page_delete_removes_entity_from_its_activity()
-    {
-        $page = Page::query()->first();
-
-        $this->asEditor()->put($page->getUrl(), [
-            'name' => 'My updated page',
-            'html' => '<p>updated content</p>',
-        ]);
-        $page->refresh();
-
-        $this->seeInDatabase('activities', [
-            'entity_id' => $page->id,
-            'entity_type' => $page->getMorphClass(),
-        ]);
-
-        $resp = $this->delete($page->getUrl());
-        $resp->assertResponseStatus(302);
-
-        $this->dontSeeInDatabase('activities', [
-            'entity_id' => $page->id,
-            'entity_type' => $page->getMorphClass(),
-        ]);
-
-        $this->seeInDatabase('activities', [
-            'extra' => 'My updated page',
-            'entity_id' => 0,
-            'entity_type' => '',
-        ]);
-    }
-
 }
index b1e6eb5fb712f673ab29d362218d64c4ce2d9ba0..1e44f015a5a0b69f8520c9227b971e79f17c0b63 100644 (file)
@@ -1,7 +1,8 @@
 <?php namespace Tests\Entity;
 
-use BookStack\Entities\Chapter;
-use BookStack\Entities\Page;
+
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
 use Illuminate\Support\Facades\Storage;
 use Illuminate\Support\Str;
 use Tests\TestCase;
@@ -166,12 +167,28 @@ class ExportTest extends TestCase
         $resp->assertSee('<img src="data:image/svg+xml;base64');
     }
 
+    public function test_page_image_containment_works_on_multiple_images_within_a_single_line()
+    {
+        $page = Page::first();
+        Storage::disk('local')->makeDirectory('uploads/images/gallery');
+        Storage::disk('local')->put('uploads/images/gallery/svg_test.svg', '<svg></svg>');
+        Storage::disk('local')->put('uploads/images/gallery/svg_test2.svg', '<svg></svg>');
+        $page->html = '<img src="http://localhost/uploads/images/gallery/svg_test.svg" class="a"><img src="http://localhost/uploads/images/gallery/svg_test2.svg" class="b">';
+        $page->save();
+
+        $resp = $this->asEditor()->get($page->getUrl('/export/html'));
+        Storage::disk('local')->delete('uploads/images/gallery/svg_test.svg');
+        Storage::disk('local')->delete('uploads/images/gallery/svg_test2.svg');
+
+        $resp->assertDontSee('http://localhost/uploads/images/gallery/svg_test');
+    }
+
     public function test_page_export_contained_html_image_fetches_only_run_when_url_points_to_image_upload_folder()
     {
         $page = Page::first();
         $page->html = '<img src="http://localhost/uploads/images/gallery/svg_test.svg"/>'
-            ."\n".'<img src="http://localhost/uploads/svg_test.svg"/>'
-            ."\n".'<img src="/uploads/svg_test.svg"/>';
+            .'<img src="http://localhost/uploads/svg_test.svg"/>'
+            .'<img src="/uploads/svg_test.svg"/>';
         $storageDisk = Storage::disk('local');
         $storageDisk->makeDirectory('uploads/images/gallery');
         $storageDisk->put('uploads/images/gallery/svg_test.svg', '<svg>good</svg>');
@@ -188,4 +205,4 @@ class ExportTest extends TestCase
         $resp->assertSee('src="/uploads/svg_test.svg"');
     }
 
-}
\ No newline at end of file
+}
index 452b4c07f379ed1a1ed4e7e1c747192ca541700c..5e5fa8a0c2f7b52427784e295bef3b935e250294 100644 (file)
@@ -9,7 +9,7 @@ class MarkdownTest extends BrowserKitTest
     public function setUp(): void
     {
         parent::setUp();
-        $this->page = \BookStack\Entities\Page::first();
+        $this->page = \BookStack\Entities\Models\Page::first();
     }
 
     protected function setMarkdownEditor()
index e97df2c7edd80725bb9e830f9cebf8146def5c69..51a8568bfa9a7e8a32e45b844da214b7f4592aa1 100644 (file)
@@ -1,7 +1,7 @@
 <?php namespace Tests\Entity;
 
-use BookStack\Entities\Managers\PageContent;
-use BookStack\Entities\Page;
+use BookStack\Entities\Tools\PageContent;
+use BookStack\Entities\Models\Page;
 use Tests\TestCase;
 
 class PageContentTest extends TestCase
index a0cf9e5fca9267aa6a8866f00f9e940e87b27ece..0e3980c6702217cd10043748a9d8ad89dbb18bcc 100644 (file)
@@ -1,6 +1,6 @@
 <?php namespace Tests\Entity;
 
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Page;
 use BookStack\Entities\Repos\PageRepo;
 use Tests\BrowserKitTest;
 
@@ -16,7 +16,7 @@ class PageDraftTest extends BrowserKitTest
     public function setUp(): void
     {
         parent::setUp();
-        $this->page = \BookStack\Entities\Page::first();
+        $this->page = \BookStack\Entities\Models\Page::first();
         $this->pageRepo = app(PageRepo::class);
     }
 
@@ -56,7 +56,7 @@ class PageDraftTest extends BrowserKitTest
 
     public function test_alert_message_shows_if_someone_else_editing()
     {
-        $nonEditedPage = \BookStack\Entities\Page::take(10)->get()->last();
+        $nonEditedPage = \BookStack\Entities\Models\Page::take(10)->get()->last();
         $addedContent = '<p>test message content</p>';
         $this->asAdmin()->visit($this->page->getUrl('/edit'))
             ->dontSeeInField('html', $addedContent);
@@ -75,7 +75,7 @@ class PageDraftTest extends BrowserKitTest
 
     public function test_draft_pages_show_on_homepage()
     {
-        $book = \BookStack\Entities\Book::first();
+        $book = \BookStack\Entities\Models\Book::first();
         $this->asAdmin()->visit('/')
             ->dontSeeInElement('#recent-drafts', 'New Page')
             ->visit($book->getUrl() . '/create-page')
@@ -85,7 +85,7 @@ class PageDraftTest extends BrowserKitTest
 
     public function test_draft_pages_not_visible_by_others()
     {
-        $book = \BookStack\Entities\Book::first();
+        $book = \BookStack\Entities\Models\Book::first();
         $chapter = $book->chapters->first();
         $newUser = $this->getEditor();
 
index 1e9dbd626b78fd011184e3549cbf239578365b6a..6eaea129c0ace0a333393bc853b09dc2fb9934f2 100644 (file)
@@ -1,6 +1,6 @@
 <?php namespace Tests\Entity;
 
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Page;
 use BookStack\Entities\Repos\PageRepo;
 use Tests\TestCase;
 
index 8eba1355792f593be11a7431c6f6866eaa3732cc..a5594e8b8df06ececf95ae1df6d8256590738e91 100644 (file)
@@ -1,6 +1,6 @@
 <?php namespace Tests\Entity;
 
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Page;
 use Tests\TestCase;
 
 class PageTemplateTest extends TestCase
diff --git a/tests/Entity/PageTest.php b/tests/Entity/PageTest.php
new file mode 100644 (file)
index 0000000..887dfe8
--- /dev/null
@@ -0,0 +1,27 @@
+<?php namespace Tests\Entity;
+
+use BookStack\Entities\Models\Page;
+use Tests\TestCase;
+
+class PageTest extends TestCase
+{
+    public function test_page_delete()
+    {
+        $page = Page::query()->first();
+        $this->assertNull($page->deleted_at);
+
+        $deleteViewReq = $this->asEditor()->get($page->getUrl('/delete'));
+        $deleteViewReq->assertSeeText('Are you sure you want to delete this page?');
+
+        $deleteReq = $this->delete($page->getUrl());
+        $deleteReq->assertRedirect($page->getParent()->getUrl());
+        $this->assertActivityExists('page_delete', $page);
+
+        $page->refresh();
+        $this->assertNotNull($page->deleted_at);
+        $this->assertTrue($page->deletions()->count() === 1);
+
+        $redirectReq = $this->get($deleteReq->baseResponse->headers->get('location'));
+        $redirectReq->assertNotificationContains('Page Successfully Deleted');
+    }
+}
\ No newline at end of file
index 727db553367fe647462a9c880a4c9bbb16998dc8..c9e116523ed0fdf943622fc4c6a77a7d07ceb689 100644 (file)
@@ -1,6 +1,6 @@
 <?php namespace Tests\Entity;
 
-use BookStack\Entities\SearchOptions;
+use BookStack\Entities\Tools\SearchOptions;
 use Tests\TestCase;
 
 class SearchOptionsTest extends TestCase
index 28c3adf312682fa84783a78634eb946681c98e70..bb67bfc3ec81b1dc85d913b93b1a2847750f52db 100644 (file)
@@ -1,8 +1,8 @@
 <?php namespace Tests\Entity;
 
-use BookStack\Entities\Book;
-use BookStack\Entities\Chapter;
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
 use BookStack\Entities\Repos\PageRepo;
 use Tests\TestCase;
 
@@ -79,7 +79,7 @@ class SortTest extends TestCase
         $movePageResp = $this->actingAs($this->getEditor())->put($page->getUrl('/move'), [
             'entity_selection' => 'book:' . $newBook->id
         ]);
-        $page = Page::find($page->id);
+        $page->refresh();
 
         $movePageResp->assertRedirect($page->getUrl());
         $this->assertTrue($page->book->id == $newBook->id, 'Page parent is now the new book');
index e8a99cf781b6bd972ee43708025da51d081c1c51..3ad10641ef3d0196ce15800d18d0ed149e3ed50a 100644 (file)
@@ -1,10 +1,10 @@
 <?php namespace Tests\Entity;
 
-use BookStack\Entities\Book;
-use BookStack\Entities\Chapter;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
 use BookStack\Actions\Tag;
-use BookStack\Entities\Entity;
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Models\Page;
 use BookStack\Auth\Permissions\PermissionService;
 use Tests\BrowserKitTest;
 
index 8f6867cdeb0c357e16f9b7ce7df7df3813984200..1558df78d1c200d59b29d559ceef38f687b81e4f 100644 (file)
@@ -1,6 +1,6 @@
 <?php namespace Tests;
 
-use BookStack\Entities\Book;
+use BookStack\Entities\Models\Book;
 use Illuminate\Support\Facades\Log;
 
 class ErrorTest extends TestCase
index ada1f5aafde22b4929d31166be3d156a7c98e053..3bc85b39e7e09fad373282118d1f330492b85235 100644 (file)
@@ -1,6 +1,6 @@
 <?php namespace Tests;
 
-use BookStack\Entities\Bookshelf;
+use BookStack\Entities\Models\Bookshelf;
 
 class HomepageTest extends TestCase
 {
index 2dcc0ea695e207887692525fcf3bd460fd8ef23c..c3c6aa44fa6e4631203ec1af7c3417a406365edb 100644 (file)
@@ -1,11 +1,11 @@
 <?php namespace Tests\Permissions;
 
-use BookStack\Entities\Book;
-use BookStack\Entities\Bookshelf;
-use BookStack\Entities\Chapter;
-use BookStack\Entities\Entity;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Bookshelf;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Entity;
 use BookStack\Auth\User;
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Page;
 use Tests\BrowserKitTest;
 
 class RestrictionsTest extends BrowserKitTest
@@ -58,7 +58,7 @@ class RestrictionsTest extends BrowserKitTest
 
     public function test_bookshelf_update_restriction()
     {
-        $shelf = BookShelf::first();
+        $shelf = Bookshelf::first();
 
         $this->actingAs($this->user)
             ->visit($shelf->getUrl('/edit'))
index 73060c834383726a138fa8ce180980123070104a..9f32a9f494a45a49a1ed91dee889eea441e40ceb 100644 (file)
@@ -1,8 +1,13 @@
 <?php namespace Tests\Permissions;
 
-use BookStack\Entities\Bookshelf;
-use BookStack\Entities\Page;
+use BookStack\Actions\Comment;
+use BookStack\Auth\User;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Bookshelf;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
 use BookStack\Auth\Role;
+use BookStack\Uploads\Image;
 use Laravel\BrowserKitTesting\HttpException;
 use Tests\BrowserKitTest;
 
@@ -23,7 +28,7 @@ class RolesTest extends BrowserKitTest
 
     public function test_cannot_delete_admin_role()
     {
-        $adminRole = \BookStack\Auth\Role::getRole('admin');
+        $adminRole = Role::getRole('admin');
         $deletePageUrl = '/settings/roles/delete/' . $adminRole->id;
         $this->asAdmin()->visit($deletePageUrl)
             ->press('Confirm')
@@ -195,7 +200,7 @@ class RolesTest extends BrowserKitTest
 
     public function test_restrictions_manage_all_permission()
     {
-        $page = \BookStack\Entities\Page::take(1)->get()->first();
+        $page = Page::take(1)->get()->first();
         $this->actingAs($this->user)->visit($page->getUrl())
             ->dontSee('Permissions')
             ->visit($page->getUrl() . '/permissions')
@@ -209,7 +214,7 @@ class RolesTest extends BrowserKitTest
 
     public function test_restrictions_manage_own_permission()
     {
-        $otherUsersPage = \BookStack\Entities\Page::first();
+        $otherUsersPage = Page::first();
         $content = $this->createEntityChainBelongingToUser($this->user);
         // Check can't restrict other's content
         $this->actingAs($this->user)->visit($otherUsersPage->getUrl())
@@ -301,7 +306,7 @@ class RolesTest extends BrowserKitTest
 
     public function test_bookshelves_edit_all_permission()
     {
-        $otherShelf = \BookStack\Entities\Bookshelf::first();
+        $otherShelf = Bookshelf::first();
         $this->checkAccessPermission('bookshelf-update-all', [
             $otherShelf->getUrl('/edit')
         ], [
@@ -312,7 +317,7 @@ class RolesTest extends BrowserKitTest
     public function test_bookshelves_delete_own_permission()
     {
         $this->giveUserPermissions($this->user, ['bookshelf-update-all']);
-        $otherShelf = \BookStack\Entities\Bookshelf::first();
+        $otherShelf = Bookshelf::first();
         $ownShelf = $this->newShelf(['name' => 'test-shelf', 'slug' => 'test-shelf']);
         $ownShelf->forceFill(['created_by' => $this->user->id, 'updated_by' => $this->user->id])->save();
         $this->regenEntityPermissions($ownShelf);
@@ -336,7 +341,7 @@ class RolesTest extends BrowserKitTest
     public function test_bookshelves_delete_all_permission()
     {
         $this->giveUserPermissions($this->user, ['bookshelf-update-all']);
-        $otherShelf = \BookStack\Entities\Bookshelf::first();
+        $otherShelf = Bookshelf::first();
         $this->checkAccessPermission('bookshelf-delete-all', [
             $otherShelf->getUrl('/delete')
         ], [
@@ -366,7 +371,7 @@ class RolesTest extends BrowserKitTest
 
     public function test_books_edit_own_permission()
     {
-        $otherBook = \BookStack\Entities\Book::take(1)->get()->first();
+        $otherBook = Book::take(1)->get()->first();
         $ownBook = $this->createEntityChainBelongingToUser($this->user)['book'];
         $this->checkAccessPermission('book-update-own', [
             $ownBook->getUrl() . '/edit'
@@ -382,7 +387,7 @@ class RolesTest extends BrowserKitTest
 
     public function test_books_edit_all_permission()
     {
-        $otherBook = \BookStack\Entities\Book::take(1)->get()->first();
+        $otherBook = Book::take(1)->get()->first();
         $this->checkAccessPermission('book-update-all', [
             $otherBook->getUrl() . '/edit'
         ], [
@@ -393,7 +398,7 @@ class RolesTest extends BrowserKitTest
     public function test_books_delete_own_permission()
     {
         $this->giveUserPermissions($this->user, ['book-update-all']);
-        $otherBook = \BookStack\Entities\Book::take(1)->get()->first();
+        $otherBook = Book::take(1)->get()->first();
         $ownBook = $this->createEntityChainBelongingToUser($this->user)['book'];
         $this->checkAccessPermission('book-delete-own', [
             $ownBook->getUrl() . '/delete'
@@ -414,7 +419,7 @@ class RolesTest extends BrowserKitTest
     public function test_books_delete_all_permission()
     {
         $this->giveUserPermissions($this->user, ['book-update-all']);
-        $otherBook = \BookStack\Entities\Book::take(1)->get()->first();
+        $otherBook = Book::take(1)->get()->first();
         $this->checkAccessPermission('book-delete-all', [
             $otherBook->getUrl() . '/delete'
         ], [
@@ -429,7 +434,7 @@ class RolesTest extends BrowserKitTest
 
     public function test_chapter_create_own_permissions()
     {
-        $book = \BookStack\Entities\Book::take(1)->get()->first();
+        $book = Book::take(1)->get()->first();
         $ownBook = $this->createEntityChainBelongingToUser($this->user)['book'];
         $this->checkAccessPermission('chapter-create-own', [
             $ownBook->getUrl('/create-chapter')
@@ -451,7 +456,7 @@ class RolesTest extends BrowserKitTest
 
     public function test_chapter_create_all_permissions()
     {
-        $book = \BookStack\Entities\Book::take(1)->get()->first();
+        $book = Book::take(1)->get()->first();
         $this->checkAccessPermission('chapter-create-all', [
             $book->getUrl('/create-chapter')
         ], [
@@ -467,7 +472,7 @@ class RolesTest extends BrowserKitTest
 
     public function test_chapter_edit_own_permission()
     {
-        $otherChapter = \BookStack\Entities\Chapter::take(1)->get()->first();
+        $otherChapter = Chapter::take(1)->get()->first();
         $ownChapter = $this->createEntityChainBelongingToUser($this->user)['chapter'];
         $this->checkAccessPermission('chapter-update-own', [
             $ownChapter->getUrl() . '/edit'
@@ -483,7 +488,7 @@ class RolesTest extends BrowserKitTest
 
     public function test_chapter_edit_all_permission()
     {
-        $otherChapter = \BookStack\Entities\Chapter::take(1)->get()->first();
+        $otherChapter = Chapter::take(1)->get()->first();
         $this->checkAccessPermission('chapter-update-all', [
             $otherChapter->getUrl() . '/edit'
         ], [
@@ -494,7 +499,7 @@ class RolesTest extends BrowserKitTest
     public function test_chapter_delete_own_permission()
     {
         $this->giveUserPermissions($this->user, ['chapter-update-all']);
-        $otherChapter = \BookStack\Entities\Chapter::take(1)->get()->first();
+        $otherChapter = Chapter::take(1)->get()->first();
         $ownChapter = $this->createEntityChainBelongingToUser($this->user)['chapter'];
         $this->checkAccessPermission('chapter-delete-own', [
             $ownChapter->getUrl() . '/delete'
@@ -516,7 +521,7 @@ class RolesTest extends BrowserKitTest
     public function test_chapter_delete_all_permission()
     {
         $this->giveUserPermissions($this->user, ['chapter-update-all']);
-        $otherChapter = \BookStack\Entities\Chapter::take(1)->get()->first();
+        $otherChapter = Chapter::take(1)->get()->first();
         $this->checkAccessPermission('chapter-delete-all', [
             $otherChapter->getUrl() . '/delete'
         ], [
@@ -532,8 +537,8 @@ class RolesTest extends BrowserKitTest
 
     public function test_page_create_own_permissions()
     {
-        $book = \BookStack\Entities\Book::first();
-        $chapter = \BookStack\Entities\Chapter::first();
+        $book = Book::first();
+        $chapter = Chapter::first();
 
         $entities = $this->createEntityChainBelongingToUser($this->user);
         $ownBook = $entities['book'];
@@ -557,7 +562,7 @@ class RolesTest extends BrowserKitTest
 
         foreach ($accessUrls as $index => $url) {
             $this->actingAs($this->user)->visit($url);
-            $expectedUrl = \BookStack\Entities\Page::where('draft', '=', true)->orderBy('id', 'desc')->first()->getUrl();
+            $expectedUrl = Page::where('draft', '=', true)->orderBy('id', 'desc')->first()->getUrl();
             $this->seePageIs($expectedUrl);
         }
 
@@ -579,8 +584,8 @@ class RolesTest extends BrowserKitTest
 
     public function test_page_create_all_permissions()
     {
-        $book = \BookStack\Entities\Book::take(1)->get()->first();
-        $chapter = \BookStack\Entities\Chapter::take(1)->get()->first();
+        $book = Book::take(1)->get()->first();
+        $chapter = Chapter::take(1)->get()->first();
         $baseUrl = $book->getUrl() . '/page';
         $createUrl = $book->getUrl('/create-page');
 
@@ -601,7 +606,7 @@ class RolesTest extends BrowserKitTest
 
         foreach ($accessUrls as $index => $url) {
             $this->actingAs($this->user)->visit($url);
-            $expectedUrl = \BookStack\Entities\Page::where('draft', '=', true)->orderBy('id', 'desc')->first()->getUrl();
+            $expectedUrl = Page::where('draft', '=', true)->orderBy('id', 'desc')->first()->getUrl();
             $this->seePageIs($expectedUrl);
         }
 
@@ -620,7 +625,7 @@ class RolesTest extends BrowserKitTest
 
     public function test_page_edit_own_permission()
     {
-        $otherPage = \BookStack\Entities\Page::take(1)->get()->first();
+        $otherPage = Page::take(1)->get()->first();
         $ownPage = $this->createEntityChainBelongingToUser($this->user)['page'];
         $this->checkAccessPermission('page-update-own', [
             $ownPage->getUrl() . '/edit'
@@ -636,7 +641,7 @@ class RolesTest extends BrowserKitTest
 
     public function test_page_edit_all_permission()
     {
-        $otherPage = \BookStack\Entities\Page::take(1)->get()->first();
+        $otherPage = Page::take(1)->get()->first();
         $this->checkAccessPermission('page-update-all', [
             $otherPage->getUrl() . '/edit'
         ], [
@@ -647,7 +652,7 @@ class RolesTest extends BrowserKitTest
     public function test_page_delete_own_permission()
     {
         $this->giveUserPermissions($this->user, ['page-update-all']);
-        $otherPage = \BookStack\Entities\Page::take(1)->get()->first();
+        $otherPage = Page::take(1)->get()->first();
         $ownPage = $this->createEntityChainBelongingToUser($this->user)['page'];
         $this->checkAccessPermission('page-delete-own', [
             $ownPage->getUrl() . '/delete'
@@ -669,7 +674,7 @@ class RolesTest extends BrowserKitTest
     public function test_page_delete_all_permission()
     {
         $this->giveUserPermissions($this->user, ['page-update-all']);
-        $otherPage = \BookStack\Entities\Page::take(1)->get()->first();
+        $otherPage = Page::take(1)->get()->first();
         $this->checkAccessPermission('page-delete-all', [
             $otherPage->getUrl() . '/delete'
         ], [
@@ -685,7 +690,7 @@ class RolesTest extends BrowserKitTest
 
     public function test_public_role_visible_in_user_edit_screen()
     {
-        $user = \BookStack\Auth\User::first();
+        $user = User::first();
         $adminRole = Role::getSystemRole('admin');
         $publicRole = Role::getSystemRole('public');
         $this->asAdmin()->visit('/settings/users/' . $user->id)
@@ -721,8 +726,8 @@ class RolesTest extends BrowserKitTest
     public function test_image_delete_own_permission()
     {
         $this->giveUserPermissions($this->user, ['image-update-all']);
-        $page = \BookStack\Entities\Page::first();
-        $image = factory(\BookStack\Uploads\Image::class)->create(['uploaded_to' => $page->id, 'created_by' => $this->user->id, 'updated_by' => $this->user->id]);
+        $page = Page::first();
+        $image = factory(Image::class)->create(['uploaded_to' => $page->id, 'created_by' => $this->user->id, 'updated_by' => $this->user->id]);
 
         $this->actingAs($this->user)->json('delete', '/images/' . $image->id)
             ->seeStatusCode(403);
@@ -738,8 +743,8 @@ class RolesTest extends BrowserKitTest
     {
         $this->giveUserPermissions($this->user, ['image-update-all']);
         $admin = $this->getAdmin();
-        $page = \BookStack\Entities\Page::first();
-        $image = factory(\BookStack\Uploads\Image::class)->create(['uploaded_to' => $page->id, 'created_by' => $admin->id, 'updated_by' => $admin->id]);
+        $page = Page::first();
+        $image = factory(Image::class)->create(['uploaded_to' => $page->id, 'created_by' => $admin->id, 'updated_by' => $admin->id]);
 
         $this->actingAs($this->user)->json('delete', '/images/' . $image->id)
             ->seeStatusCode(403);
@@ -760,7 +765,7 @@ class RolesTest extends BrowserKitTest
     {
         // To cover issue fixed in f99c8ff99aee9beb8c692f36d4b84dc6e651e50a.
         $page = Page::first();
-        $viewerRole = \BookStack\Auth\Role::getRole('viewer');
+        $viewerRole = Role::getRole('viewer');
         $viewer = $this->getViewer();
         $this->actingAs($viewer)->visit($page->getUrl())->assertResponseStatus(200);
 
@@ -778,14 +783,14 @@ class RolesTest extends BrowserKitTest
     {
         $admin = $this->getAdmin();
         // Book links
-        $book = factory(\BookStack\Entities\Book::class)->create(['created_by' => $admin->id, 'updated_by' => $admin->id]);
+        $book = factory(Book::class)->create(['created_by' => $admin->id, 'updated_by' => $admin->id]);
         $this->updateEntityPermissions($book);
         $this->actingAs($this->getViewer())->visit($book->getUrl())
             ->dontSee('Create a new page')
             ->dontSee('Add a chapter');
 
         // Chapter links
-        $chapter = factory(\BookStack\Entities\Chapter::class)->create(['created_by' => $admin->id, 'updated_by' => $admin->id, 'book_id' => $book->id]);
+        $chapter = factory(Chapter::class)->create(['created_by' => $admin->id, 'updated_by' => $admin->id, 'book_id' => $book->id]);
         $this->updateEntityPermissions($chapter);
         $this->actingAs($this->getViewer())->visit($chapter->getUrl())
             ->dontSee('Create a new page')
@@ -869,7 +874,7 @@ class RolesTest extends BrowserKitTest
     }
 
     private function addComment($page) {
-        $comment = factory(\BookStack\Actions\Comment::class)->make();
+        $comment = factory(Comment::class)->make();
         $url = "/comment/$page->id";
         $request = [
             'text' => $comment->text,
@@ -882,7 +887,7 @@ class RolesTest extends BrowserKitTest
     }
 
     private function updateComment($commentId) {
-        $comment = factory(\BookStack\Actions\Comment::class)->make();
+        $comment = factory(Comment::class)->make();
         $url = "/comment/$commentId";
         $request = [
             'text' => $comment->text,
index 3670df87d39df58e28ea7383955e3f9db473ab7f..1941901240b5288d628a8264c5bd83a1bef0b074 100644 (file)
@@ -5,9 +5,9 @@ use BookStack\Auth\Permissions\PermissionService;
 use BookStack\Auth\Permissions\RolePermission;
 use BookStack\Auth\Role;
 use BookStack\Auth\User;
-use BookStack\Entities\Book;
-use BookStack\Entities\Chapter;
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
 
 class PublicActionTest extends BrowserKitTest
 {
diff --git a/tests/RecycleBinTest.php b/tests/RecycleBinTest.php
new file mode 100644 (file)
index 0000000..60f06cf
--- /dev/null
@@ -0,0 +1,232 @@
+<?php namespace Tests;
+
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Deletion;
+use BookStack\Entities\Models\Page;
+use DB;
+use Illuminate\Support\Carbon;
+
+class RecycleBinTest extends TestCase
+{
+    public function test_recycle_bin_routes_permissions()
+    {
+        $page = Page::query()->first();
+        $editor = $this->getEditor();
+        $this->actingAs($editor)->delete($page->getUrl());
+        $deletion = Deletion::query()->firstOrFail();
+
+        $routes = [
+            'GET:/settings/recycle-bin',
+            'POST:/settings/recycle-bin/empty',
+            "GET:/settings/recycle-bin/{$deletion->id}/destroy",
+            "GET:/settings/recycle-bin/{$deletion->id}/restore",
+            "POST:/settings/recycle-bin/{$deletion->id}/restore",
+            "DELETE:/settings/recycle-bin/{$deletion->id}",
+        ];
+
+        foreach($routes as $route) {
+            [$method, $url] = explode(':', $route);
+            $resp = $this->call($method, $url);
+            $this->assertPermissionError($resp);
+        }
+
+        $this->giveUserPermissions($editor, ['restrictions-manage-all']);
+
+        foreach($routes as $route) {
+            [$method, $url] = explode(':', $route);
+            $resp = $this->call($method, $url);
+            $this->assertPermissionError($resp);
+        }
+
+        $this->giveUserPermissions($editor, ['settings-manage']);
+
+        foreach($routes as $route) {
+            DB::beginTransaction();
+            [$method, $url] = explode(':', $route);
+            $resp = $this->call($method, $url);
+            $this->assertNotPermissionError($resp);
+            DB::rollBack();
+        }
+
+    }
+
+    public function test_recycle_bin_view()
+    {
+        $page = Page::query()->first();
+        $book = Book::query()->whereHas('pages')->whereHas('chapters')->withCount(['pages', 'chapters'])->first();
+        $editor = $this->getEditor();
+        $this->actingAs($editor)->delete($page->getUrl());
+        $this->actingAs($editor)->delete($book->getUrl());
+
+        $viewReq = $this->asAdmin()->get('/settings/recycle-bin');
+        $viewReq->assertElementContains('table.table', $page->name);
+        $viewReq->assertElementContains('table.table', $editor->name);
+        $viewReq->assertElementContains('table.table', $book->name);
+        $viewReq->assertElementContains('table.table', $book->pages_count . ' Pages');
+        $viewReq->assertElementContains('table.table', $book->chapters_count . ' Chapters');
+    }
+
+    public function test_recycle_bin_empty()
+    {
+        $page = Page::query()->first();
+        $book = Book::query()->where('id' , '!=', $page->book_id)->whereHas('pages')->whereHas('chapters')->with(['pages', 'chapters'])->firstOrFail();
+        $editor = $this->getEditor();
+        $this->actingAs($editor)->delete($page->getUrl());
+        $this->actingAs($editor)->delete($book->getUrl());
+
+        $this->assertTrue(Deletion::query()->count() === 2);
+        $emptyReq = $this->asAdmin()->post('/settings/recycle-bin/empty');
+        $emptyReq->assertRedirect('/settings/recycle-bin');
+
+        $this->assertTrue(Deletion::query()->count() === 0);
+        $this->assertDatabaseMissing('books', ['id' => $book->id]);
+        $this->assertDatabaseMissing('pages', ['id' => $page->id]);
+        $this->assertDatabaseMissing('pages', ['id' => $book->pages->first()->id]);
+        $this->assertDatabaseMissing('chapters', ['id' => $book->chapters->first()->id]);
+
+        $itemCount = 2 + $book->pages->count() + $book->chapters->count();
+        $redirectReq = $this->get('/settings/recycle-bin');
+        $redirectReq->assertNotificationContains('Deleted '.$itemCount.' total items from the recycle bin');
+    }
+
+    public function test_entity_restore()
+    {
+        $book = Book::query()->whereHas('pages')->whereHas('chapters')->with(['pages', 'chapters'])->firstOrFail();
+        $this->asEditor()->delete($book->getUrl());
+        $deletion = Deletion::query()->firstOrFail();
+
+        $this->assertEquals($book->pages->count(), DB::table('pages')->where('book_id', '=', $book->id)->whereNotNull('deleted_at')->count());
+        $this->assertEquals($book->chapters->count(), DB::table('chapters')->where('book_id', '=', $book->id)->whereNotNull('deleted_at')->count());
+
+        $restoreReq = $this->asAdmin()->post("/settings/recycle-bin/{$deletion->id}/restore");
+        $restoreReq->assertRedirect('/settings/recycle-bin');
+        $this->assertTrue(Deletion::query()->count() === 0);
+
+        $this->assertEquals($book->pages->count(), DB::table('pages')->where('book_id', '=', $book->id)->whereNull('deleted_at')->count());
+        $this->assertEquals($book->chapters->count(), DB::table('chapters')->where('book_id', '=', $book->id)->whereNull('deleted_at')->count());
+
+        $itemCount = 1 + $book->pages->count() + $book->chapters->count();
+        $redirectReq = $this->get('/settings/recycle-bin');
+        $redirectReq->assertNotificationContains('Restored '.$itemCount.' total items from the recycle bin');
+    }
+
+    public function test_permanent_delete()
+    {
+        $book = Book::query()->whereHas('pages')->whereHas('chapters')->with(['pages', 'chapters'])->firstOrFail();
+        $this->asEditor()->delete($book->getUrl());
+        $deletion = Deletion::query()->firstOrFail();
+
+        $deleteReq = $this->asAdmin()->delete("/settings/recycle-bin/{$deletion->id}");
+        $deleteReq->assertRedirect('/settings/recycle-bin');
+        $this->assertTrue(Deletion::query()->count() === 0);
+
+        $this->assertDatabaseMissing('books', ['id' => $book->id]);
+        $this->assertDatabaseMissing('pages', ['id' => $book->pages->first()->id]);
+        $this->assertDatabaseMissing('chapters', ['id' => $book->chapters->first()->id]);
+
+        $itemCount = 1 + $book->pages->count() + $book->chapters->count();
+        $redirectReq = $this->get('/settings/recycle-bin');
+        $redirectReq->assertNotificationContains('Deleted '.$itemCount.' total items from the recycle bin');
+    }
+
+    public function test_permanent_entity_delete_updates_existing_activity_with_entity_name()
+    {
+        $page = Page::query()->firstOrFail();
+        $this->asEditor()->delete($page->getUrl());
+        $deletion = $page->deletions()->firstOrFail();
+
+        $this->assertDatabaseHas('activities', [
+            'type' => 'page_delete',
+            'entity_id' => $page->id,
+            'entity_type' => $page->getMorphClass(),
+        ]);
+
+        $this->asAdmin()->delete("/settings/recycle-bin/{$deletion->id}");
+
+        $this->assertDatabaseMissing('activities', [
+            'type' => 'page_delete',
+            'entity_id' => $page->id,
+            'entity_type' => $page->getMorphClass(),
+        ]);
+
+        $this->assertDatabaseHas('activities', [
+            'type' => 'page_delete',
+            'entity_id' => null,
+            'entity_type' => null,
+            'detail' => $page->name,
+        ]);
+    }
+
+    public function test_auto_clear_functionality_works()
+    {
+        config()->set('app.recycle_bin_lifetime', 5);
+        $page = Page::query()->firstOrFail();
+        $otherPage = Page::query()->where('id', '!=', $page->id)->firstOrFail();
+
+        $this->asEditor()->delete($page->getUrl());
+        $this->assertDatabaseHas('pages', ['id' => $page->id]);
+        $this->assertEquals(1, Deletion::query()->count());
+
+        Carbon::setTestNow(Carbon::now()->addDays(6));
+        $this->asEditor()->delete($otherPage->getUrl());
+        $this->assertEquals(1, Deletion::query()->count());
+
+        $this->assertDatabaseMissing('pages', ['id' => $page->id]);
+    }
+
+    public function test_auto_clear_functionality_with_negative_time_keeps_forever()
+    {
+        config()->set('app.recycle_bin_lifetime', -1);
+        $page = Page::query()->firstOrFail();
+        $otherPage = Page::query()->where('id', '!=', $page->id)->firstOrFail();
+
+        $this->asEditor()->delete($page->getUrl());
+        $this->assertEquals(1, Deletion::query()->count());
+
+        Carbon::setTestNow(Carbon::now()->addDays(6000));
+        $this->asEditor()->delete($otherPage->getUrl());
+        $this->assertEquals(2, Deletion::query()->count());
+
+        $this->assertDatabaseHas('pages', ['id' => $page->id]);
+    }
+
+    public function test_auto_clear_functionality_with_zero_time_deletes_instantly()
+    {
+        config()->set('app.recycle_bin_lifetime', 0);
+        $page = Page::query()->firstOrFail();
+
+        $this->asEditor()->delete($page->getUrl());
+        $this->assertDatabaseMissing('pages', ['id' => $page->id]);
+        $this->assertEquals(0, Deletion::query()->count());
+    }
+
+    public function test_restore_flow_when_restoring_nested_delete_first()
+    {
+        $book = Book::query()->whereHas('pages')->whereHas('chapters')->with(['pages', 'chapters'])->firstOrFail();
+        $chapter = $book->chapters->first();
+        $this->asEditor()->delete($chapter->getUrl());
+        $this->asEditor()->delete($book->getUrl());
+
+        $bookDeletion = $book->deletions()->first();
+        $chapterDeletion = $chapter->deletions()->first();
+
+        $chapterRestoreView = $this->asAdmin()->get("/settings/recycle-bin/{$chapterDeletion->id}/restore");
+        $chapterRestoreView->assertStatus(200);
+        $chapterRestoreView->assertSeeText($chapter->name);
+
+        $chapterRestore = $this->post("/settings/recycle-bin/{$chapterDeletion->id}/restore");
+        $chapterRestore->assertRedirect("/settings/recycle-bin");
+        $this->assertDatabaseMissing("deletions", ["id" => $chapterDeletion->id]);
+
+        $chapter->refresh();
+        $this->assertNotNull($chapter->deleted_at);
+
+        $bookRestoreView = $this->asAdmin()->get("/settings/recycle-bin/{$bookDeletion->id}/restore");
+        $bookRestoreView->assertStatus(200);
+        $bookRestoreView->assertSeeText($chapter->name);
+
+        $this->post("/settings/recycle-bin/{$bookDeletion->id}/restore");
+        $chapter->refresh();
+        $this->assertNull($chapter->deleted_at);
+    }
+}
\ No newline at end of file
index c7659a02dabae0168348553d4f0efd360f5598d9..ffcc6f40ca3f0b55ee20e72ded30feaac211f24f 100644 (file)
@@ -1,11 +1,11 @@
 <?php namespace Tests;
 
 use BookStack\Auth\User;
-use BookStack\Entities\Book;
-use BookStack\Entities\Bookshelf;
-use BookStack\Entities\Chapter;
-use BookStack\Entities\Entity;
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Bookshelf;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Models\Page;
 use BookStack\Entities\Repos\BookRepo;
 use BookStack\Entities\Repos\BookshelfRepo;
 use BookStack\Entities\Repos\ChapterRepo;
@@ -15,12 +15,14 @@ use BookStack\Auth\Permissions\PermissionService;
 use BookStack\Entities\Repos\PageRepo;
 use BookStack\Settings\SettingService;
 use BookStack\Uploads\HttpFetcher;
+use Illuminate\Http\Response;
 use Illuminate\Support\Env;
 use Illuminate\Support\Facades\Log;
 use Mockery;
 use Monolog\Handler\TestHandler;
 use Monolog\Logger;
 use Throwable;
+use Illuminate\Foundation\Testing\Assert as PHPUnit;
 
 trait SharedTestHelpers
 {
@@ -270,14 +272,25 @@ trait SharedTestHelpers
      */
     protected function assertPermissionError($response)
     {
-        if ($response instanceof BrowserKitTest) {
-            $response = \Illuminate\Foundation\Testing\TestResponse::fromBaseResponse($response->response);
-        }
+        PHPUnit::assertTrue($this->isPermissionError($response->baseResponse ?? $response->response), "Failed asserting the response contains a permission error.");
+    }
 
-        $response->assertRedirect('/');
-        $this->assertSessionHas('error');
-        $error = session()->pull('error');
-        $this->assertStringStartsWith('You do not have permission to access', $error);
+    /**
+     * Assert a permission error has occurred.
+     */
+    protected function assertNotPermissionError($response)
+    {
+        PHPUnit::assertFalse($this->isPermissionError($response->baseResponse ?? $response->response), "Failed asserting the response does not contain a permission error.");
+    }
+
+    /**
+     * Check if the given response is a permission error.
+     */
+    private function isPermissionError($response): bool
+    {
+        return $response->status() === 302
+            && $response->headers->get('Location') === url('/')
+            && strpos(session()->pull('error', ''), 'You do not have permission to access') === 0;
     }
 
     /**
index 1f1d5ece7288e88575b49975848812bac5915173..2c901981af53cb9e22909fdcc89673cb3cf5fece 100644 (file)
@@ -1,6 +1,6 @@
 <?php namespace Tests;
 
-use BookStack\Entities\Entity;
+use BookStack\Entities\Models\Entity;
 use Illuminate\Foundation\Testing\DatabaseTransactions;
 use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
 
@@ -53,9 +53,9 @@ abstract class TestCase extends BaseTestCase
      * Assert that an activity entry exists of the given key.
      * Checks the activity belongs to the given entity if provided.
      */
-    protected function assertActivityExists(string $key, Entity $entity = null)
+    protected function assertActivityExists(string $type, Entity $entity = null)
     {
-        $detailsToCheck = ['key' => $key];
+        $detailsToCheck = ['type' => $type];
 
         if ($entity) {
             $detailsToCheck['entity_type'] = $entity->getMorphClass();
index a68a5783fa044c881bfbf8fa39b66355128ae8be..9c6b78782b4c91bb8576bfb535666559a588d772 100644 (file)
@@ -15,9 +15,8 @@ class TestResponse extends BaseTestResponse {
 
     /**
      * Get the DOM Crawler for the response content.
-     * @return Crawler
      */
-    protected function crawler()
+    protected function crawler(): Crawler
     {
         if (!is_object($this->crawlerInstance)) {
             $this->crawlerInstance = new Crawler($this->getContent());
@@ -27,7 +26,6 @@ class TestResponse extends BaseTestResponse {
 
     /**
      * Assert the response contains the specified element.
-     * @param string $selector
      * @return $this
      */
     public function assertElementExists(string $selector)
@@ -45,7 +43,6 @@ class TestResponse extends BaseTestResponse {
 
     /**
      * Assert the response does not contain the specified element.
-     * @param string $selector
      * @return $this
      */
     public function assertElementNotExists(string $selector)
@@ -63,8 +60,6 @@ class TestResponse extends BaseTestResponse {
 
     /**
      * Assert the response includes a specific element containing the given text.
-     * @param string $selector
-     * @param string $text
      * @return $this
      */
     public function assertElementContains(string $selector, string $text)
@@ -95,8 +90,6 @@ class TestResponse extends BaseTestResponse {
 
     /**
      * Assert the response does not include a specific element containing the given text.
-     * @param string $selector
-     * @param string $text
      * @return $this
      */
     public function assertElementNotContains(string $selector, string $text)
@@ -125,12 +118,20 @@ class TestResponse extends BaseTestResponse {
         return $this;
     }
 
+    /**
+     * Assert there's a notification within the view containing the given text.
+     * @return $this
+     */
+    public function assertNotificationContains(string $text)
+    {
+        return $this->assertElementContains('[notification]', $text);
+    }
+
     /**
      * Get the escaped text pattern for the constraint.
-     * @param  string  $text
      * @return string
      */
-    protected function getEscapedPattern($text)
+    protected function getEscapedPattern(string $text)
     {
         $rawPattern = preg_quote($text, '/');
         $escapedPattern = preg_quote(e($text), '/');
index 5838b019e4affb526e50472bf439285f0c24c865..1ca9ea23b17d5d04101c2203173d394b3455b379 100644 (file)
@@ -1,7 +1,9 @@
 <?php namespace Tests\Uploads;
 
+use BookStack\Entities\Tools\TrashCan;
+use BookStack\Entities\Repos\PageRepo;
 use BookStack\Uploads\Attachment;
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Page;
 use BookStack\Auth\Permissions\PermissionService;
 use BookStack\Uploads\AttachmentService;
 use Illuminate\Http\UploadedFile;
@@ -213,7 +215,8 @@ class AttachmentTest extends TestCase
             'name' => $fileName
         ]);
 
-        $this->call('DELETE', $page->getUrl());
+        app(PageRepo::class)->destroy($page);
+        app(TrashCan::class)->empty();
 
         $this->assertDatabaseMissing('attachments', [
             'name' => $fileName
index 3fc009c8ab11b7fd58a447d349a74f08dbdfd0a4..d134135aa6e9aed7a6f3fceb0a07d37702252ec0 100644 (file)
@@ -1,6 +1,6 @@
 <?php namespace Tests\Uploads;
 
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Page;
 use BookStack\Uploads\Image;
 use Tests\TestCase;
 
index 08ac633268c68fd71241069ce24ebb137104c9da..1c736d672d977b8727c8ee5cf08b9f9ee5ba1538 100644 (file)
@@ -2,7 +2,7 @@
 
 use BookStack\Entities\Repos\PageRepo;
 use BookStack\Uploads\Image;
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Page;
 use BookStack\Uploads\ImageService;
 use Illuminate\Support\Str;
 use Tests\TestCase;
index f5d1032ad1f7e6844f5f379a9db85438193ccc01..64f26dea8a9be7c847909d0d192a780a2ed25f02 100644 (file)
@@ -1,6 +1,6 @@
 <?php namespace Tests\Uploads;
 
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Page;
 use Illuminate\Http\UploadedFile;
 
 trait UsesImages
index f738eb579e4f9a836bc7f818e7de39e59a78ace9..df686dd77df953423a103d8caa57313539dd832e 100644 (file)
@@ -1,5 +1,6 @@
-<?php namespace Test\User;
+<?php namespace Tests\User;
 
+use BookStack\Actions\ActivityType;
 use BookStack\Api\ApiToken;
 use Carbon\Carbon;
 use Tests\TestCase;
@@ -67,6 +68,7 @@ class UserApiTokenTest extends TestCase
         $this->assertTrue(strlen($secret) === 32);
 
         $this->assertSessionHas('success');
+        $this->assertActivityExists(ActivityType::API_TOKEN_CREATE);
     }
 
     public function test_create_with_no_expiry_sets_expiry_hundred_years_away()
@@ -124,6 +126,7 @@ class UserApiTokenTest extends TestCase
 
         $this->assertDatabaseHas('api_tokens', array_merge($updateData, ['id' => $token->id]));
         $this->assertSessionHas('success');
+        $this->assertActivityExists(ActivityType::API_TOKEN_UPDATE);
     }
 
     public function test_token_update_with_blank_expiry_sets_to_hundred_years_away()
@@ -162,6 +165,7 @@ class UserApiTokenTest extends TestCase
         $resp = $this->delete($tokenUrl);
         $resp->assertRedirect($editor->getEditUrl('#api_tokens'));
         $this->assertDatabaseMissing('api_tokens', ['id' => $token->id]);
+        $this->assertActivityExists(ActivityType::API_TOKEN_DELETE);
     }
 
     public function test_user_manage_can_delete_token_without_api_permission_themselves()
index 0db4f803aff0bf268f4004b2339280dba10cc3fd..7ffc8f9db7085958b9ef5e5a80bc4bfed22a3022 100644 (file)
@@ -1,4 +1,4 @@
-<?php namespace Test\User;
+<?php namespace Tests\User;
 
 use Tests\TestCase;
 
index 0a3a1a6b202fcf776457f9478ddba01e39cbd76b..27d97381e54393a8f469fcce6f47038cd3690dae 100644 (file)
@@ -1,8 +1,9 @@
-<?php namespace Test\User;
+<?php namespace Tests\User;
 
 use Activity;
+use BookStack\Actions\ActivityType;
 use BookStack\Auth\User;
-use BookStack\Entities\Bookshelf;
+use BookStack\Entities\Models\Bookshelf;
 use Tests\BrowserKitTest;
 
 class UserProfileTest extends BrowserKitTest
@@ -60,8 +61,8 @@ class UserProfileTest extends BrowserKitTest
         $newUser = $this->getNewBlankUser();
         $this->actingAs($newUser);
         $entities = $this->createEntityChainBelongingToUser($newUser, $newUser);
-        Activity::add($entities['book'], 'book_update', $entities['book']->id);
-        Activity::add($entities['page'], 'page_create', $entities['book']->id);
+        Activity::addForEntity($entities['book'], ActivityType::BOOK_UPDATE);
+        Activity::addForEntity($entities['page'], ActivityType::PAGE_CREATE);
 
         $this->asAdmin()->visit('/user/' . $newUser->id)
             ->seeInElement('#recent-user-activity', 'updated book')
@@ -74,8 +75,8 @@ class UserProfileTest extends BrowserKitTest
         $newUser = $this->getNewBlankUser();
         $this->actingAs($newUser);
         $entities = $this->createEntityChainBelongingToUser($newUser, $newUser);
-        Activity::add($entities['book'], 'book_update', $entities['book']->id);
-        Activity::add($entities['page'], 'page_create', $entities['book']->id);
+        Activity::addForEntity($entities['book'], ActivityType::BOOK_UPDATE);
+        Activity::addForEntity($entities['page'], ActivityType::PAGE_CREATE);
 
         $this->asAdmin()->visit('/')->clickInElement('#recent-activity', $newUser->name)
             ->seePageIs('/user/' . $newUser->id)