From 6070d804f8ac331c66dd689d8f55ba8a08fb8cf9 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 16 Jan 2023 16:54:53 +0000 Subject: [PATCH 01/41] Fixed incorrect pluralisation for de_informal Updated language system to only use initial part of locale for translation pluralisation to better match the hard-coded logic of the built-in MessageSelector. Extends and overrides Laravel's default for this system. Added test to cover. Related to #3976. --- app/Providers/TranslationServiceProvider.php | 31 ++++++++++++++++++++ app/Translation/MessageSelector.php | 19 ++++++++++++ tests/LanguageTest.php | 14 ++++++++- 3 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 app/Translation/MessageSelector.php diff --git a/app/Providers/TranslationServiceProvider.php b/app/Providers/TranslationServiceProvider.php index 3610a1e22..6bf57e021 100644 --- a/app/Providers/TranslationServiceProvider.php +++ b/app/Providers/TranslationServiceProvider.php @@ -3,10 +3,41 @@ namespace BookStack\Providers; use BookStack\Translation\FileLoader; +use BookStack\Translation\MessageSelector; use Illuminate\Translation\TranslationServiceProvider as BaseProvider; +use Illuminate\Translation\Translator; class TranslationServiceProvider extends BaseProvider { + /** + * Register the service provider. + * + * @return void + */ + public function register() + { + $this->registerLoader(); + + // This is a tweak upon Laravel's based translation service registration to allow + // usage of a custom MessageSelector class + $this->app->singleton('translator', function ($app) { + $loader = $app['translation.loader']; + + // When registering the translator component, we'll need to set the default + // locale as well as the fallback locale. So, we'll grab the application + // configuration so we can easily get both of these values from there. + $locale = $app['config']['app.locale']; + + $trans = new Translator($loader, $locale); + $trans->setFallback($app['config']['app.fallback_locale']); + $trans->setSelector(new MessageSelector()); + + return $trans; + }); + } + + + /** * Register the translation line loader. * Overrides the default register action from Laravel so a custom loader can be used. diff --git a/app/Translation/MessageSelector.php b/app/Translation/MessageSelector.php new file mode 100644 index 000000000..1a4771b3f --- /dev/null +++ b/app/Translation/MessageSelector.php @@ -0,0 +1,19 @@ +get('/'); $this->assertTrue(config('app.rtl'), 'App RTL config should have been set to true by middleware'); } + + public function test_pluralisation_for_non_standard_locales() + { + $text = trans_choice('entities.x_pages', 1, [], 'de_informal'); + $this->assertEquals('1 Seite', $text); + + $text = trans_choice('entities.x_pages', 2, [], 'de_informal'); + $this->assertEquals('2 Seiten', $text); + + $text = trans_choice('entities.x_pages', 0, [], 'de_informal'); + $this->assertEquals('0 Seiten', $text); + } } From c724bfe4d37037e90a305b5ff9410070ccf90bb9 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 21 Jan 2023 11:08:34 +0000 Subject: [PATCH 02/41] Copied over work from user_permissions branch Only that relevant to the additional testing work. --- dev/docs/permission-scenario-testing.md | 421 ++++++++++++++++++ tests/Actions/AuditLogTest.php | 30 +- tests/Actions/WebhookCallTest.php | 4 +- tests/Actions/WebhookFormatTesting.php | 2 +- tests/Actions/WebhookManagementTest.php | 4 +- tests/Api/ApiAuthTest.php | 14 +- tests/Api/AttachmentsApiTest.php | 8 +- tests/Api/BooksApiTest.php | 2 +- tests/Api/ChaptersApiTest.php | 2 +- tests/Api/PagesApiTest.php | 4 +- tests/Api/RecycleBinApiTest.php | 14 +- tests/Api/TestsApi.php | 4 +- tests/Api/UsersApiTest.php | 8 +- tests/Auth/AuthTest.php | 6 +- tests/Auth/GroupSyncServiceTest.php | 8 +- tests/Auth/LdapTest.php | 4 +- tests/Auth/LoginAutoInitiateTest.php | 2 +- tests/Auth/MfaConfigurationTest.php | 18 +- tests/Auth/MfaVerificationTest.php | 10 +- tests/Auth/OidcTest.php | 6 +- tests/Auth/ResetPasswordTest.php | 2 +- tests/Auth/Saml2Test.php | 2 +- tests/Auth/SocialAuthTest.php | 6 +- tests/Auth/UserInviteTest.php | 10 +- tests/Commands/ClearActivityCommandTest.php | 2 +- tests/Commands/ClearViewsCommandTest.php | 4 +- .../CopyShelfPermissionsCommandTest.php | 8 +- .../RegeneratePermissionsCommandTest.php | 21 +- tests/Entity/BookShelfTest.php | 27 +- tests/Entity/BookTest.php | 14 +- tests/Entity/ChapterTest.php | 10 +- tests/Entity/ConvertTest.php | 16 +- tests/Entity/EntityAccessTest.php | 8 +- tests/Entity/EntitySearchTest.php | 16 +- tests/Entity/ExportTest.php | 7 +- tests/Entity/PageContentTest.php | 2 +- tests/Entity/PageDraftTest.php | 14 +- tests/Entity/PageRevisionTest.php | 8 +- tests/Entity/PageTemplateTest.php | 8 +- tests/Entity/PageTest.php | 22 +- tests/Entity/SortTest.php | 52 +-- tests/Entity/TagTest.php | 4 +- tests/ErrorTest.php | 6 +- tests/FavouriteTest.php | 14 +- tests/Helpers/EntityProvider.php | 40 -- tests/Helpers/PermissionsProvider.php | 136 ++++++ tests/Helpers/UserRoleProvider.php | 97 ++++ tests/HomepageTest.php | 10 +- tests/LanguageTest.php | 16 +- tests/Permissions/EntityPermissionsTest.php | 43 +- tests/Permissions/ExportPermissionsTest.php | 8 +- tests/Permissions/RolesTest.php | 90 ++-- .../Scenarios/EntityRolePermissionsTest.php | 201 +++++++++ .../Scenarios/EntityUserPermissionsTest.php | 209 +++++++++ .../Scenarios/PermissionScenarioTestCase.php | 38 ++ .../Scenarios/RoleContentPermissionsTest.php | 59 +++ tests/PublicActionTest.php | 4 +- tests/References/ReferencesTest.php | 2 +- tests/Settings/RecycleBinTest.php | 10 +- tests/Settings/RegenerateReferencesTest.php | 4 +- tests/Settings/TestEmailTest.php | 8 +- tests/TestCase.php | 114 +---- tests/ThemeTest.php | 2 +- tests/Unit/FrameworkAssumptionTest.php | 2 +- tests/Uploads/AttachmentTest.php | 10 +- tests/Uploads/DrawioTest.php | 6 +- tests/Uploads/ImageTest.php | 34 +- tests/User/UserApiTokenTest.php | 30 +- tests/User/UserManagementTest.php | 46 +- tests/User/UserPreferencesTest.php | 20 +- tests/User/UserProfileTest.php | 4 +- tests/User/UserSearchTest.php | 14 +- 72 files changed, 1566 insertions(+), 545 deletions(-) create mode 100644 dev/docs/permission-scenario-testing.md create mode 100644 tests/Helpers/PermissionsProvider.php create mode 100644 tests/Helpers/UserRoleProvider.php create mode 100644 tests/Permissions/Scenarios/EntityRolePermissionsTest.php create mode 100644 tests/Permissions/Scenarios/EntityUserPermissionsTest.php create mode 100644 tests/Permissions/Scenarios/PermissionScenarioTestCase.php create mode 100644 tests/Permissions/Scenarios/RoleContentPermissionsTest.php diff --git a/dev/docs/permission-scenario-testing.md b/dev/docs/permission-scenario-testing.md new file mode 100644 index 000000000..6d0935f09 --- /dev/null +++ b/dev/docs/permission-scenario-testing.md @@ -0,0 +1,421 @@ +# Permission Scenario Testing + +Due to complexity that can arise in the various combinations of permissions, this document details scenarios and their expected results. + +Test cases are written ability abstract, since all abilities should act the same in theory. Functional test cases may test abilities separate due to implementation differences. + +Tests are categorised by the most specific element involved in the scenario, where the below list is most specific to least: + +- User entity permissions. +- Role entity permissions. +- Fallback entity permissions. +- Role permissions. + +- TODO - Test fallback in the context of the above. + +## General Permission Logical Rules + +The below are some general rules we follow to standardise the behaviour of permissions in the platform: + +- Most specific permission application (as above) take priority and can deny less specific permissions. +- Parent user/role entity permissions that may be inherited, are considered to essentially be applied on the item they are inherited to unless a lower level has its own permission rule for an already specific role/user. +- Where both grant and deny exist at the same specificity, we side towards grant. + +## Cases + +### Content Role Permissions + +These are tests related to item/entity permissions that are set only at a role level. + +#### test_01_allow + +- Role A has role all-page permission. +- User has Role A. + +User granted page permission. + +#### test_02_deny + +- Role A has no page permission. +- User has Role A. + +User denied page permission. + +#### test_10_allow_on_own_with_own + +- Role A has role own-page permission. +- User has Role A. +- User is owner of page. + +User granted page permission. + +#### test_11_deny_on_other_with_own + +- Role A has role own-page permission. +- User has Role A. +- User is not owner of page. + +User denied page permission. + +#### test_20_multiple_role_conflicting_all + +- Role A has role all-page permission. +- Role B has no page permission. +- User has Role A & B. + +User granted page permission. + +#### test_21_multiple_role_conflicting_own + +- Role A has role own-page permission. +- Role B has no page permission. +- User has Role A & B. +- User is owner of page. + +User granted page permission. + +--- + +### Entity Role Permissions + +These are tests related to entity-level role-specific permission overrides. + +#### test_01_explicit_allow + +- Page permissions have inherit disabled. +- Role A has entity allow page permission. +- User has Role A. + +User granted page permission. + +#### test_02_explicit_deny + +- Page permissions have inherit disabled. +- Role A has entity deny page permission. +- User has Role A. + +User denied page permission. + +#### test_03_same_level_conflicting + +- Page permissions have inherit disabled. +- Role A has entity allow page permission. +- Role B has entity deny page permission. +- User has both Role A & B. + +User granted page permission. +Explicit grant overrides entity deny at same level. + +#### test_20_inherit_allow + +- Page permissions have inherit enabled. +- Chapter permissions has inherit disabled. +- Role A has entity allow chapter permission. +- User has Role A. + +User granted page permission. + +#### test_21_inherit_deny + +- Page permissions have inherit enabled. +- Chapter permissions has inherit disabled. +- Role A has entity deny chapter permission. +- User has Role A. + +User denied page permission. + +#### test_22_same_level_conflict_inherit + +- Page permissions have inherit enabled. +- Chapter permissions has inherit disabled. +- Role A has entity deny chapter permission. +- Role B has entity allow chapter permission. +- User has both Role A & B. + +User granted page permission. + +#### test_30_child_inherit_override_allow + +- Page permissions have inherit enabled. +- Chapter permissions has inherit disabled. +- Role A has entity deny chapter permission. +- Role A has entity allow page permission. +- User has Role A. + +User granted page permission. + +#### test_31_child_inherit_override_deny + +- Page permissions have inherit enabled. +- Chapter permissions has inherit disabled. +- Role A has entity allow chapter permission. +- Role A has entity deny page permission. +- User has Role A. + +User denied page permission. + +#### test_40_multi_role_inherit_conflict_override_deny + +- Page permissions have inherit enabled. +- Chapter permissions has inherit disabled. +- Role A has entity deny page permission. +- Role B has entity allow chapter permission. +- User has Role A & B. + +User granted page permission. + +#### test_41_multi_role_inherit_conflict_retain_allow + +- Page permissions have inherit enabled. +- Chapter permissions has inherit disabled. +- Role A has entity allow page permission. +- Role B has entity deny chapter permission. +- User has Role A & B. + +User granted page permission. + +#### test_50_role_override_allow + +- Page permissions have inherit enabled. +- Role A has no page role permission. +- Role A has entity allow page permission. +- User has Role A. + +User granted page permission. + +#### test_51_role_override_deny + +- Page permissions have inherit enabled. +- Role A has no page-view-all role permission. +- Role A has entity deny page permission. +- User has Role A. + +User denied page permission. + +#### test_60_inherited_role_override_allow + +- Page permissions have inherit enabled. +- Chapter permissions have inherit enabled. +- Role A has no page role permission. +- Role A has entity allow chapter permission. +- User has Role A. + +User granted page permission. + +#### test_61_inherited_role_override_deny + +- Page permissions have inherit enabled. +- Chapter permissions have inherit enabled. +- Role A has page role permission. +- Role A has entity denied chapter permission. +- User has Role A. + +User denied page permission. + +#### test_62_inherited_role_override_deny_on_own + +- Page permissions have inherit enabled. +- Chapter permissions have inherit enabled. +- Role A has own-page role permission. +- Role A has entity denied chapter permission. +- User has Role A. +- User owns Page. + +User denied page permission. + +#### test_70_multi_role_inheriting_deny + +- Page permissions have inherit enabled. +- Role A has all page role permission. +- Role B has entity denied page permission. +- User has Role A and B. + +User denied page permission. + +#### test_80_multi_role_inherited_deny_via_parent + +- Page permissions have inherit enabled. +- Chapter permissions have inherit enabled. +- Role A has all-pages role permission. +- Role B has entity denied chapter permission. +- User has Role A & B. + +User denied page permission. + +--- + +### Entity User Permissions + +These are tests related to entity-level user-specific permission overrides. + +#### test_01_explicit_allow + +- Page permissions have inherit disabled. +- User has entity allow page permission. + +User granted page permission. + +#### test_02_explicit_deny + +- Page permissions have inherit disabled. +- User has entity deny page permission. + +User denied page permission. + +#### test_10_allow_inherit + +- Page permissions have inherit enabled. +- Chapter permissions have inherit disabled. +- User has entity allow chapter permission. + +User granted page permission. + +#### test_11_deny_inherit + +- Page permissions have inherit enabled. +- Chapter permissions have inherit disabled. +- User has entity deny chapter permission. + +User denied page permission. + +#### test_12_allow_inherit_override + +- Page permissions have inherit enabled. +- Chapter permissions have inherit disabled. +- User has entity deny chapter permission. +- User has entity allow page permission. + +User granted page permission. + +#### test_13_deny_inherit_override + +- Page permissions have inherit enabled. +- Chapter permissions have inherit disabled. +- User has entity allow chapter permission. +- User has entity deny page permission. + +User denied page permission. + +#### test_40_entity_role_override_allow + +- Page permissions have inherit disabled. +- User has entity allow page permission. +- Role A has entity deny page permission. +- User has role A. + +User granted page permission. + +#### test_41_entity_role_override_deny + +- Page permissions have inherit disabled. +- User has entity deny page permission. +- Role A has entity allow page permission. +- User has role A. + +User denied page permission. + +#### test_42_entity_role_override_allow_via_inherit + +- Page permissions have inherit enabled. +- Chapter permissions have inherit disabled. +- User has entity allow chapter permission. +- Role A has entity deny page permission. +- User has role A. + +User granted page permission. + +#### test_43_entity_role_override_deny_via_inherit + +- Page permissions have inherit enabled. +- Chapter permissions have inherit disabled. +- User has entity deny chapter permission. +- Role A has entity allow page permission. +- User has role A. + +User denied page permission. + +#### test_50_role_override_allow + +- Page permissions have inherit enabled. +- Role A has no page role permission. +- User has entity allow page permission. +- User has Role A. + +User granted page permission. + +#### test_51_role_override_deny + +- Page permissions have inherit enabled. +- Role A has all-page role permission. +- User has entity deny page permission. +- User has Role A. + +User denied page permission. + +#### test_60_inherited_role_override_allow + +- Page permissions have inherit enabled. +- Role A has no page role permission. +- User has entity allow chapter permission. +- User has Role A. + +User granted page permission. + +#### test_61_inherited_role_override_deny + +- Page permissions have inherit enabled. +- Role A has view-all page role permission. +- User has entity deny chapter permission. +- User has Role A. + +User denied page permission. + +#### test_61_inherited_role_override_deny_on_own + +- Page permissions have inherit enabled. +- Role A has view-own page role permission. +- User has entity deny chapter permission. +- User has Role A. +- User owns Page. + +User denied page permission. + +#### test_70_all_override_allow + +- Page permissions have inherit enabled. +- Role A has no page role permission. +- Role A has entity deny page permission. +- User has entity allow page permission. +- User has Role A. + +User granted page permission. + +#### test_71_all_override_deny + +- Page permissions have inherit enabled. +- Role A has page-all role permission. +- Role A has entity allow page permission. +- User has entity deny page permission. +- User has Role A. + +User denied page permission. + +#### test_80_inherited_all_override_allow + +- Page permissions have inherit enabled. +- Role A has no page role permission. +- Role A has entity deny chapter permission. +- User has entity allow chapter permission. +- User has Role A. + +User granted page permission. + +#### test_81_inherited_all_override_deny + +- Page permissions have inherit enabled. +- Role A has view-all page role permission. +- Role A has entity allow chapter permission. +- User has entity deny chapter permission. +- User has Role A. + +User denied page permission. \ No newline at end of file diff --git a/tests/Actions/AuditLogTest.php b/tests/Actions/AuditLogTest.php index 25fa2b796..52b45e712 100644 --- a/tests/Actions/AuditLogTest.php +++ b/tests/Actions/AuditLogTest.php @@ -23,17 +23,17 @@ class AuditLogTest extends TestCase public function test_only_accessible_with_right_permissions() { - $viewer = $this->getViewer(); + $viewer = $this->users->viewer(); $this->actingAs($viewer); $resp = $this->get('/settings/audit'); $this->assertPermissionError($resp); - $this->giveUserPermissions($viewer, ['settings-manage']); + $this->permissions->grantUserRolePermissions($viewer, ['settings-manage']); $resp = $this->get('/settings/audit'); $this->assertPermissionError($resp); - $this->giveUserPermissions($viewer, ['users-manage']); + $this->permissions->grantUserRolePermissions($viewer, ['users-manage']); $resp = $this->get('/settings/audit'); $resp->assertStatus(200); $resp->assertSeeText('Audit Log'); @@ -41,7 +41,7 @@ class AuditLogTest extends TestCase public function test_shows_activity() { - $admin = $this->getAdmin(); + $admin = $this->users->admin(); $this->actingAs($admin); $page = $this->entities->page(); $this->activityService->add(ActivityType::PAGE_CREATE, $page); @@ -56,7 +56,7 @@ class AuditLogTest extends TestCase public function test_shows_name_for_deleted_items() { - $this->actingAs($this->getAdmin()); + $this->actingAs($this->users->admin()); $page = $this->entities->page(); $pageName = $page->name; $this->activityService->add(ActivityType::PAGE_CREATE, $page); @@ -71,12 +71,12 @@ class AuditLogTest extends TestCase public function test_shows_activity_for_deleted_users() { - $viewer = $this->getViewer(); + $viewer = $this->users->viewer(); $this->actingAs($viewer); $page = $this->entities->page(); $this->activityService->add(ActivityType::PAGE_CREATE, $page); - $this->actingAs($this->getAdmin()); + $this->actingAs($this->users->admin()); app(UserRepo::class)->destroy($viewer); $resp = $this->get('settings/audit'); @@ -85,7 +85,7 @@ class AuditLogTest extends TestCase public function test_filters_by_key() { - $this->actingAs($this->getAdmin()); + $this->actingAs($this->users->admin()); $page = $this->entities->page(); $this->activityService->add(ActivityType::PAGE_CREATE, $page); @@ -98,7 +98,7 @@ class AuditLogTest extends TestCase public function test_date_filters() { - $this->actingAs($this->getAdmin()); + $this->actingAs($this->users->admin()); $page = $this->entities->page(); $this->activityService->add(ActivityType::PAGE_CREATE, $page); @@ -120,8 +120,8 @@ class AuditLogTest extends TestCase public function test_user_filter() { - $admin = $this->getAdmin(); - $editor = $this->getEditor(); + $admin = $this->users->admin(); + $editor = $this->users->editor(); $this->actingAs($admin); $page = $this->entities->page(); $this->activityService->add(ActivityType::PAGE_CREATE, $page); @@ -142,7 +142,7 @@ class AuditLogTest extends TestCase public function test_ip_address_logged_and_visible() { config()->set('app.proxies', '*'); - $editor = $this->getEditor(); + $editor = $this->users->editor(); $page = $this->entities->page(); $this->actingAs($editor)->put($page->getUrl(), [ @@ -166,7 +166,7 @@ class AuditLogTest extends TestCase public function test_ip_address_is_searchable() { config()->set('app.proxies', '*'); - $editor = $this->getEditor(); + $editor = $this->users->editor(); $page = $this->entities->page(); $this->actingAs($editor)->put($page->getUrl(), [ @@ -192,7 +192,7 @@ class AuditLogTest extends TestCase { config()->set('app.proxies', '*'); config()->set('app.env', 'demo'); - $editor = $this->getEditor(); + $editor = $this->users->editor(); $page = $this->entities->page(); $this->actingAs($editor)->put($page->getUrl(), [ @@ -215,7 +215,7 @@ class AuditLogTest extends TestCase { config()->set('app.proxies', '*'); config()->set('app.ip_address_precision', 2); - $editor = $this->getEditor(); + $editor = $this->users->editor(); $page = $this->entities->page(); $this->actingAs($editor)->put($page->getUrl(), [ diff --git a/tests/Actions/WebhookCallTest.php b/tests/Actions/WebhookCallTest.php index 7ca190200..078b8bdf4 100644 --- a/tests/Actions/WebhookCallTest.php +++ b/tests/Actions/WebhookCallTest.php @@ -88,7 +88,7 @@ class WebhookCallTest extends TestCase ]); $webhook = $this->newWebhook(['active' => true, 'endpoint' => 'https://wh.example.com'], ['all']); $page = $this->entities->page(); - $editor = $this->getEditor(); + $editor = $this->users->editor(); $this->runEvent(ActivityType::PAGE_UPDATE, $page, $editor); @@ -111,7 +111,7 @@ class WebhookCallTest extends TestCase protected function runEvent(string $event, $detail = '', ?User $user = null) { if (is_null($user)) { - $user = $this->getEditor(); + $user = $this->users->editor(); } $this->actingAs($user); diff --git a/tests/Actions/WebhookFormatTesting.php b/tests/Actions/WebhookFormatTesting.php index 07341c75b..be67d4d52 100644 --- a/tests/Actions/WebhookFormatTesting.php +++ b/tests/Actions/WebhookFormatTesting.php @@ -41,7 +41,7 @@ class WebhookFormatTesting extends TestCase protected function getWebhookData(string $event, $detail): array { $webhook = Webhook::factory()->make(); - $user = $this->getEditor(); + $user = $this->users->editor(); $formatter = WebhookFormatter::getDefault($event, $webhook, $detail, $user, time()); return $formatter->format(); diff --git a/tests/Actions/WebhookManagementTest.php b/tests/Actions/WebhookManagementTest.php index f106f303a..52838beca 100644 --- a/tests/Actions/WebhookManagementTest.php +++ b/tests/Actions/WebhookManagementTest.php @@ -135,7 +135,7 @@ class WebhookManagementTest extends TestCase public function test_settings_manage_permission_required_for_webhook_routes() { - $editor = $this->getEditor(); + $editor = $this->users->editor(); $this->actingAs($editor); $routes = [ @@ -153,7 +153,7 @@ class WebhookManagementTest extends TestCase $this->assertPermissionError($resp); } - $this->giveUserPermissions($editor, ['settings-manage']); + $this->permissions->grantUserRolePermissions($editor, ['settings-manage']); foreach ($routes as [$method, $endpoint]) { $resp = $this->call($method, $endpoint); diff --git a/tests/Api/ApiAuthTest.php b/tests/Api/ApiAuthTest.php index cc6818e27..038c4e067 100644 --- a/tests/Api/ApiAuthTest.php +++ b/tests/Api/ApiAuthTest.php @@ -16,8 +16,8 @@ class ApiAuthTest extends TestCase public function test_requests_succeed_with_default_auth() { - $viewer = $this->getViewer(); - $this->giveUserPermissions($viewer, ['access-api']); + $viewer = $this->users->viewer(); + $this->permissions->grantUserRolePermissions($viewer, ['access-api']); $resp = $this->get($this->endpoint); $resp->assertStatus(401); @@ -63,7 +63,7 @@ class ApiAuthTest extends TestCase auth()->logout(); $accessApiPermission = RolePermission::getByName('access-api'); - $editorRole = $this->getEditor()->roles()->first(); + $editorRole = $this->users->editor()->roles()->first(); $editorRole->detachPermission($accessApiPermission); $resp = $this->get($this->endpoint, $this->apiAuthHeader()); @@ -73,7 +73,7 @@ class ApiAuthTest extends TestCase public function test_api_access_permission_required_to_access_api_with_session_auth() { - $editor = $this->getEditor(); + $editor = $this->users->editor(); $this->actingAs($editor, 'standard'); $resp = $this->get($this->endpoint); @@ -81,7 +81,7 @@ class ApiAuthTest extends TestCase auth('standard')->logout(); $accessApiPermission = RolePermission::getByName('access-api'); - $editorRole = $this->getEditor()->roles()->first(); + $editorRole = $this->users->editor()->roles()->first(); $editorRole->detachPermission($accessApiPermission); $editor = User::query()->where('id', '=', $editor->id)->first(); @@ -114,7 +114,7 @@ class ApiAuthTest extends TestCase public function test_token_expiry_checked() { - $editor = $this->getEditor(); + $editor = $this->users->editor(); $token = $editor->apiTokens()->first(); $resp = $this->get($this->endpoint, $this->apiAuthHeader()); @@ -130,7 +130,7 @@ class ApiAuthTest extends TestCase public function test_email_confirmation_checked_using_api_auth() { - $editor = $this->getEditor(); + $editor = $this->users->editor(); $editor->email_confirmed = false; $editor->save(); diff --git a/tests/Api/AttachmentsApiTest.php b/tests/Api/AttachmentsApiTest.php index 4d1d3b340..b03f280ac 100644 --- a/tests/Api/AttachmentsApiTest.php +++ b/tests/Api/AttachmentsApiTest.php @@ -50,7 +50,7 @@ class AttachmentsApiTest extends TestCase ], ]]); - $this->entities->setPermissions($page, [], []); + $this->permissions->setEntityPermissions($page, [], []); $resp = $this->getJson($this->baseEndpoint . '?count=1&sort=+id'); $resp->assertJsonMissing(['data' => [ @@ -246,13 +246,13 @@ class AttachmentsApiTest extends TestCase public function test_attachment_not_visible_on_other_users_draft() { $this->actingAsApiAdmin(); - $editor = $this->getEditor(); + $editor = $this->users->editor(); $page = $this->entities->page(); $page->draft = true; $page->owned_by = $editor->id; $page->save(); - $this->entities->regenPermissions($page); + $this->permissions->regenerateForEntity($page); $attachment = $this->createAttachmentForPage($page, [ 'name' => 'my attachment', @@ -342,7 +342,7 @@ class AttachmentsApiTest extends TestCase protected function createAttachmentForPage(Page $page, $attributes = []): Attachment { - $admin = $this->getAdmin(); + $admin = $this->users->admin(); /** @var Attachment $attachment */ $attachment = $page->attachments()->forceCreate(array_merge([ 'uploaded_to' => $page->id, diff --git a/tests/Api/BooksApiTest.php b/tests/Api/BooksApiTest.php index 614185c93..dd187672e 100644 --- a/tests/Api/BooksApiTest.php +++ b/tests/Api/BooksApiTest.php @@ -246,7 +246,7 @@ class BooksApiTest extends TestCase { $types = ['html', 'plaintext', 'pdf', 'markdown']; $this->actingAsApiEditor(); - $this->removePermissionFromUser($this->getEditor(), 'content-export'); + $this->permissions->removeUserRolePermissions($this->users->editor(), ['content-export']); $book = $this->entities->book(); foreach ($types as $type) { diff --git a/tests/Api/ChaptersApiTest.php b/tests/Api/ChaptersApiTest.php index d2db0313f..a48e3b026 100644 --- a/tests/Api/ChaptersApiTest.php +++ b/tests/Api/ChaptersApiTest.php @@ -221,7 +221,7 @@ class ChaptersApiTest extends TestCase { $types = ['html', 'plaintext', 'pdf', 'markdown']; $this->actingAsApiEditor(); - $this->removePermissionFromUser($this->getEditor(), 'content-export'); + $this->permissions->removeUserRolePermissions($this->users->editor(), ['content-export']); $chapter = Chapter::visible()->has('pages')->first(); foreach ($types as $type) { diff --git a/tests/Api/PagesApiTest.php b/tests/Api/PagesApiTest.php index 8c533680f..12b38bc07 100644 --- a/tests/Api/PagesApiTest.php +++ b/tests/Api/PagesApiTest.php @@ -209,7 +209,7 @@ class PagesApiTest extends TestCase $this->actingAsApiEditor(); $page = $this->entities->page(); $chapter = Chapter::visible()->where('book_id', '!=', $page->book_id)->first(); - $this->entities->setPermissions($chapter, ['view'], [$this->getEditor()->roles()->first()]); + $this->permissions->setEntityPermissions($chapter, ['view'], [$this->users->editor()->roles()->first()]); $details = [ 'name' => 'My updated API page', 'chapter_id' => $chapter->id, @@ -315,7 +315,7 @@ class PagesApiTest extends TestCase { $types = ['html', 'plaintext', 'pdf', 'markdown']; $this->actingAsApiEditor(); - $this->removePermissionFromUser($this->getEditor(), 'content-export'); + $this->permissions->removeUserRolePermissions($this->users->editor(), ['content-export']); $page = $this->entities->page(); foreach ($types as $type) { diff --git a/tests/Api/RecycleBinApiTest.php b/tests/Api/RecycleBinApiTest.php index bc7249987..d174838c2 100644 --- a/tests/Api/RecycleBinApiTest.php +++ b/tests/Api/RecycleBinApiTest.php @@ -21,8 +21,8 @@ class RecycleBinApiTest extends TestCase public function test_settings_manage_permission_needed_for_all_endpoints() { - $editor = $this->getEditor(); - $this->giveUserPermissions($editor, ['settings-manage']); + $editor = $this->users->editor(); + $this->permissions->grantUserRolePermissions($editor, ['settings-manage']); $this->actingAs($editor); foreach ($this->endpointMap as [$method, $uri]) { @@ -34,8 +34,8 @@ class RecycleBinApiTest extends TestCase public function test_restrictions_manage_all_permission_needed_for_all_endpoints() { - $editor = $this->getEditor(); - $this->giveUserPermissions($editor, ['restrictions-manage-all']); + $editor = $this->users->editor(); + $this->permissions->grantUserRolePermissions($editor, ['restrictions-manage-all']); $this->actingAs($editor); foreach ($this->endpointMap as [$method, $uri]) { @@ -47,7 +47,7 @@ class RecycleBinApiTest extends TestCase public function test_index_endpoint_returns_expected_page() { - $admin = $this->getAdmin(); + $admin = $this->users->admin(); $page = $this->entities->page(); $book = $this->entities->book(); @@ -82,7 +82,7 @@ class RecycleBinApiTest extends TestCase public function test_index_endpoint_returns_children_count() { - $admin = $this->getAdmin(); + $admin = $this->users->admin(); $book = Book::query()->whereHas('pages')->whereHas('chapters')->withCount(['pages', 'chapters'])->first(); $this->actingAs($admin)->delete($book->getUrl()); @@ -109,7 +109,7 @@ class RecycleBinApiTest extends TestCase public function test_index_endpoint_returns_parent() { - $admin = $this->getAdmin(); + $admin = $this->users->admin(); $page = $this->entities->pageWithinChapter(); $this->actingAs($admin)->delete($page->getUrl()); diff --git a/tests/Api/TestsApi.php b/tests/Api/TestsApi.php index 0cdd93741..501f28754 100644 --- a/tests/Api/TestsApi.php +++ b/tests/Api/TestsApi.php @@ -12,7 +12,7 @@ trait TestsApi */ protected function actingAsApiEditor() { - $this->actingAs($this->getEditor(), 'api'); + $this->actingAs($this->users->editor(), 'api'); return $this; } @@ -22,7 +22,7 @@ trait TestsApi */ protected function actingAsApiAdmin() { - $this->actingAs($this->getAdmin(), 'api'); + $this->actingAs($this->users->admin(), 'api'); return $this; } diff --git a/tests/Api/UsersApiTest.php b/tests/Api/UsersApiTest.php index 739981f24..c89f9e6e3 100644 --- a/tests/Api/UsersApiTest.php +++ b/tests/Api/UsersApiTest.php @@ -175,7 +175,7 @@ class UsersApiTest extends TestCase { $this->actingAsApiAdmin(); /** @var User $user */ - $user = $this->getAdmin(); + $user = $this->users->admin(); $roles = Role::query()->pluck('id'); $resp = $this->putJson($this->baseEndpoint . "/{$user->id}", [ 'name' => 'My updated user', @@ -204,7 +204,7 @@ class UsersApiTest extends TestCase { $this->actingAsApiAdmin(); /** @var User $user */ - $user = $this->getAdmin(); + $user = $this->users->admin(); $roleCount = $user->roles()->count(); $resp = $this->putJson($this->baseEndpoint . "/{$user->id}", []); @@ -222,7 +222,7 @@ class UsersApiTest extends TestCase { $this->actingAsApiAdmin(); /** @var User $user */ - $user = User::query()->where('id', '!=', $this->getAdmin()->id) + $user = User::query()->where('id', '!=', $this->users->admin()->id) ->whereNull('system_name') ->first(); @@ -236,7 +236,7 @@ class UsersApiTest extends TestCase { $this->actingAsApiAdmin(); /** @var User $user */ - $user = User::query()->where('id', '!=', $this->getAdmin()->id) + $user = User::query()->where('id', '!=', $this->users->admin()->id) ->whereNull('system_name') ->first(); $entityChain = $this->entities->createChainBelongingToUser($user); diff --git a/tests/Auth/AuthTest.php b/tests/Auth/AuthTest.php index 3220b2aac..fe7e62568 100644 --- a/tests/Auth/AuthTest.php +++ b/tests/Auth/AuthTest.php @@ -44,7 +44,7 @@ class AuthTest extends TestCase public function test_mfa_session_cleared_on_logout() { - $user = $this->getEditor(); + $user = $this->users->editor(); $mfaSession = $this->app->make(MfaSession::class); $mfaSession->markVerifiedForUser($user); @@ -94,7 +94,7 @@ class AuthTest extends TestCase public function test_login_authenticates_nonadmins_on_default_guard_only() { - $editor = $this->getEditor(); + $editor = $this->users->editor(); $editor->password = bcrypt('password'); $editor->save(); @@ -120,7 +120,7 @@ class AuthTest extends TestCase public function test_logged_in_user_with_unconfirmed_email_is_logged_out() { $this->setSettings(['registration-confirmation' => 'true']); - $user = $this->getEditor(); + $user = $this->users->editor(); $user->email_confirmed = false; $user->save(); diff --git a/tests/Auth/GroupSyncServiceTest.php b/tests/Auth/GroupSyncServiceTest.php index 2fad53b26..dbf4110d8 100644 --- a/tests/Auth/GroupSyncServiceTest.php +++ b/tests/Auth/GroupSyncServiceTest.php @@ -11,7 +11,7 @@ class GroupSyncServiceTest extends TestCase { public function test_user_is_assigned_to_matching_roles() { - $user = $this->getViewer(); + $user = $this->users->viewer(); $roleA = Role::factory()->create(['display_name' => 'Wizards']); $roleB = Role::factory()->create(['display_name' => 'Gremlins']); @@ -33,7 +33,7 @@ class GroupSyncServiceTest extends TestCase public function test_multiple_values_in_role_external_auth_id_handled() { - $user = $this->getViewer(); + $user = $this->users->viewer(); $role = Role::factory()->create(['display_name' => 'ABC123', 'external_auth_id' => 'sales, engineering, developers, marketers']); $this->assertFalse($user->hasRole($role->id)); @@ -45,7 +45,7 @@ class GroupSyncServiceTest extends TestCase public function test_commas_can_be_used_in_external_auth_id_if_escaped() { - $user = $this->getViewer(); + $user = $this->users->viewer(); $role = Role::factory()->create(['display_name' => 'ABC123', 'external_auth_id' => 'sales\,-developers, marketers']); $this->assertFalse($user->hasRole($role->id)); @@ -57,7 +57,7 @@ class GroupSyncServiceTest extends TestCase public function test_external_auth_id_matches_ignoring_case() { - $user = $this->getViewer(); + $user = $this->users->viewer(); $role = Role::factory()->create(['display_name' => 'ABC123', 'external_auth_id' => 'WaRRioRs']); $this->assertFalse($user->hasRole($role->id)); diff --git a/tests/Auth/LdapTest.php b/tests/Auth/LdapTest.php index 978420f86..cac2ea5e1 100644 --- a/tests/Auth/LdapTest.php +++ b/tests/Auth/LdapTest.php @@ -235,7 +235,7 @@ class LdapTest extends TestCase public function test_user_edit_form() { - $editUser = $this->getNormalUser(); + $editUser = $this->users->viewer(); $editPage = $this->asAdmin()->get("/settings/users/{$editUser->id}"); $editPage->assertSee('Edit User'); $editPage->assertDontSee('Password'); @@ -257,7 +257,7 @@ class LdapTest extends TestCase public function test_non_admins_cannot_change_auth_id() { - $testUser = $this->getNormalUser(); + $testUser = $this->users->viewer(); $this->actingAs($testUser) ->get('/settings/users/' . $testUser->id) ->assertDontSee('External Authentication'); diff --git a/tests/Auth/LoginAutoInitiateTest.php b/tests/Auth/LoginAutoInitiateTest.php index 2d0384435..fcb4431af 100644 --- a/tests/Auth/LoginAutoInitiateTest.php +++ b/tests/Auth/LoginAutoInitiateTest.php @@ -70,7 +70,7 @@ class LoginAutoInitiateTest extends TestCase config()->set([ 'auth.method' => 'oidc', ]); - $this->actingAs($this->getEditor()); + $this->actingAs($this->users->editor()); $req = $this->post('/logout'); $req->assertRedirect('/login?prevent_auto_init=true'); diff --git a/tests/Auth/MfaConfigurationTest.php b/tests/Auth/MfaConfigurationTest.php index 3416263f3..fb941f00b 100644 --- a/tests/Auth/MfaConfigurationTest.php +++ b/tests/Auth/MfaConfigurationTest.php @@ -13,7 +13,7 @@ class MfaConfigurationTest extends TestCase { public function test_totp_setup() { - $editor = $this->getEditor(); + $editor = $this->users->editor(); $this->assertDatabaseMissing('mfa_values', ['user_id' => $editor->id]); // Setup page state @@ -66,7 +66,7 @@ class MfaConfigurationTest extends TestCase public function test_backup_codes_setup() { - $editor = $this->getEditor(); + $editor = $this->users->editor(); $this->assertDatabaseMissing('mfa_values', ['user_id' => $editor->id]); // Setup page state @@ -112,8 +112,8 @@ class MfaConfigurationTest extends TestCase public function test_mfa_method_count_is_visible_on_user_edit_page() { - $user = $this->getEditor(); - $resp = $this->actingAs($this->getAdmin())->get($user->getEditUrl()); + $user = $this->users->editor(); + $resp = $this->actingAs($this->users->admin())->get($user->getEditUrl()); $resp->assertSee('0 methods configured'); MfaValue::upsertWithValue($user, MfaValue::METHOD_TOTP, 'test'); @@ -127,17 +127,17 @@ class MfaConfigurationTest extends TestCase public function test_mfa_setup_link_only_shown_when_viewing_own_user_edit_page() { - $admin = $this->getAdmin(); + $admin = $this->users->admin(); $resp = $this->actingAs($admin)->get($admin->getEditUrl()); $this->withHtml($resp)->assertElementExists('a[href$="/mfa/setup"]'); - $resp = $this->actingAs($admin)->get($this->getEditor()->getEditUrl()); + $resp = $this->actingAs($admin)->get($this->users->editor()->getEditUrl()); $this->withHtml($resp)->assertElementNotExists('a[href$="/mfa/setup"]'); } public function test_mfa_indicator_shows_in_user_list() { - $admin = $this->getAdmin(); + $admin = $this->users->admin(); User::query()->where('id', '!=', $admin->id)->delete(); $resp = $this->actingAs($admin)->get('/settings/users'); @@ -150,7 +150,7 @@ class MfaConfigurationTest extends TestCase public function test_remove_mfa_method() { - $admin = $this->getAdmin(); + $admin = $this->users->admin(); MfaValue::upsertWithValue($admin, MfaValue::METHOD_TOTP, 'test'); $this->assertEquals(1, $admin->mfaValues()->count()); @@ -168,7 +168,7 @@ class MfaConfigurationTest extends TestCase public function test_totp_setup_url_shows_correct_user_when_setup_forced_upon_login() { - $admin = $this->getAdmin(); + $admin = $this->users->admin(); /** @var Role $role */ $role = $admin->roles()->first(); $role->mfa_enforced = true; diff --git a/tests/Auth/MfaVerificationTest.php b/tests/Auth/MfaVerificationTest.php index ba4c9b983..e23250314 100644 --- a/tests/Auth/MfaVerificationTest.php +++ b/tests/Auth/MfaVerificationTest.php @@ -140,7 +140,7 @@ class MfaVerificationTest extends TestCase public function test_both_mfa_options_available_if_set_on_profile() { - $user = $this->getEditor(); + $user = $this->users->editor(); $user->password = Hash::make('password'); $user->save(); @@ -165,7 +165,7 @@ class MfaVerificationTest extends TestCase public function test_mfa_required_with_no_methods_leads_to_setup() { - $user = $this->getEditor(); + $user = $this->users->editor(); $user->password = Hash::make('password'); $user->save(); /** @var Role $role */ @@ -222,7 +222,7 @@ class MfaVerificationTest extends TestCase // Attempted login user, who has configured mfa, access // Sets up user that has MFA required after attempted login. $loginService = $this->app->make(LoginService::class); - $user = $this->getEditor(); + $user = $this->users->editor(); /** @var Role $role */ $role = $user->roles->first(); $role->mfa_enforced = true; @@ -257,7 +257,7 @@ class MfaVerificationTest extends TestCase protected function startTotpLogin(): array { $secret = $this->app->make(TotpService::class)->generateSecret(); - $user = $this->getEditor(); + $user = $this->users->editor(); $user->password = Hash::make('password'); $user->save(); MfaValue::upsertWithValue($user, MfaValue::METHOD_TOTP, $secret); @@ -274,7 +274,7 @@ class MfaVerificationTest extends TestCase */ protected function startBackupCodeLogin($codes = ['kzzu6-1pgll', 'bzxnf-plygd', 'bwdsp-ysl51', '1vo93-ioy7n', 'lf7nw-wdyka', 'xmtrd-oplac']): array { - $user = $this->getEditor(); + $user = $this->users->editor(); $user->password = Hash::make('password'); $user->save(); MfaValue::upsertWithValue($user, MfaValue::METHOD_BACKUP_CODES, json_encode($codes)); diff --git a/tests/Auth/OidcTest.php b/tests/Auth/OidcTest.php index db1f87bd5..32c2d4ae2 100644 --- a/tests/Auth/OidcTest.php +++ b/tests/Auth/OidcTest.php @@ -93,7 +93,7 @@ class OidcTest extends TestCase public function test_logout_route_functions() { - $this->actingAs($this->getEditor()); + $this->actingAs($this->users->editor()); $this->post('/logout'); $this->assertFalse(auth()->check()); } @@ -228,7 +228,7 @@ class OidcTest extends TestCase public function test_auth_login_as_existing_user() { - $editor = $this->getEditor(); + $editor = $this->users->editor(); $editor->external_auth_id = 'benny505'; $editor->save(); @@ -245,7 +245,7 @@ class OidcTest extends TestCase public function test_auth_login_as_existing_user_email_with_different_auth_id_fails() { - $editor = $this->getEditor(); + $editor = $this->users->editor(); $editor->external_auth_id = 'editor101'; $editor->save(); diff --git a/tests/Auth/ResetPasswordTest.php b/tests/Auth/ResetPasswordTest.php index 7b2d2e72b..72e26f10c 100644 --- a/tests/Auth/ResetPasswordTest.php +++ b/tests/Auth/ResetPasswordTest.php @@ -85,7 +85,7 @@ class ResetPasswordTest extends TestCase public function test_reset_request_is_throttled() { - $editor = $this->getEditor(); + $editor = $this->users->editor(); Notification::fake(); $this->get('/password/email'); $this->followingRedirects()->post('/password/email', [ diff --git a/tests/Auth/Saml2Test.php b/tests/Auth/Saml2Test.php index 4c8d14dd5..0ee419610 100644 --- a/tests/Auth/Saml2Test.php +++ b/tests/Auth/Saml2Test.php @@ -170,7 +170,7 @@ class Saml2Test extends TestCase 'saml2.onelogin.strict' => false, ]); - $resp = $this->actingAs($this->getEditor())->get('/'); + $resp = $this->actingAs($this->users->editor())->get('/'); $this->withHtml($resp)->assertElementContains('form[action$="/saml2/logout"] button', 'Logout'); } diff --git a/tests/Auth/SocialAuthTest.php b/tests/Auth/SocialAuthTest.php index 67da771a5..24deedd5f 100644 --- a/tests/Auth/SocialAuthTest.php +++ b/tests/Auth/SocialAuthTest.php @@ -77,18 +77,18 @@ class SocialAuthTest extends TestCase // Test social callback with matching social account DB::table('social_accounts')->insert([ - 'user_id' => $this->getAdmin()->id, + 'user_id' => $this->users->admin()->id, 'driver' => 'github', 'driver_id' => 'logintest123', ]); $resp = $this->followingRedirects()->get('/login/service/github/callback'); $resp->assertDontSee('login-form'); - $this->assertActivityExists(ActivityType::AUTH_LOGIN, null, 'github; (' . $this->getAdmin()->id . ') ' . $this->getAdmin()->name); + $this->assertActivityExists(ActivityType::AUTH_LOGIN, null, 'github; (' . $this->users->admin()->id . ') ' . $this->users->admin()->name); } public function test_social_account_detach() { - $editor = $this->getEditor(); + $editor = $this->users->editor(); config([ 'GITHUB_APP_ID' => 'abc123', 'GITHUB_APP_SECRET' => '123abc', 'APP_URL' => 'http://localhost', diff --git a/tests/Auth/UserInviteTest.php b/tests/Auth/UserInviteTest.php index ccbb538a6..e82ce4638 100644 --- a/tests/Auth/UserInviteTest.php +++ b/tests/Auth/UserInviteTest.php @@ -17,7 +17,7 @@ class UserInviteTest extends TestCase public function test_user_creation_creates_invite() { Notification::fake(); - $admin = $this->getAdmin(); + $admin = $this->users->admin(); $email = Str::random(16) . '@example.com'; $resp = $this->actingAs($admin)->post('/settings/users/create', [ @@ -38,7 +38,7 @@ class UserInviteTest extends TestCase public function test_user_invite_sent_in_selected_language() { Notification::fake(); - $admin = $this->getAdmin(); + $admin = $this->users->admin(); $email = Str::random(16) . '@example.com'; $resp = $this->actingAs($admin)->post('/settings/users/create', [ @@ -62,7 +62,7 @@ class UserInviteTest extends TestCase public function test_invite_set_password() { Notification::fake(); - $user = $this->getViewer(); + $user = $this->users->viewer(); $inviteService = app(UserInviteService::class); $inviteService->sendInvitation($user); @@ -91,7 +91,7 @@ class UserInviteTest extends TestCase public function test_invite_set_has_password_validation() { Notification::fake(); - $user = $this->getViewer(); + $user = $this->users->viewer(); $inviteService = app(UserInviteService::class); $inviteService->sendInvitation($user); @@ -126,7 +126,7 @@ class UserInviteTest extends TestCase public function test_token_expires_after_two_weeks() { Notification::fake(); - $user = $this->getViewer(); + $user = $this->users->viewer(); $inviteService = app(UserInviteService::class); $inviteService->sendInvitation($user); diff --git a/tests/Commands/ClearActivityCommandTest.php b/tests/Commands/ClearActivityCommandTest.php index cf2fba0d6..b2624e23d 100644 --- a/tests/Commands/ClearActivityCommandTest.php +++ b/tests/Commands/ClearActivityCommandTest.php @@ -19,7 +19,7 @@ class ClearActivityCommandTest extends TestCase $this->assertDatabaseHas('activities', [ 'type' => 'page_update', 'entity_id' => $page->id, - 'user_id' => $this->getEditor()->id, + 'user_id' => $this->users->editor()->id, ]); DB::rollBack(); diff --git a/tests/Commands/ClearViewsCommandTest.php b/tests/Commands/ClearViewsCommandTest.php index bbd06fa01..c9179089b 100644 --- a/tests/Commands/ClearViewsCommandTest.php +++ b/tests/Commands/ClearViewsCommandTest.php @@ -16,7 +16,7 @@ class ClearViewsCommandTest extends TestCase $this->get($page->getUrl()); $this->assertDatabaseHas('views', [ - 'user_id' => $this->getEditor()->id, + 'user_id' => $this->users->editor()->id, 'viewable_id' => $page->id, 'views' => 1, ]); @@ -27,7 +27,7 @@ class ClearViewsCommandTest extends TestCase $this->assertTrue($exitCode === 0, 'Command executed successfully'); $this->assertDatabaseMissing('views', [ - 'user_id' => $this->getEditor()->id, + 'user_id' => $this->users->editor()->id, ]); } } diff --git a/tests/Commands/CopyShelfPermissionsCommandTest.php b/tests/Commands/CopyShelfPermissionsCommandTest.php index cb9a845fd..c4b9fe6f3 100644 --- a/tests/Commands/CopyShelfPermissionsCommandTest.php +++ b/tests/Commands/CopyShelfPermissionsCommandTest.php @@ -18,11 +18,11 @@ class CopyShelfPermissionsCommandTest extends TestCase { $shelf = $this->entities->shelf(); $child = $shelf->books()->first(); - $editorRole = $this->getEditor()->roles()->first(); + $editorRole = $this->users->editor()->roles()->first(); $this->assertFalse($child->hasPermissions(), 'Child book should not be restricted by default'); $this->assertTrue($child->permissions()->count() === 0, 'Child book should have no permissions by default'); - $this->entities->setPermissions($shelf, ['view', 'update'], [$editorRole]); + $this->permissions->setEntityPermissions($shelf, ['view', 'update'], [$editorRole]); $this->artisan('bookstack:copy-shelf-permissions', [ '--slug' => $shelf->slug, ]); @@ -43,11 +43,11 @@ class CopyShelfPermissionsCommandTest extends TestCase $shelf = $this->entities->shelf(); Bookshelf::query()->where('id', '!=', $shelf->id)->delete(); $child = $shelf->books()->first(); - $editorRole = $this->getEditor()->roles()->first(); + $editorRole = $this->users->editor()->roles()->first(); $this->assertFalse($child->hasPermissions(), 'Child book should not be restricted by default'); $this->assertTrue($child->permissions()->count() === 0, 'Child book should have no permissions by default'); - $this->entities->setPermissions($shelf, ['view', 'update'], [$editorRole]); + $this->permissions->setEntityPermissions($shelf, ['view', 'update'], [$editorRole]); $this->artisan('bookstack:copy-shelf-permissions --all') ->expectsQuestion('Permission settings for all shelves will be cascaded. Books assigned to multiple shelves will receive only the permissions of it\'s last processed shelf. Are you sure you want to proceed?', 'y'); $child = $shelf->books()->first(); diff --git a/tests/Commands/RegeneratePermissionsCommandTest.php b/tests/Commands/RegeneratePermissionsCommandTest.php index d514e5f9d..cc53b460d 100644 --- a/tests/Commands/RegeneratePermissionsCommandTest.php +++ b/tests/Commands/RegeneratePermissionsCommandTest.php @@ -2,8 +2,7 @@ namespace Tests\Commands; -use BookStack\Auth\Permissions\JointPermission; -use BookStack\Entities\Models\Page; +use BookStack\Auth\Permissions\CollapsedPermission; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\DB; use Tests\TestCase; @@ -13,15 +12,23 @@ class RegeneratePermissionsCommandTest extends TestCase public function test_regen_permissions_command() { DB::rollBack(); - JointPermission::query()->truncate(); - $page = Page::first(); + $page = $this->entities->page(); + $editor = $this->users->editor(); + $this->permissions->addEntityPermission($page, ['view'], null, $editor); + CollapsedPermission::query()->truncate(); - $this->assertDatabaseMissing('joint_permissions', ['entity_id' => $page->id]); + $this->assertDatabaseMissing('entity_permissions_collapsed', ['entity_id' => $page->id]); $exitCode = Artisan::call('bookstack:regenerate-permissions'); $this->assertTrue($exitCode === 0, 'Command executed successfully'); - DB::beginTransaction(); - $this->assertDatabaseHas('joint_permissions', ['entity_id' => $page->id]); + $this->assertDatabaseHas('entity_permissions_collapsed', [ + 'entity_id' => $page->id, + 'user_id' => $editor->id, + 'view' => 1, + ]); + + CollapsedPermission::query()->truncate(); + DB::beginTransaction(); } } diff --git a/tests/Entity/BookShelfTest.php b/tests/Entity/BookShelfTest.php index 5d919f12b..5c6489281 100644 --- a/tests/Entity/BookShelfTest.php +++ b/tests/Entity/BookShelfTest.php @@ -16,21 +16,20 @@ class BookShelfTest extends TestCase public function test_shelves_shows_in_header_if_have_view_permissions() { - $viewer = $this->getViewer(); + $viewer = $this->users->viewer(); $resp = $this->actingAs($viewer)->get('/'); $this->withHtml($resp)->assertElementContains('header', 'Shelves'); $viewer->roles()->delete(); - $this->giveUserPermissions($viewer); $resp = $this->actingAs($viewer)->get('/'); $this->withHtml($resp)->assertElementNotContains('header', 'Shelves'); - $this->giveUserPermissions($viewer, ['bookshelf-view-all']); + $this->permissions->grantUserRolePermissions($viewer, ['bookshelf-view-all']); $resp = $this->actingAs($viewer)->get('/'); $this->withHtml($resp)->assertElementContains('header', 'Shelves'); $viewer->roles()->delete(); - $this->giveUserPermissions($viewer, ['bookshelf-view-own']); + $this->permissions->grantUserRolePermissions($viewer, ['bookshelf-view-own']); $resp = $this->actingAs($viewer)->get('/'); $this->withHtml($resp)->assertElementContains('header', 'Shelves'); } @@ -38,14 +37,14 @@ class BookShelfTest extends TestCase public function test_shelves_shows_in_header_if_have_any_shelve_view_permission() { $user = User::factory()->create(); - $this->giveUserPermissions($user, ['image-create-all']); + $this->permissions->grantUserRolePermissions($user, ['image-create-all']); $shelf = $this->entities->shelf(); $userRole = $user->roles()->first(); $resp = $this->actingAs($user)->get('/'); $this->withHtml($resp)->assertElementNotContains('header', 'Shelves'); - $this->entities->setPermissions($shelf, ['view'], [$userRole]); + $this->permissions->setEntityPermissions($shelf, ['view'], [$userRole]); $resp = $this->get('/'); $this->withHtml($resp)->assertElementContains('header', 'Shelves'); @@ -69,7 +68,7 @@ class BookShelfTest extends TestCase $resp->assertSee($book->name); $resp->assertSee($book->getUrl()); - $this->entities->setPermissions($book, []); + $this->permissions->setEntityPermissions($book, []); $resp = $this->asEditor()->get('/shelves'); $resp->assertDontSee($book->name); @@ -93,7 +92,7 @@ class BookShelfTest extends TestCase ], ])); $resp->assertRedirect(); - $editorId = $this->getEditor()->id; + $editorId = $this->users->editor()->id; $this->assertDatabaseHas('bookshelves', array_merge($shelfInfo, ['created_by' => $editorId, 'updated_by' => $editorId])); $shelf = Bookshelf::where('name', '=', $shelfInfo['name'])->first(); @@ -186,13 +185,13 @@ class BookShelfTest extends TestCase $this->withHtml($resp)->assertElementContains('.book-content a.grid-card:nth-child(1)', $books[0]->name); $this->withHtml($resp)->assertElementNotContains('.book-content a.grid-card:nth-child(3)', $books[0]->name); - setting()->putUser($this->getEditor(), 'shelf_books_sort_order', 'desc'); + setting()->putUser($this->users->editor(), 'shelf_books_sort_order', 'desc'); $resp = $this->asEditor()->get($shelf->getUrl()); $this->withHtml($resp)->assertElementNotContains('.book-content a.grid-card:nth-child(1)', $books[0]->name); $this->withHtml($resp)->assertElementContains('.book-content a.grid-card:nth-child(3)', $books[0]->name); - setting()->putUser($this->getEditor(), 'shelf_books_sort_order', 'desc'); - setting()->putUser($this->getEditor(), 'shelf_books_sort', 'name'); + setting()->putUser($this->users->editor(), 'shelf_books_sort_order', 'desc'); + setting()->putUser($this->users->editor(), 'shelf_books_sort', 'name'); $resp = $this->asEditor()->get($shelf->getUrl()); $this->withHtml($resp)->assertElementContains('.book-content a.grid-card:nth-child(1)', 'hdgfgdfg'); $this->withHtml($resp)->assertElementContains('.book-content a.grid-card:nth-child(2)', 'bsfsdfsdfsd'); @@ -224,7 +223,7 @@ class BookShelfTest extends TestCase $resp->assertRedirect($shelf->getUrl()); $this->assertSessionHas('success'); - $editorId = $this->getEditor()->id; + $editorId = $this->users->editor()->id; $this->assertDatabaseHas('bookshelves', array_merge($shelfInfo, ['id' => $shelf->id, 'created_by' => $editorId, 'updated_by' => $editorId])); $shelfPage = $this->get($shelf->getUrl()); @@ -294,11 +293,11 @@ class BookShelfTest extends TestCase $resp->assertSee("action=\"{$shelf->getUrl('/copy-permissions')}\"", false); $child = $shelf->books()->first(); - $editorRole = $this->getEditor()->roles()->first(); + $editorRole = $this->users->editor()->roles()->first(); $this->assertFalse($child->hasPermissions(), 'Child book should not be restricted by default'); $this->assertTrue($child->permissions()->count() === 0, 'Child book should have no permissions by default'); - $this->entities->setPermissions($shelf, ['view', 'update'], [$editorRole]); + $this->permissions->setEntityPermissions($shelf, ['view', 'update'], [$editorRole]); $resp = $this->post($shelf->getUrl('/copy-permissions')); $child = $shelf->books()->first(); diff --git a/tests/Entity/BookTest.php b/tests/Entity/BookTest.php index 9e2750fd0..8435c534f 100644 --- a/tests/Entity/BookTest.php +++ b/tests/Entity/BookTest.php @@ -221,7 +221,7 @@ class BookTest extends TestCase public function test_books_view_shows_view_toggle_option() { /** @var Book $book */ - $editor = $this->getEditor(); + $editor = $this->users->editor(); setting()->putUser($editor, 'books_view_type', 'list'); $resp = $this->actingAs($editor)->get('/books'); @@ -304,7 +304,7 @@ class BookTest extends TestCase // Hide child content /** @var BookChild $page */ foreach ($book->getDirectChildren() as $child) { - $this->entities->setPermissions($child, [], []); + $this->permissions->setEntityPermissions($child, [], []); } $this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book']); @@ -318,8 +318,8 @@ class BookTest extends TestCase { /** @var Book $book */ $book = Book::query()->whereHas('chapters')->whereHas('directPages')->whereHas('chapters')->first(); - $viewer = $this->getViewer(); - $this->giveUserPermissions($viewer, ['book-create-all']); + $viewer = $this->users->viewer(); + $this->permissions->grantUserRolePermissions($viewer, ['book-create-all']); $this->actingAs($viewer)->post($book->getUrl('/copy'), ['name' => 'My copy book']); /** @var Book $copy */ @@ -354,9 +354,9 @@ class BookTest extends TestCase $shelfA->appendBook($book); $shelfB->appendBook($book); - $viewer = $this->getViewer(); - $this->giveUserPermissions($viewer, ['book-update-all', 'book-create-all', 'bookshelf-update-all']); - $this->entities->setPermissions($shelfB); + $viewer = $this->users->viewer(); + $this->permissions->grantUserRolePermissions($viewer, ['book-update-all', 'book-create-all', 'bookshelf-update-all']); + $this->permissions->setEntityPermissions($shelfB); $this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book']); diff --git a/tests/Entity/ChapterTest.php b/tests/Entity/ChapterTest.php index b726280c9..7fa32c252 100644 --- a/tests/Entity/ChapterTest.php +++ b/tests/Entity/ChapterTest.php @@ -101,7 +101,7 @@ class ChapterTest extends TestCase // Hide pages to all non-admin roles /** @var Page $page */ foreach ($chapter->pages as $page) { - $this->entities->setPermissions($page, [], []); + $this->permissions->setEntityPermissions($page, [], []); } $this->asEditor()->post($chapter->getUrl('/copy'), [ @@ -116,8 +116,8 @@ class ChapterTest extends TestCase public function test_copy_does_not_copy_pages_if_user_cant_page_create() { $chapter = $this->entities->chapterHasPages(); - $viewer = $this->getViewer(); - $this->giveUserPermissions($viewer, ['chapter-create-all']); + $viewer = $this->users->viewer(); + $this->permissions->grantUserRolePermissions($viewer, ['chapter-create-all']); // Lacking permission results in no copied pages $this->actingAs($viewer)->post($chapter->getUrl('/copy'), [ @@ -128,7 +128,7 @@ class ChapterTest extends TestCase $newChapter = Chapter::query()->where('name', '=', 'My copied chapter')->first(); $this->assertEquals(0, $newChapter->pages()->count()); - $this->giveUserPermissions($viewer, ['page-create-all']); + $this->permissions->grantUserRolePermissions($viewer, ['page-create-all']); // Having permission rules in copied pages $this->actingAs($viewer)->post($chapter->getUrl('/copy'), [ @@ -144,7 +144,7 @@ class ChapterTest extends TestCase { $chapter = $this->entities->chapter(); - $resp = $this->actingAs($this->getViewer())->get($chapter->getUrl()); + $resp = $this->actingAs($this->users->viewer())->get($chapter->getUrl()); $this->withHtml($resp)->assertLinkNotExists($chapter->book->getUrl('sort')); $resp = $this->asEditor()->get($chapter->getUrl()); diff --git a/tests/Entity/ConvertTest.php b/tests/Entity/ConvertTest.php index 16dd89068..4beec7fa6 100644 --- a/tests/Entity/ConvertTest.php +++ b/tests/Entity/ConvertTest.php @@ -49,16 +49,16 @@ class ConvertTest extends TestCase public function test_convert_chapter_to_book_requires_permissions() { $chapter = $this->entities->chapter(); - $user = $this->getViewer(); + $user = $this->users->viewer(); $permissions = ['chapter-delete-all', 'book-create-all', 'chapter-update-all']; - $this->giveUserPermissions($user, $permissions); + $this->permissions->grantUserRolePermissions($user, $permissions); foreach ($permissions as $permission) { - $this->removePermissionFromUser($user, $permission); + $this->permissions->removeUserRolePermissions($user, [$permission]); $resp = $this->actingAs($user)->post($chapter->getUrl('/convert-to-book')); $this->assertPermissionError($resp); - $this->giveUserPermissions($user, [$permission]); + $this->permissions->grantUserRolePermissions($user, [$permission]); } $resp = $this->actingAs($user)->post($chapter->getUrl('/convert-to-book')); @@ -122,16 +122,16 @@ class ConvertTest extends TestCase public function test_book_convert_to_shelf_requires_permissions() { $book = $this->entities->book(); - $user = $this->getViewer(); + $user = $this->users->viewer(); $permissions = ['book-delete-all', 'bookshelf-create-all', 'book-update-all', 'book-create-all']; - $this->giveUserPermissions($user, $permissions); + $this->permissions->grantUserRolePermissions($user, $permissions); foreach ($permissions as $permission) { - $this->removePermissionFromUser($user, $permission); + $this->permissions->removeUserRolePermissions($user, [$permission]); $resp = $this->actingAs($user)->post($book->getUrl('/convert-to-shelf')); $this->assertPermissionError($resp); - $this->giveUserPermissions($user, [$permission]); + $this->permissions->grantUserRolePermissions($user, [$permission]); } $resp = $this->actingAs($user)->post($book->getUrl('/convert-to-shelf')); diff --git a/tests/Entity/EntityAccessTest.php b/tests/Entity/EntityAccessTest.php index 2bb32fde8..ab7587a3b 100644 --- a/tests/Entity/EntityAccessTest.php +++ b/tests/Entity/EntityAccessTest.php @@ -11,8 +11,8 @@ class EntityAccessTest extends TestCase public function test_entities_viewable_after_creator_deletion() { // Create required assets and revisions - $creator = $this->getEditor(); - $updater = $this->getViewer(); + $creator = $this->users->editor(); + $updater = $this->users->viewer(); $entities = $this->entities->createChainBelongingToUser($creator, $updater); app()->make(UserRepo::class)->destroy($creator); $this->entities->updatePage($entities['page'], ['html' => '

hello!

>']); @@ -23,8 +23,8 @@ class EntityAccessTest extends TestCase public function test_entities_viewable_after_updater_deletion() { // Create required assets and revisions - $creator = $this->getViewer(); - $updater = $this->getEditor(); + $creator = $this->users->viewer(); + $updater = $this->users->editor(); $entities = $this->entities->createChainBelongingToUser($creator, $updater); app()->make(UserRepo::class)->destroy($updater); $this->entities->updatePage($entities['page'], ['html' => '

Hello there!

']); diff --git a/tests/Entity/EntitySearchTest.php b/tests/Entity/EntitySearchTest.php index 2650b6743..4563fb651 100644 --- a/tests/Entity/EntitySearchTest.php +++ b/tests/Entity/EntitySearchTest.php @@ -132,7 +132,7 @@ class EntitySearchTest extends TestCase public function test_search_filters() { $page = $this->entities->newPage(['name' => 'My new test quaffleachits', 'html' => 'this is about an orange donkey danzorbhsing']); - $editor = $this->getEditor(); + $editor = $this->users->editor(); $this->actingAs($editor); // Viewed filter searches @@ -171,7 +171,7 @@ class EntitySearchTest extends TestCase // Restricted filter $this->get('/search?term=' . urlencode('danzorbhsing {is_restricted}'))->assertDontSee($page->name); - $this->entities->setPermissions($page, ['view'], [$editor->roles->first()]); + $this->permissions->setEntityPermissions($page, ['view'], [$editor->roles->first()]); $this->get('/search?term=' . urlencode('danzorbhsing {is_restricted}'))->assertSee($page->name); // Date filters @@ -235,7 +235,7 @@ class EntitySearchTest extends TestCase $this->withHtml($resp)->assertElementContains($baseSelector, $page->name); $this->withHtml($resp)->assertElementNotContains($baseSelector, "You don't have the required permissions to select this item"); - $resp = $this->actingAs($this->getViewer())->get($searchUrl); + $resp = $this->actingAs($this->users->viewer())->get($searchUrl); $this->withHtml($resp)->assertElementContains($baseSelector, $page->name); $this->withHtml($resp)->assertElementContains($baseSelector, "You don't have the required permissions to select this item"); } @@ -246,7 +246,7 @@ class EntitySearchTest extends TestCase $this->assertGreaterThan(2, count($chapter->pages), 'Ensure we\'re testing with at least 1 sibling'); $page = $chapter->pages->first(); - $search = $this->actingAs($this->getViewer())->get("/search/entity/siblings?entity_id={$page->id}&entity_type=page"); + $search = $this->actingAs($this->users->viewer())->get("/search/entity/siblings?entity_id={$page->id}&entity_type=page"); $search->assertSuccessful(); foreach ($chapter->pages as $page) { $search->assertSee($page->name); @@ -261,7 +261,7 @@ class EntitySearchTest extends TestCase $bookChildren = $page->book->getDirectChildren(); $this->assertGreaterThan(2, count($bookChildren), 'Ensure we\'re testing with at least 1 sibling'); - $search = $this->actingAs($this->getViewer())->get("/search/entity/siblings?entity_id={$page->id}&entity_type=page"); + $search = $this->actingAs($this->users->viewer())->get("/search/entity/siblings?entity_id={$page->id}&entity_type=page"); $search->assertSuccessful(); foreach ($bookChildren as $child) { $search->assertSee($child->name); @@ -276,7 +276,7 @@ class EntitySearchTest extends TestCase $bookChildren = $chapter->book->getDirectChildren(); $this->assertGreaterThan(2, count($bookChildren), 'Ensure we\'re testing with at least 1 sibling'); - $search = $this->actingAs($this->getViewer())->get("/search/entity/siblings?entity_id={$chapter->id}&entity_type=chapter"); + $search = $this->actingAs($this->users->viewer())->get("/search/entity/siblings?entity_id={$chapter->id}&entity_type=chapter"); $search->assertSuccessful(); foreach ($bookChildren as $child) { $search->assertSee($child->name); @@ -291,7 +291,7 @@ class EntitySearchTest extends TestCase $book = $books->first(); $this->assertGreaterThan(2, count($books), 'Ensure we\'re testing with at least 1 sibling'); - $search = $this->actingAs($this->getViewer())->get("/search/entity/siblings?entity_id={$book->id}&entity_type=book"); + $search = $this->actingAs($this->users->viewer())->get("/search/entity/siblings?entity_id={$book->id}&entity_type=book"); $search->assertSuccessful(); foreach ($books as $expectedBook) { $search->assertSee($expectedBook->name); @@ -304,7 +304,7 @@ class EntitySearchTest extends TestCase $shelf = $shelves->first(); $this->assertGreaterThan(2, count($shelves), 'Ensure we\'re testing with at least 1 sibling'); - $search = $this->actingAs($this->getViewer())->get("/search/entity/siblings?entity_id={$shelf->id}&entity_type=bookshelf"); + $search = $this->actingAs($this->users->viewer())->get("/search/entity/siblings?entity_id={$shelf->id}&entity_type=bookshelf"); $search->assertSuccessful(); foreach ($shelves as $expectedShelf) { $search->assertSee($expectedShelf->name); diff --git a/tests/Entity/ExportTest.php b/tests/Entity/ExportTest.php index 0f80bdd49..0f8d0f48c 100644 --- a/tests/Entity/ExportTest.php +++ b/tests/Entity/ExportTest.php @@ -275,7 +275,7 @@ class ExportTest extends TestCase public function test_page_export_with_deleted_creator_and_updater() { - $user = $this->getViewer(['name' => 'ExportWizardTheFifth']); + $user = $this->users->viewer(['name' => 'ExportWizardTheFifth']); $page = $this->entities->page(); $page->created_by = $user->id; $page->updated_by = $user->id; @@ -409,7 +409,7 @@ class ExportTest extends TestCase $chapter = $book->chapters()->first(); $page = $chapter->pages()->first(); $entities = [$book, $chapter, $page]; - $user = $this->getViewer(); + $user = $this->users->viewer(); $this->actingAs($user); foreach ($entities as $entity) { @@ -417,8 +417,7 @@ class ExportTest extends TestCase $resp->assertSee('/export/pdf'); } - /** @var Role $role */ - $this->removePermissionFromUser($user, 'content-export'); + $this->permissions->removeUserRolePermissions($user, ['content-export']); foreach ($entities as $entity) { $resp = $this->get($entity->getUrl()); diff --git a/tests/Entity/PageContentTest.php b/tests/Entity/PageContentTest.php index 0c9854206..e24ee4fb5 100644 --- a/tests/Entity/PageContentTest.php +++ b/tests/Entity/PageContentTest.php @@ -483,7 +483,7 @@ class PageContentTest extends TestCase { $page = $this->entities->page(); - $this->actingAs($this->getAdmin()) + $this->actingAs($this->users->admin()) ->put($page->getUrl(''), [ 'name' => 'Testing', 'html' => '

"Hello & welcome"

', diff --git a/tests/Entity/PageDraftTest.php b/tests/Entity/PageDraftTest.php index 010173852..75b1933ea 100644 --- a/tests/Entity/PageDraftTest.php +++ b/tests/Entity/PageDraftTest.php @@ -39,7 +39,7 @@ class PageDraftTest extends TestCase $this->withHtml($resp)->assertElementNotContains('[name="html"]', $addedContent); $newContent = $this->page->html . $addedContent; - $newUser = $this->getEditor(); + $newUser = $this->users->editor(); $this->pageRepo->updatePageDraft($this->page, ['html' => $newContent]); $resp = $this->actingAs($newUser)->get($this->page->getUrl('/edit')); @@ -62,7 +62,7 @@ class PageDraftTest extends TestCase $this->withHtml($resp)->assertElementNotContains('[name="html"]', $addedContent); $newContent = $this->page->html . $addedContent; - $newUser = $this->getEditor(); + $newUser = $this->users->editor(); $this->pageRepo->updatePageDraft($this->page, ['html' => $newContent]); $this->actingAs($newUser) @@ -75,8 +75,8 @@ class PageDraftTest extends TestCase public function test_draft_save_shows_alert_if_draft_older_than_last_page_update() { - $admin = $this->getAdmin(); - $editor = $this->getEditor(); + $admin = $this->users->admin(); + $editor = $this->users->editor(); $page = $this->entities->page(); $this->actingAs($editor)->put('/ajax/page/' . $page->id . '/save-draft', [ @@ -109,8 +109,8 @@ class PageDraftTest extends TestCase public function test_draft_save_shows_alert_if_draft_edit_started_by_someone_else() { - $admin = $this->getAdmin(); - $editor = $this->getEditor(); + $admin = $this->users->admin(); + $editor = $this->users->editor(); $page = $this->entities->page(); $this->actingAs($admin)->put('/ajax/page/' . $page->id . '/save-draft', [ @@ -143,7 +143,7 @@ class PageDraftTest extends TestCase { $book = $this->entities->book(); $chapter = $book->chapters->first(); - $newUser = $this->getEditor(); + $newUser = $this->users->editor(); $this->actingAs($newUser)->get($book->getUrl('/create-page')); $this->get($chapter->getUrl('/create-page')); diff --git a/tests/Entity/PageRevisionTest.php b/tests/Entity/PageRevisionTest.php index 0749888c8..0df37728e 100644 --- a/tests/Entity/PageRevisionTest.php +++ b/tests/Entity/PageRevisionTest.php @@ -208,13 +208,13 @@ class PageRevisionTest extends TestCase $page = $this->entities->page(); $this->createRevisions($page, 2); - $viewer = $this->getViewer(); + $viewer = $this->users->viewer(); $this->actingAs($viewer); $respHtml = $this->withHtml($this->get($page->getUrl('/revisions'))); $respHtml->assertElementNotContains('.actions a', 'Restore'); $respHtml->assertElementNotExists('form[action$="/restore"]'); - $this->giveUserPermissions($viewer, ['page-update-all']); + $this->permissions->grantUserRolePermissions($viewer, ['page-update-all']); $respHtml = $this->withHtml($this->get($page->getUrl('/revisions'))); $respHtml->assertElementContains('.actions a', 'Restore'); @@ -226,13 +226,13 @@ class PageRevisionTest extends TestCase $page = $this->entities->page(); $this->createRevisions($page, 2); - $viewer = $this->getViewer(); + $viewer = $this->users->viewer(); $this->actingAs($viewer); $respHtml = $this->withHtml($this->get($page->getUrl('/revisions'))); $respHtml->assertElementNotContains('.actions a', 'Delete'); $respHtml->assertElementNotExists('form[action$="/delete"]'); - $this->giveUserPermissions($viewer, ['page-delete-all']); + $this->permissions->grantUserRolePermissions($viewer, ['page-delete-all']); $respHtml = $this->withHtml($this->get($page->getUrl('/revisions'))); $respHtml->assertElementContains('.actions a', 'Delete'); diff --git a/tests/Entity/PageTemplateTest.php b/tests/Entity/PageTemplateTest.php index dc45fcfb8..6a68c3ab1 100644 --- a/tests/Entity/PageTemplateTest.php +++ b/tests/Entity/PageTemplateTest.php @@ -25,7 +25,7 @@ class PageTemplateTest extends TestCase public function test_manage_templates_permission_required_to_change_page_template_status() { $page = $this->entities->page(); - $editor = $this->getEditor(); + $editor = $this->users->editor(); $this->actingAs($editor); $pageUpdateData = [ @@ -40,7 +40,7 @@ class PageTemplateTest extends TestCase 'template' => false, ]); - $this->giveUserPermissions($editor, ['templates-manage']); + $this->permissions->grantUserRolePermissions($editor, ['templates-manage']); $this->put($page->getUrl(), $pageUpdateData); $this->assertDatabaseHas('pages', [ @@ -53,7 +53,7 @@ class PageTemplateTest extends TestCase { $content = '
my_custom_template_content
'; $page = $this->entities->page(); - $editor = $this->getEditor(); + $editor = $this->users->editor(); $this->actingAs($editor); $templateFetch = $this->get('/templates/' . $page->id); @@ -73,7 +73,7 @@ class PageTemplateTest extends TestCase public function test_template_endpoint_returns_paginated_list_of_templates() { - $editor = $this->getEditor(); + $editor = $this->users->editor(); $this->actingAs($editor); $toBeTemplates = Page::query()->orderBy('name', 'asc')->take(12)->get(); diff --git a/tests/Entity/PageTest.php b/tests/Entity/PageTest.php index f481ffb61..370c4381c 100644 --- a/tests/Entity/PageTest.php +++ b/tests/Entity/PageTest.php @@ -38,8 +38,8 @@ class PageTest extends TestCase public function test_page_view_when_creator_is_deleted_but_owner_exists() { $page = $this->entities->page(); - $user = $this->getViewer(); - $owner = $this->getEditor(); + $user = $this->users->viewer(); + $owner = $this->users->editor(); $page->created_by = $user->id; $page->owned_by = $owner->id; $page->save(); @@ -190,15 +190,15 @@ class PageTest extends TestCase $page = $this->entities->page(); $currentBook = $page->book; $newBook = Book::where('id', '!=', $currentBook->id)->first(); - $viewer = $this->getViewer(); + $viewer = $this->users->viewer(); $resp = $this->actingAs($viewer)->get($page->getUrl()); $resp->assertDontSee($page->getUrl('/copy')); $newBook->owned_by = $viewer->id; $newBook->save(); - $this->giveUserPermissions($viewer, ['page-create-own']); - $this->entities->regenPermissions($newBook); + $this->permissions->grantUserRolePermissions($viewer, ['page-create-own']); + $this->permissions->regenerateForEntity($newBook); $resp = $this->actingAs($viewer)->get($page->getUrl()); $resp->assertSee($page->getUrl('/copy')); @@ -249,7 +249,7 @@ class PageTest extends TestCase public function test_recently_updated_pages_view() { - $user = $this->getEditor(); + $user = $this->users->editor(); $content = $this->entities->createChainBelongingToUser($user); $resp = $this->asAdmin()->get('/pages/recently-updated'); @@ -258,7 +258,7 @@ class PageTest extends TestCase public function test_recently_updated_pages_view_shows_updated_by_details() { - $user = $this->getEditor(); + $user = $this->users->editor(); $page = $this->entities->page(); $this->actingAs($user)->put($page->getUrl(), [ @@ -272,7 +272,7 @@ class PageTest extends TestCase public function test_recently_updated_pages_view_shows_parent_chain() { - $user = $this->getEditor(); + $user = $this->users->editor(); $page = $this->entities->pageWithinChapter(); $this->actingAs($user)->put($page->getUrl(), [ @@ -287,7 +287,7 @@ class PageTest extends TestCase public function test_recently_updated_pages_view_does_not_show_parent_if_not_visible() { - $user = $this->getEditor(); + $user = $this->users->editor(); $page = $this->entities->pageWithinChapter(); $this->actingAs($user)->put($page->getUrl(), [ @@ -295,8 +295,8 @@ class PageTest extends TestCase 'html' => '

Updated content

', ]); - $this->entities->setPermissions($page->book); - $this->entities->setPermissions($page, ['view'], [$user->roles->first()]); + $this->permissions->setEntityPermissions($page->book); + $this->permissions->setEntityPermissions($page, ['view'], [$user->roles->first()]); $resp = $this->get('/pages/recently-updated'); $resp->assertDontSee($page->book->getShortName(42)); diff --git a/tests/Entity/SortTest.php b/tests/Entity/SortTest.php index f02e15d21..9a5a2fe17 100644 --- a/tests/Entity/SortTest.php +++ b/tests/Entity/SortTest.php @@ -53,7 +53,7 @@ class SortTest extends TestCase $newBook = Book::query()->where('id', '!=', $currentBook->id)->first(); $newChapter = $newBook->chapters()->first(); - $movePageResp = $this->actingAs($this->getEditor())->put($page->getUrl('/move'), [ + $movePageResp = $this->actingAs($this->users->editor())->put($page->getUrl('/move'), [ 'entity_selection' => 'chapter:' . $newChapter->id, ]); $page->refresh(); @@ -71,7 +71,7 @@ class SortTest extends TestCase $page = $oldChapter->pages()->first(); $newBook = Book::query()->where('id', '!=', $oldChapter->book_id)->first(); - $movePageResp = $this->actingAs($this->getEditor())->put($page->getUrl('/move'), [ + $movePageResp = $this->actingAs($this->users->editor())->put($page->getUrl('/move'), [ 'entity_selection' => 'book:' . $newBook->id, ]); $page->refresh(); @@ -89,16 +89,16 @@ class SortTest extends TestCase $page = $this->entities->page(); $currentBook = $page->book; $newBook = Book::query()->where('id', '!=', $currentBook->id)->first(); - $editor = $this->getEditor(); + $editor = $this->users->editor(); - $this->entities->setPermissions($newBook, ['view', 'update', 'delete'], $editor->roles->all()); + $this->permissions->setEntityPermissions($newBook, ['view', 'update', 'delete'], $editor->roles->all()); $movePageResp = $this->actingAs($editor)->put($page->getUrl('/move'), [ 'entity_selection' => 'book:' . $newBook->id, ]); $this->assertPermissionError($movePageResp); - $this->entities->setPermissions($newBook, ['view', 'update', 'delete', 'create'], $editor->roles->all()); + $this->permissions->setEntityPermissions($newBook, ['view', 'update', 'delete', 'create'], $editor->roles->all()); $movePageResp = $this->put($page->getUrl('/move'), [ 'entity_selection' => 'book:' . $newBook->id, ]); @@ -114,10 +114,10 @@ class SortTest extends TestCase $page = $this->entities->page(); $currentBook = $page->book; $newBook = Book::query()->where('id', '!=', $currentBook->id)->first(); - $editor = $this->getEditor(); + $editor = $this->users->editor(); - $this->entities->setPermissions($newBook, ['view', 'update', 'create', 'delete'], $editor->roles->all()); - $this->entities->setPermissions($page, ['view', 'update', 'create'], $editor->roles->all()); + $this->permissions->setEntityPermissions($newBook, ['view', 'update', 'create', 'delete'], $editor->roles->all()); + $this->permissions->setEntityPermissions($page, ['view', 'update', 'create'], $editor->roles->all()); $movePageResp = $this->actingAs($editor)->put($page->getUrl('/move'), [ 'entity_selection' => 'book:' . $newBook->id, @@ -126,7 +126,7 @@ class SortTest extends TestCase $pageView = $this->get($page->getUrl()); $pageView->assertDontSee($page->getUrl('/move')); - $this->entities->setPermissions($page, ['view', 'update', 'create', 'delete'], $editor->roles->all()); + $this->permissions->setEntityPermissions($page, ['view', 'update', 'create', 'delete'], $editor->roles->all()); $movePageResp = $this->put($page->getUrl('/move'), [ 'entity_selection' => 'book:' . $newBook->id, ]); @@ -169,10 +169,10 @@ class SortTest extends TestCase $chapter = $this->entities->chapter(); $currentBook = $chapter->book; $newBook = Book::query()->where('id', '!=', $currentBook->id)->first(); - $editor = $this->getEditor(); + $editor = $this->users->editor(); - $this->entities->setPermissions($newBook, ['view', 'update', 'create', 'delete'], $editor->roles->all()); - $this->entities->setPermissions($chapter, ['view', 'update', 'create'], $editor->roles->all()); + $this->permissions->setEntityPermissions($newBook, ['view', 'update', 'create', 'delete'], $editor->roles->all()); + $this->permissions->setEntityPermissions($chapter, ['view', 'update', 'create'], $editor->roles->all()); $moveChapterResp = $this->actingAs($editor)->put($chapter->getUrl('/move'), [ 'entity_selection' => 'book:' . $newBook->id, @@ -181,7 +181,7 @@ class SortTest extends TestCase $pageView = $this->get($chapter->getUrl()); $pageView->assertDontSee($chapter->getUrl('/move')); - $this->entities->setPermissions($chapter, ['view', 'update', 'create', 'delete'], $editor->roles->all()); + $this->permissions->setEntityPermissions($chapter, ['view', 'update', 'create', 'delete'], $editor->roles->all()); $moveChapterResp = $this->put($chapter->getUrl('/move'), [ 'entity_selection' => 'book:' . $newBook->id, ]); @@ -196,17 +196,17 @@ class SortTest extends TestCase $chapter = $this->entities->chapter(); $currentBook = $chapter->book; $newBook = Book::query()->where('id', '!=', $currentBook->id)->first(); - $editor = $this->getEditor(); + $editor = $this->users->editor(); - $this->entities->setPermissions($newBook, ['view', 'update', 'delete'], [$editor->roles->first()]); - $this->entities->setPermissions($chapter, ['view', 'update', 'create', 'delete'], [$editor->roles->first()]); + $this->permissions->setEntityPermissions($newBook, ['view', 'update', 'delete'], [$editor->roles->first()]); + $this->permissions->setEntityPermissions($chapter, ['view', 'update', 'create', 'delete'], [$editor->roles->first()]); $moveChapterResp = $this->actingAs($editor)->put($chapter->getUrl('/move'), [ 'entity_selection' => 'book:' . $newBook->id, ]); $this->assertPermissionError($moveChapterResp); - $this->entities->setPermissions($newBook, ['view', 'update', 'create', 'delete'], [$editor->roles->first()]); + $this->permissions->setEntityPermissions($newBook, ['view', 'update', 'create', 'delete'], [$editor->roles->first()]); $moveChapterResp = $this->put($chapter->getUrl('/move'), [ 'entity_selection' => 'book:' . $newBook->id, ]); @@ -313,7 +313,7 @@ class SortTest extends TestCase $page = $this->entities->pageWithinChapter(); /** @var Chapter $otherChapter */ $otherChapter = Chapter::query()->where('book_id', '!=', $page->book_id)->first(); - $this->entities->setPermissions($otherChapter); + $this->permissions->setEntityPermissions($otherChapter); $sortData = [ 'id' => $page->id, @@ -334,8 +334,8 @@ class SortTest extends TestCase $page = $this->entities->pageWithinChapter(); /** @var Chapter $otherChapter */ $otherChapter = Chapter::query()->where('book_id', '!=', $page->book_id)->first(); - $editor = $this->getEditor(); - $this->entities->setPermissions($otherChapter->book, ['update', 'delete'], [$editor->roles()->first()]); + $editor = $this->users->editor(); + $this->permissions->setEntityPermissions($otherChapter->book, ['update', 'delete'], [$editor->roles()->first()]); $sortData = [ 'id' => $page->id, @@ -356,8 +356,8 @@ class SortTest extends TestCase $page = $this->entities->pageWithinChapter(); /** @var Chapter $otherChapter */ $otherChapter = Chapter::query()->where('book_id', '!=', $page->book_id)->first(); - $editor = $this->getEditor(); - $this->entities->setPermissions($otherChapter, ['view', 'delete'], [$editor->roles()->first()]); + $editor = $this->users->editor(); + $this->permissions->setEntityPermissions($otherChapter, ['view', 'delete'], [$editor->roles()->first()]); $sortData = [ 'id' => $page->id, @@ -378,8 +378,8 @@ class SortTest extends TestCase $page = $this->entities->pageWithinChapter(); /** @var Chapter $otherChapter */ $otherChapter = Chapter::query()->where('book_id', '!=', $page->book_id)->first(); - $editor = $this->getEditor(); - $this->entities->setPermissions($page, ['view', 'delete'], [$editor->roles()->first()]); + $editor = $this->users->editor(); + $this->permissions->setEntityPermissions($page, ['view', 'delete'], [$editor->roles()->first()]); $sortData = [ 'id' => $page->id, @@ -400,8 +400,8 @@ class SortTest extends TestCase $page = $this->entities->pageWithinChapter(); /** @var Chapter $otherChapter */ $otherChapter = Chapter::query()->where('book_id', '!=', $page->book_id)->first(); - $editor = $this->getEditor(); - $this->entities->setPermissions($page, ['view', 'update'], [$editor->roles()->first()]); + $editor = $this->users->editor(); + $this->permissions->setEntityPermissions($page, ['view', 'update'], [$editor->roles()->first()]); $sortData = [ 'id' => $page->id, diff --git a/tests/Entity/TagTest.php b/tests/Entity/TagTest.php index ab06686e0..7e6674959 100644 --- a/tests/Entity/TagTest.php +++ b/tests/Entity/TagTest.php @@ -75,7 +75,7 @@ class TagTest extends TestCase $this->asEditor()->get('/ajax/tags/suggest/names?search=co')->assertSimilarJson(['color', 'country']); // Set restricted permission the page - $this->entities->setPermissions($page, [], []); + $this->permissions->setEntityPermissions($page, [], []); $this->asAdmin()->get('/ajax/tags/suggest/names?search=co')->assertSimilarJson(['color', 'country']); $this->asEditor()->get('/ajax/tags/suggest/names?search=co')->assertSimilarJson([]); @@ -178,7 +178,7 @@ class TagTest extends TestCase $resp = $this->get('/tags?name=SuperCategory'); $resp->assertSee('GreatTestContent'); - $this->entities->setPermissions($page, [], []); + $this->permissions->setEntityPermissions($page, [], []); $resp = $this->asEditor()->get('/tags'); $resp->assertDontSee('SuperCategory'); diff --git a/tests/ErrorTest.php b/tests/ErrorTest.php index ebd9874d3..6ba01dd88 100644 --- a/tests/ErrorTest.php +++ b/tests/ErrorTest.php @@ -11,7 +11,7 @@ class ErrorTest extends TestCase // Due to middleware being handled differently this will not fail // if our custom, middleware-loaded handler fails but this is here // as a reminder and as a general check in the event of other issues. - $editor = $this->getEditor(); + $editor = $this->users->editor(); $editor->name = 'tester'; $editor->save(); @@ -24,7 +24,7 @@ class ErrorTest extends TestCase public function test_item_not_found_does_not_get_logged_to_file() { - $this->actingAs($this->getViewer()); + $this->actingAs($this->users->viewer()); $handler = $this->withTestLogger(); $book = $this->entities->book(); @@ -41,7 +41,7 @@ class ErrorTest extends TestCase public function test_access_to_non_existing_image_location_provides_404_response() { - $resp = $this->actingAs($this->getViewer())->get('/uploads/images/gallery/2021-05/anonexistingimage.png'); + $resp = $this->actingAs($this->users->viewer())->get('/uploads/images/gallery/2021-05/anonexistingimage.png'); $resp->assertStatus(404); $resp->assertSeeText('Image Not Found'); } diff --git a/tests/FavouriteTest.php b/tests/FavouriteTest.php index 456f2213c..7778aa8e9 100644 --- a/tests/FavouriteTest.php +++ b/tests/FavouriteTest.php @@ -10,7 +10,7 @@ class FavouriteTest extends TestCase public function test_page_add_favourite_flow() { $page = $this->entities->page(); - $editor = $this->getEditor(); + $editor = $this->users->editor(); $resp = $this->actingAs($editor)->get($page->getUrl()); $this->withHtml($resp)->assertElementContains('button', 'Favourite'); @@ -33,7 +33,7 @@ class FavouriteTest extends TestCase public function test_page_remove_favourite_flow() { $page = $this->entities->page(); - $editor = $this->getEditor(); + $editor = $this->users->editor(); Favourite::query()->forceCreate([ 'user_id' => $editor->id, 'favouritable_id' => $page->id, @@ -63,7 +63,7 @@ class FavouriteTest extends TestCase $book->owned_by = $user->id; $book->save(); - $this->giveUserPermissions($user, ['book-view-own']); + $this->permissions->grantUserRolePermissions($user, ['book-view-own']); $this->actingAs($user)->get($book->getUrl()); $resp = $this->post('/favourites/add', [ @@ -81,7 +81,7 @@ class FavouriteTest extends TestCase public function test_each_entity_type_shows_favourite_button() { - $this->actingAs($this->getEditor()); + $this->actingAs($this->users->editor()); foreach ($this->entities->all() as $entity) { $resp = $this->get($entity->getUrl()); @@ -94,13 +94,13 @@ class FavouriteTest extends TestCase $this->setSettings(['app-public' => 'true']); $resp = $this->get('/'); $this->withHtml($resp)->assertElementNotContains('header', 'My Favourites'); - $resp = $this->actingAs($this->getViewer())->get('/'); + $resp = $this->actingAs($this->users->viewer())->get('/'); $this->withHtml($resp)->assertElementContains('header a', 'My Favourites'); } public function test_favourites_shown_on_homepage() { - $editor = $this->getEditor(); + $editor = $this->users->editor(); $resp = $this->actingAs($editor)->get('/'); $this->withHtml($resp)->assertElementNotExists('#top-favourites'); @@ -116,7 +116,7 @@ class FavouriteTest extends TestCase public function test_favourites_list_page_shows_favourites_and_has_working_pagination() { $page = $this->entities->page(); - $editor = $this->getEditor(); + $editor = $this->users->editor(); $resp = $this->actingAs($editor)->get('/favourites'); $resp->assertDontSee($page->name); diff --git a/tests/Helpers/EntityProvider.php b/tests/Helpers/EntityProvider.php index 9e8cf0b73..d79015f75 100644 --- a/tests/Helpers/EntityProvider.php +++ b/tests/Helpers/EntityProvider.php @@ -2,8 +2,6 @@ namespace Tests\Helpers; -use BookStack\Auth\Permissions\EntityPermission; -use BookStack\Auth\Role; use BookStack\Auth\User; use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Bookshelf; @@ -186,44 +184,6 @@ class EntityProvider return $pageRepo->publishDraft($draftPage, $input); } - /** - * Regenerate the permission for an entity. - * Centralised to manage clearing of cached elements between requests. - */ - public function regenPermissions(Entity $entity): void - { - $entity->rebuildPermissions(); - $entity->load('jointPermissions'); - } - - /** - * Set the given entity as having restricted permissions, and apply the given - * permissions for the given roles. - * @param string[] $actions - * @param Role[] $roles - */ - public function setPermissions(Entity $entity, array $actions = [], array $roles = []): void - { - $entity->permissions()->delete(); - - $permissions = [ - // Set default permissions to not allow actions so that only the provided role permissions are at play. - ['role_id' => 0, 'view' => false, 'create' => false, 'update' => false, 'delete' => false], - ]; - - foreach ($roles as $role) { - $permission = ['role_id' => $role->id]; - foreach (EntityPermission::PERMISSIONS as $possibleAction) { - $permission[$possibleAction] = in_array($possibleAction, $actions); - } - $permissions[] = $permission; - } - - $entity->permissions()->createMany($permissions); - $entity->load('permissions'); - $this->regenPermissions($entity); - } - /** * @param Entity|Entity[] $entities */ diff --git a/tests/Helpers/PermissionsProvider.php b/tests/Helpers/PermissionsProvider.php new file mode 100644 index 000000000..ac9a2a68a --- /dev/null +++ b/tests/Helpers/PermissionsProvider.php @@ -0,0 +1,136 @@ +userRoleProvider = $userRoleProvider; + } + + /** + * Grant role permissions to the provided user. + */ + public function grantUserRolePermissions(User $user, array $permissions): void + { + $newRole = $this->userRoleProvider->createRole($permissions); + $user->attachRole($newRole); + $user->load('roles'); + $user->clearPermissionCache(); + } + + /** + * Completely remove specific role permissions from the provided user. + */ + public function removeUserRolePermissions(User $user, array $permissions): void + { + foreach ($permissions as $permissionName) { + /** @var RolePermission $permission */ + $permission = RolePermission::query() + ->where('name', '=', $permissionName) + ->firstOrFail(); + + $roles = $user->roles()->whereHas('permissions', function ($query) use ($permission) { + $query->where('id', '=', $permission->id); + })->get(); + + /** @var Role $role */ + foreach ($roles as $role) { + $role->detachPermission($permission); + } + + $user->clearPermissionCache(); + } + } + + /** + * Change the owner of the given entity to the given user. + */ + public function changeEntityOwner(Entity $entity, User $newOwner): void + { + $entity->owned_by = $newOwner->id; + $entity->save(); + $entity->rebuildPermissions(); + } + + /** + * Regenerate the permission for an entity. + * Centralised to manage clearing of cached elements between requests. + */ + public function regenerateForEntity(Entity $entity): void + { + $entity->rebuildPermissions(); + } + + /** + * Set the given entity as having restricted permissions, and apply the given + * permissions for the given roles. + * @param string[] $actions + * @param Role[] $roles + */ + public function setEntityPermissions(Entity $entity, array $actions = [], array $roles = [], $inherit = false): void + { + $entity->permissions()->delete(); + + $permissions = []; + + if (!$inherit) { + // Set default permissions to not allow actions so that only the provided role permissions are at play. + $permissions[] = ['role_id' => null, 'user_id' => null, 'view' => false, 'create' => false, 'update' => false, 'delete' => false]; + } + + foreach ($roles as $role) { + $permissions[] = $this->actionListToEntityPermissionData($actions, $role->id); + } + + $this->addEntityPermissionEntries($entity, $permissions); + } + + public function addEntityPermission(Entity $entity, array $actionList, ?Role $role = null, ?User $user = null) + { + $permissionData = $this->actionListToEntityPermissionData($actionList, $role->id ?? null, $user->id ?? null); + $this->addEntityPermissionEntries($entity, [$permissionData]); + } + + /** + * Disable inherited permissions on the given entity. + * Effectively sets the "Other Users" UI permission option to not inherit, with no permissions. + */ + public function disableEntityInheritedPermissions(Entity $entity): void + { + $entity->permissions()->whereNull(['user_id', 'role_id'])->delete(); + $fallback = $this->actionListToEntityPermissionData([]); + $this->addEntityPermissionEntries($entity, [$fallback]); + } + + protected function addEntityPermissionEntries(Entity $entity, array $entityPermissionData): void + { + $entity->permissions()->createMany($entityPermissionData); + $entity->load('permissions'); + $this->regenerateForEntity($entity); + } + + /** + * For the given simple array of string actions (view, create, update, delete), convert + * the format to entity permission data, where permission is granted if the action is in the + * given actionList array. + */ + protected function actionListToEntityPermissionData(array $actionList, int $roleId = null, int $userId = null): array + { + $permissionData = ['role_id' => $roleId, 'user_id' => $userId]; + foreach (EntityPermission::PERMISSIONS as $possibleAction) { + $permissionData[$possibleAction] = in_array($possibleAction, $actionList); + } + + return $permissionData; + } +} diff --git a/tests/Helpers/UserRoleProvider.php b/tests/Helpers/UserRoleProvider.php new file mode 100644 index 000000000..355c1687c --- /dev/null +++ b/tests/Helpers/UserRoleProvider.php @@ -0,0 +1,97 @@ +admin)) { + $adminRole = Role::getSystemRole('admin'); + $this->admin = $adminRole->users->first(); + } + + return $this->admin; + } + + /** + * Get a typical "Editor" user. + */ + public function editor(): User + { + if ($this->editor === null) { + $editorRole = Role::getRole('editor'); + $this->editor = $editorRole->users->first(); + } + + return $this->editor; + } + + /** + * Get a typical "Viewer" user. + */ + public function viewer(array $attributes = []): User + { + $user = Role::getRole('viewer')->users()->first(); + if (!empty($attributes)) { + $user->forceFill($attributes)->save(); + } + + return $user; + } + + /** + * Create a new fresh user without any relations. + */ + public function newUser(array $attrs = []): User + { + return User::factory()->create($attrs); + } + + /** + * Create a new fresh user, with the given attrs, that has assigned a fresh role + * that has the given role permissions. + * Intended as a helper to create a blank slate baseline user and role. + * @return array{0: User, 1: Role} + */ + public function newUserWithRole(array $userAttrs = [], array $rolePermissions = []): array + { + $user = $this->newUser($userAttrs); + $role = $this->attachNewRole($user, $rolePermissions); + + return [$user, $role]; + } + + /** + * Attach a new role, with the given role permissions, to the given user + * and return that role. + */ + public function attachNewRole(User $user, array $rolePermissions = []): Role + { + $role = $this->createRole($rolePermissions); + $user->attachRole($role); + return $role; + } + + /** + * Create a new basic role with the given role permissions. + */ + public function createRole(array $rolePermissions = []): Role + { + $permissionRepo = app(PermissionsRepo::class); + $roleData = Role::factory()->make()->toArray(); + $roleData['permissions'] = array_flip($rolePermissions); + + return $permissionRepo->saveNewRole($roleData); + } +} diff --git a/tests/HomepageTest.php b/tests/HomepageTest.php index cf69425fb..c7e8b69bb 100644 --- a/tests/HomepageTest.php +++ b/tests/HomepageTest.php @@ -114,7 +114,7 @@ class HomepageTest extends TestCase public function test_set_book_homepage() { - $editor = $this->getEditor(); + $editor = $this->users->editor(); setting()->putUser($editor, 'books_view_type', 'grid'); $this->setSettings(['app-homepage-type' => 'books']); @@ -133,7 +133,7 @@ class HomepageTest extends TestCase public function test_set_bookshelves_homepage() { - $editor = $this->getEditor(); + $editor = $this->users->editor(); setting()->putUser($editor, 'bookshelves_view_type', 'grid'); $shelf = $this->entities->shelf(); @@ -152,7 +152,7 @@ class HomepageTest extends TestCase public function test_shelves_list_homepage_adheres_to_book_visibility_permissions() { - $editor = $this->getEditor(); + $editor = $this->users->editor(); setting()->putUser($editor, 'bookshelves_view_type', 'list'); $this->setSettings(['app-homepage-type' => 'bookshelves']); $this->asEditor(); @@ -167,13 +167,13 @@ class HomepageTest extends TestCase // Ensure book no longer visible without view permission $editor->roles()->detach(); - $this->giveUserPermissions($editor, ['bookshelf-view-all']); + $this->permissions->grantUserRolePermissions($editor, ['bookshelf-view-all']); $homeVisit = $this->get('/'); $this->withHtml($homeVisit)->assertElementContains('.content-wrap', $shelf->name); $this->withHtml($homeVisit)->assertElementNotContains('.content-wrap', $book->name); // Ensure is visible again with entity-level view permission - $this->entities->setPermissions($book, ['view'], [$editor->roles()->first()]); + $this->permissions->setEntityPermissions($book, ['view'], [$editor->roles()->first()]); $homeVisit = $this->get('/'); $this->withHtml($homeVisit)->assertElementContains('.content-wrap', $shelf->name); $this->withHtml($homeVisit)->assertElementContains('.content-wrap', $book->name); diff --git a/tests/LanguageTest.php b/tests/LanguageTest.php index 27de5f875..ba522a74e 100644 --- a/tests/LanguageTest.php +++ b/tests/LanguageTest.php @@ -4,7 +4,7 @@ namespace Tests; class LanguageTest extends TestCase { - protected array $langs; + protected $langs; /** * LanguageTest constructor. @@ -77,20 +77,8 @@ class LanguageTest extends TestCase { $this->asEditor(); $this->assertFalse(config('app.rtl'), 'App RTL config should be false by default'); - setting()->putUser($this->getEditor(), 'language', 'ar'); + setting()->putUser($this->users->editor(), 'language', 'ar'); $this->get('/'); $this->assertTrue(config('app.rtl'), 'App RTL config should have been set to true by middleware'); } - - public function test_pluralisation_for_non_standard_locales() - { - $text = trans_choice('entities.x_pages', 1, [], 'de_informal'); - $this->assertEquals('1 Seite', $text); - - $text = trans_choice('entities.x_pages', 2, [], 'de_informal'); - $this->assertEquals('2 Seiten', $text); - - $text = trans_choice('entities.x_pages', 0, [], 'de_informal'); - $this->assertEquals('0 Seiten', $text); - } } diff --git a/tests/Permissions/EntityPermissionsTest.php b/tests/Permissions/EntityPermissionsTest.php index 4b613b49c..68a4ed244 100644 --- a/tests/Permissions/EntityPermissionsTest.php +++ b/tests/Permissions/EntityPermissionsTest.php @@ -2,6 +2,7 @@ namespace Tests\Permissions; +use BookStack\Auth\Role; use BookStack\Auth\User; use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Bookshelf; @@ -20,8 +21,8 @@ class EntityPermissionsTest extends TestCase protected function setUp(): void { parent::setUp(); - $this->user = $this->getEditor(); - $this->viewer = $this->getViewer(); + $this->user = $this->users->editor(); + $this->viewer = $this->users->viewer(); } protected function setRestrictionsForTestRoles(Entity $entity, array $actions = []) @@ -30,7 +31,7 @@ class EntityPermissionsTest extends TestCase $this->user->roles->first(), $this->viewer->roles->first(), ]; - $this->entities->setPermissions($entity, $actions, $roles); + $this->permissions->setEntityPermissions($entity, $actions, $roles); } public function test_bookshelf_view_restriction() @@ -378,8 +379,10 @@ class EntityPermissionsTest extends TestCase $this->put($modelInstance->getUrl('/permissions'), [ 'permissions' => [ - $roleId => [ - $permission => 'true', + 'role' => [ + $roleId => [ + $permission => 'true', + ], ], ], ]); @@ -655,6 +658,34 @@ class EntityPermissionsTest extends TestCase $resp->assertRedirect($book->getUrl('/page/test-page')); } + public function test_access_to_item_prevented_if_inheritance_active_but_permission_prevented_via_role() + { + $user = $this->users->viewer(); + $viewerRole = $user->roles->first(); + $chapter = $this->entities->chapter(); + $book = $chapter->book; + + $this->permissions->setEntityPermissions($book, ['edit'], [$viewerRole], false); + $this->permissions->setEntityPermissions($chapter, [], [$viewerRole], true); + + $this->assertFalse(userCan('chapter-update', $chapter)); + } + + public function test_access_to_item_allowed_if_inheritance_active_and_permission_prevented_via_role_but_allowed_via_parent() + { + $user = $this->users->viewer(); + $viewerRole = $user->roles->first(); + $editorRole = Role::getRole('Editor'); + $user->attachRole($editorRole); + $chapter = $this->entities->chapter(); + $book = $chapter->book; + + $this->permissions->setEntityPermissions($book, ['edit'], [$editorRole], false); + $this->permissions->setEntityPermissions($chapter, [], [$viewerRole], true); + + $this->assertTrue(userCan('chapter-update', $chapter)); + } + public function test_book_permissions_can_be_generated_without_error_if_child_chapter_is_in_recycle_bin() { $book = $this->entities->bookHasChaptersAndPages(); @@ -665,7 +696,7 @@ class EntityPermissionsTest extends TestCase $error = null; try { - $this->entities->setPermissions($book, ['view'], []); + $this->permissions->setEntityPermissions($book, ['view'], []); } catch (Exception $e) { $error = $e; } diff --git a/tests/Permissions/ExportPermissionsTest.php b/tests/Permissions/ExportPermissionsTest.php index 642cf1beb..8072221e5 100644 --- a/tests/Permissions/ExportPermissionsTest.php +++ b/tests/Permissions/ExportPermissionsTest.php @@ -14,7 +14,7 @@ class ExportPermissionsTest extends TestCase $pageContent = Str::random(48); $page->html = '

' . $pageContent . '

'; $page->save(); - $viewer = $this->getViewer(); + $viewer = $this->users->viewer(); $this->actingAs($viewer); $formats = ['html', 'plaintext']; @@ -25,7 +25,7 @@ class ExportPermissionsTest extends TestCase $resp->assertSee($pageContent); } - $this->entities->setPermissions($page, []); + $this->permissions->setEntityPermissions($page, []); foreach ($formats as $format) { $resp = $this->get($chapter->getUrl("export/{$format}")); @@ -42,7 +42,7 @@ class ExportPermissionsTest extends TestCase $pageContent = Str::random(48); $page->html = '

' . $pageContent . '

'; $page->save(); - $viewer = $this->getViewer(); + $viewer = $this->users->viewer(); $this->actingAs($viewer); $formats = ['html', 'plaintext']; @@ -53,7 +53,7 @@ class ExportPermissionsTest extends TestCase $resp->assertSee($pageContent); } - $this->entities->setPermissions($page, []); + $this->permissions->setEntityPermissions($page, []); foreach ($formats as $format) { $resp = $this->get($book->getUrl("export/{$format}")); diff --git a/tests/Permissions/RolesTest.php b/tests/Permissions/RolesTest.php index 88d400259..8bf700c07 100644 --- a/tests/Permissions/RolesTest.php +++ b/tests/Permissions/RolesTest.php @@ -22,7 +22,7 @@ class RolesTest extends TestCase protected function setUp(): void { parent::setUp(); - $this->user = $this->getViewer(); + $this->user = $this->users->viewer(); } public function test_admin_can_see_settings() @@ -42,7 +42,7 @@ class RolesTest extends TestCase public function test_role_cannot_be_deleted_if_default() { - $newRole = $this->createNewRole(); + $newRole = $this->users->createRole(); $this->setSettings(['registration-role' => $newRole->id]); $deletePageUrl = '/settings/roles/delete/' . $newRole->id; @@ -121,11 +121,11 @@ class RolesTest extends TestCase { /** @var Role $adminRole */ $adminRole = Role::query()->where('system_name', '=', 'admin')->first(); - $adminUser = $this->getAdmin(); + $adminUser = $this->users->admin(); $adminRole->users()->where('id', '!=', $adminUser->id)->delete(); $this->assertEquals(1, $adminRole->users()->count()); - $viewerRole = $this->getViewer()->roles()->first(); + $viewerRole = $this->users->viewer()->roles()->first(); $editUrl = '/settings/users/' . $adminUser->id; $resp = $this->actingAs($adminUser)->put($editUrl, [ @@ -169,7 +169,7 @@ class RolesTest extends TestCase $roleA = Role::query()->create(['display_name' => 'Entity Permissions Delete Test']); $page = $this->entities->page(); - $this->entities->setPermissions($page, ['view'], [$roleA]); + $this->permissions->setEntityPermissions($page, ['view'], [$roleA]); $this->assertDatabaseHas('entity_permissions', [ 'role_id' => $roleA->id, @@ -214,7 +214,7 @@ class RolesTest extends TestCase public function test_manage_user_permission() { $this->actingAs($this->user)->get('/settings/users')->assertRedirect('/'); - $this->giveUserPermissions($this->user, ['users-manage']); + $this->permissions->grantUserRolePermissions($this->user, ['users-manage']); $this->actingAs($this->user)->get('/settings/users')->assertOk(); } @@ -222,9 +222,9 @@ class RolesTest extends TestCase { $usersLink = 'href="' . url('/settings/users') . '"'; $this->actingAs($this->user)->get('/')->assertDontSee($usersLink, false); - $this->giveUserPermissions($this->user, ['users-manage']); + $this->permissions->grantUserRolePermissions($this->user, ['users-manage']); $this->actingAs($this->user)->get('/')->assertSee($usersLink, false); - $this->giveUserPermissions($this->user, ['settings-manage', 'users-manage']); + $this->permissions->grantUserRolePermissions($this->user, ['settings-manage', 'users-manage']); $this->actingAs($this->user)->get('/')->assertDontSee($usersLink, false); } @@ -247,7 +247,7 @@ class RolesTest extends TestCase 'name' => 'my_new_name', ]); - $this->giveUserPermissions($this->user, ['users-manage']); + $this->permissions->grantUserRolePermissions($this->user, ['users-manage']); $resp = $this->get($userProfileUrl) ->assertOk(); @@ -269,7 +269,7 @@ class RolesTest extends TestCase { $this->actingAs($this->user)->get('/settings/roles')->assertRedirect('/'); $this->get('/settings/roles/1')->assertRedirect('/'); - $this->giveUserPermissions($this->user, ['user-roles-manage']); + $this->permissions->grantUserRolePermissions($this->user, ['user-roles-manage']); $this->actingAs($this->user)->get('/settings/roles')->assertOk(); $this->get('/settings/roles/1') ->assertOk() @@ -279,7 +279,7 @@ class RolesTest extends TestCase public function test_settings_manage_permission() { $this->actingAs($this->user)->get('/settings/features')->assertRedirect('/'); - $this->giveUserPermissions($this->user, ['settings-manage']); + $this->permissions->grantUserRolePermissions($this->user, ['settings-manage']); $this->get('/settings/features')->assertOk(); $resp = $this->post('/settings/features', []); @@ -295,7 +295,7 @@ class RolesTest extends TestCase $this->actingAs($this->user)->get($page->getUrl())->assertDontSee('Permissions'); $this->get($page->getUrl('/permissions'))->assertRedirect('/'); - $this->giveUserPermissions($this->user, ['restrictions-manage-all']); + $this->permissions->grantUserRolePermissions($this->user, ['restrictions-manage-all']); $this->actingAs($this->user)->get($page->getUrl())->assertSee('Permissions'); @@ -325,7 +325,7 @@ class RolesTest extends TestCase $this->actingAs($this->user)->get($page->getUrl())->assertDontSee('Permissions'); $this->get($page->getUrl('/permissions'))->assertRedirect('/'); - $this->giveUserPermissions($this->user, ['restrictions-manage-own']); + $this->permissions->grantUserRolePermissions($this->user, ['restrictions-manage-own']); // Check can't restrict other's content $this->actingAs($this->user)->get($otherUsersPage->getUrl())->assertDontSee('Permissions'); @@ -350,7 +350,7 @@ class RolesTest extends TestCase $this->withHtml($resp)->assertElementNotContains('.action-buttons', $text); } - $this->giveUserPermissions($this->user, [$permission]); + $this->permissions->grantUserRolePermissions($this->user, [$permission]); foreach ($accessUrls as $url) { $this->actingAs($this->user)->get($url)->assertOk(); @@ -380,7 +380,7 @@ class RolesTest extends TestCase $otherShelf = Bookshelf::query()->first(); $ownShelf = $this->entities->newShelf(['name' => 'test-shelf', 'slug' => 'test-shelf']); $ownShelf->forceFill(['owned_by' => $this->user->id, 'updated_by' => $this->user->id])->save(); - $this->entities->regenPermissions($ownShelf); + $this->permissions->regenerateForEntity($ownShelf); $this->checkAccessPermission('bookshelf-update-own', [ $ownShelf->getUrl('/edit'), @@ -406,12 +406,12 @@ class RolesTest extends TestCase public function test_bookshelves_delete_own_permission() { - $this->giveUserPermissions($this->user, ['bookshelf-update-all']); + $this->permissions->grantUserRolePermissions($this->user, ['bookshelf-update-all']); /** @var Bookshelf $otherShelf */ $otherShelf = Bookshelf::query()->first(); $ownShelf = $this->entities->newShelf(['name' => 'test-shelf', 'slug' => 'test-shelf']); $ownShelf->forceFill(['owned_by' => $this->user->id, 'updated_by' => $this->user->id])->save(); - $this->entities->regenPermissions($ownShelf); + $this->permissions->regenerateForEntity($ownShelf); $this->checkAccessPermission('bookshelf-delete-own', [ $ownShelf->getUrl('/delete'), @@ -430,7 +430,7 @@ class RolesTest extends TestCase public function test_bookshelves_delete_all_permission() { - $this->giveUserPermissions($this->user, ['bookshelf-update-all']); + $this->permissions->grantUserRolePermissions($this->user, ['bookshelf-update-all']); /** @var Bookshelf $otherShelf */ $otherShelf = Bookshelf::query()->first(); $this->checkAccessPermission('bookshelf-delete-all', [ @@ -486,7 +486,7 @@ class RolesTest extends TestCase public function test_books_delete_own_permission() { - $this->giveUserPermissions($this->user, ['book-update-all']); + $this->permissions->grantUserRolePermissions($this->user, ['book-update-all']); /** @var Book $otherBook */ $otherBook = Book::query()->take(1)->get()->first(); $ownBook = $this->entities->createChainBelongingToUser($this->user)['book']; @@ -506,7 +506,7 @@ class RolesTest extends TestCase public function test_books_delete_all_permission() { - $this->giveUserPermissions($this->user, ['book-update-all']); + $this->permissions->grantUserRolePermissions($this->user, ['book-update-all']); /** @var Book $otherBook */ $otherBook = Book::query()->take(1)->get()->first(); $this->checkAccessPermission('book-delete-all', [ @@ -585,7 +585,7 @@ class RolesTest extends TestCase public function test_chapter_delete_own_permission() { - $this->giveUserPermissions($this->user, ['chapter-update-all']); + $this->permissions->grantUserRolePermissions($this->user, ['chapter-update-all']); /** @var Chapter $otherChapter */ $otherChapter = Chapter::query()->first(); $ownChapter = $this->entities->createChainBelongingToUser($this->user)['chapter']; @@ -607,7 +607,7 @@ class RolesTest extends TestCase public function test_chapter_delete_all_permission() { - $this->giveUserPermissions($this->user, ['chapter-update-all']); + $this->permissions->grantUserRolePermissions($this->user, ['chapter-update-all']); /** @var Chapter $otherChapter */ $otherChapter = Chapter::query()->first(); $this->checkAccessPermission('chapter-delete-all', [ @@ -645,7 +645,7 @@ class RolesTest extends TestCase $ownChapter->getUrl() => 'New Page', ]); - $this->giveUserPermissions($this->user, ['page-create-own']); + $this->permissions->grantUserRolePermissions($this->user, ['page-create-own']); foreach ($accessUrls as $index => $url) { $resp = $this->actingAs($this->user)->get($url); @@ -688,7 +688,7 @@ class RolesTest extends TestCase $chapter->getUrl() => 'New Page', ]); - $this->giveUserPermissions($this->user, ['page-create-all']); + $this->permissions->grantUserRolePermissions($this->user, ['page-create-all']); foreach ($accessUrls as $index => $url) { $resp = $this->actingAs($this->user)->get($url); @@ -742,7 +742,7 @@ class RolesTest extends TestCase public function test_page_delete_own_permission() { - $this->giveUserPermissions($this->user, ['page-update-all']); + $this->permissions->grantUserRolePermissions($this->user, ['page-update-all']); /** @var Page $otherPage */ $otherPage = Page::query()->first(); $ownPage = $this->entities->createChainBelongingToUser($this->user)['page']; @@ -764,7 +764,7 @@ class RolesTest extends TestCase public function test_page_delete_all_permission() { - $this->giveUserPermissions($this->user, ['page-update-all']); + $this->permissions->grantUserRolePermissions($this->user, ['page-update-all']); /** @var Page $otherPage */ $otherPage = Page::query()->first(); @@ -823,7 +823,7 @@ class RolesTest extends TestCase public function test_image_delete_own_permission() { - $this->giveUserPermissions($this->user, ['image-update-all']); + $this->permissions->grantUserRolePermissions($this->user, ['image-update-all']); $page = $this->entities->page(); $image = Image::factory()->create([ 'uploaded_to' => $page->id, @@ -833,7 +833,7 @@ class RolesTest extends TestCase $this->actingAs($this->user)->json('delete', '/images/' . $image->id)->assertStatus(403); - $this->giveUserPermissions($this->user, ['image-delete-own']); + $this->permissions->grantUserRolePermissions($this->user, ['image-delete-own']); $this->actingAs($this->user)->json('delete', '/images/' . $image->id)->assertOk(); $this->assertDatabaseMissing('images', ['id' => $image->id]); @@ -841,18 +841,18 @@ class RolesTest extends TestCase public function test_image_delete_all_permission() { - $this->giveUserPermissions($this->user, ['image-update-all']); - $admin = $this->getAdmin(); + $this->permissions->grantUserRolePermissions($this->user, ['image-update-all']); + $admin = $this->users->admin(); $page = $this->entities->page(); $image = Image::factory()->create(['uploaded_to' => $page->id, 'created_by' => $admin->id, 'updated_by' => $admin->id]); $this->actingAs($this->user)->json('delete', '/images/' . $image->id)->assertStatus(403); - $this->giveUserPermissions($this->user, ['image-delete-own']); + $this->permissions->grantUserRolePermissions($this->user, ['image-delete-own']); $this->actingAs($this->user)->json('delete', '/images/' . $image->id)->assertStatus(403); - $this->giveUserPermissions($this->user, ['image-delete-all']); + $this->permissions->grantUserRolePermissions($this->user, ['image-delete-all']); $this->actingAs($this->user)->json('delete', '/images/' . $image->id)->assertOk(); $this->assertDatabaseMissing('images', ['id' => $image->id]); @@ -863,7 +863,7 @@ class RolesTest extends TestCase // To cover issue fixed in f99c8ff99aee9beb8c692f36d4b84dc6e651e50a. $page = $this->entities->page(); $viewerRole = Role::getRole('viewer'); - $viewer = $this->getViewer(); + $viewer = $this->users->viewer(); $this->actingAs($viewer)->get($page->getUrl())->assertOk(); $this->asAdmin()->put('/settings/roles/' . $viewerRole->id, [ @@ -877,18 +877,18 @@ class RolesTest extends TestCase public function test_empty_state_actions_not_visible_without_permission() { - $admin = $this->getAdmin(); + $admin = $this->users->admin(); // Book links $book = Book::factory()->create(['created_by' => $admin->id, 'updated_by' => $admin->id]); - $this->entities->regenPermissions($book); - $this->actingAs($this->getViewer())->get($book->getUrl()) + $this->permissions->regenerateForEntity($book); + $this->actingAs($this->users->viewer())->get($book->getUrl()) ->assertDontSee('Create a new page') ->assertDontSee('Add a chapter'); // Chapter links $chapter = Chapter::factory()->create(['created_by' => $admin->id, 'updated_by' => $admin->id, 'book_id' => $book->id]); - $this->entities->regenPermissions($chapter); - $this->actingAs($this->getViewer())->get($chapter->getUrl()) + $this->permissions->regenerateForEntity($chapter); + $this->actingAs($this->users->viewer())->get($chapter->getUrl()) ->assertDontSee('Create a new page') ->assertDontSee('Sort the current book'); } @@ -901,7 +901,7 @@ class RolesTest extends TestCase ->addComment($ownPage) ->assertStatus(403); - $this->giveUserPermissions($this->user, ['comment-create-all']); + $this->permissions->grantUserRolePermissions($this->user, ['comment-create-all']); $this->actingAs($this->user) ->addComment($ownPage) @@ -911,7 +911,7 @@ class RolesTest extends TestCase public function test_comment_update_own_permission() { $ownPage = $this->entities->createChainBelongingToUser($this->user)['page']; - $this->giveUserPermissions($this->user, ['comment-create-all']); + $this->permissions->grantUserRolePermissions($this->user, ['comment-create-all']); $this->actingAs($this->user)->addComment($ownPage); /** @var Comment $comment */ $comment = $ownPage->comments()->latest()->first(); @@ -919,7 +919,7 @@ class RolesTest extends TestCase // no comment-update-own $this->actingAs($this->user)->updateComment($comment)->assertStatus(403); - $this->giveUserPermissions($this->user, ['comment-update-own']); + $this->permissions->grantUserRolePermissions($this->user, ['comment-update-own']); // now has comment-update-own $this->actingAs($this->user)->updateComment($comment)->assertOk(); @@ -936,7 +936,7 @@ class RolesTest extends TestCase // no comment-update-all $this->actingAs($this->user)->updateComment($comment)->assertStatus(403); - $this->giveUserPermissions($this->user, ['comment-update-all']); + $this->permissions->grantUserRolePermissions($this->user, ['comment-update-all']); // now has comment-update-all $this->actingAs($this->user)->updateComment($comment)->assertOk(); @@ -946,7 +946,7 @@ class RolesTest extends TestCase { /** @var Page $ownPage */ $ownPage = $this->entities->createChainBelongingToUser($this->user)['page']; - $this->giveUserPermissions($this->user, ['comment-create-all']); + $this->permissions->grantUserRolePermissions($this->user, ['comment-create-all']); $this->actingAs($this->user)->addComment($ownPage); /** @var Comment $comment */ @@ -955,7 +955,7 @@ class RolesTest extends TestCase // no comment-delete-own $this->actingAs($this->user)->deleteComment($comment)->assertStatus(403); - $this->giveUserPermissions($this->user, ['comment-delete-own']); + $this->permissions->grantUserRolePermissions($this->user, ['comment-delete-own']); // now has comment-update-own $this->actingAs($this->user)->deleteComment($comment)->assertOk(); @@ -972,7 +972,7 @@ class RolesTest extends TestCase // no comment-delete-all $this->actingAs($this->user)->deleteComment($comment)->assertStatus(403); - $this->giveUserPermissions($this->user, ['comment-delete-all']); + $this->permissions->grantUserRolePermissions($this->user, ['comment-delete-all']); // now has comment-delete-all $this->actingAs($this->user)->deleteComment($comment)->assertOk(); diff --git a/tests/Permissions/Scenarios/EntityRolePermissionsTest.php b/tests/Permissions/Scenarios/EntityRolePermissionsTest.php new file mode 100644 index 000000000..b92ce620b --- /dev/null +++ b/tests/Permissions/Scenarios/EntityRolePermissionsTest.php @@ -0,0 +1,201 @@ +users->newUserWithRole(); + $page = $this->entities->page(); + $this->permissions->setEntityPermissions($page, ['view'], [$role], false); + + $this->assertVisibleToUser($page, $user); + } + + public function test_02_explicit_deny() + { + [$user, $role] = $this->users->newUserWithRole(); + $page = $this->entities->page(); + $this->permissions->setEntityPermissions($page, [], [$role], false); + + $this->assertNotVisibleToUser($page, $user); + } + + public function test_03_same_level_conflicting() + { + [$user, $roleA] = $this->users->newUserWithRole(); + $roleB = $this->users->attachNewRole($user); + $page = $this->entities->page(); + + $this->permissions->disableEntityInheritedPermissions($page); + $this->permissions->addEntityPermission($page, [], $roleA); + $this->permissions->addEntityPermission($page, ['view'], $roleB); + + $this->assertVisibleToUser($page, $user); + } + + public function test_20_inherit_allow() + { + [$user, $roleA] = $this->users->newUserWithRole(); + $page = $this->entities->pageWithinChapter(); + $chapter = $page->chapter; + + $this->permissions->disableEntityInheritedPermissions($chapter); + $this->permissions->addEntityPermission($chapter, ['view'], $roleA); + + $this->assertVisibleToUser($page, $user); + } + + public function test_21_inherit_deny() + { + [$user, $roleA] = $this->users->newUserWithRole(); + $page = $this->entities->pageWithinChapter(); + $chapter = $page->chapter; + + $this->permissions->disableEntityInheritedPermissions($chapter); + $this->permissions->addEntityPermission($chapter, [], $roleA); + + $this->assertNotVisibleToUser($page, $user); + } + + public function test_22_same_level_conflict_inherit() + { + [$user, $roleA] = $this->users->newUserWithRole(); + $roleB = $this->users->attachNewRole($user); + $page = $this->entities->pageWithinChapter(); + $chapter = $page->chapter; + + $this->permissions->disableEntityInheritedPermissions($chapter); + $this->permissions->addEntityPermission($chapter, [], $roleA); + $this->permissions->addEntityPermission($chapter, ['view'], $roleB); + + $this->assertVisibleToUser($page, $user); + } + + public function test_30_child_inherit_override_allow() + { + [$user, $roleA] = $this->users->newUserWithRole(); + $page = $this->entities->pageWithinChapter(); + $chapter = $page->chapter; + + $this->permissions->disableEntityInheritedPermissions($chapter); + $this->permissions->addEntityPermission($chapter, [], $roleA); + $this->permissions->addEntityPermission($page, ['view'], $roleA); + + $this->assertVisibleToUser($page, $user); + } + + public function test_31_child_inherit_override_deny() + { + [$user, $roleA] = $this->users->newUserWithRole(); + $page = $this->entities->pageWithinChapter(); + $chapter = $page->chapter; + + $this->permissions->disableEntityInheritedPermissions($chapter); + $this->permissions->addEntityPermission($chapter, ['view'], $roleA); + $this->permissions->addEntityPermission($page, [], $roleA); + + $this->assertNotVisibleToUser($page, $user); + } + + public function test_40_multi_role_inherit_conflict_override_deny() + { + [$user, $roleA] = $this->users->newUserWithRole(); + $roleB = $this->users->attachNewRole($user); + $page = $this->entities->pageWithinChapter(); + $chapter = $page->chapter; + + $this->permissions->disableEntityInheritedPermissions($chapter); + $this->permissions->addEntityPermission($page, [], $roleA); + $this->permissions->addEntityPermission($chapter, ['view'], $roleB); + + $this->assertVisibleToUser($page, $user); + } + + public function test_41_multi_role_inherit_conflict_retain_allow() + { + [$user, $roleA] = $this->users->newUserWithRole(); + $roleB = $this->users->attachNewRole($user); + $page = $this->entities->pageWithinChapter(); + $chapter = $page->chapter; + + $this->permissions->disableEntityInheritedPermissions($chapter); + $this->permissions->addEntityPermission($page, ['view'], $roleA); + $this->permissions->addEntityPermission($chapter, [], $roleB); + + $this->assertVisibleToUser($page, $user); + } + + public function test_50_role_override_allow() + { + [$user, $roleA] = $this->users->newUserWithRole(); + $page = $this->entities->page(); + $this->permissions->addEntityPermission($page, ['view'], $roleA); + + $this->assertVisibleToUser($page, $user); + } + + public function test_51_role_override_deny() + { + [$user, $roleA] = $this->users->newUserWithRole([], ['page-view-all']); + $page = $this->entities->page(); + $this->permissions->addEntityPermission($page, [], $roleA); + + $this->assertNotVisibleToUser($page, $user); + } + + public function test_60_inherited_role_override_allow() + { + [$user, $roleA] = $this->users->newUserWithRole([], []); + $page = $this->entities->pageWithinChapter(); + $chapter = $page->chapter; + $this->permissions->addEntityPermission($chapter, ['view'], $roleA); + + $this->assertVisibleToUser($page, $user); + } + + public function test_61_inherited_role_override_deny() + { + [$user, $roleA] = $this->users->newUserWithRole([], ['page-view-all']); + $page = $this->entities->pageWithinChapter(); + $chapter = $page->chapter; + $this->permissions->addEntityPermission($chapter, [], $roleA); + + $this->assertNotVisibleToUser($page, $user); + } + + public function test_62_inherited_role_override_deny_on_own() + { + [$user, $roleA] = $this->users->newUserWithRole([], ['page-view-own']); + $page = $this->entities->pageWithinChapter(); + $chapter = $page->chapter; + $this->permissions->addEntityPermission($chapter, [], $roleA); + $this->permissions->changeEntityOwner($page, $user); + + $this->assertNotVisibleToUser($page, $user); + } + + public function test_70_multi_role_inheriting_deny() + { + [$user, $roleA] = $this->users->newUserWithRole([], ['page-view-all']); + $roleB = $this->users->attachNewRole($user); + $page = $this->entities->page(); + + $this->permissions->addEntityPermission($page, [], $roleB); + + $this->assertNotVisibleToUser($page, $user); + } + + public function test_80_multi_role_inherited_deny_via_parent() + { + [$user, $roleA] = $this->users->newUserWithRole([], ['page-view-all']); + $roleB = $this->users->attachNewRole($user); + $page = $this->entities->pageWithinChapter(); + $chapter = $page->chapter; + + $this->permissions->addEntityPermission($chapter, [], $roleB); + + $this->assertNotVisibleToUser($page, $user); + } +} diff --git a/tests/Permissions/Scenarios/EntityUserPermissionsTest.php b/tests/Permissions/Scenarios/EntityUserPermissionsTest.php new file mode 100644 index 000000000..4fa805805 --- /dev/null +++ b/tests/Permissions/Scenarios/EntityUserPermissionsTest.php @@ -0,0 +1,209 @@ +users->newUser(); + $page = $this->entities->page(); + $this->permissions->disableEntityInheritedPermissions($page); + $this->permissions->addEntityPermission($page, ['view'], null, $user); + + $this->assertVisibleToUser($page, $user); + } + + public function test_02_explicit_deny() + { + $user = $this->users->newUser(); + $page = $this->entities->page(); + $this->permissions->disableEntityInheritedPermissions($page); + $this->permissions->addEntityPermission($page, [], null, $user); + + $this->assertNotVisibleToUser($page, $user); + } + + public function test_10_allow_inherit() + { + $user = $this->users->newUser(); + $page = $this->entities->pageWithinChapter(); + $chapter = $page->chapter; + $this->permissions->disableEntityInheritedPermissions($chapter); + $this->permissions->addEntityPermission($chapter, ['view'], null, $user); + + $this->assertVisibleToUser($page, $user); + } + + public function test_11_deny_inherit() + { + $user = $this->users->newUser(); + $page = $this->entities->pageWithinChapter(); + $chapter = $page->chapter; + $this->permissions->disableEntityInheritedPermissions($chapter); + $this->permissions->addEntityPermission($chapter, [], null, $user); + + $this->assertNotVisibleToUser($page, $user); + } + + public function test_12_allow_inherit_override() + { + $user = $this->users->newUser(); + $page = $this->entities->pageWithinChapter(); + $chapter = $page->chapter; + $this->permissions->disableEntityInheritedPermissions($chapter); + $this->permissions->addEntityPermission($chapter, [], null, $user); + $this->permissions->addEntityPermission($page, ['view'], null, $user); + + $this->assertVisibleToUser($page, $user); + } + + public function test_13_deny_inherit_override() + { + $user = $this->users->newUser(); + $page = $this->entities->pageWithinChapter(); + $chapter = $page->chapter; + $this->permissions->disableEntityInheritedPermissions($chapter); + $this->permissions->addEntityPermission($chapter, ['view'], null, $user); + $this->permissions->addEntityPermission($page, ['deny'], null, $user); + + $this->assertNotVisibleToUser($page, $user); + } + + public function test_40_entity_role_override_allow() + { + [$user, $role] = $this->users->newUserWithRole(); + $page = $this->entities->page(); + $this->permissions->disableEntityInheritedPermissions($page); + $this->permissions->addEntityPermission($page, ['view'], null, $user); + $this->permissions->addEntityPermission($page, [], $role); + + $this->assertVisibleToUser($page, $user); + } + + public function test_41_entity_role_override_deny() + { + [$user, $role] = $this->users->newUserWithRole(); + $page = $this->entities->page(); + $this->permissions->disableEntityInheritedPermissions($page); + $this->permissions->addEntityPermission($page, [], null, $user); + $this->permissions->addEntityPermission($page, ['view'], $role); + + $this->assertNotVisibleToUser($page, $user); + } + + public function test_42_entity_role_override_allow_via_inherit() + { + [$user, $role] = $this->users->newUserWithRole(); + $page = $this->entities->pageWithinChapter(); + $chapter = $page->chapter; + $this->permissions->disableEntityInheritedPermissions($chapter); + $this->permissions->addEntityPermission($chapter, ['view'], null, $user); + $this->permissions->addEntityPermission($page, [], $role); + + $this->assertVisibleToUser($page, $user); + } + + public function test_43_entity_role_override_deny_via_inherit() + { + [$user, $role] = $this->users->newUserWithRole(); + $page = $this->entities->pageWithinChapter(); + $chapter = $page->chapter; + $this->permissions->disableEntityInheritedPermissions($chapter); + $this->permissions->addEntityPermission($chapter, [], null, $user); + $this->permissions->addEntityPermission($page, ['view'], $role); + + $this->assertNotVisibleToUser($page, $user); + } + + public function test_50_role_override_allow() + { + [$user, $roleA] = $this->users->newUserWithRole(); + $page = $this->entities->page(); + $this->permissions->addEntityPermission($page, ['view'], null, $user); + + $this->assertVisibleToUser($page, $user); + } + + public function test_51_role_override_deny() + { + [$user, $roleA] = $this->users->newUserWithRole([], ['page-view-all']); + $page = $this->entities->page(); + $this->permissions->addEntityPermission($page, [], null, $user); + + $this->assertNotVisibleToUser($page, $user); + } + + public function test_60_inherited_role_override_allow() + { + [$user, $roleA] = $this->users->newUserWithRole([], []); + $page = $this->entities->pageWithinChapter(); + $chapter = $page->chapter; + $this->permissions->addEntityPermission($chapter, ['view'], null, $user); + + $this->assertVisibleToUser($page, $user); + } + + public function test_61_inherited_role_override_deny() + { + [$user, $roleA] = $this->users->newUserWithRole([], ['page-view-all']); + $page = $this->entities->pageWithinChapter(); + $chapter = $page->chapter; + $this->permissions->addEntityPermission($chapter, [], null, $user); + + $this->assertNotVisibleToUser($page, $user); + } + + public function test_61_inherited_role_override_deny_on_own() + { + [$user, $roleA] = $this->users->newUserWithRole([], ['page-view-own']); + $page = $this->entities->pageWithinChapter(); + $chapter = $page->chapter; + $this->permissions->addEntityPermission($chapter, [], null, $user); + $this->permissions->changeEntityOwner($page, $user); + + $this->assertNotVisibleToUser($page, $user); + } + + public function test_70_all_override_allow() + { + [$user, $roleA] = $this->users->newUserWithRole([], []); + $page = $this->entities->page(); + $this->permissions->addEntityPermission($page, [], $roleA, null); + $this->permissions->addEntityPermission($page, ['view'], null, $user); + + $this->assertVisibleToUser($page, $user); + } + + public function test_71_all_override_deny() + { + [$user, $roleA] = $this->users->newUserWithRole([], ['page-view-all']); + $page = $this->entities->page(); + $this->permissions->addEntityPermission($page, ['view'], $roleA, null); + $this->permissions->addEntityPermission($page, [], null, $user); + + $this->assertNotVisibleToUser($page, $user); + } + + public function test_80_inherited_all_override_allow() + { + [$user, $roleA] = $this->users->newUserWithRole([], []); + $page = $this->entities->pageWithinChapter(); + $chapter = $page->chapter; + $this->permissions->addEntityPermission($chapter, [], $roleA, null); + $this->permissions->addEntityPermission($chapter, ['view'], null, $user); + + $this->assertVisibleToUser($page, $user); + } + + public function test_81_inherited_all_override_deny() + { + [$user, $roleA] = $this->users->newUserWithRole([], ['page-view-all']); + $page = $this->entities->pageWithinChapter(); + $chapter = $page->chapter; + $this->permissions->addEntityPermission($chapter, ['view'], $roleA, null); + $this->permissions->addEntityPermission($chapter, [], null, $user); + + $this->assertNotVisibleToUser($page, $user); + } +} diff --git a/tests/Permissions/Scenarios/PermissionScenarioTestCase.php b/tests/Permissions/Scenarios/PermissionScenarioTestCase.php new file mode 100644 index 000000000..5352f468a --- /dev/null +++ b/tests/Permissions/Scenarios/PermissionScenarioTestCase.php @@ -0,0 +1,38 @@ +actingAs($user); + $funcView = userCan($entity->getMorphClass() . '-view', $entity); + $queryView = $entity->newQuery()->scopes(['visible'])->find($entity->id) !== null; + + $id = $entity->getMorphClass() . ':' . $entity->id; + $msg = "Item [{$id}] should be visible but was not found via "; + $msg .= implode(' and ', array_filter([!$funcView ? 'userCan' : '', !$queryView ? 'query' : ''])); + + static::assertTrue($funcView && $queryView, $msg); + } + + protected function assertNotVisibleToUser(Entity $entity, User $user) + { + $this->actingAs($user); + $funcView = userCan($entity->getMorphClass() . '-view', $entity); + $queryView = $entity->newQuery()->scopes(['visible'])->find($entity->id) !== null; + + $id = $entity->getMorphClass() . ':' . $entity->id; + $msg = "Item [{$id}] should not be visible but was found via "; + $msg .= implode(' and ', array_filter([$funcView ? 'userCan' : '', $queryView ? 'query' : ''])); + + static::assertTrue(!$funcView && !$queryView, $msg); + } +} diff --git a/tests/Permissions/Scenarios/RoleContentPermissionsTest.php b/tests/Permissions/Scenarios/RoleContentPermissionsTest.php new file mode 100644 index 000000000..8b8c9031c --- /dev/null +++ b/tests/Permissions/Scenarios/RoleContentPermissionsTest.php @@ -0,0 +1,59 @@ +users->newUserWithRole([], ['page-view-all']); + $page = $this->entities->page(); + + $this->assertVisibleToUser($page, $user); + } + + public function test_02_deny() + { + [$user] = $this->users->newUserWithRole([], []); + $page = $this->entities->page(); + + $this->assertNotVisibleToUser($page, $user); + } + + public function test_10_allow_on_own_with_own() + { + [$user] = $this->users->newUserWithRole([], ['page-view-own']); + $page = $this->entities->page(); + $this->permissions->changeEntityOwner($page, $user); + + $this->assertVisibleToUser($page, $user); + } + + public function test_11_deny_on_other_with_own() + { + [$user] = $this->users->newUserWithRole([], ['page-view-own']); + $page = $this->entities->page(); + $this->permissions->changeEntityOwner($page, $this->users->editor()); + + $this->assertNotVisibleToUser($page, $user); + } + + public function test_20_multiple_role_conflicting_all() + { + [$user] = $this->users->newUserWithRole([], ['page-view-all']); + $this->users->attachNewRole($user, []); + $page = $this->entities->page(); + + $this->assertVisibleToUser($page, $user); + } + + public function test_21_multiple_role_conflicting_own() + { + [$user] = $this->users->newUserWithRole([], ['page-view-own']); + $this->users->attachNewRole($user, []); + $page = $this->entities->page(); + $this->permissions->changeEntityOwner($page, $user); + + $this->assertVisibleToUser($page, $user); + } +} diff --git a/tests/PublicActionTest.php b/tests/PublicActionTest.php index 7e3f7be00..afc7fcef3 100644 --- a/tests/PublicActionTest.php +++ b/tests/PublicActionTest.php @@ -2,7 +2,6 @@ namespace Tests; -use BookStack\Auth\Permissions\JointPermissionBuilder; use BookStack\Auth\Permissions\RolePermission; use BookStack\Auth\Role; use BookStack\Auth\User; @@ -89,7 +88,6 @@ class PublicActionTest extends TestCase foreach (RolePermission::all() as $perm) { $publicRole->attachPermission($perm); } - $this->app->make(JointPermissionBuilder::class)->rebuildForRole($publicRole); user()->clearPermissionCache(); $chapter = $this->entities->chapter(); @@ -173,7 +171,7 @@ class PublicActionTest extends TestCase { $this->setSettings(['app-public' => 'true']); $book = $this->entities->book(); - $this->entities->setPermissions($book); + $this->permissions->setEntityPermissions($book); $resp = $this->get($book->getUrl()); $resp->assertSee('Book not found'); diff --git a/tests/References/ReferencesTest.php b/tests/References/ReferencesTest.php index 148b2197c..4330598ba 100644 --- a/tests/References/ReferencesTest.php +++ b/tests/References/ReferencesTest.php @@ -91,7 +91,7 @@ class ReferencesTest extends TestCase $pageB = $this->entities->page(); $this->createReference($pageB, $page); - $this->entities->setPermissions($pageB); + $this->permissions->setEntityPermissions($pageB); $this->asEditor()->get($page->getUrl('/references'))->assertDontSee($pageB->name); $this->asAdmin()->get($page->getUrl('/references'))->assertSee($pageB->name); diff --git a/tests/Settings/RecycleBinTest.php b/tests/Settings/RecycleBinTest.php index 990df607e..8adc92f25 100644 --- a/tests/Settings/RecycleBinTest.php +++ b/tests/Settings/RecycleBinTest.php @@ -14,7 +14,7 @@ class RecycleBinTest extends TestCase public function test_recycle_bin_routes_permissions() { $page = $this->entities->page(); - $editor = $this->getEditor(); + $editor = $this->users->editor(); $this->actingAs($editor)->delete($page->getUrl()); $deletion = Deletion::query()->firstOrFail(); @@ -33,7 +33,7 @@ class RecycleBinTest extends TestCase $this->assertPermissionError($resp); } - $this->giveUserPermissions($editor, ['restrictions-manage-all']); + $this->permissions->grantUserRolePermissions($editor, ['restrictions-manage-all']); foreach ($routes as $route) { [$method, $url] = explode(':', $route); @@ -41,7 +41,7 @@ class RecycleBinTest extends TestCase $this->assertPermissionError($resp); } - $this->giveUserPermissions($editor, ['settings-manage']); + $this->permissions->grantUserRolePermissions($editor, ['settings-manage']); foreach ($routes as $route) { DB::beginTransaction(); @@ -56,7 +56,7 @@ class RecycleBinTest extends TestCase { $page = $this->entities->page(); $book = Book::query()->whereHas('pages')->whereHas('chapters')->withCount(['pages', 'chapters'])->first(); - $editor = $this->getEditor(); + $editor = $this->users->editor(); $this->actingAs($editor)->delete($page->getUrl()); $this->actingAs($editor)->delete($book->getUrl()); @@ -73,7 +73,7 @@ class RecycleBinTest extends TestCase { $page = $this->entities->page(); $book = Book::query()->where('id', '!=', $page->book_id)->whereHas('pages')->whereHas('chapters')->with(['pages', 'chapters'])->firstOrFail(); - $editor = $this->getEditor(); + $editor = $this->users->editor(); $this->actingAs($editor)->delete($page->getUrl()); $this->actingAs($editor)->delete($book->getUrl()); diff --git a/tests/Settings/RegenerateReferencesTest.php b/tests/Settings/RegenerateReferencesTest.php index 0f3122074..239f50e76 100644 --- a/tests/Settings/RegenerateReferencesTest.php +++ b/tests/Settings/RegenerateReferencesTest.php @@ -32,11 +32,11 @@ class RegenerateReferencesTest extends TestCase public function test_settings_manage_permission_required() { - $editor = $this->getEditor(); + $editor = $this->users->editor(); $resp = $this->actingAs($editor)->post('/settings/maintenance/regenerate-references'); $this->assertPermissionError($resp); - $this->giveUserPermissions($editor, ['settings-manage']); + $this->permissions->grantUserRolePermissions($editor, ['settings-manage']); $resp = $this->actingAs($editor)->post('/settings/maintenance/regenerate-references'); $this->assertNotPermissionError($resp); diff --git a/tests/Settings/TestEmailTest.php b/tests/Settings/TestEmailTest.php index 31c51158f..322f90107 100644 --- a/tests/Settings/TestEmailTest.php +++ b/tests/Settings/TestEmailTest.php @@ -20,7 +20,7 @@ class TestEmailTest extends TestCase public function test_send_test_email_endpoint_sends_email_and_redirects_user_and_shows_notification() { Notification::fake(); - $admin = $this->getAdmin(); + $admin = $this->users->admin(); $sendReq = $this->actingAs($admin)->post('/settings/maintenance/send-test-email'); $sendReq->assertRedirect('/settings/maintenance#image-cleanup'); @@ -37,7 +37,7 @@ class TestEmailTest extends TestCase $exception = new \Exception('A random error occurred when testing an email'); $mockDispatcher->shouldReceive('sendNow')->andThrow($exception); - $admin = $this->getAdmin(); + $admin = $this->users->admin(); $sendReq = $this->actingAs($admin)->post('/settings/maintenance/send-test-email'); $sendReq->assertRedirect('/settings/maintenance#image-cleanup'); $this->assertSessionHas('error'); @@ -50,12 +50,12 @@ class TestEmailTest extends TestCase public function test_send_test_email_requires_settings_manage_permission() { Notification::fake(); - $user = $this->getViewer(); + $user = $this->users->viewer(); $sendReq = $this->actingAs($user)->post('/settings/maintenance/send-test-email'); Notification::assertNothingSent(); - $this->giveUserPermissions($user, ['settings-manage']); + $this->permissions->grantUserRolePermissions($user, ['settings-manage']); $sendReq = $this->actingAs($user)->post('/settings/maintenance/send-test-email'); Notification::assertSentTo($user, TestEmail::class); } diff --git a/tests/TestCase.php b/tests/TestCase.php index d0dd7d772..70fd0da1d 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,11 +2,6 @@ namespace Tests; -use BookStack\Auth\Permissions\JointPermissionBuilder; -use BookStack\Auth\Permissions\PermissionsRepo; -use BookStack\Auth\Permissions\RolePermission; -use BookStack\Auth\Role; -use BookStack\Auth\User; use BookStack\Entities\Models\Entity; use BookStack\Settings\SettingService; use BookStack\Uploads\HttpFetcher; @@ -22,12 +17,15 @@ use Illuminate\Support\Env; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Illuminate\Testing\Assert as PHPUnit; +use Mockery; use Monolog\Handler\TestHandler; use Monolog\Logger; use Psr\Http\Client\ClientInterface; use Ssddanbrown\AssertHtml\TestsHtml; use Tests\Helpers\EntityProvider; +use Tests\Helpers\PermissionsProvider; use Tests\Helpers\TestServiceProvider; +use Tests\Helpers\UserRoleProvider; abstract class TestCase extends BaseTestCase { @@ -35,13 +33,16 @@ abstract class TestCase extends BaseTestCase use DatabaseTransactions; use TestsHtml; - protected ?User $admin = null; - protected ?User $editor = null; protected EntityProvider $entities; + protected UserRoleProvider $users; + protected PermissionsProvider $permissions; protected function setUp(): void { $this->entities = new EntityProvider(); + $this->users = new UserRoleProvider(); + $this->permissions = new PermissionsProvider($this->users); + parent::setUp(); } @@ -70,20 +71,7 @@ abstract class TestCase extends BaseTestCase */ public function asAdmin() { - return $this->actingAs($this->getAdmin()); - } - - /** - * Get the current admin user. - */ - public function getAdmin(): User - { - if (is_null($this->admin)) { - $adminRole = Role::getSystemRole('admin'); - $this->admin = $adminRole->users->first(); - } - - return $this->admin; + return $this->actingAs($this->users->admin()); } /** @@ -91,20 +79,7 @@ abstract class TestCase extends BaseTestCase */ public function asEditor() { - return $this->actingAs($this->getEditor()); - } - - /** - * Get a editor user. - */ - protected function getEditor(): User - { - if ($this->editor === null) { - $editorRole = Role::getRole('editor'); - $this->editor = $editorRole->users->first(); - } - - return $this->editor; + return $this->actingAs($this->users->editor()); } /** @@ -112,28 +87,7 @@ abstract class TestCase extends BaseTestCase */ public function asViewer() { - return $this->actingAs($this->getViewer()); - } - - /** - * Get an instance of a user with 'viewer' permissions. - */ - protected function getViewer(array $attributes = []): User - { - $user = Role::getRole('viewer')->users()->first(); - if (!empty($attributes)) { - $user->forceFill($attributes)->save(); - } - - return $user; - } - - /** - * Get a user that's not a system user such as the guest user. - */ - public function getNormalUser(): User - { - return User::query()->where('system_name', '=', null)->get()->last(); + return $this->actingAs($this->users->viewer()); } /** @@ -147,52 +101,6 @@ abstract class TestCase extends BaseTestCase } } - /** - * Give the given user some permissions. - */ - protected function giveUserPermissions(User $user, array $permissions = []): void - { - $newRole = $this->createNewRole($permissions); - $user->attachRole($newRole); - $user->load('roles'); - $user->clearPermissionCache(); - } - - /** - * Completely remove the given permission name from the given user. - */ - protected function removePermissionFromUser(User $user, string $permissionName) - { - $permissionBuilder = app()->make(JointPermissionBuilder::class); - - /** @var RolePermission $permission */ - $permission = RolePermission::query()->where('name', '=', $permissionName)->firstOrFail(); - - $roles = $user->roles()->whereHas('permissions', function ($query) use ($permission) { - $query->where('id', '=', $permission->id); - })->get(); - - /** @var Role $role */ - foreach ($roles as $role) { - $role->detachPermission($permission); - $permissionBuilder->rebuildForRole($role); - } - - $user->clearPermissionCache(); - } - - /** - * Create a new basic role for testing purposes. - */ - protected function createNewRole(array $permissions = []): Role - { - $permissionRepo = app(PermissionsRepo::class); - $roleData = Role::factory()->make()->toArray(); - $roleData['permissions'] = array_flip($permissions); - - return $permissionRepo->saveNewRole($roleData); - } - /** * Mock the HttpFetcher service and return the given data on fetch. */ diff --git a/tests/ThemeTest.php b/tests/ThemeTest.php index efab53379..ee4f20f30 100644 --- a/tests/ThemeTest.php +++ b/tests/ThemeTest.php @@ -36,7 +36,7 @@ class ThemeTest extends TestCase '; file_put_contents($translationPath . '/entities.php', $customTranslations); - $homeRequest = $this->actingAs($this->getViewer())->get('/'); + $homeRequest = $this->actingAs($this->users->viewer())->get('/'); $this->withHtml($homeRequest)->assertElementContains('header nav', 'Sandwiches'); }); } diff --git a/tests/Unit/FrameworkAssumptionTest.php b/tests/Unit/FrameworkAssumptionTest.php index 54d315de9..d4feff60c 100644 --- a/tests/Unit/FrameworkAssumptionTest.php +++ b/tests/Unit/FrameworkAssumptionTest.php @@ -25,7 +25,7 @@ class FrameworkAssumptionTest extends TestCase // Page has SoftDeletes trait by default, so we apply our custom scope and ensure // it stacks on the global scope to filter out deleted items. $query = Page::query()->scopes('visible')->toSql(); - $this->assertStringContainsString('joint_permissions', $query); + $this->assertStringContainsString('entity_permissions_collapsed', $query); $this->assertStringContainsString('`deleted_at` is null', $query); } } diff --git a/tests/Uploads/AttachmentTest.php b/tests/Uploads/AttachmentTest.php index b6fcb8f69..f2f30ff2e 100644 --- a/tests/Uploads/AttachmentTest.php +++ b/tests/Uploads/AttachmentTest.php @@ -75,7 +75,7 @@ class AttachmentTest extends TestCase { $page = $this->entities->page(); $this->asAdmin(); - $admin = $this->getAdmin(); + $admin = $this->users->admin(); $fileName = 'upload_test_file.txt'; $expectedResp = [ @@ -137,7 +137,7 @@ class AttachmentTest extends TestCase public function test_attaching_link_to_page() { $page = $this->entities->page(); - $admin = $this->getAdmin(); + $admin = $this->users->admin(); $this->asAdmin(); $linkReq = $this->call('POST', 'attachments/link', [ @@ -245,15 +245,15 @@ class AttachmentTest extends TestCase public function test_attachment_access_without_permission_shows_404() { - $admin = $this->getAdmin(); - $viewer = $this->getViewer(); + $admin = $this->users->admin(); + $viewer = $this->users->viewer(); $page = $this->entities->page(); /** @var Page $page */ $this->actingAs($admin); $fileName = 'permission_test.txt'; $this->uploadFile($fileName, $page->id); $attachment = Attachment::orderBy('id', 'desc')->take(1)->first(); - $this->entities->setPermissions($page, [], []); + $this->permissions->setEntityPermissions($page, [], []); $this->actingAs($viewer); $attachmentGet = $this->get($attachment->getUrl()); diff --git a/tests/Uploads/DrawioTest.php b/tests/Uploads/DrawioTest.php index 2ed4da7ca..080f05d74 100644 --- a/tests/Uploads/DrawioTest.php +++ b/tests/Uploads/DrawioTest.php @@ -30,7 +30,7 @@ class DrawioTest extends TestCase public function test_drawing_base64_upload() { $page = Page::first(); - $editor = $this->getEditor(); + $editor = $this->users->editor(); $this->actingAs($editor); $upload = $this->postJson('images/drawio', [ @@ -58,7 +58,7 @@ class DrawioTest extends TestCase { config()->set('services.drawio', 'http://cats.com?dog=tree'); $page = Page::first(); - $editor = $this->getEditor(); + $editor = $this->users->editor(); $resp = $this->actingAs($editor)->get($page->getUrl('/edit')); $resp->assertSee('drawio-url="http://cats.com?dog=tree"', false); @@ -68,7 +68,7 @@ class DrawioTest extends TestCase { config()->set('services.drawio', true); $page = Page::first(); - $editor = $this->getEditor(); + $editor = $this->users->editor(); $resp = $this->actingAs($editor)->get($page->getUrl('/edit')); $resp->assertSee('drawio-url="https://embed.diagrams.net/?embed=1&proto=json&spin=1&configure=1"', false); diff --git a/tests/Uploads/ImageTest.php b/tests/Uploads/ImageTest.php index 0e4065a82..c6e678ff2 100644 --- a/tests/Uploads/ImageTest.php +++ b/tests/Uploads/ImageTest.php @@ -16,7 +16,7 @@ class ImageTest extends TestCase public function test_image_upload() { $page = $this->entities->page(); - $admin = $this->getAdmin(); + $admin = $this->users->admin(); $this->actingAs($admin); $imgDetails = $this->uploadGalleryImage($page); @@ -40,7 +40,7 @@ class ImageTest extends TestCase public function test_image_display_thumbnail_generation_does_not_increase_image_size() { $page = $this->entities->page(); - $admin = $this->getAdmin(); + $admin = $this->users->admin(); $this->actingAs($admin); $originalFile = $this->getTestImageFilePath('compressed.png'); @@ -64,7 +64,7 @@ class ImageTest extends TestCase public function test_image_display_thumbnail_generation_for_apng_images_uses_original_file() { $page = $this->entities->page(); - $admin = $this->getAdmin(); + $admin = $this->users->admin(); $this->actingAs($admin); $imgDetails = $this->uploadGalleryImage($page, 'animated.png'); @@ -76,7 +76,7 @@ class ImageTest extends TestCase public function test_image_edit() { - $editor = $this->getEditor(); + $editor = $this->users->editor(); $this->actingAs($editor); $imgDetails = $this->uploadGalleryImage(); @@ -126,7 +126,7 @@ class ImageTest extends TestCase public function test_image_usage() { $page = $this->entities->page(); - $editor = $this->getEditor(); + $editor = $this->users->editor(); $this->actingAs($editor); $imgDetails = $this->uploadGalleryImage($page); @@ -146,7 +146,7 @@ class ImageTest extends TestCase public function test_php_files_cannot_be_uploaded() { $page = $this->entities->page(); - $admin = $this->getAdmin(); + $admin = $this->users->admin(); $this->actingAs($admin); $fileName = 'bad.php'; @@ -168,7 +168,7 @@ class ImageTest extends TestCase public function test_php_like_files_cannot_be_uploaded() { $page = $this->entities->page(); - $admin = $this->getAdmin(); + $admin = $this->users->admin(); $this->actingAs($admin); $fileName = 'bad.phtml'; @@ -185,7 +185,7 @@ class ImageTest extends TestCase public function test_files_with_double_extensions_will_get_sanitized() { $page = $this->entities->page(); - $admin = $this->getAdmin(); + $admin = $this->users->admin(); $this->actingAs($admin); $fileName = 'bad.phtml.png'; @@ -358,7 +358,7 @@ class ImageTest extends TestCase $this->get($expectedUrl)->assertOk(); - $this->entities->setPermissions($page, [], []); + $this->permissions->setEntityPermissions($page, [], []); $resp = $this->get($expectedUrl); $resp->assertNotFound(); @@ -382,7 +382,7 @@ class ImageTest extends TestCase $this->get($expectedUrl)->assertOk(); - $this->entities->setPermissions($page, [], []); + $this->permissions->setEntityPermissions($page, [], []); $resp = $this->get($expectedUrl); $resp->assertNotFound(); @@ -415,7 +415,7 @@ class ImageTest extends TestCase $export = $this->get($pageB->getUrl('/export/html')); $this->assertStringContainsString($encodedImageContent, $export->getContent()); - $this->entities->setPermissions($pageA, [], []); + $this->permissions->setEntityPermissions($pageA, [], []); $export = $this->get($pageB->getUrl('/export/html')); $this->assertStringNotContainsString($encodedImageContent, $export->getContent()); @@ -479,7 +479,7 @@ class ImageTest extends TestCase $imageName = 'first-image.png'; $relPath = $this->getTestImagePath('gallery', $imageName); $this->deleteImage($relPath); - $viewer = $this->getViewer(); + $viewer = $this->users->viewer(); $this->uploadImage($imageName, $page->id); $image = Image::first(); @@ -490,7 +490,7 @@ class ImageTest extends TestCase $resp = $this->actingAs($viewer)->get("/images/edit/{$image->id}"); $this->withHtml($resp)->assertElementNotExists('button#image-manager-delete[title="Delete"]'); - $this->giveUserPermissions($viewer, ['image-delete-all']); + $this->permissions->grantUserRolePermissions($viewer, ['image-delete-all']); $resp = $this->actingAs($viewer)->get("/images/edit/{$image->id}"); $this->withHtml($resp)->assertElementExists('button#image-manager-delete[title="Delete"]'); @@ -509,8 +509,8 @@ class ImageTest extends TestCase public function test_user_image_upload() { - $editor = $this->getEditor(); - $admin = $this->getAdmin(); + $editor = $this->users->editor(); + $admin = $this->users->admin(); $this->actingAs($admin); $file = $this->getTestProfileImage(); @@ -525,7 +525,7 @@ class ImageTest extends TestCase public function test_user_images_deleted_on_user_deletion() { - $editor = $this->getEditor(); + $editor = $this->users->editor(); $this->actingAs($editor); $file = $this->getTestProfileImage(); @@ -555,7 +555,7 @@ class ImageTest extends TestCase public function test_deleted_unused_images() { $page = $this->entities->page(); - $admin = $this->getAdmin(); + $admin = $this->users->admin(); $this->actingAs($admin); $imageName = 'unused-image.png'; diff --git a/tests/User/UserApiTokenTest.php b/tests/User/UserApiTokenTest.php index 716f3614c..93070b712 100644 --- a/tests/User/UserApiTokenTest.php +++ b/tests/User/UserApiTokenTest.php @@ -16,12 +16,12 @@ class UserApiTokenTest extends TestCase public function test_tokens_section_not_visible_without_access_api_permission() { - $user = $this->getViewer(); + $user = $this->users->viewer(); $resp = $this->actingAs($user)->get($user->getEditUrl()); $resp->assertDontSeeText('API Tokens'); - $this->giveUserPermissions($user, ['access-api']); + $this->permissions->grantUserRolePermissions($user, ['access-api']); $resp = $this->actingAs($user)->get($user->getEditUrl()); $resp->assertSeeText('API Tokens'); @@ -30,9 +30,9 @@ class UserApiTokenTest extends TestCase public function test_those_with_manage_users_can_view_other_user_tokens_but_not_create() { - $viewer = $this->getViewer(); - $editor = $this->getEditor(); - $this->giveUserPermissions($viewer, ['users-manage']); + $viewer = $this->users->viewer(); + $editor = $this->users->editor(); + $this->permissions->grantUserRolePermissions($viewer, ['users-manage']); $resp = $this->actingAs($viewer)->get($editor->getEditUrl()); $resp->assertSeeText('API Tokens'); @@ -41,7 +41,7 @@ class UserApiTokenTest extends TestCase public function test_create_api_token() { - $editor = $this->getEditor(); + $editor = $this->users->editor(); $resp = $this->asAdmin()->get($editor->getEditUrl('/create-api-token')); $resp->assertStatus(200); @@ -74,7 +74,7 @@ class UserApiTokenTest extends TestCase public function test_create_with_no_expiry_sets_expiry_hundred_years_away() { - $editor = $this->getEditor(); + $editor = $this->users->editor(); $this->asAdmin()->post($editor->getEditUrl('/create-api-token'), ['name' => 'No expiry token', 'expires_at' => '']); $token = ApiToken::query()->latest()->first(); @@ -88,7 +88,7 @@ class UserApiTokenTest extends TestCase public function test_created_token_displays_on_profile_page() { - $editor = $this->getEditor(); + $editor = $this->users->editor(); $this->asAdmin()->post($editor->getEditUrl('/create-api-token'), $this->testTokenData); $token = ApiToken::query()->latest()->first(); @@ -101,7 +101,7 @@ class UserApiTokenTest extends TestCase public function test_secret_shown_once_after_creation() { - $editor = $this->getEditor(); + $editor = $this->users->editor(); $resp = $this->asAdmin()->followingRedirects()->post($editor->getEditUrl('/create-api-token'), $this->testTokenData); $resp->assertSeeText('Token Secret'); @@ -114,7 +114,7 @@ class UserApiTokenTest extends TestCase public function test_token_update() { - $editor = $this->getEditor(); + $editor = $this->users->editor(); $this->asAdmin()->post($editor->getEditUrl('/create-api-token'), $this->testTokenData); $token = ApiToken::query()->latest()->first(); $updateData = [ @@ -132,7 +132,7 @@ class UserApiTokenTest extends TestCase public function test_token_update_with_blank_expiry_sets_to_hundred_years_away() { - $editor = $this->getEditor(); + $editor = $this->users->editor(); $this->asAdmin()->post($editor->getEditUrl('/create-api-token'), $this->testTokenData); $token = ApiToken::query()->latest()->first(); @@ -152,7 +152,7 @@ class UserApiTokenTest extends TestCase public function test_token_delete() { - $editor = $this->getEditor(); + $editor = $this->users->editor(); $this->asAdmin()->post($editor->getEditUrl('/create-api-token'), $this->testTokenData); $token = ApiToken::query()->latest()->first(); @@ -171,9 +171,9 @@ class UserApiTokenTest extends TestCase public function test_user_manage_can_delete_token_without_api_permission_themselves() { - $viewer = $this->getViewer(); - $editor = $this->getEditor(); - $this->giveUserPermissions($editor, ['users-manage']); + $viewer = $this->users->viewer(); + $editor = $this->users->editor(); + $this->permissions->grantUserRolePermissions($editor, ['users-manage']); $this->asAdmin()->post($viewer->getEditUrl('/create-api-token'), $this->testTokenData); $token = ApiToken::query()->latest()->first(); diff --git a/tests/User/UserManagementTest.php b/tests/User/UserManagementTest.php index b5cd764da..1c5c040da 100644 --- a/tests/User/UserManagementTest.php +++ b/tests/User/UserManagementTest.php @@ -46,7 +46,7 @@ class UserManagementTest extends TestCase public function test_user_updating() { - $user = $this->getNormalUser(); + $user = $this->users->viewer(); $password = $user->password; $resp = $this->asAdmin()->get('/settings/users/' . $user->id); @@ -65,7 +65,7 @@ class UserManagementTest extends TestCase public function test_user_password_update() { - $user = $this->getNormalUser(); + $user = $this->users->viewer(); $userProfilePage = '/settings/users/' . $user->id; $this->asAdmin()->get($userProfilePage); @@ -113,7 +113,7 @@ class UserManagementTest extends TestCase public function test_delete() { - $editor = $this->getEditor(); + $editor = $this->users->editor(); $resp = $this->asAdmin()->delete("settings/users/{$editor->id}"); $resp->assertRedirect('/settings/users'); $resp = $this->followRedirects($resp); @@ -126,7 +126,7 @@ class UserManagementTest extends TestCase public function test_delete_offers_migrate_option() { - $editor = $this->getEditor(); + $editor = $this->users->editor(); $resp = $this->asAdmin()->get("settings/users/{$editor->id}/delete"); $resp->assertSee('Migrate Ownership'); $resp->assertSee('new_owner_id'); @@ -134,13 +134,13 @@ class UserManagementTest extends TestCase public function test_migrate_option_hidden_if_user_cannot_manage_users() { - $editor = $this->getEditor(); + $editor = $this->users->editor(); $resp = $this->asEditor()->get("settings/users/{$editor->id}/delete"); $resp->assertDontSee('Migrate Ownership'); $resp->assertDontSee('new_owner_id'); - $this->giveUserPermissions($editor, ['users-manage']); + $this->permissions->grantUserRolePermissions($editor, ['users-manage']); $resp = $this->asEditor()->get("settings/users/{$editor->id}/delete"); $resp->assertSee('Migrate Ownership'); @@ -162,7 +162,7 @@ class UserManagementTest extends TestCase public function test_delete_removes_user_preferences() { - $editor = $this->getEditor(); + $editor = $this->users->editor(); setting()->putUser($editor, 'dark-mode-enabled', 'true'); $this->assertDatabaseHas('settings', [ @@ -253,7 +253,7 @@ class UserManagementTest extends TestCase public function test_user_create_update_fails_if_locale_is_invalid() { - $user = $this->getEditor(); + $user = $this->users->editor(); // Too long $resp = $this->asAdmin()->put($user->getEditUrl(), ['language' => 'this_is_too_long']); @@ -274,34 +274,4 @@ class UserManagementTest extends TestCase $resp->assertSessionHasErrors(['language' => 'The language may not be greater than 15 characters.']); $resp->assertSessionHasErrors(['language' => 'The language may only contain letters, numbers, dashes and underscores.']); } - - public function test_role_removal_on_user_edit_removes_all_role_assignments() - { - $user = $this->getEditor(); - - $this->assertEquals(1, $user->roles()->count()); - - // A roles[0] hidden fields is used to indicate the existence of role selection in the submission - // of the user edit form. We check that field is used and emulate its submission. - $resp = $this->asAdmin()->get("/settings/users/{$user->id}"); - $this->withHtml($resp)->assertElementExists('input[type="hidden"][name="roles[0]"][value="0"]'); - - $resp = $this->asAdmin()->put("/settings/users/{$user->id}", [ - 'name' => $user->name, - 'email' => $user->email, - 'roles' => ['0' => '0'], - ]); - $resp->assertRedirect("/settings/users"); - - $this->assertEquals(0, $user->roles()->count()); - } - - public function test_role_form_hidden_indicator_field_does_not_exist_where_roles_cannot_be_managed() - { - $user = $this->getEditor(); - $resp = $this->actingAs($user)->get("/settings/users/{$user->id}"); - $html = $this->withHtml($resp); - $html->assertElementExists('input[name="email"]'); - $html->assertElementNotExists('input[type="hidden"][name="roles[0]"]'); - } } diff --git a/tests/User/UserPreferencesTest.php b/tests/User/UserPreferencesTest.php index 03dad7990..e47a259a5 100644 --- a/tests/User/UserPreferencesTest.php +++ b/tests/User/UserPreferencesTest.php @@ -36,7 +36,7 @@ class UserPreferencesTest extends TestCase public function test_body_has_shortcuts_component_when_active() { - $editor = $this->getEditor(); + $editor = $this->users->editor(); $this->actingAs($editor); $this->withHtml($this->get('/'))->assertElementNotExists('body[component="shortcuts"]'); @@ -47,7 +47,7 @@ class UserPreferencesTest extends TestCase public function test_update_sort_preference() { - $editor = $this->getEditor(); + $editor = $this->users->editor(); $this->actingAs($editor); $updateRequest = $this->patch('/preferences/change-sort/books', [ @@ -70,7 +70,7 @@ class UserPreferencesTest extends TestCase public function test_update_sort_bad_entity_type_handled() { - $editor = $this->getEditor(); + $editor = $this->users->editor(); $this->actingAs($editor); $updateRequest = $this->patch('/preferences/change-sort/dogs', [ @@ -85,7 +85,7 @@ class UserPreferencesTest extends TestCase public function test_update_expansion_preference() { - $editor = $this->getEditor(); + $editor = $this->users->editor(); $this->actingAs($editor); $updateRequest = $this->patch('/preferences/change-expansion/home-details', ['expand' => 'true']); @@ -103,7 +103,7 @@ class UserPreferencesTest extends TestCase public function test_toggle_dark_mode() { - $home = $this->actingAs($this->getEditor())->get('/'); + $home = $this->actingAs($this->users->editor())->get('/'); $home->assertSee('Dark Mode'); $this->withHtml($home)->assertElementNotExists('.dark-mode'); @@ -112,7 +112,7 @@ class UserPreferencesTest extends TestCase $prefChange->assertRedirect(); $this->assertEquals(true, setting()->getForCurrentUser('dark-mode-enabled')); - $home = $this->actingAs($this->getEditor())->get('/'); + $home = $this->actingAs($this->users->editor())->get('/'); $this->withHtml($home)->assertElementExists('.dark-mode'); $home->assertDontSee('Dark Mode'); $home->assertSee('Light Mode'); @@ -133,7 +133,7 @@ class UserPreferencesTest extends TestCase public function test_books_view_type_preferences_when_list() { - $editor = $this->getEditor(); + $editor = $this->users->editor(); setting()->putUser($editor, 'books_view_type', 'list'); $resp = $this->actingAs($editor)->get('/books'); @@ -144,7 +144,7 @@ class UserPreferencesTest extends TestCase public function test_books_view_type_preferences_when_grid() { - $editor = $this->getEditor(); + $editor = $this->users->editor(); setting()->putUser($editor, 'books_view_type', 'grid'); $resp = $this->actingAs($editor)->get('/books'); @@ -153,7 +153,7 @@ class UserPreferencesTest extends TestCase public function test_shelf_view_type_change() { - $editor = $this->getEditor(); + $editor = $this->users->editor(); $shelf = $this->entities->shelf(); setting()->putUser($editor, 'bookshelf_view_type', 'list'); @@ -175,7 +175,7 @@ class UserPreferencesTest extends TestCase public function test_update_code_language_favourite() { - $editor = $this->getEditor(); + $editor = $this->users->editor(); $page = $this->entities->page(); $this->actingAs($editor); diff --git a/tests/User/UserProfileTest.php b/tests/User/UserProfileTest.php index 77f1644a5..c507e8fa6 100644 --- a/tests/User/UserProfileTest.php +++ b/tests/User/UserProfileTest.php @@ -88,8 +88,8 @@ class UserProfileTest extends TestCase public function test_profile_has_search_links_in_created_entity_lists() { - $user = $this->getEditor(); - $resp = $this->actingAs($this->getAdmin())->get('/user/' . $user->slug); + $user = $this->users->editor(); + $resp = $this->actingAs($this->users->admin())->get('/user/' . $user->slug); $expectedLinks = [ '/search?term=%7Bcreated_by%3A' . $user->slug . '%7D+%7Btype%3Apage%7D', diff --git a/tests/User/UserSearchTest.php b/tests/User/UserSearchTest.php index 243af1186..1b3ca8a35 100644 --- a/tests/User/UserSearchTest.php +++ b/tests/User/UserSearchTest.php @@ -9,8 +9,8 @@ class UserSearchTest extends TestCase { public function test_select_search_matches_by_name() { - $viewer = $this->getViewer(); - $admin = $this->getAdmin(); + $viewer = $this->users->viewer(); + $admin = $this->users->admin(); $resp = $this->actingAs($admin)->get('/search/users/select?search=' . urlencode($viewer->name)); $resp->assertOk(); @@ -30,8 +30,8 @@ class UserSearchTest extends TestCase public function test_select_search_does_not_match_by_email() { - $viewer = $this->getViewer(); - $editor = $this->getEditor(); + $viewer = $this->users->viewer(); + $editor = $this->users->editor(); $resp = $this->actingAs($editor)->get('/search/users/select?search=' . urlencode($viewer->email)); $resp->assertDontSee($viewer->name); @@ -40,13 +40,13 @@ class UserSearchTest extends TestCase public function test_select_requires_right_permission() { $permissions = ['users-manage', 'restrictions-manage-own', 'restrictions-manage-all']; - $user = $this->getViewer(); + $user = $this->users->viewer(); foreach ($permissions as $permission) { $resp = $this->actingAs($user)->get('/search/users/select?search=a'); $this->assertPermissionError($resp); - $this->giveUserPermissions($user, [$permission]); + $this->permissions->grantUserRolePermissions($user, [$permission]); $resp = $this->actingAs($user)->get('/search/users/select?search=a'); $resp->assertOk(); $user->roles()->delete(); @@ -58,7 +58,7 @@ class UserSearchTest extends TestCase { $this->setSettings(['app-public' => true]); $defaultUser = User::getDefault(); - $this->giveUserPermissions($defaultUser, ['users-manage']); + $this->permissions->grantUserRolePermissions($defaultUser, ['users-manage']); $resp = $this->get('/search/users/select?search=a'); $this->assertPermissionError($resp); From e2a72d16aa496cef250986d9f7cc02dd9564e647 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 21 Jan 2023 13:03:47 +0000 Subject: [PATCH 03/41] Made adjustments to fit copied work into dev branch Ported non-compatible elements, Now all tests passing apart from some specific permission scenario tests which are probably correctly failing. Updates some tests to better avoid messing environment state. --- dev/docs/permission-scenario-testing.md | 183 +-------------- .../RegeneratePermissionsCommandTest.php | 20 +- tests/Entity/BookShelfTest.php | 1 + tests/Helpers/PermissionsProvider.php | 12 +- tests/Permissions/EntityPermissionsTest.php | 14 +- .../Scenarios/EntityUserPermissionsTest.php | 209 ------------------ tests/TestCase.php | 3 + tests/Unit/FrameworkAssumptionTest.php | 2 +- 8 files changed, 31 insertions(+), 413 deletions(-) delete mode 100644 tests/Permissions/Scenarios/EntityUserPermissionsTest.php diff --git a/dev/docs/permission-scenario-testing.md b/dev/docs/permission-scenario-testing.md index 6d0935f09..e738fe972 100644 --- a/dev/docs/permission-scenario-testing.md +++ b/dev/docs/permission-scenario-testing.md @@ -6,19 +6,16 @@ Test cases are written ability abstract, since all abilities should act the same Tests are categorised by the most specific element involved in the scenario, where the below list is most specific to least: -- User entity permissions. - Role entity permissions. - Fallback entity permissions. - Role permissions. -- TODO - Test fallback in the context of the above. - ## General Permission Logical Rules The below are some general rules we follow to standardise the behaviour of permissions in the platform: - Most specific permission application (as above) take priority and can deny less specific permissions. -- Parent user/role entity permissions that may be inherited, are considered to essentially be applied on the item they are inherited to unless a lower level has its own permission rule for an already specific role/user. +- Parent role entity permissions that may be inherited, are considered to essentially be applied on the item they are inherited to unless a lower level has its own permission rule for an already specific role. - Where both grant and deny exist at the same specificity, we side towards grant. ## Cases @@ -241,181 +238,3 @@ User denied page permission. - User has Role A & B. User denied page permission. - ---- - -### Entity User Permissions - -These are tests related to entity-level user-specific permission overrides. - -#### test_01_explicit_allow - -- Page permissions have inherit disabled. -- User has entity allow page permission. - -User granted page permission. - -#### test_02_explicit_deny - -- Page permissions have inherit disabled. -- User has entity deny page permission. - -User denied page permission. - -#### test_10_allow_inherit - -- Page permissions have inherit enabled. -- Chapter permissions have inherit disabled. -- User has entity allow chapter permission. - -User granted page permission. - -#### test_11_deny_inherit - -- Page permissions have inherit enabled. -- Chapter permissions have inherit disabled. -- User has entity deny chapter permission. - -User denied page permission. - -#### test_12_allow_inherit_override - -- Page permissions have inherit enabled. -- Chapter permissions have inherit disabled. -- User has entity deny chapter permission. -- User has entity allow page permission. - -User granted page permission. - -#### test_13_deny_inherit_override - -- Page permissions have inherit enabled. -- Chapter permissions have inherit disabled. -- User has entity allow chapter permission. -- User has entity deny page permission. - -User denied page permission. - -#### test_40_entity_role_override_allow - -- Page permissions have inherit disabled. -- User has entity allow page permission. -- Role A has entity deny page permission. -- User has role A. - -User granted page permission. - -#### test_41_entity_role_override_deny - -- Page permissions have inherit disabled. -- User has entity deny page permission. -- Role A has entity allow page permission. -- User has role A. - -User denied page permission. - -#### test_42_entity_role_override_allow_via_inherit - -- Page permissions have inherit enabled. -- Chapter permissions have inherit disabled. -- User has entity allow chapter permission. -- Role A has entity deny page permission. -- User has role A. - -User granted page permission. - -#### test_43_entity_role_override_deny_via_inherit - -- Page permissions have inherit enabled. -- Chapter permissions have inherit disabled. -- User has entity deny chapter permission. -- Role A has entity allow page permission. -- User has role A. - -User denied page permission. - -#### test_50_role_override_allow - -- Page permissions have inherit enabled. -- Role A has no page role permission. -- User has entity allow page permission. -- User has Role A. - -User granted page permission. - -#### test_51_role_override_deny - -- Page permissions have inherit enabled. -- Role A has all-page role permission. -- User has entity deny page permission. -- User has Role A. - -User denied page permission. - -#### test_60_inherited_role_override_allow - -- Page permissions have inherit enabled. -- Role A has no page role permission. -- User has entity allow chapter permission. -- User has Role A. - -User granted page permission. - -#### test_61_inherited_role_override_deny - -- Page permissions have inherit enabled. -- Role A has view-all page role permission. -- User has entity deny chapter permission. -- User has Role A. - -User denied page permission. - -#### test_61_inherited_role_override_deny_on_own - -- Page permissions have inherit enabled. -- Role A has view-own page role permission. -- User has entity deny chapter permission. -- User has Role A. -- User owns Page. - -User denied page permission. - -#### test_70_all_override_allow - -- Page permissions have inherit enabled. -- Role A has no page role permission. -- Role A has entity deny page permission. -- User has entity allow page permission. -- User has Role A. - -User granted page permission. - -#### test_71_all_override_deny - -- Page permissions have inherit enabled. -- Role A has page-all role permission. -- Role A has entity allow page permission. -- User has entity deny page permission. -- User has Role A. - -User denied page permission. - -#### test_80_inherited_all_override_allow - -- Page permissions have inherit enabled. -- Role A has no page role permission. -- Role A has entity deny chapter permission. -- User has entity allow chapter permission. -- User has Role A. - -User granted page permission. - -#### test_81_inherited_all_override_deny - -- Page permissions have inherit enabled. -- Role A has view-all page role permission. -- Role A has entity allow chapter permission. -- User has entity deny chapter permission. -- User has Role A. - -User denied page permission. \ No newline at end of file diff --git a/tests/Commands/RegeneratePermissionsCommandTest.php b/tests/Commands/RegeneratePermissionsCommandTest.php index cc53b460d..b916a8060 100644 --- a/tests/Commands/RegeneratePermissionsCommandTest.php +++ b/tests/Commands/RegeneratePermissionsCommandTest.php @@ -3,6 +3,8 @@ namespace Tests\Commands; use BookStack\Auth\Permissions\CollapsedPermission; +use BookStack\Auth\Permissions\EntityPermission; +use BookStack\Auth\Permissions\JointPermission; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\DB; use Tests\TestCase; @@ -14,21 +16,25 @@ class RegeneratePermissionsCommandTest extends TestCase DB::rollBack(); $page = $this->entities->page(); $editor = $this->users->editor(); - $this->permissions->addEntityPermission($page, ['view'], null, $editor); - CollapsedPermission::query()->truncate(); + $role = $editor->roles()->first(); + $this->permissions->addEntityPermission($page, ['view'], $role); + JointPermission::query()->truncate(); - $this->assertDatabaseMissing('entity_permissions_collapsed', ['entity_id' => $page->id]); + $this->assertDatabaseMissing('joint_permissions', ['entity_id' => $page->id]); $exitCode = Artisan::call('bookstack:regenerate-permissions'); $this->assertTrue($exitCode === 0, 'Command executed successfully'); - $this->assertDatabaseHas('entity_permissions_collapsed', [ + $this->assertDatabaseHas('joint_permissions', [ 'entity_id' => $page->id, - 'user_id' => $editor->id, - 'view' => 1, + 'entity_type' => 'page', + 'role_id' => $role->id, + 'has_permission' => 1, ]); - CollapsedPermission::query()->truncate(); + $page->permissions()->delete(); + $page->rebuildPermissions(); + DB::beginTransaction(); } } diff --git a/tests/Entity/BookShelfTest.php b/tests/Entity/BookShelfTest.php index 5c6489281..d953f3692 100644 --- a/tests/Entity/BookShelfTest.php +++ b/tests/Entity/BookShelfTest.php @@ -21,6 +21,7 @@ class BookShelfTest extends TestCase $this->withHtml($resp)->assertElementContains('header', 'Shelves'); $viewer->roles()->delete(); + $this->permissions->grantUserRolePermissions($viewer, []); $resp = $this->actingAs($viewer)->get('/'); $this->withHtml($resp)->assertElementNotContains('header', 'Shelves'); diff --git a/tests/Helpers/PermissionsProvider.php b/tests/Helpers/PermissionsProvider.php index ac9a2a68a..2cbfb1af5 100644 --- a/tests/Helpers/PermissionsProvider.php +++ b/tests/Helpers/PermissionsProvider.php @@ -85,7 +85,7 @@ class PermissionsProvider if (!$inherit) { // Set default permissions to not allow actions so that only the provided role permissions are at play. - $permissions[] = ['role_id' => null, 'user_id' => null, 'view' => false, 'create' => false, 'update' => false, 'delete' => false]; + $permissions[] = ['role_id' => 0, 'view' => false, 'create' => false, 'update' => false, 'delete' => false]; } foreach ($roles as $role) { @@ -95,9 +95,9 @@ class PermissionsProvider $this->addEntityPermissionEntries($entity, $permissions); } - public function addEntityPermission(Entity $entity, array $actionList, ?Role $role = null, ?User $user = null) + public function addEntityPermission(Entity $entity, array $actionList, Role $role) { - $permissionData = $this->actionListToEntityPermissionData($actionList, $role->id ?? null, $user->id ?? null); + $permissionData = $this->actionListToEntityPermissionData($actionList, $role->id); $this->addEntityPermissionEntries($entity, [$permissionData]); } @@ -107,7 +107,7 @@ class PermissionsProvider */ public function disableEntityInheritedPermissions(Entity $entity): void { - $entity->permissions()->whereNull(['user_id', 'role_id'])->delete(); + $entity->permissions()->where('role_id', '=', 0)->delete(); $fallback = $this->actionListToEntityPermissionData([]); $this->addEntityPermissionEntries($entity, [$fallback]); } @@ -124,9 +124,9 @@ class PermissionsProvider * the format to entity permission data, where permission is granted if the action is in the * given actionList array. */ - protected function actionListToEntityPermissionData(array $actionList, int $roleId = null, int $userId = null): array + protected function actionListToEntityPermissionData(array $actionList, int $roleId = 0): array { - $permissionData = ['role_id' => $roleId, 'user_id' => $userId]; + $permissionData = ['role_id' => $roleId]; foreach (EntityPermission::PERMISSIONS as $possibleAction) { $permissionData[$possibleAction] = in_array($possibleAction, $actionList); } diff --git a/tests/Permissions/EntityPermissionsTest.php b/tests/Permissions/EntityPermissionsTest.php index 68a4ed244..ab8b1242d 100644 --- a/tests/Permissions/EntityPermissionsTest.php +++ b/tests/Permissions/EntityPermissionsTest.php @@ -379,19 +379,17 @@ class EntityPermissionsTest extends TestCase $this->put($modelInstance->getUrl('/permissions'), [ 'permissions' => [ - 'role' => [ - $roleId => [ - $permission => 'true', - ], + $roleId => [ + $permission => 'true', ], ], ]); $this->assertDatabaseHas('entity_permissions', [ - 'entity_id' => $modelInstance->id, - 'entity_type' => $modelInstance->getMorphClass(), - 'role_id' => $roleId, - $permission => true, + 'entity_id' => $modelInstance->id, + 'entity_type' => $modelInstance->getMorphClass(), + 'role_id' => $roleId, + $permission => true, ]); } diff --git a/tests/Permissions/Scenarios/EntityUserPermissionsTest.php b/tests/Permissions/Scenarios/EntityUserPermissionsTest.php deleted file mode 100644 index 4fa805805..000000000 --- a/tests/Permissions/Scenarios/EntityUserPermissionsTest.php +++ /dev/null @@ -1,209 +0,0 @@ -users->newUser(); - $page = $this->entities->page(); - $this->permissions->disableEntityInheritedPermissions($page); - $this->permissions->addEntityPermission($page, ['view'], null, $user); - - $this->assertVisibleToUser($page, $user); - } - - public function test_02_explicit_deny() - { - $user = $this->users->newUser(); - $page = $this->entities->page(); - $this->permissions->disableEntityInheritedPermissions($page); - $this->permissions->addEntityPermission($page, [], null, $user); - - $this->assertNotVisibleToUser($page, $user); - } - - public function test_10_allow_inherit() - { - $user = $this->users->newUser(); - $page = $this->entities->pageWithinChapter(); - $chapter = $page->chapter; - $this->permissions->disableEntityInheritedPermissions($chapter); - $this->permissions->addEntityPermission($chapter, ['view'], null, $user); - - $this->assertVisibleToUser($page, $user); - } - - public function test_11_deny_inherit() - { - $user = $this->users->newUser(); - $page = $this->entities->pageWithinChapter(); - $chapter = $page->chapter; - $this->permissions->disableEntityInheritedPermissions($chapter); - $this->permissions->addEntityPermission($chapter, [], null, $user); - - $this->assertNotVisibleToUser($page, $user); - } - - public function test_12_allow_inherit_override() - { - $user = $this->users->newUser(); - $page = $this->entities->pageWithinChapter(); - $chapter = $page->chapter; - $this->permissions->disableEntityInheritedPermissions($chapter); - $this->permissions->addEntityPermission($chapter, [], null, $user); - $this->permissions->addEntityPermission($page, ['view'], null, $user); - - $this->assertVisibleToUser($page, $user); - } - - public function test_13_deny_inherit_override() - { - $user = $this->users->newUser(); - $page = $this->entities->pageWithinChapter(); - $chapter = $page->chapter; - $this->permissions->disableEntityInheritedPermissions($chapter); - $this->permissions->addEntityPermission($chapter, ['view'], null, $user); - $this->permissions->addEntityPermission($page, ['deny'], null, $user); - - $this->assertNotVisibleToUser($page, $user); - } - - public function test_40_entity_role_override_allow() - { - [$user, $role] = $this->users->newUserWithRole(); - $page = $this->entities->page(); - $this->permissions->disableEntityInheritedPermissions($page); - $this->permissions->addEntityPermission($page, ['view'], null, $user); - $this->permissions->addEntityPermission($page, [], $role); - - $this->assertVisibleToUser($page, $user); - } - - public function test_41_entity_role_override_deny() - { - [$user, $role] = $this->users->newUserWithRole(); - $page = $this->entities->page(); - $this->permissions->disableEntityInheritedPermissions($page); - $this->permissions->addEntityPermission($page, [], null, $user); - $this->permissions->addEntityPermission($page, ['view'], $role); - - $this->assertNotVisibleToUser($page, $user); - } - - public function test_42_entity_role_override_allow_via_inherit() - { - [$user, $role] = $this->users->newUserWithRole(); - $page = $this->entities->pageWithinChapter(); - $chapter = $page->chapter; - $this->permissions->disableEntityInheritedPermissions($chapter); - $this->permissions->addEntityPermission($chapter, ['view'], null, $user); - $this->permissions->addEntityPermission($page, [], $role); - - $this->assertVisibleToUser($page, $user); - } - - public function test_43_entity_role_override_deny_via_inherit() - { - [$user, $role] = $this->users->newUserWithRole(); - $page = $this->entities->pageWithinChapter(); - $chapter = $page->chapter; - $this->permissions->disableEntityInheritedPermissions($chapter); - $this->permissions->addEntityPermission($chapter, [], null, $user); - $this->permissions->addEntityPermission($page, ['view'], $role); - - $this->assertNotVisibleToUser($page, $user); - } - - public function test_50_role_override_allow() - { - [$user, $roleA] = $this->users->newUserWithRole(); - $page = $this->entities->page(); - $this->permissions->addEntityPermission($page, ['view'], null, $user); - - $this->assertVisibleToUser($page, $user); - } - - public function test_51_role_override_deny() - { - [$user, $roleA] = $this->users->newUserWithRole([], ['page-view-all']); - $page = $this->entities->page(); - $this->permissions->addEntityPermission($page, [], null, $user); - - $this->assertNotVisibleToUser($page, $user); - } - - public function test_60_inherited_role_override_allow() - { - [$user, $roleA] = $this->users->newUserWithRole([], []); - $page = $this->entities->pageWithinChapter(); - $chapter = $page->chapter; - $this->permissions->addEntityPermission($chapter, ['view'], null, $user); - - $this->assertVisibleToUser($page, $user); - } - - public function test_61_inherited_role_override_deny() - { - [$user, $roleA] = $this->users->newUserWithRole([], ['page-view-all']); - $page = $this->entities->pageWithinChapter(); - $chapter = $page->chapter; - $this->permissions->addEntityPermission($chapter, [], null, $user); - - $this->assertNotVisibleToUser($page, $user); - } - - public function test_61_inherited_role_override_deny_on_own() - { - [$user, $roleA] = $this->users->newUserWithRole([], ['page-view-own']); - $page = $this->entities->pageWithinChapter(); - $chapter = $page->chapter; - $this->permissions->addEntityPermission($chapter, [], null, $user); - $this->permissions->changeEntityOwner($page, $user); - - $this->assertNotVisibleToUser($page, $user); - } - - public function test_70_all_override_allow() - { - [$user, $roleA] = $this->users->newUserWithRole([], []); - $page = $this->entities->page(); - $this->permissions->addEntityPermission($page, [], $roleA, null); - $this->permissions->addEntityPermission($page, ['view'], null, $user); - - $this->assertVisibleToUser($page, $user); - } - - public function test_71_all_override_deny() - { - [$user, $roleA] = $this->users->newUserWithRole([], ['page-view-all']); - $page = $this->entities->page(); - $this->permissions->addEntityPermission($page, ['view'], $roleA, null); - $this->permissions->addEntityPermission($page, [], null, $user); - - $this->assertNotVisibleToUser($page, $user); - } - - public function test_80_inherited_all_override_allow() - { - [$user, $roleA] = $this->users->newUserWithRole([], []); - $page = $this->entities->pageWithinChapter(); - $chapter = $page->chapter; - $this->permissions->addEntityPermission($chapter, [], $roleA, null); - $this->permissions->addEntityPermission($chapter, ['view'], null, $user); - - $this->assertVisibleToUser($page, $user); - } - - public function test_81_inherited_all_override_deny() - { - [$user, $roleA] = $this->users->newUserWithRole([], ['page-view-all']); - $page = $this->entities->pageWithinChapter(); - $chapter = $page->chapter; - $this->permissions->addEntityPermission($chapter, ['view'], $roleA, null); - $this->permissions->addEntityPermission($chapter, [], null, $user); - - $this->assertNotVisibleToUser($page, $user); - } -} diff --git a/tests/TestCase.php b/tests/TestCase.php index 70fd0da1d..a5d75655c 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -153,9 +153,12 @@ abstract class TestCase extends BaseTestCase DB::purge(); config()->set('database.connections.mysql_testing.database', $database); + DB::beginTransaction(); $callback(); + DB::rollBack(); + if (is_null($originalVal)) { unset($_SERVER[$name]); } else { diff --git a/tests/Unit/FrameworkAssumptionTest.php b/tests/Unit/FrameworkAssumptionTest.php index d4feff60c..54d315de9 100644 --- a/tests/Unit/FrameworkAssumptionTest.php +++ b/tests/Unit/FrameworkAssumptionTest.php @@ -25,7 +25,7 @@ class FrameworkAssumptionTest extends TestCase // Page has SoftDeletes trait by default, so we apply our custom scope and ensure // it stacks on the global scope to filter out deleted items. $query = Page::query()->scopes('visible')->toSql(); - $this->assertStringContainsString('entity_permissions_collapsed', $query); + $this->assertStringContainsString('joint_permissions', $query); $this->assertStringContainsString('`deleted_at` is null', $query); } } From 28dda39260260f8ba6e9cf0b4628d1f56f80aba2 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 21 Jan 2023 19:09:19 +0000 Subject: [PATCH 04/41] Updated PHP and JS depenencies --- composer.lock | 552 +++++++++++++---------- package-lock.json | 1076 ++++++++++++++++++++++----------------------- package.json | 8 +- 3 files changed, 855 insertions(+), 781 deletions(-) diff --git a/composer.lock b/composer.lock index c1a85651f..e58e2b109 100644 --- a/composer.lock +++ b/composer.lock @@ -58,16 +58,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.247.1", + "version": "3.257.5", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "337e447997148b9e5024c2d0ae69618b1cbf80d6" + "reference": "c600a07da531d6c29af791b9d2e8b6df796aa14b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/337e447997148b9e5024c2d0ae69618b1cbf80d6", - "reference": "337e447997148b9e5024c2d0ae69618b1cbf80d6", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/c600a07da531d6c29af791b9d2e8b6df796aa14b", + "reference": "c600a07da531d6c29af791b9d2e8b6df796aa14b", "shasum": "" }, "require": { @@ -146,22 +146,22 @@ "support": { "forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.247.1" + "source": "https://github.com/aws/aws-sdk-php/tree/3.257.5" }, - "time": "2022-11-22T19:23:34+00:00" + "time": "2023-01-20T19:34:14+00:00" }, { "name": "bacon/bacon-qr-code", - "version": "2.0.7", + "version": "2.0.8", "source": { "type": "git", "url": "https://github.com/Bacon/BaconQrCode.git", - "reference": "d70c840f68657ce49094b8d91f9ee0cc07fbf66c" + "reference": "8674e51bb65af933a5ffaf1c308a660387c35c22" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/d70c840f68657ce49094b8d91f9ee0cc07fbf66c", - "reference": "d70c840f68657ce49094b8d91f9ee0cc07fbf66c", + "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/8674e51bb65af933a5ffaf1c308a660387c35c22", + "reference": "8674e51bb65af933a5ffaf1c308a660387c35c22", "shasum": "" }, "require": { @@ -200,9 +200,9 @@ "homepage": "https://github.com/Bacon/BaconQrCode", "support": { "issues": "https://github.com/Bacon/BaconQrCode/issues", - "source": "https://github.com/Bacon/BaconQrCode/tree/2.0.7" + "source": "https://github.com/Bacon/BaconQrCode/tree/2.0.8" }, - "time": "2022-03-14T02:02:36+00:00" + "time": "2022-12-07T17:46:57+00:00" }, { "name": "barryvdh/laravel-dompdf", @@ -561,16 +561,16 @@ }, { "name": "doctrine/dbal", - "version": "3.5.1", + "version": "3.5.3", "source": { "type": "git", "url": "https://github.com/doctrine/dbal.git", - "reference": "f38ee8aaca2d58ee88653cb34a6a3880c23f38a5" + "reference": "88fa7e5189fd5ec6682477044264dc0ed4e3aa1e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/dbal/zipball/f38ee8aaca2d58ee88653cb34a6a3880c23f38a5", - "reference": "f38ee8aaca2d58ee88653cb34a6a3880c23f38a5", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/88fa7e5189fd5ec6682477044264dc0ed4e3aa1e", + "reference": "88fa7e5189fd5ec6682477044264dc0ed4e3aa1e", "shasum": "" }, "require": { @@ -583,16 +583,16 @@ "psr/log": "^1|^2|^3" }, "require-dev": { - "doctrine/coding-standard": "10.0.0", - "jetbrains/phpstorm-stubs": "2022.2", - "phpstan/phpstan": "1.8.10", + "doctrine/coding-standard": "11.0.0", + "jetbrains/phpstorm-stubs": "2022.3", + "phpstan/phpstan": "1.9.4", "phpstan/phpstan-strict-rules": "^1.4", - "phpunit/phpunit": "9.5.25", - "psalm/plugin-phpunit": "0.17.0", + "phpunit/phpunit": "9.5.27", + "psalm/plugin-phpunit": "0.18.4", "squizlabs/php_codesniffer": "3.7.1", "symfony/cache": "^5.4|^6.0", "symfony/console": "^4.4|^5.4|^6.0", - "vimeo/psalm": "4.29.0" + "vimeo/psalm": "4.30.0" }, "suggest": { "symfony/console": "For helpful console commands such as SQL execution and import of files." @@ -652,7 +652,7 @@ ], "support": { "issues": "https://github.com/doctrine/dbal/issues", - "source": "https://github.com/doctrine/dbal/tree/3.5.1" + "source": "https://github.com/doctrine/dbal/tree/3.5.3" }, "funding": [ { @@ -668,7 +668,7 @@ "type": "tidelift" } ], - "time": "2022-10-24T07:26:18+00:00" + "time": "2023-01-12T10:21:44+00:00" }, { "name": "doctrine/deprecations", @@ -1787,16 +1787,16 @@ }, { "name": "laravel/framework", - "version": "v8.83.26", + "version": "v8.83.27", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "7411d9fa71c1b0fd73a33e225f14512b74e6c81e" + "reference": "e1afe088b4ca613fb96dc57e6d8dbcb8cc2c6b49" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/7411d9fa71c1b0fd73a33e225f14512b74e6c81e", - "reference": "7411d9fa71c1b0fd73a33e225f14512b74e6c81e", + "url": "https://api.github.com/repos/laravel/framework/zipball/e1afe088b4ca613fb96dc57e6d8dbcb8cc2c6b49", + "reference": "e1afe088b4ca613fb96dc57e6d8dbcb8cc2c6b49", "shasum": "" }, "require": { @@ -1956,7 +1956,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2022-11-01T14:48:50+00:00" + "time": "2022-12-08T15:28:55+00:00" }, { "name": "laravel/serializable-closure", @@ -2020,30 +2020,30 @@ }, { "name": "laravel/socialite", - "version": "v5.5.6", + "version": "v5.6.0", "source": { "type": "git", "url": "https://github.com/laravel/socialite.git", - "reference": "1cd1682b709b8808a5b5dbb68179a58d1342aa7b" + "reference": "dae03ca4ecfe3badafcdfb81965d2279080051f4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/socialite/zipball/1cd1682b709b8808a5b5dbb68179a58d1342aa7b", - "reference": "1cd1682b709b8808a5b5dbb68179a58d1342aa7b", + "url": "https://api.github.com/repos/laravel/socialite/zipball/dae03ca4ecfe3badafcdfb81965d2279080051f4", + "reference": "dae03ca4ecfe3badafcdfb81965d2279080051f4", "shasum": "" }, "require": { "ext-json": "*", "guzzlehttp/guzzle": "^6.0|^7.0", - "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0", - "illuminate/http": "^6.0|^7.0|^8.0|^9.0", - "illuminate/support": "^6.0|^7.0|^8.0|^9.0", + "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0", + "illuminate/http": "^6.0|^7.0|^8.0|^9.0|^10.0", + "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0", "league/oauth1-client": "^1.10.1", "php": "^7.2|^8.0" }, "require-dev": { "mockery/mockery": "^1.0", - "orchestra/testbench": "^4.0|^5.0|^6.0|^7.0", + "orchestra/testbench": "^4.0|^5.0|^6.0|^7.0|^8.0", "phpunit/phpunit": "^8.0|^9.3" }, "type": "library", @@ -2085,26 +2085,26 @@ "issues": "https://github.com/laravel/socialite/issues", "source": "https://github.com/laravel/socialite" }, - "time": "2022-11-08T15:07:05+00:00" + "time": "2023-01-13T15:04:44+00:00" }, { "name": "laravel/tinker", - "version": "v2.7.3", + "version": "v2.8.0", "source": { "type": "git", "url": "https://github.com/laravel/tinker.git", - "reference": "5062061b4924af3392225dd482ca7b4d85d8b8ef" + "reference": "74d0b287cc4ae65d15c368dd697aae71d62a73ad" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/tinker/zipball/5062061b4924af3392225dd482ca7b4d85d8b8ef", - "reference": "5062061b4924af3392225dd482ca7b4d85d8b8ef", + "url": "https://api.github.com/repos/laravel/tinker/zipball/74d0b287cc4ae65d15c368dd697aae71d62a73ad", + "reference": "74d0b287cc4ae65d15c368dd697aae71d62a73ad", "shasum": "" }, "require": { - "illuminate/console": "^6.0|^7.0|^8.0|^9.0", - "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0", - "illuminate/support": "^6.0|^7.0|^8.0|^9.0", + "illuminate/console": "^6.0|^7.0|^8.0|^9.0|^10.0", + "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0", + "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0", "php": "^7.2.5|^8.0", "psy/psysh": "^0.10.4|^0.11.1", "symfony/var-dumper": "^4.3.4|^5.0|^6.0" @@ -2114,7 +2114,7 @@ "phpunit/phpunit": "^8.5.8|^9.3.3" }, "suggest": { - "illuminate/database": "The Illuminate Database package (^6.0|^7.0|^8.0|^9.0)." + "illuminate/database": "The Illuminate Database package (^6.0|^7.0|^8.0|^9.0|^10.0)." }, "type": "library", "extra": { @@ -2151,9 +2151,9 @@ ], "support": { "issues": "https://github.com/laravel/tinker/issues", - "source": "https://github.com/laravel/tinker/tree/v2.7.3" + "source": "https://github.com/laravel/tinker/tree/v2.8.0" }, - "time": "2022-11-09T15:11:38+00:00" + "time": "2023-01-10T18:03:30+00:00" }, { "name": "league/commonmark", @@ -2932,16 +2932,16 @@ }, { "name": "nesbot/carbon", - "version": "2.63.0", + "version": "2.65.0", "source": { "type": "git", "url": "https://github.com/briannesbitt/Carbon.git", - "reference": "ad35dd71a6a212b98e4b87e97389b6fa85f0e347" + "reference": "09acf64155c16dc6f580f36569ae89344e9734a3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/briannesbitt/Carbon/zipball/ad35dd71a6a212b98e4b87e97389b6fa85f0e347", - "reference": "ad35dd71a6a212b98e4b87e97389b6fa85f0e347", + "url": "https://api.github.com/repos/briannesbitt/Carbon/zipball/09acf64155c16dc6f580f36569ae89344e9734a3", + "reference": "09acf64155c16dc6f580f36569ae89344e9734a3", "shasum": "" }, "require": { @@ -2952,7 +2952,7 @@ "symfony/translation": "^3.4 || ^4.0 || ^5.0 || ^6.0" }, "require-dev": { - "doctrine/dbal": "^2.0 || ^3.0", + "doctrine/dbal": "^2.0 || ^3.1.4", "doctrine/orm": "^2.7", "friendsofphp/php-cs-fixer": "^3.0", "kylekatarnls/multi-tester": "^2.0", @@ -3030,20 +3030,20 @@ "type": "tidelift" } ], - "time": "2022-10-30T18:34:28+00:00" + "time": "2023-01-06T15:55:01+00:00" }, { "name": "nikic/php-parser", - "version": "v4.15.2", + "version": "v4.15.3", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "f59bbe44bf7d96f24f3e2b4ddc21cd52c1d2adbc" + "reference": "570e980a201d8ed0236b0a62ddf2c9cbb2034039" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/f59bbe44bf7d96f24f3e2b4ddc21cd52c1d2adbc", - "reference": "f59bbe44bf7d96f24f3e2b4ddc21cd52c1d2adbc", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/570e980a201d8ed0236b0a62ddf2c9cbb2034039", + "reference": "570e980a201d8ed0236b0a62ddf2c9cbb2034039", "shasum": "" }, "require": { @@ -3084,9 +3084,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v4.15.2" + "source": "https://github.com/nikic/PHP-Parser/tree/v4.15.3" }, - "time": "2022-11-12T15:38:23+00:00" + "time": "2023-01-16T22:05:37+00:00" }, { "name": "onelogin/php-saml", @@ -3493,16 +3493,16 @@ }, { "name": "phpseclib/phpseclib", - "version": "3.0.17", + "version": "3.0.18", "source": { "type": "git", "url": "https://github.com/phpseclib/phpseclib.git", - "reference": "dbc2307d5c69aeb22db136c52e91130d7f2ca761" + "reference": "f28693d38ba21bb0d9f0c411ee5dae2b178201da" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/dbc2307d5c69aeb22db136c52e91130d7f2ca761", - "reference": "dbc2307d5c69aeb22db136c52e91130d7f2ca761", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/f28693d38ba21bb0d9f0c411ee5dae2b178201da", + "reference": "f28693d38ba21bb0d9f0c411ee5dae2b178201da", "shasum": "" }, "require": { @@ -3583,7 +3583,7 @@ ], "support": { "issues": "https://github.com/phpseclib/phpseclib/issues", - "source": "https://github.com/phpseclib/phpseclib/tree/3.0.17" + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.18" }, "funding": [ { @@ -3599,7 +3599,7 @@ "type": "tidelift" } ], - "time": "2022-10-24T10:51:50+00:00" + "time": "2022-12-17T18:26:50+00:00" }, { "name": "pragmarx/google2fa", @@ -4129,16 +4129,16 @@ }, { "name": "psy/psysh", - "version": "v0.11.9", + "version": "v0.11.10", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "1acec99d6684a54ff92f8b548a4e41b566963778" + "reference": "e9eadffbed9c9deb5426fd107faae0452bf20a36" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/1acec99d6684a54ff92f8b548a4e41b566963778", - "reference": "1acec99d6684a54ff92f8b548a4e41b566963778", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/e9eadffbed9c9deb5426fd107faae0452bf20a36", + "reference": "e9eadffbed9c9deb5426fd107faae0452bf20a36", "shasum": "" }, "require": { @@ -4199,9 +4199,9 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.11.9" + "source": "https://github.com/bobthecow/psysh/tree/v0.11.10" }, - "time": "2022-11-06T15:29:46+00:00" + "time": "2022-12-23T17:47:18+00:00" }, { "name": "ralouphie/getallheaders", @@ -4249,42 +4249,53 @@ }, { "name": "ramsey/collection", - "version": "1.2.2", + "version": "1.3.0", "source": { "type": "git", "url": "https://github.com/ramsey/collection.git", - "reference": "cccc74ee5e328031b15640b51056ee8d3bb66c0a" + "reference": "ad7475d1c9e70b190ecffc58f2d989416af339b4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/collection/zipball/cccc74ee5e328031b15640b51056ee8d3bb66c0a", - "reference": "cccc74ee5e328031b15640b51056ee8d3bb66c0a", + "url": "https://api.github.com/repos/ramsey/collection/zipball/ad7475d1c9e70b190ecffc58f2d989416af339b4", + "reference": "ad7475d1c9e70b190ecffc58f2d989416af339b4", "shasum": "" }, "require": { - "php": "^7.3 || ^8", + "php": "^7.4 || ^8.0", "symfony/polyfill-php81": "^1.23" }, "require-dev": { - "captainhook/captainhook": "^5.3", - "dealerdirect/phpcodesniffer-composer-installer": "^0.7.0", - "ergebnis/composer-normalize": "^2.6", - "fakerphp/faker": "^1.5", - "hamcrest/hamcrest-php": "^2", - "jangregor/phpstan-prophecy": "^0.8", - "mockery/mockery": "^1.3", + "captainhook/plugin-composer": "^5.3", + "ergebnis/composer-normalize": "^2.28.3", + "fakerphp/faker": "^1.21", + "hamcrest/hamcrest-php": "^2.0", + "jangregor/phpstan-prophecy": "^1.0", + "mockery/mockery": "^1.5", + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.3", + "phpcsstandards/phpcsutils": "^1.0.0-rc1", "phpspec/prophecy-phpunit": "^2.0", - "phpstan/extension-installer": "^1", - "phpstan/phpstan": "^0.12.32", - "phpstan/phpstan-mockery": "^0.12.5", - "phpstan/phpstan-phpunit": "^0.12.11", - "phpunit/phpunit": "^8.5 || ^9", - "psy/psysh": "^0.10.4", - "slevomat/coding-standard": "^6.3", - "squizlabs/php_codesniffer": "^3.5", - "vimeo/psalm": "^4.4" + "phpstan/extension-installer": "^1.2", + "phpstan/phpstan": "^1.9", + "phpstan/phpstan-mockery": "^1.1", + "phpstan/phpstan-phpunit": "^1.3", + "phpunit/phpunit": "^9.5", + "psalm/plugin-mockery": "^1.1", + "psalm/plugin-phpunit": "^0.18.4", + "ramsey/coding-standard": "^2.0.3", + "ramsey/conventional-commits": "^1.3", + "vimeo/psalm": "^5.4" }, "type": "library", + "extra": { + "captainhook": { + "force-install": true + }, + "ramsey/conventional-commits": { + "configFile": "conventional-commits.json" + } + }, "autoload": { "psr-4": { "Ramsey\\Collection\\": "src/" @@ -4312,7 +4323,7 @@ ], "support": { "issues": "https://github.com/ramsey/collection/issues", - "source": "https://github.com/ramsey/collection/tree/1.2.2" + "source": "https://github.com/ramsey/collection/tree/1.3.0" }, "funding": [ { @@ -4324,7 +4335,7 @@ "type": "tidelift" } ], - "time": "2021-10-10T03:01:02+00:00" + "time": "2022-12-27T19:12:24+00:00" }, { "name": "ramsey/uuid", @@ -4989,16 +5000,16 @@ }, { "name": "symfony/console", - "version": "v5.4.15", + "version": "v5.4.17", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "ea59bb0edfaf9f28d18d8791410ee0355f317669" + "reference": "58422fdcb0e715ed05b385f70d3e8b5ed4bbd45f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/ea59bb0edfaf9f28d18d8791410ee0355f317669", - "reference": "ea59bb0edfaf9f28d18d8791410ee0355f317669", + "url": "https://api.github.com/repos/symfony/console/zipball/58422fdcb0e715ed05b385f70d3e8b5ed4bbd45f", + "reference": "58422fdcb0e715ed05b385f70d3e8b5ed4bbd45f", "shasum": "" }, "require": { @@ -5068,7 +5079,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v5.4.15" + "source": "https://github.com/symfony/console/tree/v5.4.17" }, "funding": [ { @@ -5084,20 +5095,20 @@ "type": "tidelift" } ], - "time": "2022-10-26T21:41:52+00:00" + "time": "2022-12-28T14:15:31+00:00" }, { "name": "symfony/css-selector", - "version": "v5.4.11", + "version": "v5.4.17", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "c1681789f059ab756001052164726ae88512ae3d" + "reference": "052ef49b660f9ad2a3adb311c555c9bc11ba61f4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/c1681789f059ab756001052164726ae88512ae3d", - "reference": "c1681789f059ab756001052164726ae88512ae3d", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/052ef49b660f9ad2a3adb311c555c9bc11ba61f4", + "reference": "052ef49b660f9ad2a3adb311c555c9bc11ba61f4", "shasum": "" }, "require": { @@ -5134,7 +5145,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v5.4.11" + "source": "https://github.com/symfony/css-selector/tree/v5.4.17" }, "funding": [ { @@ -5150,7 +5161,7 @@ "type": "tidelift" } ], - "time": "2022-06-27T16:58:25+00:00" + "time": "2022-12-23T11:40:44+00:00" }, { "name": "symfony/deprecation-contracts", @@ -5221,16 +5232,16 @@ }, { "name": "symfony/error-handler", - "version": "v5.4.15", + "version": "v5.4.17", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "539cf1428b8442303c6e876ad7bf5a7babd91091" + "reference": "b900446552833ad2f91ca7dd52aa8ffe78f66cb2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/539cf1428b8442303c6e876ad7bf5a7babd91091", - "reference": "539cf1428b8442303c6e876ad7bf5a7babd91091", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/b900446552833ad2f91ca7dd52aa8ffe78f66cb2", + "reference": "b900446552833ad2f91ca7dd52aa8ffe78f66cb2", "shasum": "" }, "require": { @@ -5272,7 +5283,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v5.4.15" + "source": "https://github.com/symfony/error-handler/tree/v5.4.17" }, "funding": [ { @@ -5288,20 +5299,20 @@ "type": "tidelift" } ], - "time": "2022-10-27T06:32:25+00:00" + "time": "2022-12-13T09:43:00+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v5.4.9", + "version": "v5.4.17", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "8e6ce1cc0279e3ff3c8ff0f43813bc88d21ca1bc" + "reference": "8e18a9d559eb8ebc2220588f1faa726a2fcd31c9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/8e6ce1cc0279e3ff3c8ff0f43813bc88d21ca1bc", - "reference": "8e6ce1cc0279e3ff3c8ff0f43813bc88d21ca1bc", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/8e18a9d559eb8ebc2220588f1faa726a2fcd31c9", + "reference": "8e18a9d559eb8ebc2220588f1faa726a2fcd31c9", "shasum": "" }, "require": { @@ -5357,7 +5368,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v5.4.9" + "source": "https://github.com/symfony/event-dispatcher/tree/v5.4.17" }, "funding": [ { @@ -5373,7 +5384,7 @@ "type": "tidelift" } ], - "time": "2022-05-05T16:45:39+00:00" + "time": "2022-12-12T15:54:21+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -5456,16 +5467,16 @@ }, { "name": "symfony/finder", - "version": "v5.4.11", + "version": "v5.4.17", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "7872a66f57caffa2916a584db1aa7f12adc76f8c" + "reference": "40c08632019838dfb3350f18cf5563b8080055fc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/7872a66f57caffa2916a584db1aa7f12adc76f8c", - "reference": "7872a66f57caffa2916a584db1aa7f12adc76f8c", + "url": "https://api.github.com/repos/symfony/finder/zipball/40c08632019838dfb3350f18cf5563b8080055fc", + "reference": "40c08632019838dfb3350f18cf5563b8080055fc", "shasum": "" }, "require": { @@ -5499,7 +5510,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v5.4.11" + "source": "https://github.com/symfony/finder/tree/v5.4.17" }, "funding": [ { @@ -5515,20 +5526,20 @@ "type": "tidelift" } ], - "time": "2022-07-29T07:37:50+00:00" + "time": "2022-12-22T10:31:03+00:00" }, { "name": "symfony/http-foundation", - "version": "v5.4.15", + "version": "v5.4.17", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "75bd663ff2db90141bfb733682459d5bbe9e29c3" + "reference": "b64a0e2df212d5849e4584cabff0cf09c5d6866a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/75bd663ff2db90141bfb733682459d5bbe9e29c3", - "reference": "75bd663ff2db90141bfb733682459d5bbe9e29c3", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/b64a0e2df212d5849e4584cabff0cf09c5d6866a", + "reference": "b64a0e2df212d5849e4584cabff0cf09c5d6866a", "shasum": "" }, "require": { @@ -5575,7 +5586,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v5.4.15" + "source": "https://github.com/symfony/http-foundation/tree/v5.4.17" }, "funding": [ { @@ -5591,20 +5602,20 @@ "type": "tidelift" } ], - "time": "2022-10-12T09:43:19+00:00" + "time": "2022-12-14T08:23:03+00:00" }, { "name": "symfony/http-kernel", - "version": "v5.4.15", + "version": "v5.4.18", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "fc63c8c3e1036d424820cc993a4ea163778dc5c7" + "reference": "5da6f57a13e5d7d77197443cf55697cdf65f1352" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/fc63c8c3e1036d424820cc993a4ea163778dc5c7", - "reference": "fc63c8c3e1036d424820cc993a4ea163778dc5c7", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/5da6f57a13e5d7d77197443cf55697cdf65f1352", + "reference": "5da6f57a13e5d7d77197443cf55697cdf65f1352", "shasum": "" }, "require": { @@ -5687,7 +5698,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v5.4.15" + "source": "https://github.com/symfony/http-kernel/tree/v5.4.18" }, "funding": [ { @@ -5703,20 +5714,20 @@ "type": "tidelift" } ], - "time": "2022-10-28T17:52:18+00:00" + "time": "2022-12-29T18:54:08+00:00" }, { "name": "symfony/mime", - "version": "v5.4.14", + "version": "v5.4.17", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "1c118b253bb3495d81e95a6e3ec6c2766a98a0c4" + "reference": "2a83d82efc91c3f03a23c8b47a896df168aa5c63" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/1c118b253bb3495d81e95a6e3ec6c2766a98a0c4", - "reference": "1c118b253bb3495d81e95a6e3ec6c2766a98a0c4", + "url": "https://api.github.com/repos/symfony/mime/zipball/2a83d82efc91c3f03a23c8b47a896df168aa5c63", + "reference": "2a83d82efc91c3f03a23c8b47a896df168aa5c63", "shasum": "" }, "require": { @@ -5771,7 +5782,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v5.4.14" + "source": "https://github.com/symfony/mime/tree/v5.4.17" }, "funding": [ { @@ -5787,7 +5798,7 @@ "type": "tidelift" } ], - "time": "2022-10-07T08:01:20+00:00" + "time": "2022-12-13T09:59:55+00:00" }, { "name": "symfony/polyfill-ctype", @@ -6670,16 +6681,16 @@ }, { "name": "symfony/routing", - "version": "v5.4.15", + "version": "v5.4.17", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "5c9b129efe9abce9470e384bf65d8a7e262eee69" + "reference": "4ce2df9a469c19ba45ca6aca04fec1c358a6e791" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/5c9b129efe9abce9470e384bf65d8a7e262eee69", - "reference": "5c9b129efe9abce9470e384bf65d8a7e262eee69", + "url": "https://api.github.com/repos/symfony/routing/zipball/4ce2df9a469c19ba45ca6aca04fec1c358a6e791", + "reference": "4ce2df9a469c19ba45ca6aca04fec1c358a6e791", "shasum": "" }, "require": { @@ -6694,7 +6705,7 @@ "symfony/yaml": "<4.4" }, "require-dev": { - "doctrine/annotations": "^1.12", + "doctrine/annotations": "^1.12|^2", "psr/log": "^1|^2|^3", "symfony/config": "^5.3|^6.0", "symfony/dependency-injection": "^4.4|^5.0|^6.0", @@ -6740,7 +6751,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v5.4.15" + "source": "https://github.com/symfony/routing/tree/v5.4.17" }, "funding": [ { @@ -6756,7 +6767,7 @@ "type": "tidelift" } ], - "time": "2022-10-13T14:10:41+00:00" + "time": "2022-12-20T11:10:57+00:00" }, { "name": "symfony/service-contracts", @@ -6843,16 +6854,16 @@ }, { "name": "symfony/string", - "version": "v5.4.15", + "version": "v5.4.17", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "571334ce9f687e3e6af72db4d3b2a9431e4fd9ed" + "reference": "55733a8664b8853b003e70251c58bc8cb2d82a6b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/571334ce9f687e3e6af72db4d3b2a9431e4fd9ed", - "reference": "571334ce9f687e3e6af72db4d3b2a9431e4fd9ed", + "url": "https://api.github.com/repos/symfony/string/zipball/55733a8664b8853b003e70251c58bc8cb2d82a6b", + "reference": "55733a8664b8853b003e70251c58bc8cb2d82a6b", "shasum": "" }, "require": { @@ -6909,7 +6920,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v5.4.15" + "source": "https://github.com/symfony/string/tree/v5.4.17" }, "funding": [ { @@ -6925,7 +6936,7 @@ "type": "tidelift" } ], - "time": "2022-10-05T15:16:54+00:00" + "time": "2022-12-12T15:54:21+00:00" }, { "name": "symfony/translation", @@ -7104,16 +7115,16 @@ }, { "name": "symfony/var-dumper", - "version": "v5.4.14", + "version": "v5.4.17", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "6894d06145fefebd9a4c7272baa026a1c394a430" + "reference": "ad74890513d07060255df2575703daf971de92c7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/6894d06145fefebd9a4c7272baa026a1c394a430", - "reference": "6894d06145fefebd9a4c7272baa026a1c394a430", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/ad74890513d07060255df2575703daf971de92c7", + "reference": "ad74890513d07060255df2575703daf971de92c7", "shasum": "" }, "require": { @@ -7173,7 +7184,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v5.4.14" + "source": "https://github.com/symfony/var-dumper/tree/v5.4.17" }, "funding": [ { @@ -7189,20 +7200,20 @@ "type": "tidelift" } ], - "time": "2022-10-07T08:01:20+00:00" + "time": "2022-12-22T10:31:03+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", - "version": "2.2.5", + "version": "2.2.6", "source": { "type": "git", "url": "https://github.com/tijsverkoyen/CssToInlineStyles.git", - "reference": "4348a3a06651827a27d989ad1d13efec6bb49b19" + "reference": "c42125b83a4fa63b187fdf29f9c93cb7733da30c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/4348a3a06651827a27d989ad1d13efec6bb49b19", - "reference": "4348a3a06651827a27d989ad1d13efec6bb49b19", + "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/c42125b83a4fa63b187fdf29f9c93cb7733da30c", + "reference": "c42125b83a4fa63b187fdf29f9c93cb7733da30c", "shasum": "" }, "require": { @@ -7240,9 +7251,9 @@ "homepage": "https://github.com/tijsverkoyen/CssToInlineStyles", "support": { "issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues", - "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/2.2.5" + "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/2.2.6" }, - "time": "2022-09-12T13:28:28+00:00" + "time": "2023-01-03T09:29:04+00:00" }, { "name": "vlucas/phpdotenv", @@ -7464,16 +7475,16 @@ "packages-dev": [ { "name": "brianium/paratest", - "version": "v6.6.5", + "version": "v6.8.1", "source": { "type": "git", "url": "https://github.com/paratestphp/paratest.git", - "reference": "31fd5d69b41725f383c9a083831eefcc7ecd9061" + "reference": "168c1cfdf79e5b19b57cb03060fc9a6a79c5f582" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paratestphp/paratest/zipball/31fd5d69b41725f383c9a083831eefcc7ecd9061", - "reference": "31fd5d69b41725f383c9a083831eefcc7ecd9061", + "url": "https://api.github.com/repos/paratestphp/paratest/zipball/168c1cfdf79e5b19b57cb03060fc9a6a79c5f582", + "reference": "168c1cfdf79e5b19b57cb03060fc9a6a79c5f582", "shasum": "" }, "require": { @@ -7481,25 +7492,25 @@ "ext-pcre": "*", "ext-reflection": "*", "ext-simplexml": "*", + "fidry/cpu-core-counter": "^0.4.1", "jean85/pretty-package-versions": "^2.0.5", "php": "^7.3 || ^8.0", - "phpunit/php-code-coverage": "^9.2.17", + "phpunit/php-code-coverage": "^9.2.23", "phpunit/php-file-iterator": "^3.0.6", "phpunit/php-timer": "^5.0.3", - "phpunit/phpunit": "^9.5.24", + "phpunit/phpunit": "^9.5.28", "sebastian/environment": "^5.1.4", - "symfony/console": "^5.4.12 || ^6.1.4", - "symfony/process": "^5.4.11 || ^6.1.3" + "symfony/console": "^5.4.16 || ^6.2.3", + "symfony/process": "^5.4.11 || ^6.2" }, "require-dev": { "doctrine/coding-standard": "^10.0.0", "ext-pcov": "*", "ext-posix": "*", - "infection/infection": "^0.26.14", - "malukenho/mcbumpface": "^1.1.5", + "infection/infection": "^0.26.16", "squizlabs/php_codesniffer": "^3.7.1", - "symfony/filesystem": "^5.4.12 || ^6.1.4", - "vimeo/psalm": "^4.27.0" + "symfony/filesystem": "^5.4.13 || ^6.2", + "vimeo/psalm": "^5.4" }, "bin": [ "bin/paratest", @@ -7540,7 +7551,7 @@ ], "support": { "issues": "https://github.com/paratestphp/paratest/issues", - "source": "https://github.com/paratestphp/paratest/tree/v6.6.5" + "source": "https://github.com/paratestphp/paratest/tree/v6.8.1" }, "funding": [ { @@ -7552,20 +7563,20 @@ "type": "paypal" } ], - "time": "2022-10-28T12:22:26+00:00" + "time": "2023-01-17T10:08:49+00:00" }, { "name": "composer/ca-bundle", - "version": "1.3.4", + "version": "1.3.5", "source": { "type": "git", "url": "https://github.com/composer/ca-bundle.git", - "reference": "69098eca243998b53eed7a48d82dedd28b447cd5" + "reference": "74780ccf8c19d6acb8d65c5f39cd72110e132bbd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/ca-bundle/zipball/69098eca243998b53eed7a48d82dedd28b447cd5", - "reference": "69098eca243998b53eed7a48d82dedd28b447cd5", + "url": "https://api.github.com/repos/composer/ca-bundle/zipball/74780ccf8c19d6acb8d65c5f39cd72110e132bbd", + "reference": "74780ccf8c19d6acb8d65c5f39cd72110e132bbd", "shasum": "" }, "require": { @@ -7612,7 +7623,7 @@ "support": { "irc": "irc://irc.freenode.org/composer", "issues": "https://github.com/composer/ca-bundle/issues", - "source": "https://github.com/composer/ca-bundle/tree/1.3.4" + "source": "https://github.com/composer/ca-bundle/tree/1.3.5" }, "funding": [ { @@ -7628,7 +7639,7 @@ "type": "tidelift" } ], - "time": "2022-10-12T12:08:29+00:00" + "time": "2023-01-11T08:27:00+00:00" }, { "name": "composer/class-map-generator", @@ -7705,23 +7716,23 @@ }, { "name": "composer/composer", - "version": "2.4.4", + "version": "2.5.1", "source": { "type": "git", "url": "https://github.com/composer/composer.git", - "reference": "e8d9087229bcdbc5867594d3098091412f1130cf" + "reference": "923278ad13e1621946eb76ab2882655d2cc396a4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/composer/zipball/e8d9087229bcdbc5867594d3098091412f1130cf", - "reference": "e8d9087229bcdbc5867594d3098091412f1130cf", + "url": "https://api.github.com/repos/composer/composer/zipball/923278ad13e1621946eb76ab2882655d2cc396a4", + "reference": "923278ad13e1621946eb76ab2882655d2cc396a4", "shasum": "" }, "require": { "composer/ca-bundle": "^1.0", "composer/class-map-generator": "^1.0", "composer/metadata-minifier": "^1.0", - "composer/pcre": "^2 || ^3", + "composer/pcre": "^2.1 || ^3.1", "composer/semver": "^3.0", "composer/spdx-licenses": "^1.5.7", "composer/xdebug-handler": "^2.0.2 || ^3.0.3", @@ -7737,10 +7748,11 @@ "symfony/finder": "^5.4 || ^6.0", "symfony/polyfill-php73": "^1.24", "symfony/polyfill-php80": "^1.24", + "symfony/polyfill-php81": "^1.24", "symfony/process": "^5.4 || ^6.0" }, "require-dev": { - "phpstan/phpstan": "^1.4.1", + "phpstan/phpstan": "^1.9.3", "phpstan/phpstan-deprecation-rules": "^1", "phpstan/phpstan-phpunit": "^1.0", "phpstan/phpstan-strict-rules": "^1", @@ -7758,7 +7770,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "2.4-dev" + "dev-main": "2.5-dev" }, "phpstan": { "includes": [ @@ -7797,7 +7809,7 @@ "support": { "irc": "ircs://irc.libera.chat:6697/composer", "issues": "https://github.com/composer/composer/issues", - "source": "https://github.com/composer/composer/tree/2.4.4" + "source": "https://github.com/composer/composer/tree/2.5.1" }, "funding": [ { @@ -7813,7 +7825,7 @@ "type": "tidelift" } ], - "time": "2022-10-27T12:39:29+00:00" + "time": "2022-12-22T14:33:54+00:00" }, { "name": "composer/metadata-minifier", @@ -8184,30 +8196,30 @@ }, { "name": "doctrine/instantiator", - "version": "1.4.1", + "version": "1.5.0", "source": { "type": "git", "url": "https://github.com/doctrine/instantiator.git", - "reference": "10dcfce151b967d20fde1b34ae6640712c3891bc" + "reference": "0a0fa9780f5d4e507415a065172d26a98d02047b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/instantiator/zipball/10dcfce151b967d20fde1b34ae6640712c3891bc", - "reference": "10dcfce151b967d20fde1b34ae6640712c3891bc", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/0a0fa9780f5d4e507415a065172d26a98d02047b", + "reference": "0a0fa9780f5d4e507415a065172d26a98d02047b", "shasum": "" }, "require": { "php": "^7.1 || ^8.0" }, "require-dev": { - "doctrine/coding-standard": "^9", + "doctrine/coding-standard": "^9 || ^11", "ext-pdo": "*", "ext-phar": "*", "phpbench/phpbench": "^0.16 || ^1", "phpstan/phpstan": "^1.4", "phpstan/phpstan-phpunit": "^1", "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", - "vimeo/psalm": "^4.22" + "vimeo/psalm": "^4.30 || ^5.4" }, "type": "library", "autoload": { @@ -8234,7 +8246,7 @@ ], "support": { "issues": "https://github.com/doctrine/instantiator/issues", - "source": "https://github.com/doctrine/instantiator/tree/1.4.1" + "source": "https://github.com/doctrine/instantiator/tree/1.5.0" }, "funding": [ { @@ -8250,7 +8262,7 @@ "type": "tidelift" } ], - "time": "2022-03-03T08:28:38+00:00" + "time": "2022-12-30T00:15:36+00:00" }, { "name": "facade/ignition-contracts", @@ -8307,20 +8319,20 @@ }, { "name": "fakerphp/faker", - "version": "v1.20.0", + "version": "v1.21.0", "source": { "type": "git", "url": "https://github.com/FakerPHP/Faker.git", - "reference": "37f751c67a5372d4e26353bd9384bc03744ec77b" + "reference": "92efad6a967f0b79c499705c69b662f738cc9e4d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/37f751c67a5372d4e26353bd9384bc03744ec77b", - "reference": "37f751c67a5372d4e26353bd9384bc03744ec77b", + "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/92efad6a967f0b79c499705c69b662f738cc9e4d", + "reference": "92efad6a967f0b79c499705c69b662f738cc9e4d", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0", + "php": "^7.4 || ^8.0", "psr/container": "^1.0 || ^2.0", "symfony/deprecation-contracts": "^2.2 || ^3.0" }, @@ -8331,7 +8343,8 @@ "bamarni/composer-bin-plugin": "^1.4.1", "doctrine/persistence": "^1.3 || ^2.0", "ext-intl": "*", - "symfony/phpunit-bridge": "^4.4 || ^5.2" + "phpunit/phpunit": "^9.5.26", + "symfony/phpunit-bridge": "^5.4.16" }, "suggest": { "doctrine/orm": "Required to use Faker\\ORM\\Doctrine", @@ -8343,7 +8356,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "v1.20-dev" + "dev-main": "v1.21-dev" } }, "autoload": { @@ -8368,9 +8381,70 @@ ], "support": { "issues": "https://github.com/FakerPHP/Faker/issues", - "source": "https://github.com/FakerPHP/Faker/tree/v1.20.0" + "source": "https://github.com/FakerPHP/Faker/tree/v1.21.0" }, - "time": "2022-07-20T13:12:54+00:00" + "time": "2022-12-13T13:54:32+00:00" + }, + { + "name": "fidry/cpu-core-counter", + "version": "0.4.1", + "source": { + "type": "git", + "url": "https://github.com/theofidry/cpu-core-counter.git", + "reference": "79261cc280aded96d098e1b0e0ba0c4881b432c2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/79261cc280aded96d098e1b0e0ba0c4881b432c2", + "reference": "79261cc280aded96d098e1b0e0ba0c4881b432c2", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "fidry/makefile": "^0.2.0", + "phpstan/extension-installer": "^1.2.0", + "phpstan/phpstan": "^1.9.2", + "phpstan/phpstan-deprecation-rules": "^1.0.0", + "phpstan/phpstan-phpunit": "^1.2.2", + "phpstan/phpstan-strict-rules": "^1.4.4", + "phpunit/phpunit": "^9.5.26 || ^8.5.31", + "theofidry/php-cs-fixer-config": "^1.0", + "webmozarts/strict-phpunit": "^7.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Fidry\\CpuCoreCounter\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Théo FIDRY", + "email": "theo.fidry@gmail.com" + } + ], + "description": "Tiny utility to get the number of CPU cores.", + "keywords": [ + "CPU", + "core" + ], + "support": { + "issues": "https://github.com/theofidry/cpu-core-counter/issues", + "source": "https://github.com/theofidry/cpu-core-counter/tree/0.4.1" + }, + "funding": [ + { + "url": "https://github.com/theofidry", + "type": "github" + } + ], + "time": "2022-12-16T22:01:02+00:00" }, { "name": "hamcrest/hamcrest-php", @@ -8425,16 +8499,16 @@ }, { "name": "itsgoingd/clockwork", - "version": "v5.1.11", + "version": "v5.1.12", "source": { "type": "git", "url": "https://github.com/itsgoingd/clockwork.git", - "reference": "a790200347f0c6d07e2fca252ccb446df87520c6" + "reference": "c9dbdbb1f0efd19bb80f1080ef63f1b9b1bc3b1b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/itsgoingd/clockwork/zipball/a790200347f0c6d07e2fca252ccb446df87520c6", - "reference": "a790200347f0c6d07e2fca252ccb446df87520c6", + "url": "https://api.github.com/repos/itsgoingd/clockwork/zipball/c9dbdbb1f0efd19bb80f1080ef63f1b9b1bc3b1b", + "reference": "c9dbdbb1f0efd19bb80f1080ef63f1b9b1bc3b1b", "shasum": "" }, "require": { @@ -8481,7 +8555,7 @@ ], "support": { "issues": "https://github.com/itsgoingd/clockwork/issues", - "source": "https://github.com/itsgoingd/clockwork/tree/v5.1.11" + "source": "https://github.com/itsgoingd/clockwork/tree/v5.1.12" }, "funding": [ { @@ -8489,7 +8563,7 @@ "type": "github" } ], - "time": "2022-11-02T21:11:04+00:00" + "time": "2022-12-13T00:04:12+00:00" }, { "name": "jean85/pretty-package-versions", @@ -9108,16 +9182,16 @@ }, { "name": "phpunit/php-code-coverage", - "version": "9.2.19", + "version": "9.2.23", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "c77b56b63e3d2031bd8997fcec43c1925ae46559" + "reference": "9f1f0f9a2fbb680b26d1cf9b61b6eac43a6e4e9c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/c77b56b63e3d2031bd8997fcec43c1925ae46559", - "reference": "c77b56b63e3d2031bd8997fcec43c1925ae46559", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/9f1f0f9a2fbb680b26d1cf9b61b6eac43a6e4e9c", + "reference": "9f1f0f9a2fbb680b26d1cf9b61b6eac43a6e4e9c", "shasum": "" }, "require": { @@ -9173,7 +9247,7 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.19" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.23" }, "funding": [ { @@ -9181,7 +9255,7 @@ "type": "github" } ], - "time": "2022-11-18T07:47:47+00:00" + "time": "2022-12-28T12:41:10+00:00" }, { "name": "phpunit/php-file-iterator", @@ -9426,20 +9500,20 @@ }, { "name": "phpunit/phpunit", - "version": "9.5.26", + "version": "9.5.28", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "851867efcbb6a1b992ec515c71cdcf20d895e9d2" + "reference": "954ca3113a03bf780d22f07bf055d883ee04b65e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/851867efcbb6a1b992ec515c71cdcf20d895e9d2", - "reference": "851867efcbb6a1b992ec515c71cdcf20d895e9d2", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/954ca3113a03bf780d22f07bf055d883ee04b65e", + "reference": "954ca3113a03bf780d22f07bf055d883ee04b65e", "shasum": "" }, "require": { - "doctrine/instantiator": "^1.3.1", + "doctrine/instantiator": "^1.3.1 || ^2", "ext-dom": "*", "ext-json": "*", "ext-libxml": "*", @@ -9508,7 +9582,7 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", - "source": "https://github.com/sebastianbergmann/phpunit/tree/9.5.26" + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.5.28" }, "funding": [ { @@ -9524,7 +9598,7 @@ "type": "tidelift" } ], - "time": "2022-10-28T06:00:21+00:00" + "time": "2023-01-14T12:32:24+00:00" }, { "name": "react/promise", @@ -10853,16 +10927,16 @@ }, { "name": "symfony/dom-crawler", - "version": "v5.4.15", + "version": "v5.4.17", "source": { "type": "git", "url": "https://github.com/symfony/dom-crawler.git", - "reference": "b8fd0ff9a0f00d944f1534f6d21e84f92eda7258" + "reference": "32a07d910edc138a1dd5508c17c6b9bc1eb27a1b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/b8fd0ff9a0f00d944f1534f6d21e84f92eda7258", - "reference": "b8fd0ff9a0f00d944f1534f6d21e84f92eda7258", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/32a07d910edc138a1dd5508c17c6b9bc1eb27a1b", + "reference": "32a07d910edc138a1dd5508c17c6b9bc1eb27a1b", "shasum": "" }, "require": { @@ -10908,7 +10982,7 @@ "description": "Eases DOM navigation for HTML and XML documents", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dom-crawler/tree/v5.4.15" + "source": "https://github.com/symfony/dom-crawler/tree/v5.4.17" }, "funding": [ { @@ -10924,7 +10998,7 @@ "type": "tidelift" } ], - "time": "2022-10-27T08:04:35+00:00" + "time": "2022-12-22T10:31:03+00:00" }, { "name": "symfony/filesystem", diff --git a/package-lock.json b/package-lock.json index dbe0f90d2..eff4bea7d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,22 +10,22 @@ "dropzone": "^5.9.3", "markdown-it": "^13.0.1", "markdown-it-task-lists": "^2.1.1", - "snabbdom": "^3.5.0", + "snabbdom": "^3.5.1", "sortablejs": "^1.15.0" }, "devDependencies": { "chokidar-cli": "^3.0", - "esbuild": "^0.15.12", + "esbuild": "^0.17.3", "livereload": "^0.9.3", "npm-run-all": "^4.1.5", - "punycode": "^2.1.1", - "sass": "^1.55.0" + "punycode": "^2.3.0", + "sass": "^1.57.0" } }, "node_modules/@esbuild/android-arm": { - "version": "0.15.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.15.12.tgz", - "integrity": "sha512-IC7TqIqiyE0MmvAhWkl/8AEzpOtbhRNDo7aph47We1NbE5w2bt/Q+giAhe0YYeVpYnIhGMcuZY92qDK6dQauvA==", + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.3.tgz", + "integrity": "sha512-1Mlz934GvbgdDmt26rTLmf03cAgLg5HyOgJN+ZGCeP3Q9ynYTNMn2/LQxIl7Uy+o4K6Rfi2OuLsr12JQQR8gNg==", "cpu": [ "arm" ], @@ -38,10 +38,154 @@ "node": ">=12" } }, + "node_modules/@esbuild/android-arm64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.17.3.tgz", + "integrity": "sha512-XvJsYo3dO3Pi4kpalkyMvfQsjxPWHYjoX4MDiB/FUM4YMfWcXa5l4VCwFWVYI1+92yxqjuqrhNg0CZg3gSouyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.17.3.tgz", + "integrity": "sha512-nuV2CmLS07Gqh5/GrZLuqkU9Bm6H6vcCspM+zjp9TdQlxJtIe+qqEXQChmfc7nWdyr/yz3h45Utk1tUn8Cz5+A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.17.3.tgz", + "integrity": "sha512-01Hxaaat6m0Xp9AXGM8mjFtqqwDjzlMP0eQq9zll9U85ttVALGCGDuEvra5Feu/NbP5AEP1MaopPwzsTcUq1cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.17.3.tgz", + "integrity": "sha512-Eo2gq0Q/er2muf8Z83X21UFoB7EU6/m3GNKvrhACJkjVThd0uA+8RfKpfNhuMCl1bKRfBzKOk6xaYKQZ4lZqvA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.3.tgz", + "integrity": "sha512-CN62ESxaquP61n1ZjQP/jZte8CE09M6kNn3baos2SeUfdVBkWN5n6vGp2iKyb/bm/x4JQzEvJgRHLGd5F5b81w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.17.3.tgz", + "integrity": "sha512-feq+K8TxIznZE+zhdVurF3WNJ/Sa35dQNYbaqM/wsCbWdzXr5lyq+AaTUSER2cUR+SXPnd/EY75EPRjf4s1SLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.17.3.tgz", + "integrity": "sha512-CLP3EgyNuPcg2cshbwkqYy5bbAgK+VhyfMU7oIYyn+x4Y67xb5C5ylxsNUjRmr8BX+MW3YhVNm6Lq6FKtRTWHQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.17.3.tgz", + "integrity": "sha512-JHeZXD4auLYBnrKn6JYJ0o5nWJI9PhChA/Nt0G4MvLaMrvXuWnY93R3a7PiXeJQphpL1nYsaMcoV2QtuvRnF/g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.17.3.tgz", + "integrity": "sha512-FyXlD2ZjZqTFh0sOQxFDiWG1uQUEOLbEh9gKN/7pFxck5Vw0qjWSDqbn6C10GAa1rXJpwsntHcmLqydY9ST9ZA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, "node_modules/@esbuild/linux-loong64": { - "version": "0.15.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.15.12.tgz", - "integrity": "sha512-tZEowDjvU7O7I04GYvWQOS4yyP9E/7YlsB0jjw1Ycukgr2ycEzKyIk5tms5WnLBymaewc6VmRKnn5IJWgK4eFw==", + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.17.3.tgz", + "integrity": "sha512-OrDGMvDBI2g7s04J8dh8/I7eSO+/E7nMDT2Z5IruBfUO/RiigF1OF6xoH33Dn4W/OwAWSUf1s2nXamb28ZklTA==", "cpu": [ "loong64" ], @@ -54,6 +198,182 @@ "node": ">=12" } }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.17.3.tgz", + "integrity": "sha512-DcnUpXnVCJvmv0TzuLwKBC2nsQHle8EIiAJiJ+PipEVC16wHXaPEKP0EqN8WnBe0TPvMITOUlP2aiL5YMld+CQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.17.3.tgz", + "integrity": "sha512-BDYf/l1WVhWE+FHAW3FzZPtVlk9QsrwsxGzABmN4g8bTjmhazsId3h127pliDRRu5674k1Y2RWejbpN46N9ZhQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.17.3.tgz", + "integrity": "sha512-WViAxWYMRIi+prTJTyV1wnqd2mS2cPqJlN85oscVhXdb/ZTFJdrpaqm/uDsZPGKHtbg5TuRX/ymKdOSk41YZow==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.17.3.tgz", + "integrity": "sha512-Iw8lkNHUC4oGP1O/KhumcVy77u2s6+KUjieUqzEU3XuWJqZ+AY7uVMrrCbAiwWTkpQHkr00BuXH5RpC6Sb/7Ug==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.17.3.tgz", + "integrity": "sha512-0AGkWQMzeoeAtXQRNB3s4J1/T2XbigM2/Mn2yU1tQSmQRmHIZdkGbVq2A3aDdNslPyhb9/lH0S5GMTZ4xsjBqg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.17.3.tgz", + "integrity": "sha512-4+rR/WHOxIVh53UIQIICryjdoKdHsFZFD4zLSonJ9RRw7bhKzVyXbnRPsWSfwybYqw9sB7ots/SYyufL1mBpEg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.17.3.tgz", + "integrity": "sha512-cVpWnkx9IYg99EjGxa5Gc0XmqumtAwK3aoz7O4Dii2vko+qXbkHoujWA68cqXjhh6TsLaQelfDO4MVnyr+ODeA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.17.3.tgz", + "integrity": "sha512-RxmhKLbTCDAY2xOfrww6ieIZkZF+KBqG7S2Ako2SljKXRFi+0863PspK74QQ7JpmWwncChY25JTJSbVBYGQk2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.17.3.tgz", + "integrity": "sha512-0r36VeEJ4efwmofxVJRXDjVRP2jTmv877zc+i+Pc7MNsIr38NfsjkQj23AfF7l0WbB+RQ7VUb+LDiqC/KY/M/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.17.3.tgz", + "integrity": "sha512-wgO6rc7uGStH22nur4aLFcq7Wh86bE9cOFmfTr/yxN3BXvDEdCSXyKkO+U5JIt53eTOgC47v9k/C1bITWL/Teg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.17.3.tgz", + "integrity": "sha512-FdVl64OIuiKjgXBjwZaJLKp0eaEckifbhn10dXWhysMJkWblg3OEEGKSIyhiD5RSgAya8WzP3DNkngtIg3Nt7g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, "node_modules/ansi-regex": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", @@ -391,9 +711,9 @@ } }, "node_modules/esbuild": { - "version": "0.15.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.15.12.tgz", - "integrity": "sha512-PcT+/wyDqJQsRVhaE9uX/Oq4XLrFh0ce/bs2TJh4CSaw9xuvI+xFrH2nAYOADbhQjUgAhNWC5LKoUsakm4dxng==", + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.3.tgz", + "integrity": "sha512-9n3AsBRe6sIyOc6kmoXg2ypCLgf3eZSraWFRpnkto+svt8cZNuKTkb1bhQcitBcvIqjNiK7K0J3KPmwGSfkA8g==", "dev": true, "hasInstallScript": true, "bin": { @@ -403,348 +723,28 @@ "node": ">=12" }, "optionalDependencies": { - "@esbuild/android-arm": "0.15.12", - "@esbuild/linux-loong64": "0.15.12", - "esbuild-android-64": "0.15.12", - "esbuild-android-arm64": "0.15.12", - "esbuild-darwin-64": "0.15.12", - "esbuild-darwin-arm64": "0.15.12", - "esbuild-freebsd-64": "0.15.12", - "esbuild-freebsd-arm64": "0.15.12", - "esbuild-linux-32": "0.15.12", - "esbuild-linux-64": "0.15.12", - "esbuild-linux-arm": "0.15.12", - "esbuild-linux-arm64": "0.15.12", - "esbuild-linux-mips64le": "0.15.12", - "esbuild-linux-ppc64le": "0.15.12", - "esbuild-linux-riscv64": "0.15.12", - "esbuild-linux-s390x": "0.15.12", - "esbuild-netbsd-64": "0.15.12", - "esbuild-openbsd-64": "0.15.12", - "esbuild-sunos-64": "0.15.12", - "esbuild-windows-32": "0.15.12", - "esbuild-windows-64": "0.15.12", - "esbuild-windows-arm64": "0.15.12" - } - }, - "node_modules/esbuild-android-64": { - "version": "0.15.12", - "resolved": "https://registry.npmjs.org/esbuild-android-64/-/esbuild-android-64-0.15.12.tgz", - "integrity": "sha512-MJKXwvPY9g0rGps0+U65HlTsM1wUs9lbjt5CU19RESqycGFDRijMDQsh68MtbzkqWSRdEtiKS1mtPzKneaAI0Q==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-android-arm64": { - "version": "0.15.12", - "resolved": "https://registry.npmjs.org/esbuild-android-arm64/-/esbuild-android-arm64-0.15.12.tgz", - "integrity": "sha512-Hc9SEcZbIMhhLcvhr1DH+lrrec9SFTiRzfJ7EGSBZiiw994gfkVV6vG0sLWqQQ6DD7V4+OggB+Hn0IRUdDUqvA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-darwin-64": { - "version": "0.15.12", - "resolved": "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.15.12.tgz", - "integrity": "sha512-qkmqrTVYPFiePt5qFjP8w/S+GIUMbt6k8qmiPraECUWfPptaPJUGkCKrWEfYFRWB7bY23FV95rhvPyh/KARP8Q==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-darwin-arm64": { - "version": "0.15.12", - "resolved": "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.15.12.tgz", - "integrity": "sha512-z4zPX02tQ41kcXMyN3c/GfZpIjKoI/BzHrdKUwhC/Ki5BAhWv59A9M8H+iqaRbwpzYrYidTybBwiZAIWCLJAkw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-freebsd-64": { - "version": "0.15.12", - "resolved": "https://registry.npmjs.org/esbuild-freebsd-64/-/esbuild-freebsd-64-0.15.12.tgz", - "integrity": "sha512-XFL7gKMCKXLDiAiBjhLG0XECliXaRLTZh6hsyzqUqPUf/PY4C6EJDTKIeqqPKXaVJ8+fzNek88285krSz1QECw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-freebsd-arm64": { - "version": "0.15.12", - "resolved": "https://registry.npmjs.org/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.15.12.tgz", - "integrity": "sha512-jwEIu5UCUk6TjiG1X+KQnCGISI+ILnXzIzt9yDVrhjug2fkYzlLbl0K43q96Q3KB66v6N1UFF0r5Ks4Xo7i72g==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-linux-32": { - "version": "0.15.12", - "resolved": "https://registry.npmjs.org/esbuild-linux-32/-/esbuild-linux-32-0.15.12.tgz", - "integrity": "sha512-uSQuSEyF1kVzGzuIr4XM+v7TPKxHjBnLcwv2yPyCz8riV8VUCnO/C4BF3w5dHiVpCd5Z1cebBtZJNlC4anWpwA==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-linux-64": { - "version": "0.15.12", - "resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.15.12.tgz", - "integrity": "sha512-QcgCKb7zfJxqT9o5z9ZUeGH1k8N6iX1Y7VNsEi5F9+HzN1OIx7ESxtQXDN9jbeUSPiRH1n9cw6gFT3H4qbdvcA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-linux-arm": { - "version": "0.15.12", - "resolved": "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.15.12.tgz", - "integrity": "sha512-Wf7T0aNylGcLu7hBnzMvsTfEXdEdJY/hY3u36Vla21aY66xR0MS5I1Hw8nVquXjTN0A6fk/vnr32tkC/C2lb0A==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-linux-arm64": { - "version": "0.15.12", - "resolved": "https://registry.npmjs.org/esbuild-linux-arm64/-/esbuild-linux-arm64-0.15.12.tgz", - "integrity": "sha512-HtNq5xm8fUpZKwWKS2/YGwSfTF+339L4aIA8yphNKYJckd5hVdhfdl6GM2P3HwLSCORS++++7++//ApEwXEuAQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-linux-mips64le": { - "version": "0.15.12", - "resolved": "https://registry.npmjs.org/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.15.12.tgz", - "integrity": "sha512-Qol3+AvivngUZkTVFgLpb0H6DT+N5/zM3V1YgTkryPYFeUvuT5JFNDR3ZiS6LxhyF8EE+fiNtzwlPqMDqVcc6A==", - "cpu": [ - "mips64el" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-linux-ppc64le": { - "version": "0.15.12", - "resolved": "https://registry.npmjs.org/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.15.12.tgz", - "integrity": "sha512-4D8qUCo+CFKaR0cGXtGyVsOI7w7k93Qxb3KFXWr75An0DHamYzq8lt7TNZKoOq/Gh8c40/aKaxvcZnTgQ0TJNg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-linux-riscv64": { - "version": "0.15.12", - "resolved": "https://registry.npmjs.org/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.15.12.tgz", - "integrity": "sha512-G9w6NcuuCI6TUUxe6ka0enjZHDnSVK8bO+1qDhMOCtl7Tr78CcZilJj8SGLN00zO5iIlwNRZKHjdMpfFgNn1VA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-linux-s390x": { - "version": "0.15.12", - "resolved": "https://registry.npmjs.org/esbuild-linux-s390x/-/esbuild-linux-s390x-0.15.12.tgz", - "integrity": "sha512-Lt6BDnuXbXeqSlVuuUM5z18GkJAZf3ERskGZbAWjrQoi9xbEIsj/hEzVnSAFLtkfLuy2DE4RwTcX02tZFunXww==", - "cpu": [ - "s390x" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-netbsd-64": { - "version": "0.15.12", - "resolved": "https://registry.npmjs.org/esbuild-netbsd-64/-/esbuild-netbsd-64-0.15.12.tgz", - "integrity": "sha512-jlUxCiHO1dsqoURZDQts+HK100o0hXfi4t54MNRMCAqKGAV33JCVvMplLAa2FwviSojT/5ZG5HUfG3gstwAG8w==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-openbsd-64": { - "version": "0.15.12", - "resolved": "https://registry.npmjs.org/esbuild-openbsd-64/-/esbuild-openbsd-64-0.15.12.tgz", - "integrity": "sha512-1o1uAfRTMIWNOmpf8v7iudND0L6zRBYSH45sofCZywrcf7NcZA+c7aFsS1YryU+yN7aRppTqdUK1PgbZVaB1Dw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-sunos-64": { - "version": "0.15.12", - "resolved": "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.15.12.tgz", - "integrity": "sha512-nkl251DpoWoBO9Eq9aFdoIt2yYmp4I3kvQjba3jFKlMXuqQ9A4q+JaqdkCouG3DHgAGnzshzaGu6xofGcXyPXg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-windows-32": { - "version": "0.15.12", - "resolved": "https://registry.npmjs.org/esbuild-windows-32/-/esbuild-windows-32-0.15.12.tgz", - "integrity": "sha512-WlGeBZHgPC00O08luIp5B2SP4cNCp/PcS+3Pcg31kdcJPopHxLkdCXtadLU9J82LCfw4TVls21A6lilQ9mzHrw==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-windows-64": { - "version": "0.15.12", - "resolved": "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.15.12.tgz", - "integrity": "sha512-VActO3WnWZSN//xjSfbiGOSyC+wkZtI8I4KlgrTo5oHJM6z3MZZBCuFaZHd8hzf/W9KPhF0lY8OqlmWC9HO5AA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-windows-arm64": { - "version": "0.15.12", - "resolved": "https://registry.npmjs.org/esbuild-windows-arm64/-/esbuild-windows-arm64-0.15.12.tgz", - "integrity": "sha512-Of3MIacva1OK/m4zCNIvBfz8VVROBmQT+gRX6pFTLPngFYcj6TFH/12VveAqq1k9VB2l28EoVMNMUCcmsfwyuA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" + "@esbuild/android-arm": "0.17.3", + "@esbuild/android-arm64": "0.17.3", + "@esbuild/android-x64": "0.17.3", + "@esbuild/darwin-arm64": "0.17.3", + "@esbuild/darwin-x64": "0.17.3", + "@esbuild/freebsd-arm64": "0.17.3", + "@esbuild/freebsd-x64": "0.17.3", + "@esbuild/linux-arm": "0.17.3", + "@esbuild/linux-arm64": "0.17.3", + "@esbuild/linux-ia32": "0.17.3", + "@esbuild/linux-loong64": "0.17.3", + "@esbuild/linux-mips64el": "0.17.3", + "@esbuild/linux-ppc64": "0.17.3", + "@esbuild/linux-riscv64": "0.17.3", + "@esbuild/linux-s390x": "0.17.3", + "@esbuild/linux-x64": "0.17.3", + "@esbuild/netbsd-x64": "0.17.3", + "@esbuild/openbsd-x64": "0.17.3", + "@esbuild/sunos-x64": "0.17.3", + "@esbuild/win32-arm64": "0.17.3", + "@esbuild/win32-ia32": "0.17.3", + "@esbuild/win32-x64": "0.17.3" } }, "node_modules/escape-string-regexp": { @@ -1551,9 +1551,9 @@ } }, "node_modules/punycode": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", + "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", "dev": true, "engines": { "node": ">=6" @@ -1649,9 +1649,9 @@ } }, "node_modules/sass": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.55.0.tgz", - "integrity": "sha512-Pk+PMy7OGLs9WaxZGJMn7S96dvlyVBwwtToX895WmCpAOr5YiJYEUJfiJidMuKb613z2xNWcXCHEuOvjZbqC6A==", + "version": "1.57.1", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.57.1.tgz", + "integrity": "sha512-O2+LwLS79op7GI0xZ8fqzF7X2m/m8WFfI02dHOdsK5R2ECeS5F62zrwg/relM1rjSLy7Vd/DiMNIvPrQGsA0jw==", "dev": true, "dependencies": { "chokidar": ">=3.0.0 <4.0.0", @@ -2040,16 +2040,156 @@ }, "dependencies": { "@esbuild/android-arm": { - "version": "0.15.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.15.12.tgz", - "integrity": "sha512-IC7TqIqiyE0MmvAhWkl/8AEzpOtbhRNDo7aph47We1NbE5w2bt/Q+giAhe0YYeVpYnIhGMcuZY92qDK6dQauvA==", + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.3.tgz", + "integrity": "sha512-1Mlz934GvbgdDmt26rTLmf03cAgLg5HyOgJN+ZGCeP3Q9ynYTNMn2/LQxIl7Uy+o4K6Rfi2OuLsr12JQQR8gNg==", + "dev": true, + "optional": true + }, + "@esbuild/android-arm64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.17.3.tgz", + "integrity": "sha512-XvJsYo3dO3Pi4kpalkyMvfQsjxPWHYjoX4MDiB/FUM4YMfWcXa5l4VCwFWVYI1+92yxqjuqrhNg0CZg3gSouyQ==", + "dev": true, + "optional": true + }, + "@esbuild/android-x64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.17.3.tgz", + "integrity": "sha512-nuV2CmLS07Gqh5/GrZLuqkU9Bm6H6vcCspM+zjp9TdQlxJtIe+qqEXQChmfc7nWdyr/yz3h45Utk1tUn8Cz5+A==", + "dev": true, + "optional": true + }, + "@esbuild/darwin-arm64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.17.3.tgz", + "integrity": "sha512-01Hxaaat6m0Xp9AXGM8mjFtqqwDjzlMP0eQq9zll9U85ttVALGCGDuEvra5Feu/NbP5AEP1MaopPwzsTcUq1cw==", + "dev": true, + "optional": true + }, + "@esbuild/darwin-x64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.17.3.tgz", + "integrity": "sha512-Eo2gq0Q/er2muf8Z83X21UFoB7EU6/m3GNKvrhACJkjVThd0uA+8RfKpfNhuMCl1bKRfBzKOk6xaYKQZ4lZqvA==", + "dev": true, + "optional": true + }, + "@esbuild/freebsd-arm64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.3.tgz", + "integrity": "sha512-CN62ESxaquP61n1ZjQP/jZte8CE09M6kNn3baos2SeUfdVBkWN5n6vGp2iKyb/bm/x4JQzEvJgRHLGd5F5b81w==", + "dev": true, + "optional": true + }, + "@esbuild/freebsd-x64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.17.3.tgz", + "integrity": "sha512-feq+K8TxIznZE+zhdVurF3WNJ/Sa35dQNYbaqM/wsCbWdzXr5lyq+AaTUSER2cUR+SXPnd/EY75EPRjf4s1SLg==", + "dev": true, + "optional": true + }, + "@esbuild/linux-arm": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.17.3.tgz", + "integrity": "sha512-CLP3EgyNuPcg2cshbwkqYy5bbAgK+VhyfMU7oIYyn+x4Y67xb5C5ylxsNUjRmr8BX+MW3YhVNm6Lq6FKtRTWHQ==", + "dev": true, + "optional": true + }, + "@esbuild/linux-arm64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.17.3.tgz", + "integrity": "sha512-JHeZXD4auLYBnrKn6JYJ0o5nWJI9PhChA/Nt0G4MvLaMrvXuWnY93R3a7PiXeJQphpL1nYsaMcoV2QtuvRnF/g==", + "dev": true, + "optional": true + }, + "@esbuild/linux-ia32": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.17.3.tgz", + "integrity": "sha512-FyXlD2ZjZqTFh0sOQxFDiWG1uQUEOLbEh9gKN/7pFxck5Vw0qjWSDqbn6C10GAa1rXJpwsntHcmLqydY9ST9ZA==", "dev": true, "optional": true }, "@esbuild/linux-loong64": { - "version": "0.15.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.15.12.tgz", - "integrity": "sha512-tZEowDjvU7O7I04GYvWQOS4yyP9E/7YlsB0jjw1Ycukgr2ycEzKyIk5tms5WnLBymaewc6VmRKnn5IJWgK4eFw==", + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.17.3.tgz", + "integrity": "sha512-OrDGMvDBI2g7s04J8dh8/I7eSO+/E7nMDT2Z5IruBfUO/RiigF1OF6xoH33Dn4W/OwAWSUf1s2nXamb28ZklTA==", + "dev": true, + "optional": true + }, + "@esbuild/linux-mips64el": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.17.3.tgz", + "integrity": "sha512-DcnUpXnVCJvmv0TzuLwKBC2nsQHle8EIiAJiJ+PipEVC16wHXaPEKP0EqN8WnBe0TPvMITOUlP2aiL5YMld+CQ==", + "dev": true, + "optional": true + }, + "@esbuild/linux-ppc64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.17.3.tgz", + "integrity": "sha512-BDYf/l1WVhWE+FHAW3FzZPtVlk9QsrwsxGzABmN4g8bTjmhazsId3h127pliDRRu5674k1Y2RWejbpN46N9ZhQ==", + "dev": true, + "optional": true + }, + "@esbuild/linux-riscv64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.17.3.tgz", + "integrity": "sha512-WViAxWYMRIi+prTJTyV1wnqd2mS2cPqJlN85oscVhXdb/ZTFJdrpaqm/uDsZPGKHtbg5TuRX/ymKdOSk41YZow==", + "dev": true, + "optional": true + }, + "@esbuild/linux-s390x": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.17.3.tgz", + "integrity": "sha512-Iw8lkNHUC4oGP1O/KhumcVy77u2s6+KUjieUqzEU3XuWJqZ+AY7uVMrrCbAiwWTkpQHkr00BuXH5RpC6Sb/7Ug==", + "dev": true, + "optional": true + }, + "@esbuild/linux-x64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.17.3.tgz", + "integrity": "sha512-0AGkWQMzeoeAtXQRNB3s4J1/T2XbigM2/Mn2yU1tQSmQRmHIZdkGbVq2A3aDdNslPyhb9/lH0S5GMTZ4xsjBqg==", + "dev": true, + "optional": true + }, + "@esbuild/netbsd-x64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.17.3.tgz", + "integrity": "sha512-4+rR/WHOxIVh53UIQIICryjdoKdHsFZFD4zLSonJ9RRw7bhKzVyXbnRPsWSfwybYqw9sB7ots/SYyufL1mBpEg==", + "dev": true, + "optional": true + }, + "@esbuild/openbsd-x64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.17.3.tgz", + "integrity": "sha512-cVpWnkx9IYg99EjGxa5Gc0XmqumtAwK3aoz7O4Dii2vko+qXbkHoujWA68cqXjhh6TsLaQelfDO4MVnyr+ODeA==", + "dev": true, + "optional": true + }, + "@esbuild/sunos-x64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.17.3.tgz", + "integrity": "sha512-RxmhKLbTCDAY2xOfrww6ieIZkZF+KBqG7S2Ako2SljKXRFi+0863PspK74QQ7JpmWwncChY25JTJSbVBYGQk2Q==", + "dev": true, + "optional": true + }, + "@esbuild/win32-arm64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.17.3.tgz", + "integrity": "sha512-0r36VeEJ4efwmofxVJRXDjVRP2jTmv877zc+i+Pc7MNsIr38NfsjkQj23AfF7l0WbB+RQ7VUb+LDiqC/KY/M/A==", + "dev": true, + "optional": true + }, + "@esbuild/win32-ia32": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.17.3.tgz", + "integrity": "sha512-wgO6rc7uGStH22nur4aLFcq7Wh86bE9cOFmfTr/yxN3BXvDEdCSXyKkO+U5JIt53eTOgC47v9k/C1bITWL/Teg==", + "dev": true, + "optional": true + }, + "@esbuild/win32-x64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.17.3.tgz", + "integrity": "sha512-FdVl64OIuiKjgXBjwZaJLKp0eaEckifbhn10dXWhysMJkWblg3OEEGKSIyhiD5RSgAya8WzP3DNkngtIg3Nt7g==", "dev": true, "optional": true }, @@ -2319,175 +2459,35 @@ } }, "esbuild": { - "version": "0.15.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.15.12.tgz", - "integrity": "sha512-PcT+/wyDqJQsRVhaE9uX/Oq4XLrFh0ce/bs2TJh4CSaw9xuvI+xFrH2nAYOADbhQjUgAhNWC5LKoUsakm4dxng==", + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.3.tgz", + "integrity": "sha512-9n3AsBRe6sIyOc6kmoXg2ypCLgf3eZSraWFRpnkto+svt8cZNuKTkb1bhQcitBcvIqjNiK7K0J3KPmwGSfkA8g==", "dev": true, "requires": { - "@esbuild/android-arm": "0.15.12", - "@esbuild/linux-loong64": "0.15.12", - "esbuild-android-64": "0.15.12", - "esbuild-android-arm64": "0.15.12", - "esbuild-darwin-64": "0.15.12", - "esbuild-darwin-arm64": "0.15.12", - "esbuild-freebsd-64": "0.15.12", - "esbuild-freebsd-arm64": "0.15.12", - "esbuild-linux-32": "0.15.12", - "esbuild-linux-64": "0.15.12", - "esbuild-linux-arm": "0.15.12", - "esbuild-linux-arm64": "0.15.12", - "esbuild-linux-mips64le": "0.15.12", - "esbuild-linux-ppc64le": "0.15.12", - "esbuild-linux-riscv64": "0.15.12", - "esbuild-linux-s390x": "0.15.12", - "esbuild-netbsd-64": "0.15.12", - "esbuild-openbsd-64": "0.15.12", - "esbuild-sunos-64": "0.15.12", - "esbuild-windows-32": "0.15.12", - "esbuild-windows-64": "0.15.12", - "esbuild-windows-arm64": "0.15.12" + "@esbuild/android-arm": "0.17.3", + "@esbuild/android-arm64": "0.17.3", + "@esbuild/android-x64": "0.17.3", + "@esbuild/darwin-arm64": "0.17.3", + "@esbuild/darwin-x64": "0.17.3", + "@esbuild/freebsd-arm64": "0.17.3", + "@esbuild/freebsd-x64": "0.17.3", + "@esbuild/linux-arm": "0.17.3", + "@esbuild/linux-arm64": "0.17.3", + "@esbuild/linux-ia32": "0.17.3", + "@esbuild/linux-loong64": "0.17.3", + "@esbuild/linux-mips64el": "0.17.3", + "@esbuild/linux-ppc64": "0.17.3", + "@esbuild/linux-riscv64": "0.17.3", + "@esbuild/linux-s390x": "0.17.3", + "@esbuild/linux-x64": "0.17.3", + "@esbuild/netbsd-x64": "0.17.3", + "@esbuild/openbsd-x64": "0.17.3", + "@esbuild/sunos-x64": "0.17.3", + "@esbuild/win32-arm64": "0.17.3", + "@esbuild/win32-ia32": "0.17.3", + "@esbuild/win32-x64": "0.17.3" } }, - "esbuild-android-64": { - "version": "0.15.12", - "resolved": "https://registry.npmjs.org/esbuild-android-64/-/esbuild-android-64-0.15.12.tgz", - "integrity": "sha512-MJKXwvPY9g0rGps0+U65HlTsM1wUs9lbjt5CU19RESqycGFDRijMDQsh68MtbzkqWSRdEtiKS1mtPzKneaAI0Q==", - "dev": true, - "optional": true - }, - "esbuild-android-arm64": { - "version": "0.15.12", - "resolved": "https://registry.npmjs.org/esbuild-android-arm64/-/esbuild-android-arm64-0.15.12.tgz", - "integrity": "sha512-Hc9SEcZbIMhhLcvhr1DH+lrrec9SFTiRzfJ7EGSBZiiw994gfkVV6vG0sLWqQQ6DD7V4+OggB+Hn0IRUdDUqvA==", - "dev": true, - "optional": true - }, - "esbuild-darwin-64": { - "version": "0.15.12", - "resolved": "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.15.12.tgz", - "integrity": "sha512-qkmqrTVYPFiePt5qFjP8w/S+GIUMbt6k8qmiPraECUWfPptaPJUGkCKrWEfYFRWB7bY23FV95rhvPyh/KARP8Q==", - "dev": true, - "optional": true - }, - "esbuild-darwin-arm64": { - "version": "0.15.12", - "resolved": "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.15.12.tgz", - "integrity": "sha512-z4zPX02tQ41kcXMyN3c/GfZpIjKoI/BzHrdKUwhC/Ki5BAhWv59A9M8H+iqaRbwpzYrYidTybBwiZAIWCLJAkw==", - "dev": true, - "optional": true - }, - "esbuild-freebsd-64": { - "version": "0.15.12", - "resolved": "https://registry.npmjs.org/esbuild-freebsd-64/-/esbuild-freebsd-64-0.15.12.tgz", - "integrity": "sha512-XFL7gKMCKXLDiAiBjhLG0XECliXaRLTZh6hsyzqUqPUf/PY4C6EJDTKIeqqPKXaVJ8+fzNek88285krSz1QECw==", - "dev": true, - "optional": true - }, - "esbuild-freebsd-arm64": { - "version": "0.15.12", - "resolved": "https://registry.npmjs.org/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.15.12.tgz", - "integrity": "sha512-jwEIu5UCUk6TjiG1X+KQnCGISI+ILnXzIzt9yDVrhjug2fkYzlLbl0K43q96Q3KB66v6N1UFF0r5Ks4Xo7i72g==", - "dev": true, - "optional": true - }, - "esbuild-linux-32": { - "version": "0.15.12", - "resolved": "https://registry.npmjs.org/esbuild-linux-32/-/esbuild-linux-32-0.15.12.tgz", - "integrity": "sha512-uSQuSEyF1kVzGzuIr4XM+v7TPKxHjBnLcwv2yPyCz8riV8VUCnO/C4BF3w5dHiVpCd5Z1cebBtZJNlC4anWpwA==", - "dev": true, - "optional": true - }, - "esbuild-linux-64": { - "version": "0.15.12", - "resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.15.12.tgz", - "integrity": "sha512-QcgCKb7zfJxqT9o5z9ZUeGH1k8N6iX1Y7VNsEi5F9+HzN1OIx7ESxtQXDN9jbeUSPiRH1n9cw6gFT3H4qbdvcA==", - "dev": true, - "optional": true - }, - "esbuild-linux-arm": { - "version": "0.15.12", - "resolved": "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.15.12.tgz", - "integrity": "sha512-Wf7T0aNylGcLu7hBnzMvsTfEXdEdJY/hY3u36Vla21aY66xR0MS5I1Hw8nVquXjTN0A6fk/vnr32tkC/C2lb0A==", - "dev": true, - "optional": true - }, - "esbuild-linux-arm64": { - "version": "0.15.12", - "resolved": "https://registry.npmjs.org/esbuild-linux-arm64/-/esbuild-linux-arm64-0.15.12.tgz", - "integrity": "sha512-HtNq5xm8fUpZKwWKS2/YGwSfTF+339L4aIA8yphNKYJckd5hVdhfdl6GM2P3HwLSCORS++++7++//ApEwXEuAQ==", - "dev": true, - "optional": true - }, - "esbuild-linux-mips64le": { - "version": "0.15.12", - "resolved": "https://registry.npmjs.org/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.15.12.tgz", - "integrity": "sha512-Qol3+AvivngUZkTVFgLpb0H6DT+N5/zM3V1YgTkryPYFeUvuT5JFNDR3ZiS6LxhyF8EE+fiNtzwlPqMDqVcc6A==", - "dev": true, - "optional": true - }, - "esbuild-linux-ppc64le": { - "version": "0.15.12", - "resolved": "https://registry.npmjs.org/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.15.12.tgz", - "integrity": "sha512-4D8qUCo+CFKaR0cGXtGyVsOI7w7k93Qxb3KFXWr75An0DHamYzq8lt7TNZKoOq/Gh8c40/aKaxvcZnTgQ0TJNg==", - "dev": true, - "optional": true - }, - "esbuild-linux-riscv64": { - "version": "0.15.12", - "resolved": "https://registry.npmjs.org/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.15.12.tgz", - "integrity": "sha512-G9w6NcuuCI6TUUxe6ka0enjZHDnSVK8bO+1qDhMOCtl7Tr78CcZilJj8SGLN00zO5iIlwNRZKHjdMpfFgNn1VA==", - "dev": true, - "optional": true - }, - "esbuild-linux-s390x": { - "version": "0.15.12", - "resolved": "https://registry.npmjs.org/esbuild-linux-s390x/-/esbuild-linux-s390x-0.15.12.tgz", - "integrity": "sha512-Lt6BDnuXbXeqSlVuuUM5z18GkJAZf3ERskGZbAWjrQoi9xbEIsj/hEzVnSAFLtkfLuy2DE4RwTcX02tZFunXww==", - "dev": true, - "optional": true - }, - "esbuild-netbsd-64": { - "version": "0.15.12", - "resolved": "https://registry.npmjs.org/esbuild-netbsd-64/-/esbuild-netbsd-64-0.15.12.tgz", - "integrity": "sha512-jlUxCiHO1dsqoURZDQts+HK100o0hXfi4t54MNRMCAqKGAV33JCVvMplLAa2FwviSojT/5ZG5HUfG3gstwAG8w==", - "dev": true, - "optional": true - }, - "esbuild-openbsd-64": { - "version": "0.15.12", - "resolved": "https://registry.npmjs.org/esbuild-openbsd-64/-/esbuild-openbsd-64-0.15.12.tgz", - "integrity": "sha512-1o1uAfRTMIWNOmpf8v7iudND0L6zRBYSH45sofCZywrcf7NcZA+c7aFsS1YryU+yN7aRppTqdUK1PgbZVaB1Dw==", - "dev": true, - "optional": true - }, - "esbuild-sunos-64": { - "version": "0.15.12", - "resolved": "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.15.12.tgz", - "integrity": "sha512-nkl251DpoWoBO9Eq9aFdoIt2yYmp4I3kvQjba3jFKlMXuqQ9A4q+JaqdkCouG3DHgAGnzshzaGu6xofGcXyPXg==", - "dev": true, - "optional": true - }, - "esbuild-windows-32": { - "version": "0.15.12", - "resolved": "https://registry.npmjs.org/esbuild-windows-32/-/esbuild-windows-32-0.15.12.tgz", - "integrity": "sha512-WlGeBZHgPC00O08luIp5B2SP4cNCp/PcS+3Pcg31kdcJPopHxLkdCXtadLU9J82LCfw4TVls21A6lilQ9mzHrw==", - "dev": true, - "optional": true - }, - "esbuild-windows-64": { - "version": "0.15.12", - "resolved": "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.15.12.tgz", - "integrity": "sha512-VActO3WnWZSN//xjSfbiGOSyC+wkZtI8I4KlgrTo5oHJM6z3MZZBCuFaZHd8hzf/W9KPhF0lY8OqlmWC9HO5AA==", - "dev": true, - "optional": true - }, - "esbuild-windows-arm64": { - "version": "0.15.12", - "resolved": "https://registry.npmjs.org/esbuild-windows-arm64/-/esbuild-windows-arm64-0.15.12.tgz", - "integrity": "sha512-Of3MIacva1OK/m4zCNIvBfz8VVROBmQT+gRX6pFTLPngFYcj6TFH/12VveAqq1k9VB2l28EoVMNMUCcmsfwyuA==", - "dev": true, - "optional": true - }, "escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", @@ -3067,9 +3067,9 @@ "dev": true }, "punycode": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", + "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", "dev": true }, "read-pkg": { @@ -3138,9 +3138,9 @@ } }, "sass": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.55.0.tgz", - "integrity": "sha512-Pk+PMy7OGLs9WaxZGJMn7S96dvlyVBwwtToX895WmCpAOr5YiJYEUJfiJidMuKb613z2xNWcXCHEuOvjZbqC6A==", + "version": "1.57.1", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.57.1.tgz", + "integrity": "sha512-O2+LwLS79op7GI0xZ8fqzF7X2m/m8WFfI02dHOdsK5R2ECeS5F62zrwg/relM1rjSLy7Vd/DiMNIvPrQGsA0jw==", "dev": true, "requires": { "chokidar": ">=3.0.0 <4.0.0", diff --git a/package.json b/package.json index 9abdcc346..89fb07492 100644 --- a/package.json +++ b/package.json @@ -16,11 +16,11 @@ }, "devDependencies": { "chokidar-cli": "^3.0", - "esbuild": "^0.15.12", + "esbuild": "^0.17.3", "livereload": "^0.9.3", "npm-run-all": "^4.1.5", - "punycode": "^2.1.1", - "sass": "^1.55.0" + "punycode": "^2.3.0", + "sass": "^1.57.0" }, "dependencies": { "clipboard": "^2.0.11", @@ -28,7 +28,7 @@ "dropzone": "^5.9.3", "markdown-it": "^13.0.1", "markdown-it-task-lists": "^2.1.1", - "snabbdom": "^3.5.0", + "snabbdom": "^3.5.1", "sortablejs": "^1.15.0" } } From 78ebcb6f38ee7a984b26cd56dff882ae9d7e9f95 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 21 Jan 2023 20:50:04 +0000 Subject: [PATCH 05/41] Addressed a range of deprecation warnings Closes #3969 --- app/Entities/Tools/Markdown/MarkdownToHtml.php | 4 ++-- app/Search/SearchIndex.php | 4 ++-- app/Util/CspService.php | 2 +- database/factories/Actions/TagFactory.php | 2 +- database/factories/Actions/WebhookFactory.php | 2 +- database/factories/Auth/UserFactory.php | 4 ++-- database/factories/Entities/Models/BookFactory.php | 4 ++-- database/factories/Entities/Models/ChapterFactory.php | 4 ++-- database/factories/Entities/Models/PageFactory.php | 2 +- database/factories/Uploads/ImageFactory.php | 6 +++--- dev/docs/development.md | 2 ++ resources/views/common/notifications.blade.php | 6 +++--- resources/views/exports/parts/meta.blade.php | 4 ++-- resources/views/pages/parts/revisions-index-row.blade.php | 2 +- tests/Entity/ExportTest.php | 4 ++-- tests/Entity/PageContentTest.php | 4 ++-- tests/TestCase.php | 4 ++++ 17 files changed, 33 insertions(+), 27 deletions(-) diff --git a/app/Entities/Tools/Markdown/MarkdownToHtml.php b/app/Entities/Tools/Markdown/MarkdownToHtml.php index f3cf7ab2f..06587ce1f 100644 --- a/app/Entities/Tools/Markdown/MarkdownToHtml.php +++ b/app/Entities/Tools/Markdown/MarkdownToHtml.php @@ -5,10 +5,10 @@ namespace BookStack\Entities\Tools\Markdown; use BookStack\Facades\Theme; use BookStack\Theming\ThemeEvents; use League\CommonMark\Block\Element\ListItem; -use League\CommonMark\CommonMarkConverter; use League\CommonMark\Environment; use League\CommonMark\Extension\Table\TableExtension; use League\CommonMark\Extension\TaskList\TaskListExtension; +use League\CommonMark\MarkdownConverter; class MarkdownToHtml { @@ -26,7 +26,7 @@ class MarkdownToHtml $environment->addExtension(new TaskListExtension()); $environment->addExtension(new CustomStrikeThroughExtension()); $environment = Theme::dispatch(ThemeEvents::COMMONMARK_ENVIRONMENT_CONFIGURE, $environment) ?? $environment; - $converter = new CommonMarkConverter([], $environment); + $converter = new MarkdownConverter($environment); $environment->addBlockRenderer(ListItem::class, new CustomListItemRenderer(), 10); diff --git a/app/Search/SearchIndex.php b/app/Search/SearchIndex.php index 8c793a109..54ed95ebb 100644 --- a/app/Search/SearchIndex.php +++ b/app/Search/SearchIndex.php @@ -112,12 +112,12 @@ class SearchIndex * * @returns array */ - protected function generateTermScoreMapFromText(string $text, int $scoreAdjustment = 1): array + protected function generateTermScoreMapFromText(string $text, float $scoreAdjustment = 1): array { $termMap = $this->textToTermCountMap($text); foreach ($termMap as $term => $count) { - $termMap[$term] = $count * $scoreAdjustment; + $termMap[$term] = floor($count * $scoreAdjustment); } return $termMap; diff --git a/app/Util/CspService.php b/app/Util/CspService.php index f9ab666ac..227ec8e0b 100644 --- a/app/Util/CspService.php +++ b/app/Util/CspService.php @@ -126,7 +126,7 @@ class CspService protected function getAllowedIframeHosts(): array { - $hosts = config('app.iframe_hosts', ''); + $hosts = config('app.iframe_hosts') ?? ''; return array_filter(explode(' ', $hosts)); } diff --git a/database/factories/Actions/TagFactory.php b/database/factories/Actions/TagFactory.php index 8d5c77e09..8b9c529f2 100644 --- a/database/factories/Actions/TagFactory.php +++ b/database/factories/Actions/TagFactory.php @@ -21,7 +21,7 @@ class TagFactory extends Factory public function definition() { return [ - 'name' => $this->faker->city, + 'name' => $this->faker->city(), 'value' => $this->faker->sentence(3), ]; } diff --git a/database/factories/Actions/WebhookFactory.php b/database/factories/Actions/WebhookFactory.php index 662f64f8b..c7393b32c 100644 --- a/database/factories/Actions/WebhookFactory.php +++ b/database/factories/Actions/WebhookFactory.php @@ -18,7 +18,7 @@ class WebhookFactory extends Factory { return [ 'name' => 'My webhook for ' . $this->faker->country(), - 'endpoint' => $this->faker->url, + 'endpoint' => $this->faker->url(), 'active' => true, 'timeout' => 3, ]; diff --git a/database/factories/Auth/UserFactory.php b/database/factories/Auth/UserFactory.php index 805782fd8..6ff62a975 100644 --- a/database/factories/Auth/UserFactory.php +++ b/database/factories/Auth/UserFactory.php @@ -22,11 +22,11 @@ class UserFactory extends Factory */ public function definition() { - $name = $this->faker->name; + $name = $this->faker->name(); return [ 'name' => $name, - 'email' => $this->faker->email, + 'email' => $this->faker->email(), 'slug' => Str::slug($name . '-' . Str::random(5)), 'password' => Str::random(10), 'remember_token' => Str::random(10), diff --git a/database/factories/Entities/Models/BookFactory.php b/database/factories/Entities/Models/BookFactory.php index 0613800a1..3bf157786 100644 --- a/database/factories/Entities/Models/BookFactory.php +++ b/database/factories/Entities/Models/BookFactory.php @@ -22,9 +22,9 @@ class BookFactory extends Factory public function definition() { return [ - 'name' => $this->faker->sentence, + 'name' => $this->faker->sentence(), 'slug' => Str::random(10), - 'description' => $this->faker->paragraph, + 'description' => $this->faker->paragraph(), ]; } } diff --git a/database/factories/Entities/Models/ChapterFactory.php b/database/factories/Entities/Models/ChapterFactory.php index 4fcd69c39..36379866e 100644 --- a/database/factories/Entities/Models/ChapterFactory.php +++ b/database/factories/Entities/Models/ChapterFactory.php @@ -22,9 +22,9 @@ class ChapterFactory extends Factory public function definition() { return [ - 'name' => $this->faker->sentence, + 'name' => $this->faker->sentence(), 'slug' => Str::random(10), - 'description' => $this->faker->paragraph, + 'description' => $this->faker->paragraph(), ]; } } diff --git a/database/factories/Entities/Models/PageFactory.php b/database/factories/Entities/Models/PageFactory.php index c83e0f828..319d97880 100644 --- a/database/factories/Entities/Models/PageFactory.php +++ b/database/factories/Entities/Models/PageFactory.php @@ -24,7 +24,7 @@ class PageFactory extends Factory $html = '

' . implode('

', $this->faker->paragraphs(5)) . '

'; return [ - 'name' => $this->faker->sentence, + 'name' => $this->faker->sentence(), 'slug' => Str::random(10), 'html' => $html, 'text' => strip_tags($html), diff --git a/database/factories/Uploads/ImageFactory.php b/database/factories/Uploads/ImageFactory.php index c6d0e0801..b66c0a52c 100644 --- a/database/factories/Uploads/ImageFactory.php +++ b/database/factories/Uploads/ImageFactory.php @@ -21,9 +21,9 @@ class ImageFactory extends Factory public function definition() { return [ - 'name' => $this->faker->slug . '.jpg', - 'url' => $this->faker->url, - 'path' => $this->faker->url, + 'name' => $this->faker->slug() . '.jpg', + 'url' => $this->faker->url(), + 'path' => $this->faker->url(), 'type' => 'gallery', 'uploaded_to' => 0, ]; diff --git a/dev/docs/development.md b/dev/docs/development.md index 1611de578..b68f2664a 100644 --- a/dev/docs/development.md +++ b/dev/docs/development.md @@ -29,6 +29,8 @@ The testing database will also need migrating and seeding beforehand. This can b Once done you can run `composer test` in the application root directory to run all tests. Tests can be ran in parallel by running them via `composer t`. This will use Laravel's built-in parallel testing functionality, and attempt to create and seed a database instance for each testing thread. If required these parallel testing instances can be reset, before testing again, by running `composer t-reset`. +If the codebase needs to be tested with deprecations, this can be done via uncommenting the relevant line within the TestCase@setUp function. + ## Code Standards PHP code standards are managed by [using PHP_CodeSniffer](https://github.com/squizlabs/PHP_CodeSniffer). diff --git a/resources/views/common/notifications.blade.php b/resources/views/common/notifications.blade.php index e06bd5fd1..8b76a8dd5 100644 --- a/resources/views/common/notifications.blade.php +++ b/resources/views/common/notifications.blade.php @@ -5,7 +5,7 @@ style="display: none;" class="notification pos" role="alert"> - @icon('check-circle') {!! nl2br(htmlentities(session()->get('success'))) !!}
@icon('close')
+ @icon('check-circle') @if(session()->has('success')){!! nl2br(htmlentities(session()->get('success'))) !!}@endif
@icon('close')
\ No newline at end of file diff --git a/resources/views/exports/parts/meta.blade.php b/resources/views/exports/parts/meta.blade.php index 02a39e78c..d4128898b 100644 --- a/resources/views/exports/parts/meta.blade.php +++ b/resources/views/exports/parts/meta.blade.php @@ -4,13 +4,13 @@ @endif @icon('star'){!! trans('entities.meta_created' . ($entity->createdBy ? '_name' : ''), [ - 'timeLength' => $entity->created_at->formatLocalized('%e %B %Y %H:%M:%S'), + 'timeLength' => $entity->created_at->isoFormat('D MMMM Y HH:mm:ss'), 'user' => e($entity->createdBy->name ?? ''), ]) !!}
@icon('edit'){!! trans('entities.meta_updated' . ($entity->updatedBy ? '_name' : ''), [ - 'timeLength' => $entity->updated_at->formatLocalized('%e %B %Y %H:%M:%S'), + 'timeLength' => $entity->updated_at->isoFormat('D MMMM Y HH:mm:ss'), 'user' => e($entity->updatedBy->name ?? '') ]) !!} \ No newline at end of file diff --git a/resources/views/pages/parts/revisions-index-row.blade.php b/resources/views/pages/parts/revisions-index-row.blade.php index 597b53234..fdc6a772d 100644 --- a/resources/views/pages/parts/revisions-index-row.blade.php +++ b/resources/views/pages/parts/revisions-index-row.blade.php @@ -17,7 +17,7 @@ @if($revision->createdBy) {{ $revision->createdBy->name }} @else {{ trans('common.deleted_user') }} @endif
- {{ $revision->created_at->formatLocalized('%e %B %Y %H:%M:%S') }} + {{ $revision->created_at->isoFormat('D MMMM Y HH:mm:ss') }} ({{ $revision->created_at->diffForHumans() }})
diff --git a/tests/Entity/ExportTest.php b/tests/Entity/ExportTest.php index 0f80bdd49..68c70e6c0 100644 --- a/tests/Entity/ExportTest.php +++ b/tests/Entity/ExportTest.php @@ -160,9 +160,9 @@ class ExportTest extends TestCase $page = $this->entities->page(); $resp = $this->asEditor()->get($page->getUrl('/export/html')); - $resp->assertSee($page->created_at->formatLocalized('%e %B %Y %H:%M:%S')); + $resp->assertSee($page->created_at->isoFormat('D MMMM Y HH:mm:ss')); $resp->assertDontSee($page->created_at->diffForHumans()); - $resp->assertSee($page->updated_at->formatLocalized('%e %B %Y %H:%M:%S')); + $resp->assertSee($page->updated_at->isoFormat('D MMMM Y HH:mm:ss')); $resp->assertDontSee($page->updated_at->diffForHumans()); } diff --git a/tests/Entity/PageContentTest.php b/tests/Entity/PageContentTest.php index 0c9854206..53107d14d 100644 --- a/tests/Entity/PageContentTest.php +++ b/tests/Entity/PageContentTest.php @@ -310,7 +310,7 @@ class PageContentTest extends TestCase { $this->asEditor(); $page = $this->entities->page(); - config()->push('app.allow_content_scripts', 'true'); + config()->set('app.allow_content_scripts', 'true'); $script = 'abc123abc123'; $page->html = "no escape {$script}"; @@ -355,7 +355,7 @@ class PageContentTest extends TestCase { $this->asEditor(); $page = $this->entities->page(); - config()->push('app.allow_content_scripts', 'true'); + config()->set('app.allow_content_scripts', 'true'); $script = '

Hello

'; $page->html = "escape {$script}"; diff --git a/tests/TestCase.php b/tests/TestCase.php index d0dd7d772..d9a614fc6 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -43,6 +43,10 @@ abstract class TestCase extends BaseTestCase { $this->entities = new EntityProvider(); parent::setUp(); + + // We can uncomment the below to run tests with failings upon deprecations. + // Can't leave on since some deprecations can only be fixed upstream. + // $this->withoutDeprecationHandling(); } /** From 1c2ae7bff6b00fbbba826a413a658978f87c8285 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 21 Jan 2023 21:34:39 +0000 Subject: [PATCH 06/41] Added gmp extension to test workflow If was not already enabled by default, should enable faster testing handling as it helps the phpseclib usage for OIDC tokens in test rocket through. --- .github/workflows/test-php.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-php.yml b/.github/workflows/test-php.yml index 903b676cd..215f98741 100644 --- a/.github/workflows/test-php.yml +++ b/.github/workflows/test-php.yml @@ -16,7 +16,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} - extensions: gd, mbstring, json, curl, xml, mysql, ldap + extensions: gd, mbstring, json, curl, xml, mysql, ldap, gmp - name: Get Composer Cache Directory id: composer-cache From f3f2a0c1d55681bbbf141f051b073a5b39100a51 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 23 Jan 2023 12:40:11 +0000 Subject: [PATCH 07/41] Updated userCan logic to meet expectations in tests Updated with similar logic to that used in the user_permissions branch, but all extracted to a seperate class for doing all fetch and collapse work. --- .../Permissions/EntityPermissionEvaluator.php | 131 ++++++++++++++++++ app/Auth/Permissions/PermissionApplicator.php | 46 +----- 2 files changed, 134 insertions(+), 43 deletions(-) create mode 100644 app/Auth/Permissions/EntityPermissionEvaluator.php diff --git a/app/Auth/Permissions/EntityPermissionEvaluator.php b/app/Auth/Permissions/EntityPermissionEvaluator.php new file mode 100644 index 000000000..91596d02a --- /dev/null +++ b/app/Auth/Permissions/EntityPermissionEvaluator.php @@ -0,0 +1,131 @@ +entity = $entity; + $this->userId = $userId; + $this->userRoleIds = $userRoleIds; + $this->action = $action; + } + + public function evaluate(): ?bool + { + if ($this->isUserSystemAdmin()) { + return true; + } + + $typeIdChain = $this->gatherEntityChainTypeIds(); + $relevantPermissions = $this->getRelevantPermissionsMapByTypeId($typeIdChain); + $permitsByType = $this->collapseAndCategorisePermissions($typeIdChain, $relevantPermissions); + + // Return grant or reject from role-level if exists + if (count($permitsByType['role']) > 0) { + return boolval(max($permitsByType['role'])); + } + + // Return fallback permission if exists + if (count($permitsByType['fallback']) > 0) { + return boolval($permitsByType['fallback'][0]); + } + + return null; + } + + /** + * @param string[] $typeIdChain + * @param array $permissionMapByTypeId + * @return array> + */ + protected function collapseAndCategorisePermissions(array $typeIdChain, array $permissionMapByTypeId): array + { + $permitsByType = ['fallback' => [], 'role' => []]; + + foreach ($typeIdChain as $typeId) { + $permissions = $permissionMapByTypeId[$typeId] ?? []; + foreach ($permissions as $permission) { + $roleId = $permission->role_id; + $type = $roleId === 0 ? 'fallback' : 'role'; + if (!isset($permitsByType[$type][$roleId])) { + $permitsByType[$type][$roleId] = $permission->{$this->action}; + } + } + } + + return $permitsByType; + } + + /** + * @param string[] $typeIdChain + * @return array + */ + protected function getRelevantPermissionsMapByTypeId(array $typeIdChain): array + { + $relevantPermissions = EntityPermission::query() + ->where(function (Builder $query) use ($typeIdChain) { + foreach ($typeIdChain as $typeId) { + $query->orWhere(function (Builder $query) use ($typeId) { + [$type, $id] = explode(':', $typeId); + $query->where('entity_type', '=', $type) + ->where('entity_id', '=', $id); + }); + } + })->where(function (Builder $query) { + $query->whereIn('role_id', [...$this->userRoleIds, 0]); + })->get(['entity_id', 'entity_type', 'role_id', $this->action]) + ->all(); + + $map = []; + foreach ($relevantPermissions as $permission) { + $key = $permission->entity_type . ':' . $permission->entity_id; + if (!isset($map[$key])) { + $map[$key] = []; + } + + $map[$key][] = $permission; + } + + return $map; + } + + /** + * @return string[] + */ + protected function gatherEntityChainTypeIds(): array + { + // The array order here is very important due to the fact we walk up the chain + // elsewhere in the class. Earlier items in the chain have higher priority. + + $chain = [$this->entity->getMorphClass() . ':' . $this->entity->id]; + + if ($this->entity instanceof Page && $this->entity->chapter_id) { + $chain[] = 'chapter:' . $this->entity->chapter_id; + } + + if ($this->entity instanceof Page || $this->entity instanceof Chapter) { + $chain[] = 'book:' . $this->entity->book_id; + } + + return $chain; + } + + protected function isUserSystemAdmin(): bool + { + $adminRoleId = Role::getSystemRole('admin')->id; + return in_array($adminRoleId, $this->userRoleIds); + } +} diff --git a/app/Auth/Permissions/PermissionApplicator.php b/app/Auth/Permissions/PermissionApplicator.php index af372cb74..3855a283b 100644 --- a/app/Auth/Permissions/PermissionApplicator.php +++ b/app/Auth/Permissions/PermissionApplicator.php @@ -4,7 +4,6 @@ namespace BookStack\Auth\Permissions; use BookStack\Auth\Role; use BookStack\Auth\User; -use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Page; use BookStack\Model; @@ -48,7 +47,7 @@ class PermissionApplicator return $hasRolePermission; } - $hasApplicableEntityPermissions = $this->hasEntityPermission($ownable, $userRoleIds, $action); + $hasApplicableEntityPermissions = $this->hasEntityPermission($ownable, $user->id, $userRoleIds, $action); return is_null($hasApplicableEntityPermissions) ? $hasRolePermission : $hasApplicableEntityPermissions; } @@ -57,50 +56,11 @@ class PermissionApplicator * Check if there are permissions that are applicable for the given entity item, action and roles. * Returns null when no entity permissions are in force. */ - protected function hasEntityPermission(Entity $entity, array $userRoleIds, string $action): ?bool + protected function hasEntityPermission(Entity $entity, int $userId, array $userRoleIds, string $action): ?bool { $this->ensureValidEntityAction($action); - $adminRoleId = Role::getSystemRole('admin')->id; - if (in_array($adminRoleId, $userRoleIds)) { - return true; - } - - // The chain order here is very important due to the fact we walk up the chain - // in the loop below. Earlier items in the chain have higher priority. - $chain = [$entity]; - if ($entity instanceof Page && $entity->chapter_id) { - $chain[] = $entity->chapter; - } - - if ($entity instanceof Page || $entity instanceof Chapter) { - $chain[] = $entity->book; - } - - foreach ($chain as $currentEntity) { - $allowedByRoleId = $currentEntity->permissions() - ->whereIn('role_id', [0, ...$userRoleIds]) - ->pluck($action, 'role_id'); - - // Continue up the chain if no applicable entity permission overrides. - if ($allowedByRoleId->isEmpty()) { - continue; - } - - // If we have user-role-specific permissions set, allow if any of those - // role permissions allow access. - $hasDefault = $allowedByRoleId->has(0); - if (!$hasDefault || $allowedByRoleId->count() > 1) { - return $allowedByRoleId->search(function (bool $allowed, int $roleId) { - return $roleId !== 0 && $allowed; - }) !== false; - } - - // Otherwise, return the default "Other roles" fallback value. - return $allowedByRoleId->get(0); - } - - return null; + return (new EntityPermissionEvaluator($entity, $userId, $userRoleIds, $action))->evaluate(); } /** From 91e613fe606777c0b036a2cfada94092b771dc22 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 23 Jan 2023 15:09:03 +0000 Subject: [PATCH 08/41] Shared entity permission logic across both query methods The runtime userCan() and the JointPermissionBuilder now share much of the same logic for handling entity permission resolution. --- .../Permissions/EntityPermissionEvaluator.php | 74 +++++---- .../Permissions/JointPermissionBuilder.php | 150 ++---------------- .../MassEntityPermissionEvaluator.php | 81 ++++++++++ app/Auth/Permissions/PermissionApplicator.php | 6 +- app/Auth/Permissions/SimpleEntityData.php | 16 ++ 5 files changed, 155 insertions(+), 172 deletions(-) create mode 100644 app/Auth/Permissions/MassEntityPermissionEvaluator.php diff --git a/app/Auth/Permissions/EntityPermissionEvaluator.php b/app/Auth/Permissions/EntityPermissionEvaluator.php index 91596d02a..99e87d769 100644 --- a/app/Auth/Permissions/EntityPermissionEvaluator.php +++ b/app/Auth/Permissions/EntityPermissionEvaluator.php @@ -3,36 +3,36 @@ namespace BookStack\Auth\Permissions; use BookStack\Auth\Role; -use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Entity; -use BookStack\Entities\Models\Page; use Illuminate\Database\Eloquent\Builder; class EntityPermissionEvaluator { - protected Entity $entity; - protected array $userRoleIds; protected string $action; - protected int $userId; - public function __construct(Entity $entity, int $userId, array $userRoleIds, string $action) + public function __construct(string $action) { - $this->entity = $entity; - $this->userId = $userId; - $this->userRoleIds = $userRoleIds; $this->action = $action; } - public function evaluate(): ?bool + public function evaluateEntityForUser(Entity $entity, array $userRoleIds): ?bool { - if ($this->isUserSystemAdmin()) { + if ($this->isUserSystemAdmin($userRoleIds)) { return true; } - $typeIdChain = $this->gatherEntityChainTypeIds(); - $relevantPermissions = $this->getRelevantPermissionsMapByTypeId($typeIdChain); + $typeIdChain = $this->gatherEntityChainTypeIds(SimpleEntityData::fromEntity($entity)); + $relevantPermissions = $this->getPermissionsMapByTypeId($typeIdChain, [...$userRoleIds, 0]); $permitsByType = $this->collapseAndCategorisePermissions($typeIdChain, $relevantPermissions); + return $this->evaluatePermitsByType($permitsByType); + } + + /** + * @param array> $permitsByType + */ + protected function evaluatePermitsByType(array $permitsByType): ?bool + { // Return grant or reject from role-level if exists if (count($permitsByType['role']) > 0) { return boolval(max($permitsByType['role'])); @@ -73,21 +73,25 @@ class EntityPermissionEvaluator * @param string[] $typeIdChain * @return array */ - protected function getRelevantPermissionsMapByTypeId(array $typeIdChain): array + protected function getPermissionsMapByTypeId(array $typeIdChain, array $filterRoleIds): array { - $relevantPermissions = EntityPermission::query() - ->where(function (Builder $query) use ($typeIdChain) { - foreach ($typeIdChain as $typeId) { - $query->orWhere(function (Builder $query) use ($typeId) { - [$type, $id] = explode(':', $typeId); - $query->where('entity_type', '=', $type) - ->where('entity_id', '=', $id); - }); - } - })->where(function (Builder $query) { - $query->whereIn('role_id', [...$this->userRoleIds, 0]); - })->get(['entity_id', 'entity_type', 'role_id', $this->action]) - ->all(); + $query = EntityPermission::query()->where(function (Builder $query) use ($typeIdChain) { + foreach ($typeIdChain as $typeId) { + $query->orWhere(function (Builder $query) use ($typeId) { + [$type, $id] = explode(':', $typeId); + $query->where('entity_type', '=', $type) + ->where('entity_id', '=', $id); + }); + } + }); + + if (!empty($filterRoleIds)) { + $query->where(function (Builder $query) use ($filterRoleIds) { + $query->whereIn('role_id', [...$filterRoleIds, 0]); + }); + } + + $relevantPermissions = $query->get(['entity_id', 'entity_type', 'role_id', $this->action])->all(); $map = []; foreach ($relevantPermissions as $permission) { @@ -105,27 +109,27 @@ class EntityPermissionEvaluator /** * @return string[] */ - protected function gatherEntityChainTypeIds(): array + protected function gatherEntityChainTypeIds(SimpleEntityData $entity): array { // The array order here is very important due to the fact we walk up the chain // elsewhere in the class. Earlier items in the chain have higher priority. - $chain = [$this->entity->getMorphClass() . ':' . $this->entity->id]; + $chain = [$entity->type . ':' . $entity->id]; - if ($this->entity instanceof Page && $this->entity->chapter_id) { - $chain[] = 'chapter:' . $this->entity->chapter_id; + if ($entity->type === 'page' && $entity->chapter_id) { + $chain[] = 'chapter:' . $entity->chapter_id; } - if ($this->entity instanceof Page || $this->entity instanceof Chapter) { - $chain[] = 'book:' . $this->entity->book_id; + if ($entity->type === 'page' || $entity->type === 'chapter') { + $chain[] = 'book:' . $entity->book_id; } return $chain; } - protected function isUserSystemAdmin(): bool + protected function isUserSystemAdmin($userRoleIds): bool { $adminRoleId = Role::getSystemRole('admin')->id; - return in_array($adminRoleId, $this->userRoleIds); + return in_array($adminRoleId, $userRoleIds); } } diff --git a/app/Auth/Permissions/JointPermissionBuilder.php b/app/Auth/Permissions/JointPermissionBuilder.php index 114cff619..91207e3ab 100644 --- a/app/Auth/Permissions/JointPermissionBuilder.php +++ b/app/Auth/Permissions/JointPermissionBuilder.php @@ -19,11 +19,6 @@ use Illuminate\Support\Facades\DB; */ class JointPermissionBuilder { - /** - * @var array> - */ - protected array $entityCache; - /** * Re-generate all entity permission from scratch. */ @@ -98,40 +93,6 @@ class JointPermissionBuilder }); } - /** - * Prepare the local entity cache and ensure it's empty. - * - * @param SimpleEntityData[] $entities - */ - protected function readyEntityCache(array $entities) - { - $this->entityCache = []; - - foreach ($entities as $entity) { - if (!isset($this->entityCache[$entity->type])) { - $this->entityCache[$entity->type] = []; - } - - $this->entityCache[$entity->type][$entity->id] = $entity; - } - } - - /** - * Get a book via ID, Checks local cache. - */ - protected function getBook(int $bookId): SimpleEntityData - { - return $this->entityCache['book'][$bookId]; - } - - /** - * Get a chapter via ID, Checks local cache. - */ - protected function getChapter(int $chapterId): SimpleEntityData - { - return $this->entityCache['chapter'][$chapterId]; - } - /** * Get a query for fetching a book with its children. */ @@ -214,13 +175,7 @@ class JointPermissionBuilder $simpleEntities = []; foreach ($entities as $entity) { - $attrs = $entity->getAttributes(); - $simple = new SimpleEntityData(); - $simple->id = $attrs['id']; - $simple->type = $entity->getMorphClass(); - $simple->owned_by = $attrs['owned_by'] ?? 0; - $simple->book_id = $attrs['book_id'] ?? null; - $simple->chapter_id = $attrs['chapter_id'] ?? null; + $simple = SimpleEntityData::fromEntity($entity); $simpleEntities[] = $simple; } @@ -236,18 +191,10 @@ class JointPermissionBuilder protected function createManyJointPermissions(array $originalEntities, array $roles) { $entities = $this->entitiesToSimpleEntities($originalEntities); - $this->readyEntityCache($entities); $jointPermissions = []; // Fetch related entity permissions - $permissions = $this->getEntityPermissionsForEntities($entities); - - // Create a mapping of explicit entity permissions - $permissionMap = []; - foreach ($permissions as $permission) { - $key = $permission->entity_type . ':' . $permission->entity_id . ':' . $permission->role_id; - $permissionMap[$key] = $permission->view; - } + $permissions = new MassEntityPermissionEvaluator($entities, 'view'); // Create a mapping of role permissions $rolePermissionMap = []; @@ -260,13 +207,14 @@ class JointPermissionBuilder // Create Joint Permission Data foreach ($entities as $entity) { foreach ($roles as $role) { - $jointPermissions[] = $this->createJointPermissionData( + $jp = $this->createJointPermissionData( $entity, $role->getRawAttribute('id'), - $permissionMap, + $permissions, $rolePermissionMap, $role->system_name === 'admin' ); + $jointPermissions[] = $jp; } } @@ -300,94 +248,28 @@ class JointPermissionBuilder return $idsByType; } - /** - * Get the entity permissions for all the given entities. - * - * @param SimpleEntityData[] $entities - * - * @return EntityPermission[] - */ - protected function getEntityPermissionsForEntities(array $entities): array - { - $idsByType = $this->entitiesToTypeIdMap($entities); - $permissionFetch = EntityPermission::query() - ->where(function (Builder $query) use ($idsByType) { - foreach ($idsByType as $type => $ids) { - $query->orWhere(function (Builder $query) use ($type, $ids) { - $query->where('entity_type', '=', $type)->whereIn('entity_id', $ids); - }); - } - }); - - return $permissionFetch->get()->all(); - } - /** * Create entity permission data for an entity and role * for a particular action. */ - protected function createJointPermissionData(SimpleEntityData $entity, int $roleId, array $permissionMap, array $rolePermissionMap, bool $isAdminRole): array + protected function createJointPermissionData(SimpleEntityData $entity, int $roleId, MassEntityPermissionEvaluator $permissionMap, array $rolePermissionMap, bool $isAdminRole): array { - $permissionPrefix = $entity->type . '-view'; - $roleHasPermission = isset($rolePermissionMap[$roleId . ':' . $permissionPrefix . '-all']); - $roleHasPermissionOwn = isset($rolePermissionMap[$roleId . ':' . $permissionPrefix . '-own']); - + // Ensure system admin role retains permissions if ($isAdminRole) { return $this->createJointPermissionDataArray($entity, $roleId, true, true); } - if ($this->entityPermissionsActiveForRole($permissionMap, $entity, $roleId)) { - $hasAccess = $this->mapHasActiveRestriction($permissionMap, $entity, $roleId); - - return $this->createJointPermissionDataArray($entity, $roleId, $hasAccess, $hasAccess); + // Return evaluated entity permission status if it has an affect. + $entityPermissionStatus = $permissionMap->evaluateEntityForRole($entity, $roleId); + if ($entityPermissionStatus !== null) { + return $this->createJointPermissionDataArray($entity, $roleId, $entityPermissionStatus, $entityPermissionStatus); } - if ($entity->type === 'book' || $entity->type === 'bookshelf') { - return $this->createJointPermissionDataArray($entity, $roleId, $roleHasPermission, $roleHasPermissionOwn); - } - - // For chapters and pages, Check if explicit permissions are set on the Book. - $book = $this->getBook($entity->book_id); - $hasExplicitAccessToParents = $this->mapHasActiveRestriction($permissionMap, $book, $roleId); - $hasPermissiveAccessToParents = !$this->entityPermissionsActiveForRole($permissionMap, $book, $roleId); - - // For pages with a chapter, Check if explicit permissions are set on the Chapter - if ($entity->type === 'page' && $entity->chapter_id !== 0) { - $chapter = $this->getChapter($entity->chapter_id); - $chapterRestricted = $this->entityPermissionsActiveForRole($permissionMap, $chapter, $roleId); - $hasPermissiveAccessToParents = $hasPermissiveAccessToParents && !$chapterRestricted; - if ($chapterRestricted) { - $hasExplicitAccessToParents = $this->mapHasActiveRestriction($permissionMap, $chapter, $roleId); - } - } - - return $this->createJointPermissionDataArray( - $entity, - $roleId, - ($hasExplicitAccessToParents || ($roleHasPermission && $hasPermissiveAccessToParents)), - ($hasExplicitAccessToParents || ($roleHasPermissionOwn && $hasPermissiveAccessToParents)) - ); - } - - /** - * Check if entity permissions are defined within the given map, for the given entity and role. - * Checks for the default `role_id=0` backup option as a fallback. - */ - protected function entityPermissionsActiveForRole(array $permissionMap, SimpleEntityData $entity, int $roleId): bool - { - $keyPrefix = $entity->type . ':' . $entity->id . ':'; - return isset($permissionMap[$keyPrefix . $roleId]) || isset($permissionMap[$keyPrefix . '0']); - } - - /** - * Check for an active restriction in an entity map. - */ - protected function mapHasActiveRestriction(array $entityMap, SimpleEntityData $entity, int $roleId): bool - { - $roleKey = $entity->type . ':' . $entity->id . ':' . $roleId; - $defaultKey = $entity->type . ':' . $entity->id . ':0'; - - return $entityMap[$roleKey] ?? $entityMap[$defaultKey] ?? false; + // Otherwise default to the role-level permissions + $permissionPrefix = $entity->type . '-view'; + $roleHasPermission = isset($rolePermissionMap[$roleId . ':' . $permissionPrefix . '-all']); + $roleHasPermissionOwn = isset($rolePermissionMap[$roleId . ':' . $permissionPrefix . '-own']); + return $this->createJointPermissionDataArray($entity, $roleId, $roleHasPermission, $roleHasPermissionOwn); } /** diff --git a/app/Auth/Permissions/MassEntityPermissionEvaluator.php b/app/Auth/Permissions/MassEntityPermissionEvaluator.php new file mode 100644 index 000000000..1bd2ec44a --- /dev/null +++ b/app/Auth/Permissions/MassEntityPermissionEvaluator.php @@ -0,0 +1,81 @@ +entitiesInvolved = $entitiesInvolved; + parent::__construct($action); + } + + public function evaluateEntityForRole(SimpleEntityData $entity, int $roleId): ?bool + { + $typeIdChain = $this->gatherEntityChainTypeIds($entity); + $relevantPermissions = $this->getPermissionMapByTypeIdForChainAndRole($typeIdChain, $roleId); + $permitsByType = $this->collapseAndCategorisePermissions($typeIdChain, $relevantPermissions); + + return $this->evaluatePermitsByType($permitsByType); + } + + /** + * @param string[] $typeIdChain + * @return array + */ + protected function getPermissionMapByTypeIdForChainAndRole(array $typeIdChain, int $roleId): array + { + $allPermissions = $this->getPermissionMapByTypeIdAndRoleForAllInvolved(); + $relevantPermissions = []; + + // Filter down permissions to just those for current typeId + // and current roleID or fallback permissions. + foreach ($typeIdChain as $typeId) { + $relevantPermissions[$typeId] = [ + ...($allPermissions[$typeId][$roleId] ?? []), + ...($allPermissions[$typeId][0] ?? []) + ]; + } + + return $relevantPermissions; + } + + /** + * @return array> + */ + protected function getPermissionMapByTypeIdAndRoleForAllInvolved(): array + { + if (isset($this->permissionMapCache)) { + return $this->permissionMapCache; + } + + $entityTypeIdChain = []; + foreach ($this->entitiesInvolved as $entity) { + $entityTypeIdChain[] = $entity->type . ':' . $entity->id; + } + + $permissionMap = $this->getPermissionsMapByTypeId($entityTypeIdChain, []); + + // Manipulate permission map to also be keyed by roleId. + foreach ($permissionMap as $typeId => $permissions) { + $permissionMap[$typeId] = []; + foreach ($permissions as $permission) { + $roleId = $permission->getRawAttribute('role_id'); + if (!isset($permissionMap[$typeId][$roleId])) { + $permissionMap[$typeId][$roleId] = []; + } + $permissionMap[$typeId][$roleId][] = $permission; + } + } + + $this->permissionMapCache = $permissionMap; + + return $this->permissionMapCache; + } +} diff --git a/app/Auth/Permissions/PermissionApplicator.php b/app/Auth/Permissions/PermissionApplicator.php index 3855a283b..5326cc340 100644 --- a/app/Auth/Permissions/PermissionApplicator.php +++ b/app/Auth/Permissions/PermissionApplicator.php @@ -47,7 +47,7 @@ class PermissionApplicator return $hasRolePermission; } - $hasApplicableEntityPermissions = $this->hasEntityPermission($ownable, $user->id, $userRoleIds, $action); + $hasApplicableEntityPermissions = $this->hasEntityPermission($ownable, $userRoleIds, $action); return is_null($hasApplicableEntityPermissions) ? $hasRolePermission : $hasApplicableEntityPermissions; } @@ -56,11 +56,11 @@ class PermissionApplicator * Check if there are permissions that are applicable for the given entity item, action and roles. * Returns null when no entity permissions are in force. */ - protected function hasEntityPermission(Entity $entity, int $userId, array $userRoleIds, string $action): ?bool + protected function hasEntityPermission(Entity $entity, array $userRoleIds, string $action): ?bool { $this->ensureValidEntityAction($action); - return (new EntityPermissionEvaluator($entity, $userId, $userRoleIds, $action))->evaluate(); + return (new EntityPermissionEvaluator($action))->evaluateEntityForUser($entity, $userRoleIds); } /** diff --git a/app/Auth/Permissions/SimpleEntityData.php b/app/Auth/Permissions/SimpleEntityData.php index 62f5984f8..2128451fe 100644 --- a/app/Auth/Permissions/SimpleEntityData.php +++ b/app/Auth/Permissions/SimpleEntityData.php @@ -2,6 +2,8 @@ namespace BookStack\Auth\Permissions; +use BookStack\Entities\Models\Entity; + class SimpleEntityData { public int $id; @@ -9,4 +11,18 @@ class SimpleEntityData public int $owned_by; public ?int $book_id; public ?int $chapter_id; + + public static function fromEntity(Entity $entity): self + { + $attrs = $entity->getAttributes(); + $simple = new self(); + + $simple->id = $attrs['id']; + $simple->type = $entity->getMorphClass(); + $simple->owned_by = $attrs['owned_by'] ?? 0; + $simple->book_id = $attrs['book_id'] ?? null; + $simple->chapter_id = $attrs['chapter_id'] ?? null; + + return $simple; + } } From 7d74575eb86d9049cd420af6f0f1218b955dcfd4 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 24 Jan 2023 13:44:38 +0000 Subject: [PATCH 09/41] Found a sql having-style approach to permissions As a way to check aggregate queries for required changes to need to analyse across combined permission values. --- app/Auth/Permissions/PermissionApplicator.php | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/app/Auth/Permissions/PermissionApplicator.php b/app/Auth/Permissions/PermissionApplicator.php index 5326cc340..e4564ddf5 100644 --- a/app/Auth/Permissions/PermissionApplicator.php +++ b/app/Auth/Permissions/PermissionApplicator.php @@ -94,10 +94,14 @@ class PermissionApplicator { return $query->where(function (Builder $parentQuery) { $parentQuery->whereHas('jointPermissions', function (Builder $permissionQuery) { - $permissionQuery->whereIn('role_id', $this->getCurrentUserRoleIds()) - ->where(function (Builder $query) { - $this->addJointHasPermissionCheck($query, $this->currentUser()->id); - }); + $permissionQuery->select(['entity_id', 'entity_type']) + ->selectRaw('max(owned_by) as owned_by') + ->selectRaw('max(has_permission) as has_permission') + ->selectRaw('max(has_permission_own) as has_permission_own') + ->whereIn('role_id', $this->getCurrentUserRoleIds()) + ->groupBy(['entity_type', 'entity_id']) + ->havingRaw('has_permission > 0') + ->orHavingRaw('(has_permission_own > 0 and owned_by = ?)', [$this->currentUser()->id]); }); }); } From 2d1f1abce4a6372b6be1833d88354149cbc7e40c Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 24 Jan 2023 14:55:34 +0000 Subject: [PATCH 10/41] Implemented alternate approach to current joint_permissions Is a tweak upon the existing approach, mainly to store and query role permission access in a way that allows muli-level states that may override eachother. These states are represented in the new PermissionStatus class. This also simplifies how own permissions are stored and queried, to be part of a single column. --- app/Actions/Activity.php | 8 +++ app/Actions/Favourite.php | 8 +++ app/Actions/Tag.php | 8 +++ app/Actions/View.php | 8 +++ .../Permissions/JointPermissionBuilder.php | 23 ++++---- app/Auth/Permissions/PermissionApplicator.php | 33 ++++-------- app/Auth/Permissions/PermissionStatus.php | 11 ++++ app/References/Reference.php | 8 +++ app/References/ReferenceFetcher.php | 17 +++--- ...625_refactor_joint_permissions_storage.php | 52 +++++++++++++++++++ 10 files changed, 137 insertions(+), 39 deletions(-) create mode 100644 app/Auth/Permissions/PermissionStatus.php create mode 100644 database/migrations/2023_01_24_104625_refactor_joint_permissions_storage.php diff --git a/app/Actions/Activity.php b/app/Actions/Activity.php index 3b1408cb9..0789fe123 100644 --- a/app/Actions/Activity.php +++ b/app/Actions/Activity.php @@ -2,10 +2,12 @@ namespace BookStack\Actions; +use BookStack\Auth\Permissions\JointPermission; use BookStack\Auth\User; use BookStack\Entities\Models\Entity; use BookStack\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Support\Str; @@ -40,6 +42,12 @@ class Activity extends Model return $this->belongsTo(User::class); } + public function jointPermissions(): HasMany + { + return $this->hasMany(JointPermission::class, 'entity_id', 'entity_id') + ->whereColumn('activities.entity_type', '=', 'joint_permissions.entity_type'); + } + /** * Returns text from the language files, Looks up by using the activity key. */ diff --git a/app/Actions/Favourite.php b/app/Actions/Favourite.php index f45894182..c5d12a151 100644 --- a/app/Actions/Favourite.php +++ b/app/Actions/Favourite.php @@ -2,7 +2,9 @@ namespace BookStack\Actions; +use BookStack\Auth\Permissions\JointPermission; use BookStack\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\MorphTo; class Favourite extends Model @@ -16,4 +18,10 @@ class Favourite extends Model { return $this->morphTo(); } + + public function jointPermissions(): HasMany + { + return $this->hasMany(JointPermission::class, 'entity_id', 'favouritable_id') + ->whereColumn('favourites.favouritable_type', '=', 'joint_permissions.entity_type'); + } } diff --git a/app/Actions/Tag.php b/app/Actions/Tag.php index 609c299ad..e173faea0 100644 --- a/app/Actions/Tag.php +++ b/app/Actions/Tag.php @@ -2,8 +2,10 @@ namespace BookStack\Actions; +use BookStack\Auth\Permissions\JointPermission; use BookStack\Model; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\MorphTo; /** @@ -27,6 +29,12 @@ class Tag extends Model return $this->morphTo('entity'); } + public function jointPermissions(): HasMany + { + return $this->hasMany(JointPermission::class, 'entity_id', 'entity_id') + ->whereColumn('tags.entity_type', '=', 'joint_permissions.entity_type'); + } + /** * Get a full URL to start a tag name search for this tag name. */ diff --git a/app/Actions/View.php b/app/Actions/View.php index 16961bd91..706467133 100644 --- a/app/Actions/View.php +++ b/app/Actions/View.php @@ -2,8 +2,10 @@ namespace BookStack\Actions; +use BookStack\Auth\Permissions\JointPermission; use BookStack\Interfaces\Viewable; use BookStack\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\MorphTo; /** @@ -28,6 +30,12 @@ class View extends Model return $this->morphTo(); } + public function jointPermissions(): HasMany + { + return $this->hasMany(JointPermission::class, 'entity_id', 'viewable_id') + ->whereColumn('views.viewable_type', '=', 'joint_permissions.entity_type'); + } + /** * Increment the current user's view count for the given viewable model. */ diff --git a/app/Auth/Permissions/JointPermissionBuilder.php b/app/Auth/Permissions/JointPermissionBuilder.php index 91207e3ab..bbdf4d6f8 100644 --- a/app/Auth/Permissions/JointPermissionBuilder.php +++ b/app/Auth/Permissions/JointPermissionBuilder.php @@ -256,35 +256,38 @@ class JointPermissionBuilder { // Ensure system admin role retains permissions if ($isAdminRole) { - return $this->createJointPermissionDataArray($entity, $roleId, true, true); + return $this->createJointPermissionDataArray($entity, $roleId, PermissionStatus::EXPLICIT_ALLOW, true); } // Return evaluated entity permission status if it has an affect. $entityPermissionStatus = $permissionMap->evaluateEntityForRole($entity, $roleId); if ($entityPermissionStatus !== null) { - return $this->createJointPermissionDataArray($entity, $roleId, $entityPermissionStatus, $entityPermissionStatus); + $status = $entityPermissionStatus ? PermissionStatus::EXPLICIT_ALLOW : PermissionStatus::EXPLICIT_DENY; + return $this->createJointPermissionDataArray($entity, $roleId, $status, $entityPermissionStatus); } // Otherwise default to the role-level permissions $permissionPrefix = $entity->type . '-view'; $roleHasPermission = isset($rolePermissionMap[$roleId . ':' . $permissionPrefix . '-all']); $roleHasPermissionOwn = isset($rolePermissionMap[$roleId . ':' . $permissionPrefix . '-own']); - return $this->createJointPermissionDataArray($entity, $roleId, $roleHasPermission, $roleHasPermissionOwn); + $status = $roleHasPermission ? PermissionStatus::IMPLICIT_ALLOW : PermissionStatus::IMPLICIT_DENY; + return $this->createJointPermissionDataArray($entity, $roleId, $status, $roleHasPermissionOwn); } /** * Create an array of data with the information of an entity jointPermissions. * Used to build data for bulk insertion. */ - protected function createJointPermissionDataArray(SimpleEntityData $entity, int $roleId, bool $permissionAll, bool $permissionOwn): array + protected function createJointPermissionDataArray(SimpleEntityData $entity, int $roleId, int $permissionStatus, bool $hasPermissionOwn): array { + $ownPermissionActive = ($hasPermissionOwn && $permissionStatus !== PermissionStatus::EXPLICIT_DENY && $entity->owned_by); + return [ - 'entity_id' => $entity->id, - 'entity_type' => $entity->type, - 'has_permission' => $permissionAll, - 'has_permission_own' => $permissionOwn, - 'owned_by' => $entity->owned_by, - 'role_id' => $roleId, + 'entity_id' => $entity->id, + 'entity_type' => $entity->type, + 'role_id' => $roleId, + 'status' => $permissionStatus, + 'owner_id' => $ownPermissionActive ? $entity->owned_by : null, ]; } } diff --git a/app/Auth/Permissions/PermissionApplicator.php b/app/Auth/Permissions/PermissionApplicator.php index e4564ddf5..4f95465af 100644 --- a/app/Auth/Permissions/PermissionApplicator.php +++ b/app/Auth/Permissions/PermissionApplicator.php @@ -95,13 +95,11 @@ class PermissionApplicator return $query->where(function (Builder $parentQuery) { $parentQuery->whereHas('jointPermissions', function (Builder $permissionQuery) { $permissionQuery->select(['entity_id', 'entity_type']) - ->selectRaw('max(owned_by) as owned_by') - ->selectRaw('max(has_permission) as has_permission') - ->selectRaw('max(has_permission_own) as has_permission_own') + ->selectRaw('max(owner_id) as owner_id') + ->selectRaw('max(status) as status') ->whereIn('role_id', $this->getCurrentUserRoleIds()) ->groupBy(['entity_type', 'entity_id']) - ->havingRaw('has_permission > 0') - ->orHavingRaw('(has_permission_own > 0 and owned_by = ?)', [$this->currentUser()->id]); + ->havingRaw('(status IN (1, 3) or owner_id = ?)', [$this->currentUser()->id]); }); }); } @@ -125,35 +123,23 @@ class PermissionApplicator * Filter items that have entities set as a polymorphic relation. * For simplicity, this will not return results attached to draft pages. * Draft pages should never really have related items though. - * - * @param Builder|QueryBuilder $query */ - public function restrictEntityRelationQuery($query, string $tableName, string $entityIdColumn, string $entityTypeColumn) + public function restrictEntityRelationQuery(Builder $query, string $tableName, string $entityIdColumn, string $entityTypeColumn): Builder { $tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn, 'entityTypeColumn' => $entityTypeColumn]; $pageMorphClass = (new Page())->getMorphClass(); - $q = $query->whereExists(function ($permissionQuery) use (&$tableDetails) { - /** @var Builder $permissionQuery */ - $permissionQuery->select(['role_id'])->from('joint_permissions') - ->whereColumn('joint_permissions.entity_id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn']) - ->whereColumn('joint_permissions.entity_type', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn']) - ->whereIn('joint_permissions.role_id', $this->getCurrentUserRoleIds()) - ->where(function (QueryBuilder $query) { - $this->addJointHasPermissionCheck($query, $this->currentUser()->id); - }); - })->where(function ($query) use ($tableDetails, $pageMorphClass) { - /** @var Builder $query */ - $query->where($tableDetails['entityTypeColumn'], '!=', $pageMorphClass) + return $this->restrictEntityQuery($query) + ->where(function ($query) use ($tableDetails, $pageMorphClass) { + /** @var Builder $query */ + $query->where($tableDetails['entityTypeColumn'], '!=', $pageMorphClass) ->orWhereExists(function (QueryBuilder $query) use ($tableDetails, $pageMorphClass) { $query->select('id')->from('pages') ->whereColumn('pages.id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn']) ->where($tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'], '=', $pageMorphClass) ->where('pages.draft', '=', false); }); - }); - - return $q; + }); } /** @@ -164,6 +150,7 @@ class PermissionApplicator */ public function restrictPageRelationQuery(Builder $query, string $tableName, string $pageIdColumn): Builder { + // TODO - Refactor $fullPageIdColumn = $tableName . '.' . $pageIdColumn; $morphClass = (new Page())->getMorphClass(); diff --git a/app/Auth/Permissions/PermissionStatus.php b/app/Auth/Permissions/PermissionStatus.php new file mode 100644 index 000000000..f8e55c20b --- /dev/null +++ b/app/Auth/Permissions/PermissionStatus.php @@ -0,0 +1,11 @@ +morphTo('to'); } + + public function jointPermissions(): HasMany + { + return $this->hasMany(JointPermission::class, 'entity_id', 'from_id') + ->whereColumn('references.from_type', '=', 'joint_permissions.entity_type'); + } } diff --git a/app/References/ReferenceFetcher.php b/app/References/ReferenceFetcher.php index a73463a95..415791857 100644 --- a/app/References/ReferenceFetcher.php +++ b/app/References/ReferenceFetcher.php @@ -5,6 +5,7 @@ namespace BookStack\References; use BookStack\Auth\Permissions\PermissionApplicator; use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Page; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Relations\Relation; @@ -23,8 +24,7 @@ class ReferenceFetcher */ public function getPageReferencesToEntity(Entity $entity): Collection { - $baseQuery = $entity->referencesTo() - ->where('from_type', '=', (new Page())->getMorphClass()) + $baseQuery = $this->queryPageReferencesToEntity($entity) ->with([ 'from' => fn (Relation $query) => $query->select(Page::$listAttributes), 'from.book' => fn (Relation $query) => $query->scopes('visible'), @@ -47,11 +47,8 @@ class ReferenceFetcher */ public function getPageReferenceCountToEntity(Entity $entity): int { - $baseQuery = $entity->referencesTo() - ->where('from_type', '=', (new Page())->getMorphClass()); - $count = $this->permissions->restrictEntityRelationQuery( - $baseQuery, + $this->queryPageReferencesToEntity($entity), 'references', 'from_id', 'from_type' @@ -59,4 +56,12 @@ class ReferenceFetcher return $count; } + + protected function queryPageReferencesToEntity(Entity $entity): Builder + { + return Reference::query() + ->where('to_type', '=', $entity->getMorphClass()) + ->where('to_id', '=', $entity->id) + ->where('from_type', '=', (new Page())->getMorphClass()); + } } diff --git a/database/migrations/2023_01_24_104625_refactor_joint_permissions_storage.php b/database/migrations/2023_01_24_104625_refactor_joint_permissions_storage.php new file mode 100644 index 000000000..49ebf5c5a --- /dev/null +++ b/database/migrations/2023_01_24_104625_refactor_joint_permissions_storage.php @@ -0,0 +1,52 @@ +truncate(); + + if (Schema::hasColumn('joint_permissions', 'owned_by')) { + Schema::table('joint_permissions', function (Blueprint $table) { + $table->dropColumn(['has_permission', 'has_permission_own', 'owned_by']); + + $table->unsignedTinyInteger('status')->index(); + $table->unsignedInteger('owner_id')->nullable()->index(); + }); + } + + // Rebuild permissions + app(JointPermissionBuilder::class)->rebuildForAll(); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + DB::table('joint_permissions')->truncate(); + + Schema::table('joint_permissions', function (Blueprint $table) { + $table->dropColumn(['status', 'owner_id']); + + $table->boolean('has_permission')->index(); + $table->boolean('has_permission_own')->index(); + $table->unsignedInteger('created_by')->index(); + }); + } +} From 1660e72cc5f3420bb704a8f159a94f0632d0b25c Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 24 Jan 2023 19:04:32 +0000 Subject: [PATCH 11/41] Migrated remaining relation permission usages Now all tests are passing. Some level of manual checks to do. --- app/Auth/Permissions/PermissionApplicator.php | 51 +++---------------- app/Auth/User.php | 1 + app/Uploads/Attachment.php | 8 +++ app/Uploads/Image.php | 8 +++ .../RegeneratePermissionsCommandTest.php | 2 +- tests/Permissions/EntityPermissionsTest.php | 5 +- 6 files changed, 29 insertions(+), 46 deletions(-) diff --git a/app/Auth/Permissions/PermissionApplicator.php b/app/Auth/Permissions/PermissionApplicator.php index 4f95465af..437ddb0fb 100644 --- a/app/Auth/Permissions/PermissionApplicator.php +++ b/app/Auth/Permissions/PermissionApplicator.php @@ -150,51 +150,16 @@ class PermissionApplicator */ public function restrictPageRelationQuery(Builder $query, string $tableName, string $pageIdColumn): Builder { - // TODO - Refactor $fullPageIdColumn = $tableName . '.' . $pageIdColumn; - $morphClass = (new Page())->getMorphClass(); - - $existsQuery = function ($permissionQuery) use ($fullPageIdColumn, $morphClass) { - /** @var Builder $permissionQuery */ - $permissionQuery->select('joint_permissions.role_id')->from('joint_permissions') - ->whereColumn('joint_permissions.entity_id', '=', $fullPageIdColumn) - ->where('joint_permissions.entity_type', '=', $morphClass) - ->whereIn('joint_permissions.role_id', $this->getCurrentUserRoleIds()) - ->where(function (QueryBuilder $query) { - $this->addJointHasPermissionCheck($query, $this->currentUser()->id); + return $this->restrictEntityQuery($query) + ->where(function ($query) use ($fullPageIdColumn) { + /** @var Builder $query */ + $query->whereExists(function (QueryBuilder $query) use ($fullPageIdColumn) { + $query->select('id')->from('pages') + ->whereColumn('pages.id', '=', $fullPageIdColumn) + ->where('pages.draft', '=', false); }); - }; - - $q = $query->where(function ($query) use ($existsQuery, $fullPageIdColumn) { - $query->whereExists($existsQuery) - ->orWhere($fullPageIdColumn, '=', 0); - }); - - // Prevent visibility of non-owned draft pages - $q->whereExists(function (QueryBuilder $query) use ($fullPageIdColumn) { - $query->select('id')->from('pages') - ->whereColumn('pages.id', '=', $fullPageIdColumn) - ->where(function (QueryBuilder $query) { - $query->where('pages.draft', '=', false) - ->orWhere('pages.owned_by', '=', $this->currentUser()->id); - }); - }); - - return $q; - } - - /** - * Add the query for checking the given user id has permission - * within the join_permissions table. - * - * @param QueryBuilder|Builder $query - */ - protected function addJointHasPermissionCheck($query, int $userIdToCheck) - { - $query->where('joint_permissions.has_permission', '=', true)->orWhere(function ($query) use ($userIdToCheck) { - $query->where('joint_permissions.has_permission_own', '=', true) - ->where('joint_permissions.owned_by', '=', $userIdToCheck); - }); + }); } /** diff --git a/app/Auth/User.php b/app/Auth/User.php index 6e66bc808..cf9f20e52 100644 --- a/app/Auth/User.php +++ b/app/Auth/User.php @@ -200,6 +200,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon public function attachRole(Role $role) { $this->roles()->attach($role->id); + $this->unsetRelation('roles'); } /** diff --git a/app/Uploads/Attachment.php b/app/Uploads/Attachment.php index 6c7066ff9..fc86d36ea 100644 --- a/app/Uploads/Attachment.php +++ b/app/Uploads/Attachment.php @@ -2,6 +2,7 @@ namespace BookStack\Uploads; +use BookStack\Auth\Permissions\JointPermission; use BookStack\Auth\Permissions\PermissionApplicator; use BookStack\Auth\User; use BookStack\Entities\Models\Entity; @@ -10,6 +11,7 @@ use BookStack\Model; use BookStack\Traits\HasCreatorAndUpdater; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; /** * @property int $id @@ -56,6 +58,12 @@ class Attachment extends Model return $this->belongsTo(Page::class, 'uploaded_to'); } + public function jointPermissions(): HasMany + { + return $this->hasMany(JointPermission::class, 'entity_id', 'uploaded_to') + ->where('joint_permissions.entity_type', '=', 'page'); + } + /** * Get the url of this file. */ diff --git a/app/Uploads/Image.php b/app/Uploads/Image.php index bdf10f080..c21a3b03f 100644 --- a/app/Uploads/Image.php +++ b/app/Uploads/Image.php @@ -2,10 +2,12 @@ namespace BookStack\Uploads; +use BookStack\Auth\Permissions\JointPermission; use BookStack\Entities\Models\Page; use BookStack\Model; use BookStack\Traits\HasCreatorAndUpdater; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\HasMany; /** * @property int $id @@ -25,6 +27,12 @@ class Image extends Model protected $fillable = ['name']; protected $hidden = []; + public function jointPermissions(): HasMany + { + return $this->hasMany(JointPermission::class, 'entity_id', 'uploaded_to') + ->where('joint_permissions.entity_type', '=', 'page'); + } + /** * Get a thumbnail for this image. * diff --git a/tests/Commands/RegeneratePermissionsCommandTest.php b/tests/Commands/RegeneratePermissionsCommandTest.php index b916a8060..9cf7dec93 100644 --- a/tests/Commands/RegeneratePermissionsCommandTest.php +++ b/tests/Commands/RegeneratePermissionsCommandTest.php @@ -29,7 +29,7 @@ class RegeneratePermissionsCommandTest extends TestCase 'entity_id' => $page->id, 'entity_type' => 'page', 'role_id' => $role->id, - 'has_permission' => 1, + 'status' => 3, // Explicit allow ]); $page->permissions()->delete(); diff --git a/tests/Permissions/EntityPermissionsTest.php b/tests/Permissions/EntityPermissionsTest.php index ab8b1242d..99a8bd88c 100644 --- a/tests/Permissions/EntityPermissionsTest.php +++ b/tests/Permissions/EntityPermissionsTest.php @@ -663,7 +663,7 @@ class EntityPermissionsTest extends TestCase $chapter = $this->entities->chapter(); $book = $chapter->book; - $this->permissions->setEntityPermissions($book, ['edit'], [$viewerRole], false); + $this->permissions->setEntityPermissions($book, ['update'], [$viewerRole], false); $this->permissions->setEntityPermissions($chapter, [], [$viewerRole], true); $this->assertFalse(userCan('chapter-update', $chapter)); @@ -678,9 +678,10 @@ class EntityPermissionsTest extends TestCase $chapter = $this->entities->chapter(); $book = $chapter->book; - $this->permissions->setEntityPermissions($book, ['edit'], [$editorRole], false); + $this->permissions->setEntityPermissions($book, ['update'], [$editorRole], false); $this->permissions->setEntityPermissions($chapter, [], [$viewerRole], true); + $this->actingAs($user); $this->assertTrue(userCan('chapter-update', $chapter)); } From d1bd6d0e3919ccdf22c74ebf29282475288132bd Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 24 Jan 2023 19:21:23 +0000 Subject: [PATCH 12/41] Fixed incorrect field in down migration --- .../2023_01_24_104625_refactor_joint_permissions_storage.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/database/migrations/2023_01_24_104625_refactor_joint_permissions_storage.php b/database/migrations/2023_01_24_104625_refactor_joint_permissions_storage.php index 49ebf5c5a..0f73f456b 100644 --- a/database/migrations/2023_01_24_104625_refactor_joint_permissions_storage.php +++ b/database/migrations/2023_01_24_104625_refactor_joint_permissions_storage.php @@ -46,7 +46,7 @@ class RefactorJointPermissionsStorage extends Migration $table->boolean('has_permission')->index(); $table->boolean('has_permission_own')->index(); - $table->unsignedInteger('created_by')->index(); + $table->unsignedInteger('owned_by')->index(); }); } } From 8be36455ab7007334ca26fff28ebb1a99886de65 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 24 Jan 2023 20:42:20 +0000 Subject: [PATCH 13/41] Addressed fallback override cases found during testing Had misalignment between query and usercan, The nuance between fallback and entity-role permissions was not taken into account by the query system. Now added with new test cases to cover. --- .../Permissions/EntityPermissionEvaluator.php | 10 +- .../Permissions/JointPermissionBuilder.php | 3 +- .../MassEntityPermissionEvaluator.php | 2 +- dev/docs/permission-scenario-testing.md | 82 +++++++++++++++- tests/Helpers/PermissionsProvider.php | 7 ++ .../Scenarios/EntityRolePermissionsTest.php | 97 ++++++++++++++++++- 6 files changed, 192 insertions(+), 9 deletions(-) diff --git a/app/Auth/Permissions/EntityPermissionEvaluator.php b/app/Auth/Permissions/EntityPermissionEvaluator.php index 99e87d769..f5e75be3e 100644 --- a/app/Auth/Permissions/EntityPermissionEvaluator.php +++ b/app/Auth/Permissions/EntityPermissionEvaluator.php @@ -25,22 +25,24 @@ class EntityPermissionEvaluator $relevantPermissions = $this->getPermissionsMapByTypeId($typeIdChain, [...$userRoleIds, 0]); $permitsByType = $this->collapseAndCategorisePermissions($typeIdChain, $relevantPermissions); - return $this->evaluatePermitsByType($permitsByType); + $status = $this->evaluatePermitsByType($permitsByType); + + return is_null($status) ? null : $status === PermissionStatus::IMPLICIT_ALLOW || $status === PermissionStatus::EXPLICIT_ALLOW; } /** * @param array> $permitsByType */ - protected function evaluatePermitsByType(array $permitsByType): ?bool + protected function evaluatePermitsByType(array $permitsByType): ?int { // Return grant or reject from role-level if exists if (count($permitsByType['role']) > 0) { - return boolval(max($permitsByType['role'])); + return max($permitsByType['role']) ? PermissionStatus::EXPLICIT_ALLOW : PermissionStatus::EXPLICIT_DENY; } // Return fallback permission if exists if (count($permitsByType['fallback']) > 0) { - return boolval($permitsByType['fallback'][0]); + return $permitsByType['fallback'][0] ? PermissionStatus::IMPLICIT_ALLOW : PermissionStatus::IMPLICIT_DENY; } return null; diff --git a/app/Auth/Permissions/JointPermissionBuilder.php b/app/Auth/Permissions/JointPermissionBuilder.php index bbdf4d6f8..4132a19af 100644 --- a/app/Auth/Permissions/JointPermissionBuilder.php +++ b/app/Auth/Permissions/JointPermissionBuilder.php @@ -262,8 +262,7 @@ class JointPermissionBuilder // Return evaluated entity permission status if it has an affect. $entityPermissionStatus = $permissionMap->evaluateEntityForRole($entity, $roleId); if ($entityPermissionStatus !== null) { - $status = $entityPermissionStatus ? PermissionStatus::EXPLICIT_ALLOW : PermissionStatus::EXPLICIT_DENY; - return $this->createJointPermissionDataArray($entity, $roleId, $status, $entityPermissionStatus); + return $this->createJointPermissionDataArray($entity, $roleId, $entityPermissionStatus, false); } // Otherwise default to the role-level permissions diff --git a/app/Auth/Permissions/MassEntityPermissionEvaluator.php b/app/Auth/Permissions/MassEntityPermissionEvaluator.php index 1bd2ec44a..a9deba16d 100644 --- a/app/Auth/Permissions/MassEntityPermissionEvaluator.php +++ b/app/Auth/Permissions/MassEntityPermissionEvaluator.php @@ -16,7 +16,7 @@ class MassEntityPermissionEvaluator extends EntityPermissionEvaluator parent::__construct($action); } - public function evaluateEntityForRole(SimpleEntityData $entity, int $roleId): ?bool + public function evaluateEntityForRole(SimpleEntityData $entity, int $roleId): ?int { $typeIdChain = $this->gatherEntityChainTypeIds($entity); $relevantPermissions = $this->getPermissionMapByTypeIdForChainAndRole($typeIdChain, $roleId); diff --git a/dev/docs/permission-scenario-testing.md b/dev/docs/permission-scenario-testing.md index e738fe972..54b1bcfe1 100644 --- a/dev/docs/permission-scenario-testing.md +++ b/dev/docs/permission-scenario-testing.md @@ -229,7 +229,7 @@ User denied page permission. User denied page permission. -#### test_80_multi_role_inherited_deny_via_parent +#### test_75_multi_role_inherited_deny_via_parent - Page permissions have inherit enabled. - Chapter permissions have inherit enabled. @@ -238,3 +238,83 @@ User denied page permission. - User has Role A & B. User denied page permission. + +#### test_80_fallback_override_allow + +- Page permissions have inherit disabled. +- Page fallback has entity deny permission. +- Role A has entity allow page permission. +- User has Role A. + +User granted page permission. + +#### test_81_fallback_override_deny + +- Page permissions have inherit disabled. +- Page fallback has entity allow permission. +- Role A has entity deny page permission. +- User has Role A. + +User denied page permission. + +#### test_84_fallback_override_allow_multi_role + +- Page permissions have inherit disabled. +- Page fallback has entity deny permission. +- Role A has entity allow page permission. +- Role B has no entity page permissions. +- User has Role A & B. + +User granted page permission. + +#### test_85_fallback_override_deny_multi_role + +- Page permissions have inherit disabled. +- Page fallback has entity allow permission. +- Role A has entity deny page permission. +- Role B has no entity page permissions. +- User has Role A & B. + +User denied page permission. + +#### test_86_fallback_override_allow_inherit + +- Chapter permissions have inherit disabled. +- Page permissions have inherit enabled. +- Chapter fallback has entity deny permission. +- Role A has entity allow chapter permission. +- User has Role A. + +User granted page permission. + +#### test_87_fallback_override_deny_inherit + +- Chapter permissions have inherit disabled. +- Page permissions have inherit enabled. +- Chapter fallback has entity allow permission. +- Role A has entity deny chapter permission. +- User has Role A. + +User denied page permission. + +#### test_88_fallback_override_allow_multi_role_inherit + +- Chapter permissions have inherit disabled. +- Page permissions have inherit enabled. +- Chapter fallback has entity deny permission. +- Role A has entity allow chapter permission. +- Role B has no entity chapter permissions. +- User has Role A & B. + +User granted page permission. + +#### test_89_fallback_override_deny_multi_role_inherit + +- Chapter permissions have inherit disabled. +- Page permissions have inherit enabled. +- Chapter fallback has entity allow permission. +- Role A has entity deny chapter permission. +- Role B has no entity chapter permissions. +- User has Role A & B. + +User denied page permission. \ No newline at end of file diff --git a/tests/Helpers/PermissionsProvider.php b/tests/Helpers/PermissionsProvider.php index 2cbfb1af5..b93c45e25 100644 --- a/tests/Helpers/PermissionsProvider.php +++ b/tests/Helpers/PermissionsProvider.php @@ -101,6 +101,13 @@ class PermissionsProvider $this->addEntityPermissionEntries($entity, [$permissionData]); } + public function setFallbackPermissions(Entity $entity, array $actionList) + { + $entity->permissions()->where('role_id', '=', 0)->delete(); + $permissionData = $this->actionListToEntityPermissionData($actionList, 0); + $this->addEntityPermissionEntries($entity, [$permissionData]); + } + /** * Disable inherited permissions on the given entity. * Effectively sets the "Other Users" UI permission option to not inherit, with no permissions. diff --git a/tests/Permissions/Scenarios/EntityRolePermissionsTest.php b/tests/Permissions/Scenarios/EntityRolePermissionsTest.php index b92ce620b..c8f1401e7 100644 --- a/tests/Permissions/Scenarios/EntityRolePermissionsTest.php +++ b/tests/Permissions/Scenarios/EntityRolePermissionsTest.php @@ -187,7 +187,7 @@ class EntityRolePermissionsTest extends PermissionScenarioTestCase $this->assertNotVisibleToUser($page, $user); } - public function test_80_multi_role_inherited_deny_via_parent() + public function test_75_multi_role_inherited_deny_via_parent() { [$user, $roleA] = $this->users->newUserWithRole([], ['page-view-all']); $roleB = $this->users->attachNewRole($user); @@ -198,4 +198,99 @@ class EntityRolePermissionsTest extends PermissionScenarioTestCase $this->assertNotVisibleToUser($page, $user); } + + public function test_80_fallback_override_allow() + { + [$user, $roleA] = $this->users->newUserWithRole(); + $page = $this->entities->page(); + + $this->permissions->setFallbackPermissions($page, []); + $this->permissions->addEntityPermission($page, ['view'], $roleA); + + $this->assertVisibleToUser($page, $user); + } + public function test_81_fallback_override_deny() + { + [$user, $roleA] = $this->users->newUserWithRole(); + $page = $this->entities->page(); + + $this->permissions->setFallbackPermissions($page, ['view']); + $this->permissions->addEntityPermission($page, [], $roleA); + + $this->assertNotVisibleToUser($page, $user); + } + + public function test_84_fallback_override_allow_multi_role() + { + [$user, $roleA] = $this->users->newUserWithRole(); + $roleB = $this->users->attachNewRole($user); + $page = $this->entities->page(); + + $this->permissions->setFallbackPermissions($page, []); + $this->permissions->addEntityPermission($page, ['view'], $roleA); + + $this->assertVisibleToUser($page, $user); + } + + public function test_85_fallback_override_deny_multi_role() + { + [$user, $roleA] = $this->users->newUserWithRole(); + $roleB = $this->users->attachNewRole($user); + $page = $this->entities->page(); + + $this->permissions->setFallbackPermissions($page, ['view']); + $this->permissions->addEntityPermission($page, [], $roleA); + + $this->assertNotVisibleToUser($page, $user); + } + + public function test_86_fallback_override_allow_inherit() + { + [$user, $roleA] = $this->users->newUserWithRole(); + $page = $this->entities->page(); + $chapter = $page->chapter; + + $this->permissions->setFallbackPermissions($chapter, []); + $this->permissions->addEntityPermission($chapter, ['view'], $roleA); + + $this->assertVisibleToUser($page, $user); + } + + public function test_87_fallback_override_deny_inherit() + { + [$user, $roleA] = $this->users->newUserWithRole(); + $page = $this->entities->page(); + $chapter = $page->chapter; + + $this->permissions->setFallbackPermissions($chapter, ['view']); + $this->permissions->addEntityPermission($chapter, [], $roleA); + + $this->assertNotVisibleToUser($page, $user); + } + + public function test_88_fallback_override_allow_multi_role_inherit() + { + [$user, $roleA] = $this->users->newUserWithRole(); + $roleB = $this->users->attachNewRole($user); + $page = $this->entities->page(); + $chapter = $page->chapter; + + $this->permissions->setFallbackPermissions($chapter, []); + $this->permissions->addEntityPermission($chapter, ['view'], $roleA); + + $this->assertVisibleToUser($page, $user); + } + + public function test_89_fallback_override_deny_multi_role_inherit() + { + [$user, $roleA] = $this->users->newUserWithRole(); + $roleB = $this->users->attachNewRole($user); + $page = $this->entities->page(); + $chapter = $page->chapter; + + $this->permissions->setFallbackPermissions($chapter, ['view']); + $this->permissions->addEntityPermission($chapter, [], $roleA); + + $this->assertNotVisibleToUser($page, $user); + } } From 1fa5a3196060092b4771c3d6b775f503800434d8 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 24 Jan 2023 21:26:41 +0000 Subject: [PATCH 14/41] Fixed role entity permissions ignoring inheritance Added additional scnenario tests to cover --- .../Permissions/EntityPermissionEvaluator.php | 4 +++ dev/docs/permission-scenario-testing.md | 23 ++++++++++++++++ .../Scenarios/EntityRolePermissionsTest.php | 27 +++++++++++++++++++ 3 files changed, 54 insertions(+) diff --git a/app/Auth/Permissions/EntityPermissionEvaluator.php b/app/Auth/Permissions/EntityPermissionEvaluator.php index f5e75be3e..51db45bbc 100644 --- a/app/Auth/Permissions/EntityPermissionEvaluator.php +++ b/app/Auth/Permissions/EntityPermissionEvaluator.php @@ -66,6 +66,10 @@ class EntityPermissionEvaluator $permitsByType[$type][$roleId] = $permission->{$this->action}; } } + + if (isset($permitsByType['fallback'][0])) { + break; + } } return $permitsByType; diff --git a/dev/docs/permission-scenario-testing.md b/dev/docs/permission-scenario-testing.md index 54b1bcfe1..7a9cc1126 100644 --- a/dev/docs/permission-scenario-testing.md +++ b/dev/docs/permission-scenario-testing.md @@ -317,4 +317,27 @@ User granted page permission. - Role B has no entity chapter permissions. - User has Role A & B. +User denied page permission. + +#### test_90_fallback_overrides_parent_entity_role_deny + +- Chapter permissions have inherit disabled. +- Page permissions have inherit disabled. +- Chapter fallback has entity deny permission. +- Page fallback has entity deny permission. +- Role A has entity allow chapter permission. +- User has Role A. + +User denied page permission. + +#### test_91_fallback_overrides_parent_entity_role_inherit + +- Book permissions have inherit disabled. +- Chapter permissions have inherit disabled. +- Page permissions have inherit enabled. +- Book fallback has entity deny permission. +- Chapter fallback has entity deny permission. +- Role A has entity allow book permission. +- User has Role A. + User denied page permission. \ No newline at end of file diff --git a/tests/Permissions/Scenarios/EntityRolePermissionsTest.php b/tests/Permissions/Scenarios/EntityRolePermissionsTest.php index c8f1401e7..bd5b31fdc 100644 --- a/tests/Permissions/Scenarios/EntityRolePermissionsTest.php +++ b/tests/Permissions/Scenarios/EntityRolePermissionsTest.php @@ -293,4 +293,31 @@ class EntityRolePermissionsTest extends PermissionScenarioTestCase $this->assertNotVisibleToUser($page, $user); } + + public function test_90_fallback_overrides_parent_entity_role_deny() + { + [$user, $roleA] = $this->users->newUserWithRole(); + $page = $this->entities->page(); + $chapter = $page->chapter; + + $this->permissions->setFallbackPermissions($chapter, []); + $this->permissions->setFallbackPermissions($page, []); + $this->permissions->addEntityPermission($chapter, ['view'], $roleA); + + $this->assertNotVisibleToUser($page, $user); + } + + public function test_91_fallback_overrides_parent_entity_role_inherit() + { + [$user, $roleA] = $this->users->newUserWithRole(); + $page = $this->entities->page(); + $chapter = $page->chapter; + $book = $page->book; + + $this->permissions->setFallbackPermissions($book, []); + $this->permissions->setFallbackPermissions($chapter, []); + $this->permissions->addEntityPermission($book, ['view'], $roleA); + + $this->assertNotVisibleToUser($page, $user); + } } From 55b6a7842ed8eda41637d3a26f0f7f23c0866478 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 25 Jan 2023 11:03:19 +0000 Subject: [PATCH 15/41] Added ability to control app icon (favicon) via settings --- app/Http/Controllers/SettingController.php | 36 +------ app/Settings/AppSettingsStore.php | 90 ++++++++++++++++++ app/Settings/SettingService.php | 12 +-- app/Uploads/ImageRepo.php | 10 +- public/icon-128.png | Bin 0 -> 3538 bytes public/icon-32.png | Bin 0 -> 1338 bytes public/icon-64.png | Bin 0 -> 1951 bytes public/icon.png | Bin 0 -> 6900 bytes resources/lang/en/settings.php | 2 +- resources/views/layouts/base.blade.php | 6 ++ .../views/settings/customization.blade.php | 19 ++++ 11 files changed, 132 insertions(+), 43 deletions(-) create mode 100644 app/Settings/AppSettingsStore.php create mode 100644 public/icon-128.png create mode 100644 public/icon-32.png create mode 100644 public/icon-64.png create mode 100644 public/icon.png diff --git a/app/Http/Controllers/SettingController.php b/app/Http/Controllers/SettingController.php index f5e48ca4c..1e13d7cb7 100644 --- a/app/Http/Controllers/SettingController.php +++ b/app/Http/Controllers/SettingController.php @@ -4,20 +4,14 @@ namespace BookStack\Http\Controllers; use BookStack\Actions\ActivityType; use BookStack\Auth\User; +use BookStack\Settings\AppSettingsStore; use BookStack\Uploads\ImageRepo; use Illuminate\Http\Request; class SettingController extends Controller { - protected ImageRepo $imageRepo; - protected array $settingCategories = ['features', 'customization', 'registration']; - public function __construct(ImageRepo $imageRepo) - { - $this->imageRepo = $imageRepo; - } - /** * Handle requests to the settings index path. */ @@ -48,37 +42,17 @@ class SettingController extends Controller /** * Update the specified settings in storage. */ - public function update(Request $request, string $category) + public function update(Request $request, AppSettingsStore $store, string $category) { $this->ensureCategoryExists($category); $this->preventAccessInDemoMode(); $this->checkPermission('settings-manage'); $this->validate($request, [ - 'app_logo' => array_merge(['nullable'], $this->getImageValidationRules()), + 'app_logo' => ['nullable', ...$this->getImageValidationRules()], + 'app_icon' => ['nullable', ...$this->getImageValidationRules()], ]); - // 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; - } - setting()->put($key, $value); - } - - // Update logo image if set - if ($category === 'customization' && $request->hasFile('app_logo')) { - $logoFile = $request->file('app_logo'); - $this->imageRepo->destroyByType('system'); - $image = $this->imageRepo->saveNew($logoFile, 'system', 0, null, 86); - setting()->put('app-logo', $image->url); - } - - // Clear logo image if requested - if ($category === 'customization' && $request->get('app_logo_reset', null)) { - $this->imageRepo->destroyByType('system'); - setting()->remove('app-logo'); - } + $store->storeFromUpdateRequest($request, $category); $this->logActivity(ActivityType::SETTINGS_UPDATE, $category); $this->showSuccessNotification(trans('settings.settings_save_success')); diff --git a/app/Settings/AppSettingsStore.php b/app/Settings/AppSettingsStore.php new file mode 100644 index 000000000..f2b6cdc52 --- /dev/null +++ b/app/Settings/AppSettingsStore.php @@ -0,0 +1,90 @@ +imageRepo = $imageRepo; + } + + public function storeFromUpdateRequest(Request $request, string $category) + { + $this->storeSimpleSettings($request); + if ($category === 'customization') { + $this->updateAppLogo($request); + $this->updateAppIcon($request); + } + } + + protected function updateAppIcon(Request $request): void + { + $sizes = [128, 64, 32]; + + // Update icon image if set + if ($request->hasFile('app_icon')) { + $iconFile = $request->file('app_icon'); + $this->destroyExistingSettingImage('app-icon'); + $image = $this->imageRepo->saveNew($iconFile, 'system', 0, 256, 256); + setting()->put('app-icon', $image->url); + + foreach ($sizes as $size) { + $icon = $this->imageRepo->saveNew($iconFile, 'system', 0, $size, $size); + setting()->put('app-icon-' . $size, $icon->url); + } + } + + // Clear icon image if requested + if ($request->get('app_icon_reset')) { + $this->destroyExistingSettingImage('app-icon'); + setting()->remove('app-icon'); + foreach ($sizes as $size) { + $this->destroyExistingSettingImage('app-icon-' . $size); + setting()->remove('app-icon-' . $size); + } + } + } + + protected function updateAppLogo(Request $request): void + { + // Update logo image if set + if ($request->hasFile('app_logo')) { + $logoFile = $request->file('app_logo'); + $this->destroyExistingSettingImage('app-logo'); + $image = $this->imageRepo->saveNew($logoFile, 'system', 0, null, 86); + setting()->put('app-logo', $image->url); + } + + // Clear logo image if requested + if ($request->get('app_logo_reset')) { + $this->destroyExistingSettingImage('app-logo'); + setting()->remove('app-logo'); + } + } + + protected function storeSimpleSettings(Request $request): void + { + foreach ($request->all() as $name => $value) { + if (strpos($name, 'setting-') !== 0) { + continue; + } + + $key = str_replace('setting-', '', trim($name)); + setting()->put($key, $value); + } + } + + protected function destroyExistingSettingImage(string $settingKey) + { + $existingVal = setting()->get($settingKey); + if ($existingVal) { + $this->imageRepo->destroyByUrlAndType($existingVal, 'system'); + } + } +} diff --git a/app/Settings/SettingService.php b/app/Settings/SettingService.php index 9f0a41ea2..d1bac164d 100644 --- a/app/Settings/SettingService.php +++ b/app/Settings/SettingService.php @@ -12,15 +12,11 @@ use Illuminate\Contracts\Cache\Repository as Cache; */ class SettingService { - protected $setting; - protected $cache; - protected $localCache = []; + protected Setting $setting; + protected Cache $cache; + protected array $localCache = []; + protected string $cachePrefix = 'setting-'; - protected $cachePrefix = 'setting-'; - - /** - * SettingService constructor. - */ public function __construct(Setting $setting, Cache $cache) { $this->setting = $setting; diff --git a/app/Uploads/ImageRepo.php b/app/Uploads/ImageRepo.php index 8770402ad..910248203 100644 --- a/app/Uploads/ImageRepo.php +++ b/app/Uploads/ImageRepo.php @@ -180,13 +180,17 @@ class ImageRepo } /** - * Destroy all images of a certain type. + * Destroy images that have a specific URL and type combination. * * @throws Exception */ - public function destroyByType(string $imageType): void + public function destroyByUrlAndType(string $url, string $imageType): void { - $images = Image::query()->where('type', '=', $imageType)->get(); + $images = Image::query() + ->where('url', '=', $url) + ->where('type', '=', $imageType) + ->get(); + foreach ($images as $image) { $this->destroyImage($image); } diff --git a/public/icon-128.png b/public/icon-128.png new file mode 100644 index 0000000000000000000000000000000000000000..46cc2811b5e7f6162e87b8e4e0cf3785f57a922c GIT binary patch literal 3538 zcmV;@4K4DCP)C0008|P)t-s03iSk zhtL3v(h-Q!0FBiYiP9H})D(@-8jIB&jMf*9(I1W18j#W~jL;mB(=LtCAClBCj?y8M z)-jLMBb3)OkkloW*fo*XCzjbZlGiws*ff>XIh5Hpmef0y+Cr4mH<;B#mDD+z)v)g|gm$wcm!b;D5H^hqU2< zx8jJk;(@s1iMHc{x#Wts5yXJ|z;E=iCjJoEAyyuF%;gP!Gi@oBKyyJ|% z}>6yamoWkRl#N?92>Yc;n zm&WCu#O9dC=by#rnaJp%#^{>K>7mEzo672=$m*oY>!-=(rONE6%I2xd=cdfa5S}v(4+R(CoC%?5@%6wa@LZ((b9$?zYhGxzOmb)9<>`>9o`7vefXq((1L; z>a*4IywmKq)$6^~?YGwLxYzE!*6z93@4wgYy4mo-*zmjC@xs~hyxj7`+VaNQ?7iLd z#M|@0-R;NQ?Y`dh!QSr3-0#BQ@X6iq!{G7C-to)d^334##^UqGGs*`_uA|D;Oh9@?DOI4`P=RJ;_Um}?)u*D z^ycmM;P3V4?)TyF_vr8V;_>+D@cHBN`Reid|N8&`{Qvv^|No39j~4&{00DGTPE!Ct=GbNc0004E zOGiWihy@);00009a7bBm001Cy001Cy0qf8I3jhELWJyFpRCwC$TTMtK*%t1isE`l} z2@=9{6GR$mW+M`mhJk=IO*1QL5XXfZM?^$qAPbR3X5*qkL}u}jmTqaJVWgV|@exFt znS~If9T*U?HNK=5CS#sRz(>pvA&aTH=iW+H-E(6lCc5dnnz6Ocse8`(zVlZP9(+6B zPLocBocor4Zz>ScrQCgK0I!}L@7pTehYBG6Vksa=o{^){ovT0u=HB%vl74)%U+>lx z!1#NEZiyZ_xSG8?6^Nj6azd3%M+qO?l?Fsib|olTPVjZ^ZbFiX$Zv)ei+FV=L7XDf zY8Alv2SXl_8q`*nVs?{zS42h)`84uz1mY6~OpMBVLPm_u%J}4G{<`^t@}b zE5Hk-X4|dC@7W-7POXSQG#hT}C4lkQI<3YJEM4k9ztri#cGPiVm?o(}9#h?T8wT|F+v|eE&iU=%9lW?;ZU@FfrOk4Wmw~$yyNR&;{UtSer=9K+EfAv@sTXmJT!i4s{n7d)u+^YlH&HN z?cG`uw&h}kSd2Y&OE(_>FE_XXCEa1q&CiltQ7uUQln~?Ns^sLz58u{s1xC6)N6(Vx zHK$*rgxDcV4o~2NPytH1T~41BhUBkuz1gcc6<@;*XvP(GIenJYo`w|pxyR{WxWd~7 zL6$)AWyC8vKkgm9KAKjY{=VD=wM%A?QvfI38_!@Ov*O(Nz88e2bL-u+b_Hmq+b!28 zUy}U+$Hv!6c;HqoT5SqI#^orC8$L=1ni!D2S`+}>jgP4<6fXx?F3sF(Qh<`~qQ8aW zB{f2lqU-mpcBv`=VZ6~Jw@|$MIpOKjL6uvX0<_W{Y^Cw#-j9T*lQSk8Qw7kBtI6U6 zivZOt7H%~wu-VG%S0_$^TW#G8P@uvbFwiRF50aVy;Z{R|1c}Y~n70+T-@QSqqSPGE zt#ed?;uEMMik}8t&6e+9$cVVrJE|K-zkp0Y^3R`(Qt8_qqjurB)itm~?p;?P*`{YG zkgTQ_nukShntdMg+{(m(0w*fexqBqBI38_|E3l*s<5nLSro?VQWtBeZDiwG7mD|g2 zpZgmMT0TYg{0M}&w`MW{>nah=t^~Pw9mafnX~7ku0y>4W2p>vB23-JCiN%{)u(Qf0$SgbJxG^BOwK!{)M#ik`6D1LgvV@5biiTWm2kiUDU z%P6NKJbw6J0=m8Klm+=fVHK!wjju?&!-r0a{=gEDE`p%y7-=UJqP(9e3ls_CzE0q# zzY!{=U8UT**cE8E7?a_C8Y_wjv2*o`DiBp_Q6Xc_jDV=MKPAS;{IfO_Zi;>$x!>mEEPRr7(D%vpdyd1n!4HZ+s^|C=sRQ?h{Mc@*c$ z*HnQ;BV5)=xTKNZ44T_%dJz}hMP!5_<3bvS8qyQI%Kn=<;cMfW?q@RofJ|bgBs9|k#0r|qB*44bQ!Pcr8r>ZgiQGk zMjCw2gXV1cXh@;Njzy;jumdH;M9S%kiQim!RmNpR5eU+tz$mfY;H0Lz}-B2Lv;$ec^a9*6& zXfhBRO(oHpIw?e`%bLQ%P#}u{C3_Xb$JCi*=^By2(n)lJhSfb)HpK9crO$EfqJJ9T zjSt;o!2}H*qL}Fz!#~e3xY}nYjk5p*54TWJouKixuc1H*dec0Wx6z*B#!V9dhAumg z6%i&TdAu^ha2yh*!y$LQRA?R19wB9A5QcRZ*kE~;0@0z$dMKFp(ib=uwvU;T=~=!C zZJRkOoA-?PXwnsUFKY<1gmsZ9tpODZ+81BWhduN7RQr#0*>p9uc;@HfDnPlGdeX~opG}K1 zE-@zB*Gg=qq0$ef!09+fX2ft;tGc_@mNEgcaOqP0zb=CeBemx|!Y8Wlp$ z7V-7N&?kxNc>!U86$!{{k!Gln4Y?`)Ax=-T+0yY95F#ccDbB8A*m9Njg9X75h7Pyc*MJSZ%zVWj{R zD!(HscWCPxo6`9gps-jAVueMi=AabQnK={E=5+`CqT;0(<27dYS*bOsJ{h2wj6vrxUQEU}aaWLJ+oP2OC;n6W3fczc4pCJ>q7jLTRhg=;9hivi(q~FzslS zSVkQeh`YhuK!v8_oFoCh>ZJyd0f{ z3R@7jUSXvRtXTJHmu~H(UI=?xP1)kD2=gjJt@teTxamv8+!8Y+7e|}OEHu2+?2laV zHE!L~s<4c0@+v#5Rqcdt9AD8ywgub#721cdDcFSt`V*H}388$_J*6?)&=2^`6Bu4IX?u-%fMRKkhgw!oC+g5dZ)H M07*qoM6N<$f;;1MtpET3 literal 0 HcmV?d00001 diff --git a/public/icon-32.png b/public/icon-32.png new file mode 100644 index 0000000000000000000000000000000000000000..7307ed8f1e88f1e5ade85925d8aedee40d771f64 GIT binary patch literal 1338 zcmV-A1;zS_P)XIh5Hpmef0y+Cr4mH<;B#mDD+z)v)g|gm$wcm!b;D5H^hqU2< zx8jJk;(@s1iMHc{x#Wts5yXJ|z;E=iCjJoEAyyuF%;gP!Gi@oBKyyJ|% z}>6yamoWkRl#N?92>Yc;n zm&WCu#O9dC=by#rnaJp%#^{>K>7mEzo672=$m*oY>!-=(rONE6%I2xd=cdfa5S}v(4+R(CoC%?5@%6wa@LZ((b9$?zYhGxzOmb)9<>`>9o`7vefXq((1L; z>a*4IywmKq)$6^~?YGwLxYzE!*6z93@4wgYy4mo-*zmjC@xs~hyxj7`+VaNQ?7iLd z#M|@0-R;NQ?Y`dh!QSr3-0#BQ@X6iq!{G7C-to)d^334##^UqGGs*`_uA|D;Oh9@?DOI4`P=RJ;_Um}?)u*D z^ycmM;P3V4?)TyF_vr8V;_>+D@cHBN`Reid|N8&`{Qvv^|Nn}pJ+%M;00DGTPE!Ct=GbNc0004E zOGiWihy@);00009a7bBm001Cy001Cy0qf8I3jhEC%t=H+R5;6(Q$1+HKorhLuqYBr z5$PC;l&(_Tg;F|)Zmz+>-9buMhk`fg zTA^=AF5j1T-}~Nsn)d&QR|ny_dwezIf5N<%2TDPY?Wgdd1Qhz?Tcn-&9{i)OBjd6f zfD141c9SgHS?G7nJpqgst0!QMwkiCzKtEb+5;jp~ol}UYPWblaDvHb>6gLNBpcwJP z5x%2mf4^Q0{g~io$HcxcA6W&F#+RkNaIz6O28gg~L@nbtZU^24px-aFIm`~L?!bk? z*n+UMA8VYvEe?EZ3(mHsS@|B-MjD2qsB-vxwube3S(phTL4cB}M%gDb-%dC+!%MQJn#?AF@z>ObBYR(IVSw*3gG;H)pDiXH6^2U}unO#s{~~X8iEp^8fGp7Zf4e`k7Q;P5=M^07*qoM6N<$f&%O@y#N3J literal 0 HcmV?d00001 diff --git a/public/icon-64.png b/public/icon-64.png new file mode 100644 index 0000000000000000000000000000000000000000..854d80faacb4ddcbbef17a357a5e7c3ca6ed69b9 GIT binary patch literal 1951 zcmV;Q2VnS#P)XIh5Hpmef0y+Cr4mH<;B#mDD+z)v)g|gm$wcm!b;D5H^hqU2< zx8jJk;(@s1iMHc{x#Wts5yXJ|z;E=iCjJoEAyyuF%;gP!Gi@oBKyyJ|% z}>6yamoWkRl#N?92>Yc;n zm&WCu#O9dC=by#rnaJp%#^{>K>7mEzo672=$m*oY>!-=(rONE6%I2xd=cdfa5S}v(4+R(CoC%?5@%6wa@LZ((b9$?zYhGxzOmb)9<>`>9o`7vefXq((1L; z>a*4IywmKq)$6^~?YGwLxYzE!*6z93@4wgYy4mo-*zmjC@xs~hyxj7`+VaNQ?7iLd z#M|@0-R;NQ?Y`dh!QSr3-0#BQ@X6iq!{G7C-to)d^334##^UqGGs*`_uA|D;Oh9@?DOI4`P=RJ;_Um}?)u*D z^ycmM;P3V4?)TyF_vr8V;_>+D@cHBN`Reid|N8&`{Qvv^|Nn}pJ+%M;00DGTPE!Ct=GbNc0004E zOGiWihy@);00009a7bBm001Cy001Cy0qf8I3jhEFF-b&0R9M5+S3O80Q4sbK5)wiP zB0;cOB#3P|f>#FQunxi%7B)dd(prd!T?#?OGMFOBRU!mL5V^)e5YaA3j8|;6k+6u> z=4Xp3Zr;9qfBDfQ!oj^Nt~;|c-+VLQys4@G;sC(c%-dJ*le@sL-=>Li)|t=+fVmbR zcrw*V5&?KCM5#FmZNEZ$Tn_-9Q^PYS*&4J<#-THg3yk<7!^{^hUARl*kK{#nWEYrx zF6f!ZiVg>YkQH+@C;*&8mV9u-LLkD-oFvL^7$FaUZY9Yqma7s765ejY>-J3X6Big` z^#`+e_X0QogV3^DqyXd@<&moMJ3i z{h>r%#4+xdRUb(|8yIBnUN4H5)^4h%<+!j!f_V$<-7ry4qTK3p0Z_{Zw6_AGXaNc< zPtY#UlHQZL(Mt-5aJcU+@*Qj!?$iDMAirrPqgq3jm;o=1ZHmUg~u@utebwdsRL}<^4vJ)Bz*_&C95`b z;EeVCS?niKZFB%oX@U83R0P61HdZ_2|21!-11E-r5G?;B`L#MuCSL8BH=*Dyg3nE4 z!Stv0lzO7tfm{O|+lzokwyQ=q_)MR);6+rM(x{U3Fj(-h>A-V#xH%omvf5N9YAb#f zHaO{Jb@~q-%c9!R{Wq}kAzsE>x{n{dhmN58C4nR&R*Wk=XqOWlSDZoCMzF`+8_VlF z`ER%2{^m$h3|jk5Xzs}5x?&;{F_s-)pBRnmVrXP6bYKP&+C!Yf-HIXX>7VC@K+;w| zHhW4H7oPmE1eJocz=sTqdD^tMpa%7dlBP|wqTKQKLxQHTfY{q+K zG_xu_FOnRv{I&3gV_6qCjwIuO#BVT_l9ye0)Q}X!@i53~_#tkMoJuA7L<$_``0dG2h}5<3%XUpkW+ z4_JFIsO&w6d46RuI+A;~uS(FKk^DP~sr^$z8g26A>3SrQL>dWE6Z1gFZxI(OS&-O~ zwkl|<3bq`w6@~dETVB+XIh5Hpmef0y+Cr4mH<;B#mDD+z)v)g|gm$wcm!b;D5H^hqU2< zx8jJk;(@s1iMHc{x#Wts5yXJ|z;E=iCjJoEAyyuF%;gP!Gi@oBKyyJ|% z}>6yamoWkRl#N?92>Yc;n zm&WCu#O9dC=by#rnaJp%#^{>K>7mEzo672=$m*oY>!-=(rONE6%I2xd=cdfa5S}v(4+R(CoC%?5@%6wa@LZ((b9$?zYhGxzOmb)9<>`>9o`7vefXq((1L; z>a*4IywmKq)$6^~?YGwLxYzE!*6z93@4wgYy4mo-*zmjC@xs~hyxj7`+VaNQ?7iLd z#M|@0-R;NQ?Y`dh!QSr3-0#BQ@X6iq!{G7C-to)d^334##^UqGGs*`_uA|D;Oh9@?DOI4`P=RJ;_Um}?)u*D z^ycmM;P3V4?)TyF_vr8V;_>+D@cHBN`Reid|N8&`{Qvv^|Nn}pJ+%M;00DGTPE!Ct=GbNc0004E zOGiWihy@);00009a7bBm001Cy001Cy0qf8I3jhEYhDk(0RCwC$U2jZOS(dl@=A~Z+ zuqf$J9XhT7K45g541c1D(o9C}h?xw%xN+ld9JJdo>BuOp&PH3KwsBg`!WTP>qgk>n z%Vc+n-EO0X2xzyu8UpOHjE1BFlOZq|BW3kM*Ozyb`Y;q#!a4W-JMYyisG_)ENHkS- z&$;)Ud(Q9tTeWIst*n)`vR2m0T3N}o!jYBc4uE=7Wx|H-_0TTZ1Em86#OR9dB z4srl*|FeCv3og&G@#W3}B>bnmE@82}rs?G@2avXz=6vQ-FIIYI*{T9a7r$4kMSf(- z)kAz)vH+4lyzecS{_>uBvHVm4BtLk5HFFs)R^VS|)-ihe+6#@zpA~}PW#%Bjb5pZoeCPMSSFLDAiej%>MRGCge-Idja`V zjXr7Tlhn2fknry|6aF9F3PE#~cLb*6+WK*E2&+=P6# zwHuH>bKQ_#(!LbH%&^mICj14>;N@33x0tYN)=^G!%nS+t=^PWM*L!-BTVU7RXVxaO zD!{^DU?%+gk?{Y+YUUy=wyu*WsgHyZucuqhgulE;f$UmKBH!gcImwgMOGHlZ0W;yx zgTl|BmP;h^r9HzuNgZWm_nJDr87H9dk9{PQ$Y({j!KFzm(-1v<9Ytn#ZxwjWxH-G# zouz3QP(9M0JnsR#{R35IPVf4gQ21|cBntn55%4P$_3l|mc_cYl0FQsPF3Sw@Y6vcD zTj{GL3jfa@flCvN`j*Vs_F9`# zMR$~u@ITmWX7^U~acKAE(S+Z71|lVX17}ID8eUvg0B^s)#!UG0TOi`~U<*?d501ccJjNnVFXn_hM#oSb&}0LW_u(mv;Me zuL8xUJlRS46f3{C2&w?*cvYAQe<{TAW^NSHZoj8~6aq^JXfH;D+e3-I1PJlkZ?-XZ z9Ktuszt`>CRa)SaCB_V)zv@A7Xj~~?@KPLODcVs z7_0zAdbg1_c0rc`k=3u3Aa~saAb*1BEUD2(zw;cxkzS%!Dhe4fAXKb!mIh&j@T!lw z>@DWK5_DD-K=P;Q8)KgYX79S*HQ=>^^ui#pa(km)@}r*DR}QKIjChq;*ci*(f2N_i z{k#90nEL;aqJ$AQcq{lOmVFUAOV6q$SA2_A_9+f=06D!jHa7d{NwVkN4LkN7J^xRw z_x^WeJOAo&TXtN%77LY(bx#_ zdiJ60a&k$#2kc)0RNGMH)5W-d&1-BrsSv+nBK7E zDT)xTV=m_x%k8ki0POVUIlM7;Gy2Va5b^4*NCf#VmeUZj%m5_(56hk3*r%d(#791o z6G6U9{>Vm2qBW=2mJ(1f`BRs35_9}pd#$GvAmRT!F&mRdk&KxmUiQcrd}9&=u<$2l zW3yZP1|nXSQbK+MrQlE*0FVEEh2%=qI=UeI2NERwCa0JA{M$aCu3#|fX6@isoRyhi*15MXY+AO zV+J6^tI5YwnEZE|X2M#5I3ori;lERs!sH7@XD|?FzyNssgVk~hlP}brptz+j0}$L@ z?@49hFM&m8O7~Wz$aNTi3i?gcJd&Dx-_`HAfeWX}03`gK#i=a(a_tz5TV7DPa9Rwo zRNIi;$6pD7KTY#e%>d+$pYf*h?azlHB|flI%Zd{jfW7g;R3<;?LV!@7jv0W2|LM+@ z7XBJw_ww&`Q_m3t!nX2HlJfXVAHe*(Tl(ecIt-Y+l+t!DLcDmtQKmj*0QgK}MhYK4 zM!ZZ?&$Ssa>-g@KQdRgN;uQ`ym{@T-42YB}r=<78${#A4B?JTps4`W4mvVYQC^{P^ z#4S|};LvUSb!s6XStnrf?aPxCMW$jvFASXX!OGN81ybd6JE`YAm;t;_+6e=*iGpVn z@brBJxBZwHw{#zT@rD7hGhZJG8-fNc{MF?($kHwv?AN34{mzu{UD2ok(uz>plfP;F2ZJV|P|fK}qUlLBJV*~KZ8Ark%DHIJ6= zfbru*g=w82es7?)aP;rj zFiCSw1H{rtK2APWAb$c7uW;}pEpEy3f3K*+1wy3&o^H8yd(Ej#e>vL{fr}H(A7KUMb1$4?|}C4%CJ`x4>m2SDq#7r6p|18ocQ#i z#>_J}qWx(^28?<2MD94mnZ{4Kmx=`}1GQP2tfMIY4KGdLEHG+CK!hJM z)V(#ZD?_MZkukH`a}3#WdUyT-MF>%6Iu#=HFU;8Vz5+{_{`9NGd=lM<;W&Wmb&}Z>0cfu<^&#xrD0%w&n{cdBZhS#Opr1H0@eiTNN1L zS4bPW1UKYQa3B?d7krY)aD;e;`O{sR!mW-B^^)FqwjOo7O=Kuts_eBPa?FbmuaQG< z>wLS40ije(`d=6h0jvZtZRxr zr}ql8uyk$5MzkD0fTZlLNLHwpmg-h;{SsI@o4u{1~YtGZO)k zBHY%S+T2tA8+2sx6j#g&KLjtH6U)=3D&#C(Cw64^_G1S0%PwhKubps%_cMf49krWW z;zqrNqsS%U=YN7iI1fsR*4*A_I55JS<|eqnx?(=lKx}k|F|+4?ZATSIuvcC<)chVX z(hHH2Fty~8l`Ks2e8YghjcF4U6&(GT8ghhjYULZt5oNX!&KKpq#8904$)BJ}dNGHp zGGY1rLV`&2e>dH>wG}ibce=q+g|)zTYTdk40SjW3b|Tx|wPUD!KKkeT1yESpGYFlq ze_l&%bRDn}uAXBaMj0*=1<#(+mUi?B<@2!+Tej}A@`BjLL%_@)Zzb;NeQ5WFT8XN6 zH3|~g>&Ofl@a%1ToL8s04HR}4!ptb8_N)lDiB%r$AU?jR5PYhU=q#;)DNsfXh?thJ zF|BfsntX1Tg#^6*EJQa;v68!}3sJ6E!tg-sd-(L8fc>UU&_; z76CmZ%z)5Z9q;5Z!aayjZN{fiMZY?$c9gy6p`=Vuo!*jfxycMNZLYzYtufe3V1RCH z6m_-=hsfX*0yT#B%HlbK|DCz!vthxJ-zHYv1G&~y!nE9B{`dOM&V@-EZ9Q>QV-Dx* zu`*Q9-$~3naUYqm(~w;?corkPmT zm|2gw2GTo3K;(gBXmz89w{1NYGlPS`#%d$uMl$Nh;e8^Or?V0iA?z-vf)x-dn7+P_ z=_5kp#`Z35a`&2QdKEOo)Bc|-c`w@0HL{-(v|*UCGK3S}#rTp65-T?OHr(TMgc_+R z?S?wkZ?GfXuaN7;P)JWqUz3h|iS zS?h2ek$1m^X&$4*#2lPd!3Q+0adm=39nlzpV;jhhGVEqT?i7jBJjgT1vXRQvC~AMAe@NDe15^gDjv6&Z!x&kVI*AoO>`3PmSKpH8r04!e;~V zS{S@-wzr=>G)<1tV?e~dqPnC<;KMY2oT$(?HFo?X5%J2gQV6ccHN%oG&FC<>jqj{I z8$|Xj@m4dk`-Wza5z}IGK1WH^X<@3}6p-kMS@PreIGVU`F@p?&Y+)SLHHbn`mmN}pxx@6hm6 z5x<69`=Oa8i&BqRF~TZP6N+{_XnQzlKa)N(ebhz+YaxQsL)2hz*G{R+=CIQrq$bMv z@atnUeKzY5E(9mO$GMHDvdy|_KHonf9{U3)=RVnC{SrvK>=c5#vT9)zlWfIoxEWG~ z$4j+sDXInf{v%|8T1*gN8*jp2y-!Swm8wwE?u~paHo3rng}4CQ`jlu*8+dM0RuTl5 zuK~6`^izeuqyv@DlfAMLt=efA8IqAd6{JtX4%#X?mp_5mIB+4G8YF<_^L}y}QNK8B zDE0-J$f&*o$F`19{nw&f<~W4vLpuj;rFk5NX&!+Kt8F1Opplvo9v6_P{tBv4A|&eH zOzjw!j|!Uxs48c>9c<3!y`VL4OglfoHWmq^g5kht#<0KlJVXn}>ZpiUn;ovn`-e5> z3h3nrxa1?Ke4dI%`;5_OeW#ZicFaEg3YO24@p--ZqcQGhI~R`-O3rn(GoC(5E(FKP zF#)QEF1W5cU@8`*$<=;oP;25&>Pu6N<3Ab@uoyG?1yT67bwjSTZz3kM9vjymEEC&8 zfu5r=Xd_Vu$0WlO^+Hh%9)-%sYKalTLR&Z?j?(J%(&oE#@xOYM!h4#y*R?1r{S38f z6biwKv*hrIlv%D3?exuN!-u2)e%e5-U9**!P>S*ry5?n^ZGHfiIJJQ_QkfPqHO$9P zs~Rb3sDf{`X))WfU!9r4d^bWiB805$TU)t0zadN=j`b3ATc(9@Y0txm^5kKexsSrk zvsY(`0wr9+JyfUnIc2C8Ei~voKV?S%h3vFLLnwcYs8T(j{u%P|3^iY`gEo0Q_PzT| zEq#QUQ4?iw$vKpy@2C;NpAXr=34IOI)PYiZos?AHx&1rz2>*!MzRk}y(gVnd*DahJ zV8c2uW8#>_sL$NczXI^w0+Xc+0cJY{vDVhqu)u~VrM zqF1U!69h&NQsI>jRJ%h*yll^tR31~Kf-q5r2dOnKYpw?POFF;?z3M+wtG6opY|YeN zxMsOFvUbB{7}J|iY|n}3f|Bd^@>^_9RfdX&Xq){6)ww;~;$(jLJH5kkj1|YD1&RKdrygZ+o?7h$3ue#pg)PLZQwiIrUz{)Z zWiB}zHEi8XA)g};Hs;ybh`p)u_!f*FA?D2C&}?U%i88pzK%qvZJoV!|Zt_*Cpvp!l zfX`fuqvfR7Fp?_#d!IQ&zG~1Qu_DKF92J+HuvX zk>eG9@{|w3Ac>oN)x1G+{~}aA-?hnd`8=Kr`jl#t)=hAS$roABN9|zsG3sD-)zWMR zVJ@ium*hxqBFGm9uQ}Yq%)m{GDG_34E>_U$`0dx3H-DNKvB#L%Q(_^wm>ff_!dUb9 zYo0LjRq3--M|t}r=)kuU?hf13j^=`Hqo&3B6HC7MB;Kb2m?*jArP<^*oUTL133onG z5>38J6;Q9H?=ni1F?>c)x{lUdP=cr>kbY4WoTc{mKsAnix;guJD6m2e65tUj2_@fd zCOt0M(rGz~({=a&wH9>0e=*4yTkZ9f#NE;Q&7=8SI2Uy6Ff~H>n1>gaeo+-P#oN<- zZoRc1mCsX6?)REQi%-5N2h_HKtgI6S!2zf%l+#jM&}AN<$EMc!3 zrR$i^1)cY|zntbvOuh;;t0RMz@Uchb^Bu&J3^ea&>BtvYpbXF(-)NW5lN&}p=Sf1o zN)-^Jf~aNA8@rd6ia1r`J^6Rr1k|B1`zl?0@DEDa0j{u@ZsS9*zvmm5V0 zlZ1THI(}p|Y)wZOP>6^7g_LwQ!^=9VQW$gzrGy2B&M*MSxr+s zWyn{lf~t5E1ma8cNCH)GXG2`&^TnM?IMwJESTNmSd(;NYg}!s@kT0@;T5_&R4-i^@ zr`Uq3;K^?5Idd{91W!5gMeF#M#RLId2%eJk3zy(~E1fhVBT{(ElP|J>n&21dWk0q& z$XCJVq(O8zj+Wx&i&jCKsq4oIzE;A^iGJY{m^3zE*%yP$jeIMPvRvcm%aD9g4(@Gb zI*3=dvOLKbS)kdb5l0A@9sMfIOh^zA8a6FM@{LtNY9L-tV`lU7Oj-G3C7cfQiw;7x z%^l9HUf$%ZR6(7ac}`C9G$LPQ!C>9 'Any content added here will be inserted into the bottom of the section of every page. This is handy for overriding styles or adding analytics code.', 'app_custom_html_disabled_notice' => 'Custom HTML head content is disabled on this settings page to ensure any breaking changes can be reverted.', 'app_logo' => 'Application Logo', - 'app_logo_desc' => 'This image should be 43px in height.
Large images will be scaled down.', + 'app_logo_desc' => 'This is used in the application header bar, among other areas. This image should be 86px in height. Large images will be scaled down.', 'app_primary_color' => 'Application Primary Color', 'app_primary_color_desc' => 'Sets the primary color for the application including the banner, buttons, and links.', 'app_homepage' => 'Application Homepage', diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php index 76d220952..b09a8dfe9 100644 --- a/resources/views/layouts/base.blade.php +++ b/resources/views/layouts/base.blade.php @@ -20,6 +20,12 @@ + + + + + + @yield('head') diff --git a/resources/views/settings/customization.blade.php b/resources/views/settings/customization.blade.php index 3748267df..847704007 100644 --- a/resources/views/settings/customization.blade.php +++ b/resources/views/settings/customization.blade.php @@ -53,6 +53,25 @@ +
+
+ +

+ This icon is used for browser tabs and shortcut icons. + This should be a 256px square PNG image. +

+
+
+ @include('form.image-picker', [ + 'removeValue' => 'none', + 'defaultImage' => url('/icon.png'), + 'currentImage' => setting('app-icon'), + 'name' => 'app_icon', + 'imageClass' => 'logo-image', + ]) +
+
+
From d8354255e7d28cefd538fcc05e671cbf99018637 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 25 Jan 2023 12:06:11 +0000 Subject: [PATCH 16/41] Added practicali to sponsor list --- readme.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/readme.md b/readme.md index b8bd17232..8b3af303e 100644 --- a/readme.md +++ b/readme.md @@ -45,7 +45,7 @@ Note: Listed services are not tested, vetted nor supported by the official BookS Diagrams.net - Cloudabove + Cloudabove @@ -55,6 +55,9 @@ Note: Listed services are not tested, vetted nor supported by the official BookS Stellar Hosted + + Stellar Hosted + ## 🛣️ Road Map From 3c658e39abe32a7dc345f3c49ea012340d4427e2 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 25 Jan 2023 16:11:34 +0000 Subject: [PATCH 17/41] Extracted app icon text, fixed issues Tweaked sizes and meta tags based unpon ipad testing. Fixed reduced sizes not being cleaned up. --- app/Settings/AppSettingsStore.php | 3 ++- app/Uploads/ImageRepo.php | 5 ++++- resources/lang/en/settings.php | 2 ++ resources/views/layouts/base.blade.php | 13 ++++++++----- resources/views/settings/customization.blade.php | 7 ++----- 5 files changed, 18 insertions(+), 12 deletions(-) diff --git a/app/Settings/AppSettingsStore.php b/app/Settings/AppSettingsStore.php index f2b6cdc52..8d7b73c1c 100644 --- a/app/Settings/AppSettingsStore.php +++ b/app/Settings/AppSettingsStore.php @@ -25,7 +25,7 @@ class AppSettingsStore protected function updateAppIcon(Request $request): void { - $sizes = [128, 64, 32]; + $sizes = [180, 128, 64, 32]; // Update icon image if set if ($request->hasFile('app_icon')) { @@ -35,6 +35,7 @@ class AppSettingsStore setting()->put('app-icon', $image->url); foreach ($sizes as $size) { + $this->destroyExistingSettingImage('app-icon-' . $size); $icon = $this->imageRepo->saveNew($iconFile, 'system', 0, $size, $size); setting()->put('app-icon-' . $size, $icon->url); } diff --git a/app/Uploads/ImageRepo.php b/app/Uploads/ImageRepo.php index 910248203..2c643a58b 100644 --- a/app/Uploads/ImageRepo.php +++ b/app/Uploads/ImageRepo.php @@ -123,7 +123,10 @@ class ImageRepo public function saveNew(UploadedFile $uploadFile, string $type, int $uploadedTo = 0, int $resizeWidth = null, int $resizeHeight = null, bool $keepRatio = true): Image { $image = $this->imageService->saveNewFromUpload($uploadFile, $type, $uploadedTo, $resizeWidth, $resizeHeight, $keepRatio); - $this->loadThumbs($image); + + if ($type !== 'system') { + $this->loadThumbs($image); + } return $image; } diff --git a/resources/lang/en/settings.php b/resources/lang/en/settings.php index 318dc0a52..023cf1beb 100755 --- a/resources/lang/en/settings.php +++ b/resources/lang/en/settings.php @@ -34,6 +34,8 @@ return [ 'app_custom_html_disabled_notice' => 'Custom HTML head content is disabled on this settings page to ensure any breaking changes can be reverted.', 'app_logo' => 'Application Logo', 'app_logo_desc' => 'This is used in the application header bar, among other areas. This image should be 86px in height. Large images will be scaled down.', + 'app_icon' => 'Application Icon', + 'app_icon_desc' => 'This icon is used for browser tabs and shortcut icons. This should be a 256px square PNG image.', 'app_primary_color' => 'Application Primary Color', 'app_primary_color_desc' => 'Sets the primary color for the application including the banner, buttons, and links.', 'app_homepage' => 'Application Homepage', diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php index b09a8dfe9..e0a6f46d0 100644 --- a/resources/views/layouts/base.blade.php +++ b/resources/views/layouts/base.blade.php @@ -6,10 +6,11 @@ {{ isset($pageTitle) ? $pageTitle . ' | ' : '' }}{{ setting('app-name') }} + - + @@ -21,10 +22,12 @@ - - - - + + + + + + @yield('head') diff --git a/resources/views/settings/customization.blade.php b/resources/views/settings/customization.blade.php index 847704007..aa37c30c9 100644 --- a/resources/views/settings/customization.blade.php +++ b/resources/views/settings/customization.blade.php @@ -55,11 +55,8 @@
- -

- This icon is used for browser tabs and shortcut icons. - This should be a 256px square PNG image. -

+ +

{{ trans('settings.app_icon_desc') }}

@include('form.image-picker', [ From a50b0ea1e5b3e3c02aacef653a8e3dd5d09eabd8 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 25 Jan 2023 16:41:41 +0000 Subject: [PATCH 18/41] Covered app icon setting with testing --- tests/Settings/SettingsTest.php | 46 +++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/Settings/SettingsTest.php b/tests/Settings/SettingsTest.php index e2ac6f27c..1161a466e 100644 --- a/tests/Settings/SettingsTest.php +++ b/tests/Settings/SettingsTest.php @@ -2,10 +2,14 @@ namespace Tests\Settings; +use Illuminate\Support\Facades\Storage; use Tests\TestCase; +use Tests\Uploads\UsesImages; class SettingsTest extends TestCase { + use UsesImages; + public function test_settings_endpoint_redirects_to_settings_view() { $resp = $this->asAdmin()->get('/settings'); @@ -40,4 +44,46 @@ class SettingsTest extends TestCase $resp->assertStatus(404); $resp->assertSee('Page Not Found'); } + + public function test_updating_and_removing_app_icon() + { + $this->asAdmin(); + $galleryFile = $this->getTestImage('my-app-icon.png'); + $expectedPath = public_path('uploads/images/system/' . date('Y-m') . '/my-app-icon.png'); + + $this->assertFalse(setting()->get('app-icon')); + $this->assertFalse(setting()->get('app-icon-180')); + $this->assertFalse(setting()->get('app-icon-128')); + $this->assertFalse(setting()->get('app-icon-64')); + $this->assertFalse(setting()->get('app-icon-32')); + + $prevFileCount = count(glob(dirname($expectedPath) . DIRECTORY_SEPARATOR . '*.png')); + + $upload = $this->call('POST', '/settings/customization', [], [], ['app_icon' => $galleryFile], []); + $upload->assertRedirect('/settings/customization'); + + $this->assertTrue(file_exists($expectedPath), 'Uploaded image not found at path: ' . $expectedPath); + $this->assertStringContainsString('my-app-icon', setting()->get('app-icon')); + $this->assertStringContainsString('my-app-icon', setting()->get('app-icon-180')); + $this->assertStringContainsString('my-app-icon', setting()->get('app-icon-128')); + $this->assertStringContainsString('my-app-icon', setting()->get('app-icon-64')); + $this->assertStringContainsString('my-app-icon', setting()->get('app-icon-32')); + + $newFileCount = count(glob(dirname($expectedPath) . DIRECTORY_SEPARATOR . '*.png')); + $this->assertEquals(5, $newFileCount - $prevFileCount); + + $resp = $this->get('/'); + $this->withHtml($resp)->assertElementCount('link[sizes][href*="my-app-icon"]', 6); + + $reset = $this->post('/settings/customization', ['app_icon_reset' => 'true']); + $reset->assertRedirect('/settings/customization'); + + $resetFileCount = count(glob(dirname($expectedPath) . DIRECTORY_SEPARATOR . '*.png')); + $this->assertEquals($prevFileCount, $resetFileCount); + $this->assertFalse(setting()->get('app-icon')); + $this->assertFalse(setting()->get('app-icon-180')); + $this->assertFalse(setting()->get('app-icon-128')); + $this->assertFalse(setting()->get('app-icon-64')); + $this->assertFalse(setting()->get('app-icon-32')); + } } From deda33174548c105e3470ecb361ea39e4f18f8f5 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 25 Jan 2023 21:46:26 +0000 Subject: [PATCH 19/41] Fixed global search preview click on safari Safari needs an element to be focusable to be able to use :focus-within. For #3926 --- resources/views/common/header.blade.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/views/common/header.blade.php b/resources/views/common/header.blade.php index 71b73215b..a8b711595 100644 --- a/resources/views/common/header.blade.php +++ b/resources/views/common/header.blade.php @@ -19,7 +19,7 @@
@if (hasAppAccess()) -
\ No newline at end of file + \ No newline at end of file diff --git a/resources/views/books/sort.blade.php b/resources/views/books/sort.blade.php index 077da101d..b778398a8 100644 --- a/resources/views/books/sort.blade.php +++ b/resources/views/books/sort.blade.php @@ -16,8 +16,10 @@
-
-

{{ trans('entities.books_sort') }}

+
+

{{ trans('entities.books_sort') }}

+

{{ trans('entities.books_sort_desc') }}

+
@include('books.parts.sort-box', ['book' => $book, 'bookChildren' => $bookChildren])
@@ -35,8 +37,9 @@
-
-

{{ trans('entities.books_sort_show_other') }}

+
+

{{ trans('entities.books_sort_show_other') }}

+

{{ trans('entities.books_sort_show_other_desc') }}

@include('entities.selector', ['name' => 'books_list', 'selectorSize' => 'compact', 'entityTypes' => 'book', 'entityPermission' => 'update', 'showAdd' => true]) From 7cacbaadf0dc2d9b94e163597bd2b3d2cc05be53 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 27 Jan 2023 13:08:35 +0000 Subject: [PATCH 27/41] Added functionality/logic for button-based sorting --- resources/js/components/book-sort.js | 167 ++++++++++++++++++ .../books/parts/sort-box-actions.blade.php | 12 ++ .../views/books/parts/sort-box.blade.php | 9 +- 3 files changed, 186 insertions(+), 2 deletions(-) create mode 100644 resources/views/books/parts/sort-box-actions.blade.php diff --git a/resources/js/components/book-sort.js b/resources/js/components/book-sort.js index 2722eb586..3c849c5c6 100644 --- a/resources/js/components/book-sort.js +++ b/resources/js/components/book-sort.js @@ -37,6 +37,113 @@ const sortOperations = { }, }; +/** + * The available move actions. + * The active function indicates if the action is possible for the given item. + * The run function performs the move. + * @type {{up: {active(Element, ?Element, Element): boolean, run(Element, ?Element, Element)}}} + */ +const moveActions = { + up: { + active(elem, parent, book) { + return !(elem.previousElementSibling === null && !parent); + }, + run(elem, parent, book) { + const newSibling = elem.previousElementSibling || parent; + newSibling.insertAdjacentElement('beforebegin', elem); + } + }, + down: { + active(elem, parent, book) { + return !(elem.nextElementSibling === null && !parent); + }, + run(elem, parent, book) { + const newSibling = elem.nextElementSibling || parent; + newSibling.insertAdjacentElement('afterend', elem); + } + }, + next_book: { + active(elem, parent, book) { + return book.nextElementSibling !== null; + }, + run(elem, parent, book) { + const newList = book.nextElementSibling.querySelector('ul'); + newList.prepend(elem); + } + }, + prev_book: { + active(elem, parent, book) { + return book.previousElementSibling !== null; + }, + run(elem, parent, book) { + const newList = book.previousElementSibling.querySelector('ul'); + newList.appendChild(elem); + } + }, + next_chapter: { + active(elem, parent, book) { + return elem.dataset.type === 'page' && this.getNextChapter(elem, parent); + }, + run(elem, parent, book) { + const nextChapter = this.getNextChapter(elem, parent); + nextChapter.querySelector('ul').prepend(elem); + }, + getNextChapter(elem, parent) { + const topLevel = (parent || elem); + const topItems = Array.from(topLevel.parentElement.children); + const index = topItems.indexOf(topLevel); + return topItems.slice(index + 1).find(elem => elem.dataset.type === 'chapter'); + } + }, + prev_chapter: { + active(elem, parent, book) { + return elem.dataset.type === 'page' && this.getPrevChapter(elem, parent); + }, + run(elem, parent, book) { + const prevChapter = this.getPrevChapter(elem, parent); + prevChapter.querySelector('ul').append(elem); + }, + getPrevChapter(elem, parent) { + const topLevel = (parent || elem); + const topItems = Array.from(topLevel.parentElement.children); + const index = topItems.indexOf(topLevel); + return topItems.slice(0, index).reverse().find(elem => elem.dataset.type === 'chapter'); + } + }, + book_end: { + active(elem, parent, book) { + return parent || (parent === null && elem.nextElementSibling); + }, + run(elem, parent, book) { + book.querySelector('ul').append(elem); + } + }, + book_start: { + active(elem, parent, book) { + return parent || (parent === null && elem.previousElementSibling); + }, + run(elem, parent, book) { + book.querySelector('ul').prepend(elem); + } + }, + before_chapter: { + active(elem, parent, book) { + return parent; + }, + run(elem, parent, book) { + parent.insertAdjacentElement('beforebegin', elem); + } + }, + after_chapter: { + active(elem, parent, book) { + return parent; + }, + run(elem, parent, book) { + parent.insertAdjacentElement('afterend', elem); + } + }, +}; + export class BookSort extends Component { setup() { @@ -49,10 +156,35 @@ export class BookSort extends Component { const initialSortBox = this.container.querySelector('.sort-box'); this.setupBookSortable(initialSortBox); this.setupSortPresets(); + this.setupMoveActions(); window.$events.listen('entity-select-confirm', this.bookSelect.bind(this)); } + /** + * Setup the handlers for the item-level move buttons. + */ + setupMoveActions() { + // Handle move button click + this.container.addEventListener('click', event => { + if (event.target.matches('[data-move]')) { + const action = event.target.getAttribute('data-move'); + const sortItem = event.target.closest('[data-id]'); + this.runSortAction(sortItem, action); + } + }); + // TODO - Probably can remove this + // // Handle action updating on likely use + // this.container.addEventListener('focusin', event => { + // const sortItem = event.target.closest('[data-type="chapter"],[data-type="page"]'); + // if (sortItem) { + // this.updateMoveActionState(sortItem); + // } + // }); + + this.updateMoveActionStateForAll(); + } + /** * Setup the handlers for the preset sort type buttons. */ @@ -102,6 +234,7 @@ export class BookSort extends Component { const newBookContainer = htmlToDom(resp.data); this.sortContainer.append(newBookContainer); this.setupBookSortable(newBookContainer); + this.updateMoveActionStateForAll(); }); } @@ -204,4 +337,38 @@ export class BookSort extends Component { } } + /** + * Run the given sort action up the provided sort item. + * @param {Element} item + * @param {String} action + */ + runSortAction(item, action) { + const parentItem = item.parentElement.closest('li[data-id]'); + const parentBook = item.parentElement.closest('[data-type="book"]'); + moveActions[action].run(item, parentItem, parentBook); + this.updateMapInput(); + this.updateMoveActionStateForAll(); + item.scrollIntoView({behavior: 'smooth', block: 'nearest'}); + item.focus(); + } + + /** + * Update the state of the available move actions on this item. + * @param {Element} item + */ + updateMoveActionState(item) { + const parentItem = item.parentElement.closest('li[data-id]'); + const parentBook = item.parentElement.closest('[data-type="book"]'); + for (const [action, functions] of Object.entries(moveActions)) { + const moveButton = item.querySelector(`[data-move="${action}"]`); + moveButton.disabled = !functions.active(item, parentItem, parentBook); + } + } + + updateMoveActionStateForAll() { + const items = this.container.querySelectorAll('[data-type="chapter"],[data-type="page"]'); + for (const item of items) { + this.updateMoveActionState(item); + } + } } \ No newline at end of file diff --git a/resources/views/books/parts/sort-box-actions.blade.php b/resources/views/books/parts/sort-box-actions.blade.php new file mode 100644 index 000000000..0c91f42da --- /dev/null +++ b/resources/views/books/parts/sort-box-actions.blade.php @@ -0,0 +1,12 @@ +
+ + + + + + + + + + +
\ No newline at end of file diff --git a/resources/views/books/parts/sort-box.blade.php b/resources/views/books/parts/sort-box.blade.php index 819f1e063..77a03f831 100644 --- a/resources/views/books/parts/sort-box.blade.php +++ b/resources/views/books/parts/sort-box.blade.php @@ -23,7 +23,8 @@
  • + data-updated="{{ $bookChild->updated_at->timestamp }}" tabindex="0"> +
    @icon('grip')
    @icon($bookChild->getType())
    @@ -33,17 +34,21 @@
  • + @include('books.parts.sort-box-actions') @if($bookChild->isA('chapter'))
      @foreach($bookChild->visible_pages as $page)
    • + data-updated="{{ $page->updated_at->timestamp }}" + tabindex="0"> +
      @icon('grip')
      @icon('page') {{ $page->name }}
      + @include('books.parts.sort-box-actions')
    • @endforeach
    From 40e112fc5b4896c52d5aa09ea2ad2a9da6eccfa1 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 27 Jan 2023 13:26:58 +0000 Subject: [PATCH 28/41] Extracted text & added dropdown for book sort move actions Primarily styling and testing left to do. --- resources/lang/en/entities.php | 11 ++++++- resources/sass/_lists.scss | 4 +-- .../books/parts/sort-box-actions.blade.php | 31 +++++++++++++------ .../views/books/parts/sort-box.blade.php | 2 +- 4 files changed, 34 insertions(+), 14 deletions(-) diff --git a/resources/lang/en/entities.php b/resources/lang/en/entities.php index 5b019e848..834cfacba 100644 --- a/resources/lang/en/entities.php +++ b/resources/lang/en/entities.php @@ -150,7 +150,16 @@ return [ 'books_sort_chapters_last' => 'Chapters Last', 'books_sort_show_other' => 'Show Other Books', 'books_sort_show_other_desc' => 'Add other books here to include them in the sort operation, and allow easy cross-book reorganisation.', - 'books_sort_save' => 'Save New Order', + 'books_sort_move_up' => 'Move Up', + 'books_sort_move_down' => 'Move Down', + 'books_sort_move_prev_book' => 'Move To Previous Book', + 'books_sort_move_next_book' => 'Move To Next Book', + 'books_sort_move_prev_chapter' => 'Move Into Previous Chapter', + 'books_sort_move_next_chapter' => 'Move Into Next Chapter', + 'books_sort_move_book_start' => 'Move To Start of Book', + 'books_sort_move_book_end' => 'Move To End of Book', + 'books_sort_move_before_chapter' => 'Move To Before Chapter', + 'books_sort_move_after_chapter' => 'Move To After Chapter', 'books_copy' => 'Copy Book', 'books_copy_success' => 'Book successfully copied', diff --git a/resources/sass/_lists.scss b/resources/sass/_lists.scss index 1ae801267..39b2afee6 100644 --- a/resources/sass/_lists.scss +++ b/resources/sass/_lists.scss @@ -232,7 +232,7 @@ } // Sortable Lists -.sortable-page-list, .sortable-page-list ul { +.sortable-page-list, .sortable-page-sublist { list-style: none; } .sort-box { @@ -278,7 +278,7 @@ > ul { margin-inline-start: 0; } - ul { + .sortable-page-sublist { margin-bottom: $-m; margin-top: 0; padding-inline-start: $-m; diff --git a/resources/views/books/parts/sort-box-actions.blade.php b/resources/views/books/parts/sort-box-actions.blade.php index 0c91f42da..c9a1d323e 100644 --- a/resources/views/books/parts/sort-box-actions.blade.php +++ b/resources/views/books/parts/sort-box-actions.blade.php @@ -1,12 +1,23 @@
    - - - - - - - - - - + + +
    \ No newline at end of file diff --git a/resources/views/books/parts/sort-box.blade.php b/resources/views/books/parts/sort-box.blade.php index 77a03f831..33448f483 100644 --- a/resources/views/books/parts/sort-box.blade.php +++ b/resources/views/books/parts/sort-box.blade.php @@ -36,7 +36,7 @@
    @include('books.parts.sort-box-actions') @if($bookChild->isA('chapter')) -
      +
        @foreach($bookChild->visible_pages as $page)
      • Date: Fri, 27 Jan 2023 16:25:06 +0000 Subject: [PATCH 29/41] Finished off design and fixing of sort buttons --- resources/js/components/book-sort.js | 18 ++++-------- resources/js/services/keyboard-navigation.js | 2 +- resources/lang/en/entities.php | 13 +++++---- resources/sass/_lists.scss | 19 +++++++++++- .../books/parts/sort-box-actions.blade.php | 8 +++-- .../views/books/parts/sort-box.blade.php | 29 +++++++++++-------- 6 files changed, 54 insertions(+), 35 deletions(-) diff --git a/resources/js/components/book-sort.js b/resources/js/components/book-sort.js index 3c849c5c6..e8ecd49a4 100644 --- a/resources/js/components/book-sort.js +++ b/resources/js/components/book-sort.js @@ -162,7 +162,7 @@ export class BookSort extends Component { } /** - * Setup the handlers for the item-level move buttons. + * Set up the handlers for the item-level move buttons. */ setupMoveActions() { // Handle move button click @@ -173,20 +173,12 @@ export class BookSort extends Component { this.runSortAction(sortItem, action); } }); - // TODO - Probably can remove this - // // Handle action updating on likely use - // this.container.addEventListener('focusin', event => { - // const sortItem = event.target.closest('[data-type="chapter"],[data-type="page"]'); - // if (sortItem) { - // this.updateMoveActionState(sortItem); - // } - // }); this.updateMoveActionStateForAll(); } /** - * Setup the handlers for the preset sort type buttons. + * Set up the handlers for the preset sort type buttons. */ setupSortPresets() { let lastSort = ''; @@ -235,6 +227,9 @@ export class BookSort extends Component { this.sortContainer.append(newBookContainer); this.setupBookSortable(newBookContainer); this.updateMoveActionStateForAll(); + + const summary = newBookContainer.querySelector('summary'); + summary.focus(); }); } @@ -243,8 +238,7 @@ export class BookSort extends Component { * @param {Element} bookContainer */ setupBookSortable(bookContainer) { - const sortElems = [bookContainer.querySelector('.sort-list')]; - sortElems.push(...bookContainer.querySelectorAll('.entity-list-item + ul')); + const sortElems = Array.from(bookContainer.querySelectorAll('.sort-list, .sortable-page-sublist')); const bookGroupConfig = { name: 'book', diff --git a/resources/js/services/keyboard-navigation.js b/resources/js/services/keyboard-navigation.js index 0e1dcf1a7..0f866ceaa 100644 --- a/resources/js/services/keyboard-navigation.js +++ b/resources/js/services/keyboard-navigation.js @@ -86,7 +86,7 @@ export class KeyboardNavigationHandler { */ #getFocusable() { const focusable = []; - const selector = '[tabindex]:not([tabindex="-1"]),[href],button:not([tabindex="-1"]),input:not([type=hidden])'; + const selector = '[tabindex]:not([tabindex="-1"]),[href],button:not([tabindex="-1"],[disabled]),input:not([type=hidden])'; for (const container of this.containers) { focusable.push(...container.querySelectorAll(selector)) } diff --git a/resources/lang/en/entities.php b/resources/lang/en/entities.php index 834cfacba..8bf805774 100644 --- a/resources/lang/en/entities.php +++ b/resources/lang/en/entities.php @@ -149,17 +149,18 @@ return [ 'books_sort_chapters_first' => 'Chapters First', 'books_sort_chapters_last' => 'Chapters Last', 'books_sort_show_other' => 'Show Other Books', + 'books_sort_save' => 'Save New Order', 'books_sort_show_other_desc' => 'Add other books here to include them in the sort operation, and allow easy cross-book reorganisation.', 'books_sort_move_up' => 'Move Up', 'books_sort_move_down' => 'Move Down', - 'books_sort_move_prev_book' => 'Move To Previous Book', - 'books_sort_move_next_book' => 'Move To Next Book', + 'books_sort_move_prev_book' => 'Move to Previous Book', + 'books_sort_move_next_book' => 'Move to Next Book', 'books_sort_move_prev_chapter' => 'Move Into Previous Chapter', 'books_sort_move_next_chapter' => 'Move Into Next Chapter', - 'books_sort_move_book_start' => 'Move To Start of Book', - 'books_sort_move_book_end' => 'Move To End of Book', - 'books_sort_move_before_chapter' => 'Move To Before Chapter', - 'books_sort_move_after_chapter' => 'Move To After Chapter', + 'books_sort_move_book_start' => 'Move to Start of Book', + 'books_sort_move_book_end' => 'Move to End of Book', + 'books_sort_move_before_chapter' => 'Move to Before Chapter', + 'books_sort_move_after_chapter' => 'Move to After Chapter', 'books_copy' => 'Copy Book', 'books_copy_success' => 'Book successfully copied', diff --git a/resources/sass/_lists.scss b/resources/sass/_lists.scss index 39b2afee6..33e500d6a 100644 --- a/resources/sass/_lists.scss +++ b/resources/sass/_lists.scss @@ -267,7 +267,7 @@ .entity-list-item > span:first-child { align-self: flex-start; } - .sortable-selected .entity-list-item, .sortable-selected .entity-list-item:hover { + .sortable-selected, .sortable-selected:hover { outline: 1px dotted var(--color-primary); background-color: var(--color-primary-light) !important; } @@ -284,6 +284,7 @@ padding-inline-start: $-m; } li { + @include lightDark(background-color, #FFF, #222); border: 1px solid; @include lightDark(border-color, #DDD, #666); margin-top: -1px; @@ -316,6 +317,22 @@ details.sort-box summary .caret-container svg { details.sort-box[open] summary .caret-container svg { transform: rotate(90deg); } +.sort-box-actions .icon-button { + opacity: .6; +} +.sort-box .flex-container-row:hover .sort-box-actions .icon-button, +.sort-box .flex-container-row:focus-within .sort-box-actions .icon-button { + opacity: 1; +} +.sort-box-actions .icon-button[disabled] { + visibility: hidden; +} +.sort-box-actions .dropdown-menu button[disabled] { + display: none; +} +.sort-list-handle { + cursor: grab; +} .activity-list-item { padding: $-s 0; diff --git a/resources/views/books/parts/sort-box-actions.blade.php b/resources/views/books/parts/sort-box-actions.blade.php index c9a1d323e..cd5a59e15 100644 --- a/resources/views/books/parts/sort-box-actions.blade.php +++ b/resources/views/books/parts/sort-box-actions.blade.php @@ -1,10 +1,12 @@ -
        - - +
        \ No newline at end of file From 87e371ffde35043b8d889d4012fee0fbdf5a0e36 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 27 Jan 2023 17:39:51 +0000 Subject: [PATCH 31/41] Added prevention of nested chapters on sort --- resources/js/components/book-sort.js | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/resources/js/components/book-sort.js b/resources/js/components/book-sort.js index 6e56e43a5..5ae283fd0 100644 --- a/resources/js/components/book-sort.js +++ b/resources/js/components/book-sort.js @@ -260,7 +260,8 @@ export class BookSort extends Component { animation: 150, fallbackOnBody: true, swapThreshold: 0.65, - onSort: () => { + onSort: (event) => { + this.ensureNoNestedChapters() this.updateMapInput(); this.updateMoveActionStateForAll(); }, @@ -273,6 +274,20 @@ export class BookSort extends Component { } } + /** + * Handle nested chapters by moving them to the parent book. + * Needed since sorting with multi-sort only checks group rules based on the active item, + * not all in group, therefore need to manually check after a sort. + * Must be done before updating the map input. + */ + ensureNoNestedChapters() { + const nestedChapters = this.container.querySelectorAll('[data-type="chapter"] [data-type="chapter"]'); + for (const chapter of nestedChapters) { + const parentChapter = chapter.parentElement.closest('[data-type="chapter"]'); + parentChapter.insertAdjacentElement('afterend', chapter); + } + } + /** * Update the input with our sort data. */ From 1f69965c1e61a6ef3d46eb46ef4290889ba34381 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 28 Jan 2023 11:50:46 +0000 Subject: [PATCH 32/41] Updated settings view to have dark-mode color options Also added link color option, not yet used. Cleaned up tabbed interface control design as part of this. --- app/Config/setting-defaults.php | 1 + resources/lang/en/settings.php | 10 ++-- resources/sass/_blocks.scss | 7 +++ resources/sass/_components.scss | 31 +++++----- .../views/pages/parts/image-manager.blade.php | 4 +- .../views/settings/customization.blade.php | 56 ++++++++----------- ...ade.php => setting-color-picker.blade.php} | 19 ++++--- .../parts/setting-color-scheme.blade.php | 25 +++++++++ 8 files changed, 92 insertions(+), 61 deletions(-) rename resources/views/settings/parts/{setting-entity-color-picker.blade.php => setting-color-picker.blade.php} (57%) create mode 100644 resources/views/settings/parts/setting-color-scheme.blade.php diff --git a/app/Config/setting-defaults.php b/app/Config/setting-defaults.php index 5e1e4348a..37270cf31 100644 --- a/app/Config/setting-defaults.php +++ b/app/Config/setting-defaults.php @@ -16,6 +16,7 @@ return [ 'app-editor' => 'wysiwyg', 'app-color' => '#206ea7', 'app-color-light' => 'rgba(32,110,167,0.15)', + 'link-color' => '#206ea7', 'bookshelf-color' => '#a94747', 'book-color' => '#077b70', 'chapter-color' => '#af4d0d', diff --git a/resources/lang/en/settings.php b/resources/lang/en/settings.php index 023cf1beb..f9abadc0c 100755 --- a/resources/lang/en/settings.php +++ b/resources/lang/en/settings.php @@ -36,8 +36,6 @@ return [ 'app_logo_desc' => 'This is used in the application header bar, among other areas. This image should be 86px in height. Large images will be scaled down.', 'app_icon' => 'Application Icon', 'app_icon_desc' => 'This icon is used for browser tabs and shortcut icons. This should be a 256px square PNG image.', - 'app_primary_color' => 'Application Primary Color', - 'app_primary_color_desc' => 'Sets the primary color for the application including the banner, buttons, and links.', 'app_homepage' => 'Application Homepage', 'app_homepage_desc' => 'Select a view to show on the homepage instead of the default view. Page permissions are ignored for selected pages.', 'app_homepage_select' => 'Select a page', @@ -51,8 +49,12 @@ return [ 'app_disable_comments_desc' => 'Disables comments across all pages in the application.
        Existing comments are not shown.', // Color settings - 'content_colors' => 'Content Colors', - 'content_colors_desc' => 'Sets colors for all elements in the page organisation hierarchy. Choosing colors with a similar brightness to the default colors is recommended for readability.', + 'color_scheme' => 'Application Color Scheme', + 'color_scheme_desc' => 'Set the colors to use in the BookStack interface. Colors can be configured separately for dark and light mode to best fit the theme and ensure legibility.', + 'ui_colors_desc' => 'Set the primary and link colors used in BookStack. The primary color is mainly used for the header banner, buttons and UI decorations, in addition to a few other components.', + 'app_color' => 'Primary Color', + 'link_color' => 'Link Color', + 'content_colors_desc' => 'Set colors for all elements in the page organisation hierarchy. Choosing colors with a similar brightness to the default colors is recommended for readability.', 'bookshelf_color' => 'Shelf Color', 'book_color' => 'Book Color', 'chapter_color' => 'Chapter Color', diff --git a/resources/sass/_blocks.scss b/resources/sass/_blocks.scss index 2794dd954..1d9bfc272 100644 --- a/resources/sass/_blocks.scss +++ b/resources/sass/_blocks.scss @@ -237,6 +237,13 @@ } } +.sub-card { + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1); + border: 1.5px solid; + @include lightDark(border-color, #E2E2E2, #444); + border-radius: 4px; +} + .outline-hover { border: 1px solid transparent !important; &:hover { diff --git a/resources/sass/_components.scss b/resources/sass/_components.scss index ab1d506c7..b902220a7 100644 --- a/resources/sass/_components.scss +++ b/resources/sass/_components.scss @@ -608,36 +608,39 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { .tab-container .nav-tabs { + display: flex; + align-items: end; + justify-items: start; text-align: start; border-bottom: 1px solid #DDD; @include lightDark(border-color, #ddd, #444); margin-bottom: $-m; - .tab-item { - padding: $-s; - @include lightDark(color, #666, #999); - &.selected { - border-bottom-width: 3px; - } - } } .nav-tabs { text-align: center; - a, .tab-item { - padding: $-m; + .tab-item { display: inline-block; - @include lightDark(color, #666, #999); + padding: $-s; + @include lightDark(color, rgba(0, 0, 0, .5), rgba(255, 255, 255, .5)); cursor: pointer; - border-right: 1px solid rgba(0, 0, 0, 0.1); border-bottom: 2px solid transparent; + margin-bottom: -1px; &.selected { - border-bottom: 2px solid var(--color-primary); + color: var(--color-primary) !important; + border-bottom-color: var(--color-primary) !important; } - &:last-child { - border-right: 0; + &:hover, &:focus { + @include lightDark(color, rgba(0, 0, 0, .8), rgba(255, 255, 255, .8)); + @include lightDark(border-bottom-color, rgba(0, 0, 0, .2), rgba(255, 255, 255, .2)); } } } +.nav-tabs.controls-card { + margin-bottom: 0; + border-bottom: 0; + padding: 0 $-xs; +} .image-picker .none { display: none; diff --git a/resources/views/pages/parts/image-manager.blade.php b/resources/views/pages/parts/image-manager.blade.php index 50a0cd8c3..a21a5fdac 100644 --- a/resources/views/pages/parts/image-manager.blade.php +++ b/resources/views/pages/parts/image-manager.blade.php @@ -21,10 +21,10 @@ type="button" class="tab-item selected" title="{{ trans('components.image_all_title') }}">@icon('images') {{ trans('components.image_all') }} + type="button" class="tab-item" title="{{ trans('components.image_book_title') }}">@icon('book', ['class' => 'svg-icon']) {{ trans('entities.book') }} + type="button" class="tab-item" title="{{ trans('components.image_page_title') }}">@icon('page', ['class' => 'svg-icon']) {{ trans('entities.page') }}
    diff --git a/resources/views/settings/customization.blade.php b/resources/views/settings/customization.blade.php index aa37c30c9..d3c20c4b1 100644 --- a/resources/views/settings/customization.blade.php +++ b/resources/views/settings/customization.blade.php @@ -69,42 +69,32 @@
    - -
    -
    - -

    {!! trans('settings.app_primary_color_desc') !!}

    -
    -
    - - -
    - - | - -
    - -
    -
    - - +
    -
    - -

    {!! trans('settings.content_colors_desc') !!}

    +
    + +

    {{ trans('settings.color_scheme_desc') }}

    -
    -
    - @include('settings.parts.setting-entity-color-picker', ['type' => 'bookshelf']) - @include('settings.parts.setting-entity-color-picker', ['type' => 'book']) - @include('settings.parts.setting-entity-color-picker', ['type' => 'chapter']) + + @php + $darkMode = boolval(setting()->getForCurrentUser('dark-mode-enabled')); + @endphp +
    + -
    - @include('settings.parts.setting-entity-color-picker', ['type' => 'page']) - @include('settings.parts.setting-entity-color-picker', ['type' => 'page-draft']) +
    +
    + @include('settings.parts.setting-color-scheme', ['mode' => 'light']) +
    +
    + @include('settings.parts.setting-color-scheme', ['mode' => 'dark']) +
    diff --git a/resources/views/settings/parts/setting-entity-color-picker.blade.php b/resources/views/settings/parts/setting-color-picker.blade.php similarity index 57% rename from resources/views/settings/parts/setting-entity-color-picker.blade.php rename to resources/views/settings/parts/setting-color-picker.blade.php index e7bfc3fe9..d6707fb50 100644 --- a/resources/views/settings/parts/setting-entity-color-picker.blade.php +++ b/resources/views/settings/parts/setting-color-picker.blade.php @@ -1,12 +1,15 @@ {{-- - @type - Name of entity type + @type - Name of color setting --}} +@php + $keyAppends = ($mode === 'light' ? '' : '-' . $mode); +@endphp
    - + | @@ -14,10 +17,10 @@
    diff --git a/resources/views/settings/parts/setting-color-scheme.blade.php b/resources/views/settings/parts/setting-color-scheme.blade.php new file mode 100644 index 000000000..1b18a9a6a --- /dev/null +++ b/resources/views/settings/parts/setting-color-scheme.blade.php @@ -0,0 +1,25 @@ +{{-- + @mode - 'light' or 'dark'. +--}} +

    {{ trans('settings.ui_colors_desc') }}

    +
    +
    + @include('settings.parts.setting-color-picker', ['type' => 'app', 'mode' => $mode]) +
    +
    + @include('settings.parts.setting-color-picker', ['type' => 'link', 'mode' => $mode]) +
    +
    +
    +

    {!! trans('settings.content_colors_desc') !!}

    +
    +
    + @include('settings.parts.setting-color-picker', ['type' => 'bookshelf', 'mode' => $mode]) + @include('settings.parts.setting-color-picker', ['type' => 'book', 'mode' => $mode]) + @include('settings.parts.setting-color-picker', ['type' => 'chapter', 'mode' => $mode]) +
    +
    + @include('settings.parts.setting-color-picker', ['type' => 'page', 'mode' => $mode]) + @include('settings.parts.setting-color-picker', ['type' => 'page-draft', 'mode' => $mode]) +
    +
    \ No newline at end of file From e708ce93baa19715b0951c86c111320d4af2dc82 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 28 Jan 2023 12:50:51 +0000 Subject: [PATCH 33/41] Updated generic tab styles and js to force accessible usage Added use of more accessible tags to create tabbed-interfaces then updated css and JS to require use of those attributes rather than custom techniques. Updated relevant parts of app. Some custom parts using their own tabs though, something to improve in future. --- resources/js/components/attachments.js | 2 +- resources/js/components/image-manager.js | 7 +-- resources/js/components/tabs.js | 62 +++++++++---------- resources/sass/_components.scss | 36 +++++------ resources/views/attachments/manager.blade.php | 45 +++++++++++--- .../views/pages/parts/image-manager.blade.php | 10 ++- .../views/settings/customization.blade.php | 32 +++++++--- 7 files changed, 119 insertions(+), 75 deletions(-) diff --git a/resources/js/components/attachments.js b/resources/js/components/attachments.js index b4e400aeb..d8a506270 100644 --- a/resources/js/components/attachments.js +++ b/resources/js/components/attachments.js @@ -45,7 +45,7 @@ export class Attachments extends Component { this.stopEdit(); /** @var {Tabs} */ const tabs = window.$components.firstOnElement(this.mainTabs, 'tabs'); - tabs.show('items'); + tabs.show('attachment-panel-items'); window.$http.get(`/attachments/get/page/${this.pageId}`).then(resp => { this.list.innerHTML = resp.data; window.$components.init(this.list); diff --git a/resources/js/components/image-manager.js b/resources/js/components/image-manager.js index a44fffc1b..418b7c98a 100644 --- a/resources/js/components/image-manager.js +++ b/resources/js/components/image-manager.js @@ -140,10 +140,9 @@ export class ImageManager extends Component { } setActiveFilterTab(filterName) { - this.filterTabs.forEach(t => t.classList.remove('selected')); - const activeTab = this.filterTabs.find(t => t.dataset.filter === filterName); - if (activeTab) { - activeTab.classList.add('selected'); + for (const tab of this.filterTabs) { + const selected = tab.dataset.filter === filterName; + tab.setAttribute('aria-selected', selected ? 'true' : 'false'); } } diff --git a/resources/js/components/tabs.js b/resources/js/components/tabs.js index 46063d240..ebab4191c 100644 --- a/resources/js/components/tabs.js +++ b/resources/js/components/tabs.js @@ -1,48 +1,46 @@ -import {onSelect} from "../services/dom"; import {Component} from "./component"; /** * Tabs - * Works by matching 'tabToggle' with 'tabContent' sections. + * Uses accessible attributes to drive its functionality. + * On tab wrapping element: + * - role=tablist + * On tabs (Should be a button): + * - id + * - role=tab + * - aria-selected=true/false + * - aria-controls= + * On panels: + * - id + * - tabindex=0 + * - role=tabpanel + * - aria-labelledby= + * - hidden (If not shown by default). */ export class Tabs extends Component { setup() { - this.tabContentsByName = {}; - this.tabButtonsByName = {}; - this.allContents = []; - this.allButtons = []; + this.container = this.$el; + this.tabs = Array.from(this.container.querySelectorAll('[role="tab"]')); + this.panels = Array.from(this.container.querySelectorAll('[role="tabpanel"]')); - for (const [key, elems] of Object.entries(this.$manyRefs || {})) { - if (key.startsWith('toggle')) { - const cleanKey = key.replace('toggle', '').toLowerCase(); - onSelect(elems, e => this.show(cleanKey)); - this.allButtons.push(...elems); - this.tabButtonsByName[cleanKey] = elems; + this.container.addEventListener('click', event => { + const button = event.target.closest('[role="tab"]'); + if (button) { + this.show(button.getAttribute('aria-controls')); } - if (key.startsWith('content')) { - const cleanKey = key.replace('content', '').toLowerCase(); - this.tabContentsByName[cleanKey] = elems; - this.allContents.push(...elems); - } - } + }); } - show(key) { - this.allContents.forEach(c => { - c.classList.add('hidden'); - c.classList.remove('selected'); - }); - this.allButtons.forEach(b => b.classList.remove('selected')); + show(sectionId) { + for (const panel of this.panels) { + panel.toggleAttribute('hidden', panel.id !== sectionId); + } - const contents = this.tabContentsByName[key] || []; - const buttons = this.tabButtonsByName[key] || []; - if (contents.length > 0) { - contents.forEach(c => { - c.classList.remove('hidden') - c.classList.add('selected') - }); - buttons.forEach(b => b.classList.add('selected')); + for (const tab of this.tabs) { + const tabSection = tab.getAttribute('aria-controls'); + const selected = tabSection === sectionId; + tab.setAttribute('aria-selected', selected ? 'true' : 'false'); } } diff --git a/resources/sass/_components.scss b/resources/sass/_components.scss index b902220a7..c8ecd438d 100644 --- a/resources/sass/_components.scss +++ b/resources/sass/_components.scss @@ -607,7 +607,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { } -.tab-container .nav-tabs { +.tab-container [role="tablist"] { display: flex; align-items: end; justify-items: start; @@ -617,26 +617,24 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { margin-bottom: $-m; } -.nav-tabs { - text-align: center; - .tab-item { - display: inline-block; - padding: $-s; - @include lightDark(color, rgba(0, 0, 0, .5), rgba(255, 255, 255, .5)); - cursor: pointer; - border-bottom: 2px solid transparent; - margin-bottom: -1px; - &.selected { - color: var(--color-primary) !important; - border-bottom-color: var(--color-primary) !important; - } - &:hover, &:focus { - @include lightDark(color, rgba(0, 0, 0, .8), rgba(255, 255, 255, .8)); - @include lightDark(border-bottom-color, rgba(0, 0, 0, .2), rgba(255, 255, 255, .2)); - } +.tab-container [role="tablist"] button[role="tab"], +.image-manager [role="tablist"] button[role="tab"] { + display: inline-block; + padding: $-s; + @include lightDark(color, rgba(0, 0, 0, .5), rgba(255, 255, 255, .5)); + cursor: pointer; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + &[aria-selected="true"] { + color: var(--color-primary) !important; + border-bottom-color: var(--color-primary) !important; + } + &:hover, &:focus { + @include lightDark(color, rgba(0, 0, 0, .8), rgba(255, 255, 255, .8)); + @include lightDark(border-bottom-color, rgba(0, 0, 0, .2), rgba(255, 255, 255, .2)); } } -.nav-tabs.controls-card { +.tab-container [role="tablist"].controls-card { margin-bottom: 0; border-bottom: 0; padding: 0 $-xs; diff --git a/resources/views/attachments/manager.blade.php b/resources/views/attachments/manager.blade.php index 724ca9c8e..7d14d00e7 100644 --- a/resources/views/attachments/manager.blade.php +++ b/resources/views/attachments/manager.blade.php @@ -9,25 +9,54 @@
    -

    {{ trans('entities.attachments_explain') }} {{ trans('entities.attachments_explain_instant_save') }}

    +

    {{ trans('entities.attachments_explain') }} {{ trans('entities.attachments_explain_instant_save') }}

    -