From a8777c6198edd520011731d939ae92b45fcec176 Mon Sep 17 00:00:00 2001 From: Sami Mazouz Date: Fri, 21 Jun 2024 09:36:32 +0100 Subject: [PATCH] refactor: JSON:API (#3971) * refactor: json:api refactor iteration 1 * chore: delete dead code * fix: regressions * chore: move additions/changes to package * feat: AccessTokenResource * feat: allow dependency injection in resources * feat: `ApiResource` extender * feat: improve * feat: refactor tags extension * feat: refactor flags extension * fix: regressions * fix: drop bc layer * feat: refactor suspend extension * feat: refactor subscriptions extension * feat: refactor approval extension * feat: refactor sticky extension * feat: refactor nicknames extension * feat: refactor mentions extension * feat: refactor lock extension * feat: refactor likes extension * chore: merge conflicts * feat: refactor extension-manager extension * feat: context current endpoint helpers * chore: minor * feat: cleaner sortmap implementation * chore: drop old package * chore: not needed (auto scoping) * fix: actor only fields * refactor: simplify index endpoint * feat: eager loading * test: adapt * test: phpstan * test: adapt * fix: typing * fix: approving content * tet: adapt frontend tests * chore: typings * chore: review * fix: breaking change --- composer.json | 4 +- extensions/approval/extend.php | 21 +- .../approval/src/Api/PostResourceFields.php | 29 + .../integration/api/ApprovePostsTest.php | 123 ++++ .../tests/integration/api/CreatePostsTest.php | 153 +++++ extensions/flags/extend.php | 53 +- extensions/flags/js/dist/forum.js | 2 +- extensions/flags/js/dist/forum.js.map | 2 +- .../js/src/forum/components/FlagPostModal.js | 1 - .../flags/src/Access/ScopeFlagVisibility.php | 44 +- extensions/flags/src/AddCanFlagAttribute.php | 39 -- .../flags/src/AddFlagsApiAttributes.php | 40 -- .../flags/src/AddNewFlagCountAttribute.php | 32 - .../Api/Controller/CreateFlagController.php | 43 -- .../Api/Controller/ListFlagsController.php | 81 --- .../flags/src/Api/ForumResourceFields.php | 32 + .../flags/src/Api/PostResourceFields.php | 42 ++ .../flags/src/Api/Resource/FlagResource.php | 165 +++++ .../src/Api/Serializer/FlagSerializer.php | 48 -- .../flags/src/Api/UserResourceFields.php | 36 ++ .../flags/src/Command/CreateFlagHandler.php | 79 --- extensions/flags/src/Flag.php | 4 + extensions/flags/src/PrepareFlagsApiData.php | 64 -- .../tests/integration/api/flags/ListTest.php | 12 +- .../api/flags/ListWithTagsTest.php | 8 +- .../api/posts/IncludeFlagsVisibilityTest.php | 145 +++++ extensions/likes/extend.php | 55 +- .../likes/src/Api/LoadLikesRelationship.php | 65 -- .../likes/src/Api/PostResourceFields.php | 65 ++ .../src/Listener/SaveLikesToDatabase.php | 55 -- .../tests/integration/api/LikePostTest.php | 4 +- .../tests/integration/api/ListPostsTest.php | 25 +- extensions/lock/extend.php | 38 +- .../src/Listener/SaveLockedToDatabase.php | 40 -- extensions/mentions/extend.php | 120 ++-- .../src/Api/LoadMentionedByRelationship.php | 82 --- .../mentions/src/Api/PostResourceFields.php | 37 ++ .../integration/api/GroupMentionsTest.php | 22 +- .../tests/integration/api/ListPostsTest.php | 71 ++- .../integration/api/PostMentionsTest.php | 27 +- .../tests/integration/api/TagMentionsTest.php | 22 +- .../integration/api/UserMentionsTest.php | 16 +- extensions/nicknames/extend.php | 18 +- .../nicknames/src/AddNicknameValidation.php | 50 -- .../nicknames/src/Api/UserResourceFields.php | 61 ++ .../nicknames/src/SaveNicknameToDatabase.php | 40 -- .../tests/integration/api/EditUserTest.php | 35 +- .../tests/integration/api/RegisterTest.php | 8 +- extensions/package-manager/extend.php | 4 +- .../js/src/admin/components/ConfigureJson.tsx | 7 +- .../js/src/admin/states/QueueState.ts | 2 +- .../Api/Controller/ListTasksController.php | 58 -- .../src/Api/Resource/TaskResource.php | 60 ++ .../src/Api/Serializer/TaskSerializer.php | 50 -- .../src/Task/TaskRepository.php | 34 - .../src/Api/Controller/ShowStatisticsData.php | 2 +- extensions/sticky/extend.php | 31 +- .../src/Api/DiscussionResourceFields.php | 48 ++ .../src/Listener/SaveStickyToDatabase.php | 40 -- .../integration/api/ListDiscussionsTest.php | 24 +- .../integration/api/StickyDiscussionsTest.php | 91 +++ extensions/subscriptions/extend.php | 19 +- .../src/Api/UserResourceFields.php | 37 ++ .../api/discussions/ReplyNotificationTest.php | 7 + .../api/discussions/SubscribeTest.php | 94 +++ extensions/suspend/extend.php | 27 +- .../suspend/src/AddUserSuspendAttributes.php | 35 - .../suspend/src/Api/UserResourceFields.php | 35 + .../src/Listener/SaveSuspensionToDatabase.php | 60 -- .../suspend/src/Listener/SavingUser.php | 37 ++ extensions/suspend/src/SuspendValidator.php | 19 - .../tests/integration/api/UseForumTest.php | 2 + .../integration/api/users/SuspendUserTest.php | 1 + extensions/tags/extend.php | 149 ++--- .../js/src/admin/components/EditTagModal.tsx | 8 +- .../components/SelectTagsSettingComponent.tsx | 4 +- .../common/components/TagSelectionModal.tsx | 8 +- extensions/tags/js/src/common/models/Tag.ts | 5 +- extensions/tags/js/src/forum/addTagFilter.tsx | 2 +- ...3_000000_add_is_primary_column_to_tags.php | 29 + .../Api/Controller/CreateTagController.php | 39 -- .../Api/Controller/DeleteTagController.php | 32 - .../src/Api/Controller/ListTagsController.php | 78 --- .../Api/Controller/OrderTagsController.php | 8 +- .../src/Api/Controller/ShowTagController.php | 69 -- .../Api/Controller/UpdateTagController.php | 41 -- .../tags/src/Api/DiscussionResourceFields.php | 114 ++++ .../tags/src/Api/Resource/TagResource.php | 168 +++++ .../tags/src/Api/Serializer/TagSerializer.php | 75 --- .../tags/src/Command/CreateTagHandler.php | 66 -- extensions/tags/src/Command/DeleteTag.php | 22 - .../tags/src/Command/DeleteTagHandler.php | 39 -- extensions/tags/src/Command/EditTag.php | 22 - .../tags/src/Command/EditTagHandler.php | 75 --- extensions/tags/src/Content/Tag.php | 16 +- .../tags/src/Listener/SaveTagsToDatabase.php | 116 ---- .../tags/src/LoadForumTagsRelationship.php | 43 -- extensions/tags/src/Tag.php | 13 +- extensions/tags/src/TagRepository.php | 28 - extensions/tags/src/TagValidator.php | 23 - .../RetrievesRepresentativeTags.php | 28 +- .../api/discussions/CreateTest.php | 34 +- .../api/discussions/UpdateTest.php | 1 + .../tests/integration/api/posts/ListTest.php | 8 +- .../tests/integration/api/tags/CreateTest.php | 11 +- .../tests/integration/api/tags/ListTest.php | 13 +- framework/core/composer.json | 2 +- .../src/admin/components/CreateUserModal.tsx | 8 +- .../src/common/states/PaginatedListState.ts | 4 + .../tests/unit/common/GambitManager.test.ts | 6 +- framework/core/locale/validation.yml | 188 +++--- framework/core/src/Api/ApiServiceProvider.php | 103 ++- framework/core/src/Api/Context.php | 99 +++ .../Controller/AbstractCreateController.php | 21 - .../Api/Controller/AbstractListController.php | 46 -- .../AbstractSerializeController.php | 432 ------------- .../Api/Controller/AbstractShowController.php | 21 - .../CreateAccessTokenController.php | 60 -- .../Controller/CreateDiscussionController.php | 62 -- .../Api/Controller/CreateGroupController.php | 36 -- .../Api/Controller/CreatePostController.php | 66 -- .../Api/Controller/CreateUserController.php | 36 -- .../DeleteAccessTokenController.php | 46 -- .../Api/Controller/DeleteAvatarController.php | 35 - .../Controller/DeleteDiscussionController.php | 35 - .../Api/Controller/DeleteGroupController.php | 31 - .../Api/Controller/DeletePostController.php | 31 - .../Api/Controller/DeleteUserController.php | 31 - .../Controller/ListAccessTokensController.php | 53 -- .../Controller/ListDiscussionsController.php | 99 --- .../Api/Controller/ListGroupsController.php | 65 -- .../ListNotificationsController.php | 100 --- .../Api/Controller/ListPostsController.php | 124 ---- .../Api/Controller/ListUsersController.php | 81 --- .../Controller/ShowDiscussionController.php | 178 ------ .../ShowExtensionReadmeController.php | 37 -- .../Api/Controller/ShowForumController.php | 29 +- .../Api/Controller/ShowGroupController.php | 38 -- .../Controller/ShowMailSettingsController.php | 40 +- .../src/Api/Controller/ShowPostController.php | 48 -- .../src/Api/Controller/ShowUserController.php | 51 -- .../Controller/UpdateDiscussionController.php | 74 --- .../Api/Controller/UpdateGroupController.php | 40 -- .../UpdateNotificationController.php | 39 -- .../Api/Controller/UpdatePostController.php | 49 -- .../Api/Controller/UpdateUserController.php | 58 -- .../Api/Controller/UploadAvatarController.php | 40 -- .../Controller/UploadFaviconController.php | 4 +- .../Api/Controller/UploadImageController.php | 10 +- .../Api/Controller/UploadLogoController.php | 4 +- .../Concerns/ExtractsListingParams.php | 126 ++++ .../Endpoint/Concerns/HasAuthorization.php | 91 +++ .../Api/Endpoint/Concerns/HasCustomHooks.php | 25 + framework/core/src/Api/Endpoint/Create.php | 25 + framework/core/src/Api/Endpoint/Delete.php | 20 + framework/core/src/Api/Endpoint/Endpoint.php | 22 + .../src/Api/Endpoint/EndpointInterface.php | 18 + framework/core/src/Api/Endpoint/Index.php | 86 +++ framework/core/src/Api/Endpoint/Show.php | 27 + framework/core/src/Api/Endpoint/Update.php | 25 + framework/core/src/Api/JsonApi.php | 165 +++++ framework/core/src/Api/JsonApiResponse.php | 3 +- .../Api/Resource/AbstractDatabaseResource.php | 205 ++++++ .../src/Api/Resource/AbstractResource.php | 27 + .../src/Api/Resource/AccessTokenResource.php | 143 +++++ .../src/Api/Resource/Concerns/Bootable.php | 41 ++ .../src/Api/Resource/Concerns/Extendable.php | 93 +++ .../src/Api/Resource/Concerns/HasSortMap.php | 29 + .../src/Api/Resource/DiscussionResource.php | 368 +++++++++++ .../Api/Resource/ExtensionReadmeResource.php | 64 ++ .../core/src/Api/Resource/ForumResource.php | 168 +++++ .../core/src/Api/Resource/GroupResource.php | 136 ++++ .../src/Api/Resource/MailSettingResource.php | 88 +++ .../src/Api/Resource/NotificationResource.php | 114 ++++ .../core/src/Api/Resource/PostResource.php | 309 +++++++++ .../core/src/Api/Resource/UserResource.php | 456 +++++++++++++ framework/core/src/Api/Schema/Arr.php | 23 + .../core/src/Api/Schema/Attribute.php | 12 +- framework/core/src/Api/Schema/Boolean.php | 20 + framework/core/src/Api/Schema/Date.php | 20 + framework/core/src/Api/Schema/DateTime.php | 45 ++ framework/core/src/Api/Schema/Integer.php | 20 + framework/core/src/Api/Schema/Number.php | 35 + .../src/Api/Schema/Relationship/ToMany.php | 12 +- .../Schema/Relationship/ToOne.php} | 12 +- framework/core/src/Api/Schema/Str.php | 46 ++ framework/core/src/Api/Schema/Type/Arr.php | 44 ++ .../src/Api/Serializer/AbstractSerializer.php | 234 ------- .../Api/Serializer/AccessTokenSerializer.php | 66 -- .../Serializer/BasicDiscussionSerializer.php | 77 --- .../Api/Serializer/BasicPostSerializer.php | 72 --- .../Api/Serializer/BasicUserSerializer.php | 53 -- .../Api/Serializer/CurrentUserSerializer.php | 39 -- .../Api/Serializer/DiscussionSerializer.php | 49 -- .../Serializer/ExtensionReadmeSerializer.php | 35 - .../src/Api/Serializer/ForumSerializer.php | 133 ---- .../src/Api/Serializer/GroupSerializer.php | 55 -- .../Api/Serializer/MailSettingsSerializer.php | 40 -- .../Api/Serializer/NotificationSerializer.php | 66 -- .../src/Api/Serializer/PostSerializer.php | 77 --- .../src/Api/Serializer/UserSerializer.php | 48 -- framework/core/src/Api/Sort/SortColumn.php | 48 ++ framework/core/src/Api/routes.php | 214 ------- framework/core/src/Database/AbstractModel.php | 57 -- .../Discussion/Command/DeleteDiscussion.php | 22 - .../Command/DeleteDiscussionHandler.php | 46 -- .../src/Discussion/Command/EditDiscussion.php | 22 - .../Command/EditDiscussionHandler.php | 67 -- .../Discussion/Command/StartDiscussion.php | 22 - .../Command/StartDiscussionHandler.php | 83 --- framework/core/src/Discussion/Discussion.php | 16 - .../src/Discussion/DiscussionValidator.php | 23 - framework/core/src/Extend/ApiController.php | 429 ------------- framework/core/src/Extend/ApiResource.php | 294 +++++++++ framework/core/src/Extend/ApiSerializer.php | 169 ----- framework/core/src/Extend/Model.php | 4 +- framework/core/src/Extend/Notification.php | 11 +- framework/core/src/Extend/Routes.php | 5 - framework/core/src/Extend/Settings.php | 19 +- .../core/src/Forum/Content/Discussion.php | 1 - framework/core/src/Forum/Content/Index.php | 15 +- .../Forum/Controller/RegisterController.php | 2 +- .../JsonApiExceptionHandler.php | 26 + .../ErrorHandling/JsonApiFormatter.php | 9 +- .../src/Foundation/ErrorHandling/Registry.php | 10 +- .../src/Foundation/ErrorServiceProvider.php | 4 +- .../Foundation/HighMaintenanceModeHandler.php | 11 +- .../Frontend/Compiler/Concerns/HasSources.php | 5 +- .../Frontend/Compiler/JsDirectoryCompiler.php | 5 + .../src/Group/Command/CreateGroupHandler.php | 57 -- .../core/src/Group/Command/DeleteGroup.php | 22 - .../src/Group/Command/DeleteGroupHandler.php | 46 -- .../core/src/Group/Command/EditGroup.php | 22 - .../src/Group/Command/EditGroupHandler.php | 70 -- framework/core/src/Group/Group.php | 32 +- framework/core/src/Group/GroupValidator.php | 20 - framework/core/src/Http/AccessToken.php | 9 +- .../Exception/InvalidParameterException.php} | 12 +- .../src/Http/Middleware/PopulateWithActor.php | 29 + framework/core/src/Http/RequestUtil.php | 139 ++++ .../core/src/Http/RouteHandlerFactory.php | 20 + .../Notification/NotificationRepository.php | 12 +- .../src/Notification/NotificationSyncer.php | 1 + .../core/src/Post/Command/DeletePost.php | 22 - .../src/Post/Command/DeletePostHandler.php | 46 -- framework/core/src/Post/Command/EditPost.php | 22 - .../core/src/Post/Command/EditPostHandler.php | 71 --- framework/core/src/Post/Command/PostReply.php | 24 - .../src/Post/Command/PostReplyHandler.php | 80 --- framework/core/src/Post/CommentPost.php | 19 +- framework/core/src/Post/PostValidator.php | 22 - .../src/Search/Database/AbstractSearcher.php | 23 +- framework/core/src/Search/SearchManager.php | 8 + .../core/src/User/Command/DeleteUser.php | 22 - .../src/User/Command/DeleteUserHandler.php | 49 -- framework/core/src/User/Command/EditUser.php | 22 - .../core/src/User/Command/EditUserHandler.php | 128 ---- .../src/User/Command/RegisterUserHandler.php | 176 ------ framework/core/src/User/User.php | 16 +- .../api/AbstractSerializeControllerTest.php | 53 -- .../api/access_tokens/CreateTest.php | 14 +- .../api/discussions/CreateTest.php | 20 +- .../integration/api/discussions/ShowTest.php | 8 +- .../tests/integration/api/forum/ShowTest.php | 3 +- .../integration/api/groups/CreateTest.php | 10 +- .../tests/integration/api/groups/ShowTest.php | 4 +- .../api/notifications/ListTest.php | 2 +- .../api/notifications/UpdateTest.php | 67 ++ .../integration/api/posts/CreateTest.php | 11 +- .../tests/integration/api/posts/ListTest.php | 6 +- .../integration/api/users/CreateTest.php | 22 +- .../integration/api/users/GroupSearchTest.php | 27 +- .../api/users/PasswordEmailTokensTest.php | 3 +- .../api/users/SendActivationEmailTest.php | 2 +- .../api/users/SendPasswordResetEmailTest.php | 2 +- .../integration/api/users/UpdateTest.php | 53 +- .../extenders/ApiControllerTest.php | 597 ++++++------------ .../extenders/ApiSerializerTest.php | 353 +++-------- .../integration/extenders/ConditionalTest.php | 123 ++-- .../tests/integration/extenders/EventTest.php | 33 +- .../tests/integration/extenders/MailTest.php | 6 +- .../extenders/ModelPrivateTest.php | 49 +- .../tests/integration/extenders/ModelTest.php | 10 +- .../extenders/NotificationTest.php | 44 +- .../integration/extenders/SearchIndexTest.php | 33 +- .../integration/extenders/SettingsTest.php | 24 + .../tests/integration/extenders/ThemeTest.php | 2 +- .../integration/extenders/ValidatorTest.php | 70 +- .../tests/integration/forum/RegisterTest.php | 5 +- .../policy/DiscussionPolicyTest.php | 31 +- js-packages/jest-config/setup-env.js | 18 +- php-packages/phpstan/extension.neon | 3 + php-packages/phpstan/larastan-extension.neon | 59 +- php-packages/phpstan/phpstan-baseline.neon | 10 + .../stubs/Tobyz/JsonApiServer/JsonApi.stub | 11 + .../testing/src/integration/TestCase.php | 4 + 296 files changed, 7148 insertions(+), 8860 deletions(-) create mode 100644 extensions/approval/src/Api/PostResourceFields.php create mode 100644 extensions/approval/tests/integration/api/ApprovePostsTest.php create mode 100644 extensions/approval/tests/integration/api/CreatePostsTest.php delete mode 100644 extensions/flags/src/AddCanFlagAttribute.php delete mode 100755 extensions/flags/src/AddFlagsApiAttributes.php delete mode 100644 extensions/flags/src/AddNewFlagCountAttribute.php delete mode 100644 extensions/flags/src/Api/Controller/CreateFlagController.php delete mode 100644 extensions/flags/src/Api/Controller/ListFlagsController.php create mode 100644 extensions/flags/src/Api/ForumResourceFields.php create mode 100644 extensions/flags/src/Api/PostResourceFields.php create mode 100644 extensions/flags/src/Api/Resource/FlagResource.php delete mode 100644 extensions/flags/src/Api/Serializer/FlagSerializer.php create mode 100644 extensions/flags/src/Api/UserResourceFields.php delete mode 100644 extensions/flags/src/Command/CreateFlagHandler.php delete mode 100755 extensions/flags/src/PrepareFlagsApiData.php create mode 100644 extensions/flags/tests/integration/api/posts/IncludeFlagsVisibilityTest.php delete mode 100644 extensions/likes/src/Api/LoadLikesRelationship.php create mode 100644 extensions/likes/src/Api/PostResourceFields.php delete mode 100755 extensions/likes/src/Listener/SaveLikesToDatabase.php delete mode 100755 extensions/lock/src/Listener/SaveLockedToDatabase.php delete mode 100644 extensions/mentions/src/Api/LoadMentionedByRelationship.php create mode 100644 extensions/mentions/src/Api/PostResourceFields.php delete mode 100644 extensions/nicknames/src/AddNicknameValidation.php create mode 100644 extensions/nicknames/src/Api/UserResourceFields.php delete mode 100644 extensions/nicknames/src/SaveNicknameToDatabase.php delete mode 100644 extensions/package-manager/src/Api/Controller/ListTasksController.php create mode 100644 extensions/package-manager/src/Api/Resource/TaskResource.php delete mode 100644 extensions/package-manager/src/Api/Serializer/TaskSerializer.php delete mode 100644 extensions/package-manager/src/Task/TaskRepository.php create mode 100644 extensions/sticky/src/Api/DiscussionResourceFields.php delete mode 100755 extensions/sticky/src/Listener/SaveStickyToDatabase.php create mode 100644 extensions/sticky/tests/integration/api/StickyDiscussionsTest.php create mode 100644 extensions/subscriptions/src/Api/UserResourceFields.php create mode 100644 extensions/subscriptions/tests/integration/api/discussions/SubscribeTest.php delete mode 100755 extensions/suspend/src/AddUserSuspendAttributes.php create mode 100644 extensions/suspend/src/Api/UserResourceFields.php delete mode 100755 extensions/suspend/src/Listener/SaveSuspensionToDatabase.php create mode 100755 extensions/suspend/src/Listener/SavingUser.php delete mode 100644 extensions/suspend/src/SuspendValidator.php create mode 100644 extensions/tags/migrations/2024_02_23_000000_add_is_primary_column_to_tags.php delete mode 100644 extensions/tags/src/Api/Controller/CreateTagController.php delete mode 100644 extensions/tags/src/Api/Controller/DeleteTagController.php delete mode 100644 extensions/tags/src/Api/Controller/ListTagsController.php delete mode 100644 extensions/tags/src/Api/Controller/ShowTagController.php delete mode 100644 extensions/tags/src/Api/Controller/UpdateTagController.php create mode 100644 extensions/tags/src/Api/DiscussionResourceFields.php create mode 100644 extensions/tags/src/Api/Resource/TagResource.php delete mode 100644 extensions/tags/src/Api/Serializer/TagSerializer.php delete mode 100644 extensions/tags/src/Command/CreateTagHandler.php delete mode 100644 extensions/tags/src/Command/DeleteTag.php delete mode 100644 extensions/tags/src/Command/DeleteTagHandler.php delete mode 100644 extensions/tags/src/Command/EditTag.php delete mode 100644 extensions/tags/src/Command/EditTagHandler.php delete mode 100755 extensions/tags/src/Listener/SaveTagsToDatabase.php delete mode 100755 extensions/tags/src/LoadForumTagsRelationship.php delete mode 100644 extensions/tags/src/TagValidator.php create mode 100644 framework/core/src/Api/Context.php delete mode 100644 framework/core/src/Api/Controller/AbstractCreateController.php delete mode 100644 framework/core/src/Api/Controller/AbstractListController.php delete mode 100644 framework/core/src/Api/Controller/AbstractSerializeController.php delete mode 100644 framework/core/src/Api/Controller/AbstractShowController.php delete mode 100644 framework/core/src/Api/Controller/CreateAccessTokenController.php delete mode 100644 framework/core/src/Api/Controller/CreateDiscussionController.php delete mode 100644 framework/core/src/Api/Controller/CreateGroupController.php delete mode 100644 framework/core/src/Api/Controller/CreatePostController.php delete mode 100644 framework/core/src/Api/Controller/CreateUserController.php delete mode 100644 framework/core/src/Api/Controller/DeleteAccessTokenController.php delete mode 100644 framework/core/src/Api/Controller/DeleteAvatarController.php delete mode 100644 framework/core/src/Api/Controller/DeleteDiscussionController.php delete mode 100644 framework/core/src/Api/Controller/DeleteGroupController.php delete mode 100644 framework/core/src/Api/Controller/DeletePostController.php delete mode 100644 framework/core/src/Api/Controller/DeleteUserController.php delete mode 100644 framework/core/src/Api/Controller/ListAccessTokensController.php delete mode 100644 framework/core/src/Api/Controller/ListDiscussionsController.php delete mode 100644 framework/core/src/Api/Controller/ListGroupsController.php delete mode 100644 framework/core/src/Api/Controller/ListNotificationsController.php delete mode 100644 framework/core/src/Api/Controller/ListPostsController.php delete mode 100644 framework/core/src/Api/Controller/ListUsersController.php delete mode 100644 framework/core/src/Api/Controller/ShowDiscussionController.php delete mode 100644 framework/core/src/Api/Controller/ShowExtensionReadmeController.php delete mode 100644 framework/core/src/Api/Controller/ShowGroupController.php delete mode 100644 framework/core/src/Api/Controller/ShowPostController.php delete mode 100644 framework/core/src/Api/Controller/ShowUserController.php delete mode 100644 framework/core/src/Api/Controller/UpdateDiscussionController.php delete mode 100644 framework/core/src/Api/Controller/UpdateGroupController.php delete mode 100644 framework/core/src/Api/Controller/UpdateNotificationController.php delete mode 100644 framework/core/src/Api/Controller/UpdatePostController.php delete mode 100644 framework/core/src/Api/Controller/UpdateUserController.php delete mode 100644 framework/core/src/Api/Controller/UploadAvatarController.php create mode 100644 framework/core/src/Api/Endpoint/Concerns/ExtractsListingParams.php create mode 100644 framework/core/src/Api/Endpoint/Concerns/HasAuthorization.php create mode 100644 framework/core/src/Api/Endpoint/Concerns/HasCustomHooks.php create mode 100644 framework/core/src/Api/Endpoint/Create.php create mode 100644 framework/core/src/Api/Endpoint/Delete.php create mode 100644 framework/core/src/Api/Endpoint/Endpoint.php create mode 100644 framework/core/src/Api/Endpoint/EndpointInterface.php create mode 100644 framework/core/src/Api/Endpoint/Index.php create mode 100644 framework/core/src/Api/Endpoint/Show.php create mode 100644 framework/core/src/Api/Endpoint/Update.php create mode 100644 framework/core/src/Api/JsonApi.php create mode 100644 framework/core/src/Api/Resource/AbstractDatabaseResource.php create mode 100644 framework/core/src/Api/Resource/AbstractResource.php create mode 100644 framework/core/src/Api/Resource/AccessTokenResource.php create mode 100644 framework/core/src/Api/Resource/Concerns/Bootable.php create mode 100644 framework/core/src/Api/Resource/Concerns/Extendable.php create mode 100644 framework/core/src/Api/Resource/Concerns/HasSortMap.php create mode 100644 framework/core/src/Api/Resource/DiscussionResource.php create mode 100644 framework/core/src/Api/Resource/ExtensionReadmeResource.php create mode 100644 framework/core/src/Api/Resource/ForumResource.php create mode 100644 framework/core/src/Api/Resource/GroupResource.php create mode 100644 framework/core/src/Api/Resource/MailSettingResource.php create mode 100644 framework/core/src/Api/Resource/NotificationResource.php create mode 100644 framework/core/src/Api/Resource/PostResource.php create mode 100644 framework/core/src/Api/Resource/UserResource.php create mode 100644 framework/core/src/Api/Schema/Arr.php rename extensions/tags/src/Command/CreateTag.php => framework/core/src/Api/Schema/Attribute.php (51%) create mode 100644 framework/core/src/Api/Schema/Boolean.php create mode 100644 framework/core/src/Api/Schema/Date.php create mode 100644 framework/core/src/Api/Schema/DateTime.php create mode 100644 framework/core/src/Api/Schema/Integer.php create mode 100644 framework/core/src/Api/Schema/Number.php rename extensions/flags/src/Command/CreateFlag.php => framework/core/src/Api/Schema/Relationship/ToMany.php (51%) rename framework/core/src/{Group/Command/CreateGroup.php => Api/Schema/Relationship/ToOne.php} (51%) create mode 100644 framework/core/src/Api/Schema/Str.php create mode 100644 framework/core/src/Api/Schema/Type/Arr.php delete mode 100644 framework/core/src/Api/Serializer/AbstractSerializer.php delete mode 100644 framework/core/src/Api/Serializer/AccessTokenSerializer.php delete mode 100644 framework/core/src/Api/Serializer/BasicDiscussionSerializer.php delete mode 100644 framework/core/src/Api/Serializer/BasicPostSerializer.php delete mode 100644 framework/core/src/Api/Serializer/BasicUserSerializer.php delete mode 100644 framework/core/src/Api/Serializer/CurrentUserSerializer.php delete mode 100644 framework/core/src/Api/Serializer/DiscussionSerializer.php delete mode 100644 framework/core/src/Api/Serializer/ExtensionReadmeSerializer.php delete mode 100644 framework/core/src/Api/Serializer/ForumSerializer.php delete mode 100644 framework/core/src/Api/Serializer/GroupSerializer.php delete mode 100644 framework/core/src/Api/Serializer/MailSettingsSerializer.php delete mode 100644 framework/core/src/Api/Serializer/NotificationSerializer.php delete mode 100644 framework/core/src/Api/Serializer/PostSerializer.php delete mode 100644 framework/core/src/Api/Serializer/UserSerializer.php create mode 100644 framework/core/src/Api/Sort/SortColumn.php delete mode 100644 framework/core/src/Discussion/Command/DeleteDiscussion.php delete mode 100644 framework/core/src/Discussion/Command/DeleteDiscussionHandler.php delete mode 100644 framework/core/src/Discussion/Command/EditDiscussion.php delete mode 100644 framework/core/src/Discussion/Command/EditDiscussionHandler.php delete mode 100644 framework/core/src/Discussion/Command/StartDiscussion.php delete mode 100644 framework/core/src/Discussion/Command/StartDiscussionHandler.php delete mode 100644 framework/core/src/Discussion/DiscussionValidator.php delete mode 100644 framework/core/src/Extend/ApiController.php create mode 100644 framework/core/src/Extend/ApiResource.php delete mode 100644 framework/core/src/Extend/ApiSerializer.php create mode 100644 framework/core/src/Foundation/ErrorHandling/ExceptionHandler/JsonApiExceptionHandler.php delete mode 100644 framework/core/src/Group/Command/CreateGroupHandler.php delete mode 100644 framework/core/src/Group/Command/DeleteGroup.php delete mode 100644 framework/core/src/Group/Command/DeleteGroupHandler.php delete mode 100644 framework/core/src/Group/Command/EditGroup.php delete mode 100644 framework/core/src/Group/Command/EditGroupHandler.php delete mode 100644 framework/core/src/Group/GroupValidator.php rename framework/core/src/{User/Command/RegisterUser.php => Http/Exception/InvalidParameterException.php} (51%) create mode 100644 framework/core/src/Http/Middleware/PopulateWithActor.php delete mode 100644 framework/core/src/Post/Command/DeletePost.php delete mode 100644 framework/core/src/Post/Command/DeletePostHandler.php delete mode 100644 framework/core/src/Post/Command/EditPost.php delete mode 100644 framework/core/src/Post/Command/EditPostHandler.php delete mode 100644 framework/core/src/Post/Command/PostReply.php delete mode 100644 framework/core/src/Post/Command/PostReplyHandler.php delete mode 100644 framework/core/src/Post/PostValidator.php delete mode 100644 framework/core/src/User/Command/DeleteUser.php delete mode 100644 framework/core/src/User/Command/DeleteUserHandler.php delete mode 100644 framework/core/src/User/Command/EditUser.php delete mode 100644 framework/core/src/User/Command/EditUserHandler.php delete mode 100644 framework/core/src/User/Command/RegisterUserHandler.php delete mode 100644 framework/core/tests/integration/api/AbstractSerializeControllerTest.php create mode 100644 framework/core/tests/integration/api/notifications/UpdateTest.php create mode 100644 php-packages/phpstan/stubs/Tobyz/JsonApiServer/JsonApi.stub diff --git a/composer.json b/composer.json index 7b0e765d7..1f17448cb 100644 --- a/composer.json +++ b/composer.json @@ -108,7 +108,7 @@ "php": "^8.1", "ext-json": "*", "components/font-awesome": "^5.15.0", - "composer/composer": "^2.0", + "composer/composer": "^2.7", "dflydev/fig-cookies": "^3.0", "doctrine/dbal": "^3.6.2", "dragonmantank/cron-expression": "^3.3", @@ -151,7 +151,6 @@ "pusher/pusher-php-server": "^7.2", "s9e/text-formatter": "^2.13", "staudenmeir/eloquent-eager-limit": "^1.8.2", - "sycho/json-api": "^0.5.0", "sycho/sourcemap": "^2.0.0", "symfony/config": "^6.3", "symfony/console": "^6.3", @@ -163,6 +162,7 @@ "symfony/postmark-mailer": "^6.3", "symfony/translation": "^6.3", "symfony/yaml": "^6.3", + "flarum/json-api-server": "^1.0.0", "wikimedia/less.php": "^4.1" }, "require-dev": { diff --git a/extensions/approval/extend.php b/extensions/approval/extend.php index 85801046c..71f984c7a 100644 --- a/extensions/approval/extend.php +++ b/extensions/approval/extend.php @@ -7,9 +7,10 @@ * LICENSE file that was distributed with this source code. */ -use Flarum\Api\Serializer\BasicDiscussionSerializer; -use Flarum\Api\Serializer\PostSerializer; +use Flarum\Api\Resource; +use Flarum\Api\Schema; use Flarum\Approval\Access; +use Flarum\Approval\Api\PostResourceFields; use Flarum\Approval\Event\PostWasApproved; use Flarum\Approval\Listener; use Flarum\Discussion\Discussion; @@ -36,17 +37,13 @@ return [ ->default('is_approved', true) ->cast('is_approved', 'bool'), - (new Extend\ApiSerializer(BasicDiscussionSerializer::class)) - ->attribute('isApproved', function (BasicDiscussionSerializer $serializer, Discussion $discussion): bool { - return $discussion->is_approved; - }), + (new Extend\ApiResource(Resource\DiscussionResource::class)) + ->fields(fn () => [ + Schema\Boolean::make('isApproved'), + ]), - (new Extend\ApiSerializer(PostSerializer::class)) - ->attribute('isApproved', function ($serializer, Post $post) { - return (bool) $post->is_approved; - })->attribute('canApprove', function (PostSerializer $serializer, Post $post) { - return (bool) $serializer->getActor()->can('approvePosts', $post->discussion); - }), + (new Extend\ApiResource(Resource\PostResource::class)) + ->fields(PostResourceFields::class), new Extend\Locales(__DIR__.'/locale'), diff --git a/extensions/approval/src/Api/PostResourceFields.php b/extensions/approval/src/Api/PostResourceFields.php new file mode 100644 index 000000000..1219e478a --- /dev/null +++ b/extensions/approval/src/Api/PostResourceFields.php @@ -0,0 +1,29 @@ +writable(fn (Post $post, Context $context) => $context->getActor()->can('approve', $post)) + // set by the ApproveContent listener. + ->set(fn () => null), + Schema\Boolean::make('canApprove') + ->get(fn (Post $post, Context $context) => $context->getActor()->can('approvePosts', $post->discussion)), + ]; + } +} diff --git a/extensions/approval/tests/integration/api/ApprovePostsTest.php b/extensions/approval/tests/integration/api/ApprovePostsTest.php new file mode 100644 index 000000000..6a9b00088 --- /dev/null +++ b/extensions/approval/tests/integration/api/ApprovePostsTest.php @@ -0,0 +1,123 @@ +extension('flarum-approval'); + + $this->prepareDatabase([ + 'users' => [ + ['id' => 1, 'username' => 'Muralf', 'email' => 'muralf@machine.local', 'is_email_confirmed' => 1], + $this->normalUser(), + ['id' => 3, 'username' => 'acme', 'email' => 'acme@machine.local', 'is_email_confirmed' => 1], + ['id' => 4, 'username' => 'luceos', 'email' => 'luceos@machine.local', 'is_email_confirmed' => 1], + ], + 'discussions' => [ + ['id' => 1, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 4, 'first_post_id' => 1, 'comment_count' => 1, 'is_approved' => 1], + ], + 'posts' => [ + ['id' => 1, 'discussion_id' => 1, 'user_id' => 4, 'type' => 'comment', 'content' => '

Text

', 'hidden_at' => 0, 'is_approved' => 1, 'number' => 1], + ['id' => 2, 'discussion_id' => 1, 'user_id' => 4, 'type' => 'comment', 'content' => '

Text

', 'hidden_at' => 0, 'is_approved' => 1, 'number' => 2], + ['id' => 3, 'discussion_id' => 1, 'user_id' => 4, 'type' => 'comment', 'content' => '

Text

', 'hidden_at' => 0, 'is_approved' => 0, 'number' => 3], + ['id' => 4, 'discussion_id' => 1, 'user_id' => 4, 'type' => 'comment', 'content' => '

Text

', 'hidden_at' => Carbon::now(), 'is_approved' => 1, 'number' => 4], + ['id' => 5, 'discussion_id' => 1, 'user_id' => 4, 'type' => 'comment', 'content' => '

Text

', 'hidden_at' => 0, 'is_approved' => 0, 'number' => 5], + ], + 'groups' => [ + ['id' => 4, 'name_singular' => 'Acme', 'name_plural' => 'Acme', 'is_hidden' => 0], + ['id' => 5, 'name_singular' => 'Acme', 'name_plural' => 'Acme', 'is_hidden' => 0], + ], + 'group_user' => [ + ['user_id' => 3, 'group_id' => 4], + ], + 'group_permission' => [ + ['group_id' => 4, 'permission' => 'discussion.approvePosts'], + ] + ]); + } + + /** + * @test + */ + public function can_approve_unapproved_post() + { + $response = $this->send( + $this->request('PATCH', '/api/posts/3', [ + 'authenticatedAs' => 3, + 'json' => [ + 'data' => [ + 'attributes' => [ + 'isApproved' => true + ] + ] + ] + ]) + ); + + $this->assertEquals(200, $response->getStatusCode(), $response->getBody()->getContents()); + $this->assertEquals(1, $this->database()->table('posts')->where('id', 3)->where('is_approved', 1)->count()); + } + + /** + * @test + */ + public function cannot_approve_post_without_permission() + { + $response = $this->send( + $this->request('PATCH', '/api/posts/3', [ + 'authenticatedAs' => 4, + 'json' => [ + 'data' => [ + 'attributes' => [ + 'isApproved' => true + ] + ] + ] + ]) + ); + + $this->assertEquals(403, $response->getStatusCode(), $response->getBody()->getContents()); + $this->assertEquals(0, $this->database()->table('posts')->where('id', 3)->where('is_approved', 1)->count()); + } + + /** + * @test + */ + public function hiding_post_silently_approves_it() + { + $response = $this->send( + $this->request('PATCH', '/api/posts/5', [ + 'authenticatedAs' => 3, + 'json' => [ + 'data' => [ + 'attributes' => [ + 'isHidden' => true + ] + ] + ] + ]) + ); + + $this->assertEquals(200, $response->getStatusCode(), $response->getBody()->getContents()); + $this->assertEquals(1, $this->database()->table('posts')->where('id', 5)->where('is_approved', 1)->count()); + } +} diff --git a/extensions/approval/tests/integration/api/CreatePostsTest.php b/extensions/approval/tests/integration/api/CreatePostsTest.php new file mode 100644 index 000000000..82b8857f9 --- /dev/null +++ b/extensions/approval/tests/integration/api/CreatePostsTest.php @@ -0,0 +1,153 @@ +extension('flarum-flags', 'flarum-approval'); + + $this->prepareDatabase([ + 'users' => [ + ['id' => 1, 'username' => 'Muralf', 'email' => 'muralf@machine.local', 'is_email_confirmed' => 1], + $this->normalUser(), + ['id' => 3, 'username' => 'acme', 'email' => 'acme@machine.local', 'is_email_confirmed' => 1], + ['id' => 4, 'username' => 'luceos', 'email' => 'luceos@machine.local', 'is_email_confirmed' => 1], + ], + 'discussions' => [ + ['id' => 1, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 4, 'first_post_id' => 1, 'comment_count' => 1, 'is_approved' => 1], + ['id' => 2, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 4, 'first_post_id' => 2, 'comment_count' => 1, 'is_approved' => 0], + ['id' => 3, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 4, 'first_post_id' => 3, 'comment_count' => 1, 'is_approved' => 0], + ], + 'posts' => [ + ['id' => 1, 'discussion_id' => 1, 'user_id' => 4, 'type' => 'comment', 'content' => '

Text

', 'is_private' => 0, 'is_approved' => 1, 'number' => 1], + ['id' => 2, 'discussion_id' => 1, 'user_id' => 4, 'type' => 'comment', 'content' => '

Text

', 'is_private' => 0, 'is_approved' => 1, 'number' => 2], + ['id' => 3, 'discussion_id' => 1, 'user_id' => 4, 'type' => 'comment', 'content' => '

Text

', 'is_private' => 0, 'is_approved' => 1, 'number' => 3], + ['id' => 4, 'discussion_id' => 2, 'user_id' => 4, 'type' => 'comment', 'content' => '

Text

', 'is_private' => 0, 'is_approved' => 1, 'number' => 1], + ['id' => 5, 'discussion_id' => 2, 'user_id' => 4, 'type' => 'comment', 'content' => '

Text

', 'is_private' => 0, 'is_approved' => 1, 'number' => 2], + ['id' => 6, 'discussion_id' => 2, 'user_id' => 4, 'type' => 'comment', 'content' => '

Text

', 'is_private' => 0, 'is_approved' => 1, 'number' => 3], + ['id' => 7, 'discussion_id' => 3, 'user_id' => 4, 'type' => 'comment', 'content' => '

Text

', 'is_private' => 0, 'is_approved' => 1, 'number' => 1], + ['id' => 8, 'discussion_id' => 3, 'user_id' => 4, 'type' => 'comment', 'content' => '

Text

', 'is_private' => 0, 'is_approved' => 1, 'number' => 2], + ['id' => 9, 'discussion_id' => 3, 'user_id' => 4, 'type' => 'comment', 'content' => '

Text

', 'is_private' => 0, 'is_approved' => 0, 'number' => 3], + ], + 'groups' => [ + ['id' => 4, 'name_singular' => 'Acme', 'name_plural' => 'Acme', 'is_hidden' => 0], + ['id' => 5, 'name_singular' => 'Acme', 'name_plural' => 'Acme', 'is_hidden' => 0], + ], + 'group_user' => [ + ['user_id' => 3, 'group_id' => 4], + ['user_id' => 2, 'group_id' => 5], + ], + 'group_permission' => [ + ['group_id' => 4, 'permission' => 'discussion.startWithoutApproval'], + ['group_id' => 5, 'permission' => 'discussion.replyWithoutApproval'], + ] + ]); + } + + /** + * @dataProvider startDiscussionDataProvider + * @test + */ + public function can_start_discussion_without_approval_when_allowed(int $authenticatedAs, bool $allowed) + { + $this->database()->table('group_permission')->where('group_id', Group::MEMBER_ID)->where('permission', 'discussion.startWithoutApproval')->delete(); + + $response = $this->send( + $this->request('POST', '/api/discussions', [ + 'authenticatedAs' => $authenticatedAs, + 'json' => [ + 'data' => [ + 'type' => 'discussions', + 'attributes' => [ + 'title' => 'This is a new discussion', + 'content' => 'This is a new discussion', + ] + ] + ] + ]) + ); + + $body = $response->getBody()->getContents(); + $json = json_decode($body, true); + + $this->assertEquals(201, $response->getStatusCode(), $body); + $this->assertEquals($allowed ? 1 : 0, $this->database()->table('discussions')->where('id', $json['data']['id'])->value('is_approved')); + } + + /** + * @dataProvider replyToDiscussionDataProvider + * @test + */ + public function can_reply_without_approval_when_allowed(?int $authenticatedAs, bool $allowed) + { + $this->database()->table('group_permission')->where('group_id', Group::MEMBER_ID)->where('permission', 'discussion.replyWithoutApproval')->delete(); + + $response = $this->send( + $this->request('POST', '/api/posts', [ + 'authenticatedAs' => $authenticatedAs, + 'json' => [ + 'data' => [ + 'type' => 'posts', + 'attributes' => [ + 'content' => 'This is a new reply', + ], + 'relationships' => [ + 'discussion' => [ + 'data' => [ + 'type' => 'discussions', + 'id' => 1 + ] + ] + ] + ] + ] + ]) + ); + + $body = $response->getBody()->getContents(); + $json = json_decode($body, true); + + $this->assertEquals(201, $response->getStatusCode(), $body); + $this->assertEquals($allowed ? 1 : 0, $this->database()->table('posts')->where('id', $json['data']['id'])->value('is_approved')); + } + + public static function startDiscussionDataProvider(): array + { + return [ + 'Admin' => [1, true], + 'User without permission' => [2, false], + 'Permission Given' => [3, true], + 'Another user without permission' => [4, false], + ]; + } + + public static function replyToDiscussionDataProvider(): array + { + return [ + 'Admin' => [1, true], + 'User without permission' => [3, false], + 'Permission Given' => [2, true], + 'Another user without permission' => [4, false], + ]; + } +} diff --git a/extensions/flags/extend.php b/extensions/flags/extend.php index 3bc6db108..71c93643f 100644 --- a/extensions/flags/extend.php +++ b/extensions/flags/extend.php @@ -7,25 +7,17 @@ * LICENSE file that was distributed with this source code. */ -use Flarum\Api\Controller\AbstractSerializeController; -use Flarum\Api\Controller\ListPostsController; -use Flarum\Api\Controller\ShowDiscussionController; -use Flarum\Api\Controller\ShowPostController; -use Flarum\Api\Serializer\CurrentUserSerializer; -use Flarum\Api\Serializer\ForumSerializer; -use Flarum\Api\Serializer\PostSerializer; +use Flarum\Api\Endpoint; +use Flarum\Api\Resource; use Flarum\Extend; use Flarum\Flags\Access\ScopeFlagVisibility; -use Flarum\Flags\AddCanFlagAttribute; -use Flarum\Flags\AddFlagsApiAttributes; -use Flarum\Flags\AddNewFlagCountAttribute; -use Flarum\Flags\Api\Controller\CreateFlagController; use Flarum\Flags\Api\Controller\DeleteFlagsController; -use Flarum\Flags\Api\Controller\ListFlagsController; -use Flarum\Flags\Api\Serializer\FlagSerializer; +use Flarum\Flags\Api\ForumResourceFields; +use Flarum\Flags\Api\PostResourceFields; +use Flarum\Flags\Api\Resource\FlagResource; +use Flarum\Flags\Api\UserResourceFields; use Flarum\Flags\Flag; use Flarum\Flags\Listener; -use Flarum\Flags\PrepareFlagsApiData; use Flarum\Forum\Content\AssertRegistered; use Flarum\Post\Event\Deleted; use Flarum\Post\Post; @@ -41,8 +33,6 @@ return [ ->js(__DIR__.'/js/dist/admin.js'), (new Extend\Routes('api')) - ->get('/flags', 'flags.index', ListFlagsController::class) - ->post('/flags', 'flags.create', CreateFlagController::class) ->delete('/posts/{id}/flags', 'flags.delete', DeleteFlagsController::class), (new Extend\Model(User::class)) @@ -51,27 +41,26 @@ return [ (new Extend\Model(Post::class)) ->hasMany('flags', Flag::class, 'post_id'), - (new Extend\ApiSerializer(PostSerializer::class)) - ->hasMany('flags', FlagSerializer::class) - ->attribute('canFlag', AddCanFlagAttribute::class), + new Extend\ApiResource(FlagResource::class), - (new Extend\ApiSerializer(CurrentUserSerializer::class)) - ->attribute('newFlagCount', AddNewFlagCountAttribute::class), + (new Extend\ApiResource(Resource\PostResource::class)) + ->fields(PostResourceFields::class), - (new Extend\ApiSerializer(ForumSerializer::class)) - ->attributes(AddFlagsApiAttributes::class), + (new Extend\ApiResource(Resource\UserResource::class)) + ->fields(UserResourceFields::class), - (new Extend\ApiController(ShowDiscussionController::class)) - ->addInclude(['posts.flags', 'posts.flags.user']), + (new Extend\ApiResource(Resource\ForumResource::class)) + ->fields(ForumResourceFields::class), - (new Extend\ApiController(ListPostsController::class)) - ->addInclude(['flags', 'flags.user']), + (new Extend\ApiResource(Resource\DiscussionResource::class)) + ->endpoint(Endpoint\Show::class, function (Endpoint\Show $endpoint) { + return $endpoint->addDefaultInclude(['posts.flags', 'posts.flags.user']); + }), - (new Extend\ApiController(ShowPostController::class)) - ->addInclude(['flags', 'flags.user']), - - (new Extend\ApiController(AbstractSerializeController::class)) - ->prepareDataForSerialization(PrepareFlagsApiData::class), + (new Extend\ApiResource(Resource\PostResource::class)) + ->endpoint([Endpoint\Index::class, Endpoint\Show::class], function (Endpoint\Index|Endpoint\Show $endpoint) { + return $endpoint->addDefaultInclude(['flags', 'flags.user']); + }), (new Extend\Settings()) ->serializeToForum('guidelinesUrl', 'flarum-flags.guidelines_url'), diff --git a/extensions/flags/js/dist/forum.js b/extensions/flags/js/dist/forum.js index f93273b12..6d70775d1 100644 --- a/extensions/flags/js/dist/forum.js +++ b/extensions/flags/js/dist/forum.js @@ -1,2 +1,2 @@ -(()=>{var t={n:a=>{var e=a&&a.__esModule?()=>a.default:()=>a;return t.d(e,{a:e}),e},d:(a,e)=>{for(var s in e)t.o(e,s)&&!t.o(a,s)&&Object.defineProperty(a,s,{enumerable:!0,get:e[s]})},o:(t,a)=>Object.prototype.hasOwnProperty.call(t,a),r:t=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})}},a={};(()=>{"use strict";t.r(a),t.d(a,{extend:()=>st});const e=flarum.reg.get("core","forum/app");var s=t.n(e);function r(t){return r="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},r(t)}const o=flarum.reg.get("core","common/states/PaginatedListState");var n=t.n(o);class l extends(n()){constructor(t){var a,e,s;super({},1,null),a=this,s=void 0,(e=function(t){var a=function(t,a){if("object"!==r(t)||null===t)return t;var e=t[Symbol.toPrimitive];if(void 0!==e){var s=e.call(t,a);if("object"!==r(s))return s;throw new TypeError("@@toPrimitive must return a primitive value.")}return String(t)}(t,"string");return"symbol"===r(a)?a:String(a)}(e="app"))in a?Object.defineProperty(a,e,{value:s,enumerable:!0,configurable:!0,writable:!0}):a[e]=s,this.app=t}get type(){return"flags"}load(){var t;return null!=(t=this.app.session.user)&&t.attribute("newFlagCount")&&(this.pages=[],this.location={page:1}),this.pages.length>0?Promise.resolve():super.loadNext()}}flarum.reg.add("flarum-flags","forum/states/FlagListState",l);const i=flarum.reg.get("core","common/extend"),u=flarum.reg.get("core","forum/utils/PostControls");var c=t.n(u);const f=flarum.reg.get("core","common/components/Button");var g=t.n(f);const d=flarum.reg.get("core","common/components/FormModal");var p=t.n(d);const h=flarum.reg.get("core","common/components/Form");var v=t.n(h);const b=flarum.reg.get("core","common/utils/Stream");var _=t.n(b);const y=flarum.reg.get("core","common/utils/withAttr");var N=t.n(y);const x=flarum.reg.get("core","common/utils/ItemList");var F=t.n(x);class P extends(p()){oninit(t){super.oninit(t),this.success=!1,this.reason=_()(""),this.reasonDetail=_()("")}className(){return"FlagPostModal Modal--medium"}title(){return s().translator.trans("flarum-flags.forum.flag_post.title")}content(){return this.success?m("div",{className:"Modal-body"},m(v(),{className:"Form--centered"},m("p",{className:"helpText"},s().translator.trans("flarum-flags.forum.flag_post.confirmation_message")),m("div",{className:"Form-group Form-controls"},m(g(),{className:"Button Button--primary Button--block",onclick:this.hide.bind(this)},s().translator.trans("flarum-flags.forum.flag_post.dismiss_button"))))):m("div",{className:"Modal-body"},m(v(),{className:"Form--centered"},m("div",{className:"Form-group"},m("div",null,this.flagReasons().toArray())),m("div",{className:"Form-group Form-controls"},m(g(),{className:"Button Button--primary Button--block",type:"submit",loading:this.loading,disabled:!this.reason()},s().translator.trans("flarum-flags.forum.flag_post.submit_button")))))}flagReasons(){const t=new(F()),a=s().forum.attribute("guidelinesUrl");return t.add("off-topic",m("label",{className:"checkbox"},m("input",{type:"radio",name:"reason",checked:"off_topic"===this.reason(),value:"off_topic",onclick:N()("value",this.reason)}),m("strong",null,s().translator.trans("flarum-flags.forum.flag_post.reason_off_topic_label")),s().translator.trans("flarum-flags.forum.flag_post.reason_off_topic_text"),"off_topic"===this.reason()&&m("textarea",{className:"FormControl",placeholder:s().translator.trans("flarum-flags.forum.flag_post.reason_details_placeholder"),value:this.reasonDetail(),oninput:N()("value",this.reasonDetail)})),70),t.add("inappropriate",m("label",{className:"checkbox"},m("input",{type:"radio",name:"reason",checked:"inappropriate"===this.reason(),value:"inappropriate",onclick:N()("value",this.reason)}),m("strong",null,s().translator.trans("flarum-flags.forum.flag_post.reason_inappropriate_label")),s().translator.trans("flarum-flags.forum.flag_post.reason_inappropriate_text",{a:a?m("a",{href:a,target:"_blank"}):void 0}),"inappropriate"===this.reason()&&m("textarea",{className:"FormControl",placeholder:s().translator.trans("flarum-flags.forum.flag_post.reason_details_placeholder"),value:this.reasonDetail(),oninput:N()("value",this.reasonDetail)})),60),t.add("spam",m("label",{className:"checkbox"},m("input",{type:"radio",name:"reason",checked:"spam"===this.reason(),value:"spam",onclick:N()("value",this.reason)}),m("strong",null,s().translator.trans("flarum-flags.forum.flag_post.reason_spam_label")),s().translator.trans("flarum-flags.forum.flag_post.reason_spam_text"),"spam"===this.reason()&&m("textarea",{className:"FormControl",placeholder:s().translator.trans("flarum-flags.forum.flag_post.reason_details_placeholder"),value:this.reasonDetail(),oninput:N()("value",this.reasonDetail)})),50),t.add("other",m("label",{className:"checkbox"},m("input",{type:"radio",name:"reason",checked:"other"===this.reason(),value:"other",onclick:N()("value",this.reason)}),m("strong",null,s().translator.trans("flarum-flags.forum.flag_post.reason_other_label")),"other"===this.reason()&&m("textarea",{className:"FormControl",value:this.reasonDetail(),oninput:N()("value",this.reasonDetail)})),10),t}onsubmit(t){t.preventDefault(),this.loading=!0,s().store.createRecord("flags").save({reason:"other"===this.reason()?null:this.reason(),reasonDetail:this.reasonDetail(),relationships:{user:s().session.user,post:this.attrs.post}},{errorHandler:this.onerror.bind(this)}).then((()=>this.success=!0)).catch((()=>{})).then(this.loaded.bind(this))}}flarum.reg.add("flarum-flags","forum/components/FlagPostModal",P);const w=flarum.reg.get("core","forum/components/HeaderSecondary");var k=t.n(w);const D=flarum.reg.get("core","forum/components/HeaderDropdown");var S=t.n(D);const C=flarum.reg.get("core","common/utils/classList");var A=t.n(C);const L=flarum.reg.get("core","common/Component");var M=t.n(L);const B=flarum.reg.get("core","common/components/Avatar");var O=t.n(B);const j=flarum.reg.get("core","common/helpers/username");var T=t.n(j);const H=flarum.reg.get("core","forum/components/HeaderList");var I=t.n(H);const R=flarum.reg.get("core","forum/components/HeaderListItem");var E=t.n(R);class U extends(M()){oninit(t){super.oninit(t)}view(){const t=this.attrs.state;return m(I(),{className:"FlagList",title:s().translator.trans("flarum-flags.forum.flagged_posts.title"),hasItems:t.hasItems(),loading:t.isLoading(),emptyText:s().translator.trans("flarum-flags.forum.flagged_posts.empty_text"),loadMore:()=>t.hasNext()&&!t.isLoadingNext()&&t.loadNext()},m("ul",{className:"HeaderListGroup-content"},this.content(t)))}content(t){return!t.isLoading()&&t.hasItems()?t.getPages().map((t=>t.items.map((t=>{const a=t.post();return m("li",null,m(E(),{className:"Flag",avatar:m(O(),{user:a.user()||null}),icon:"fas fa-flag",content:s().translator.trans("flarum-flags.forum.flagged_posts.item_text",{username:T()(a.user()),em:m("em",null),discussion:a.discussion().title()}),excerpt:a.contentPlain(),datetime:t.createdAt(),href:s().route.post(a),onclick:t=>{t.redraw=!1}}))})))):null}}flarum.reg.add("flarum-flags","forum/components/FlagList",U);class G extends(S()){static initAttrs(t){t.className=A()("FlagsDropdown",t.className),t.label=t.label||s().translator.trans("flarum-flags.forum.flagged_posts.tooltip"),t.icon=t.icon||"fas fa-flag",super.initAttrs(t)}getContent(){return m(U,{state:this.attrs.state})}goToRoute(){m.route.set(s().route("flags"))}getUnreadCount(){return s().forum.attribute("flagCount")}getNewCount(){return s().session.user.attribute("newFlagCount")}}flarum.reg.add("flarum-flags","forum/components/FlagsDropdown",G);const q=flarum.reg.get("core","forum/components/Post");var z=t.n(q);const V=flarum.reg.get("core","common/utils/humanTime");var J=t.n(V);const K=flarum.reg.get("core","common/extenders");var Q=t.n(K);const W=flarum.reg.get("core","common/models/Post");var X=t.n(W);const Y=flarum.reg.get("core","common/components/Page");var Z=t.n(Y);class $ extends(Z()){oninit(t){super.oninit(t),s().history.push("flags"),s().flags.load(),this.bodyClass="App--flags"}view(){return m("div",{className:"FlagsPage"},m(U,{state:s().flags}))}}flarum.reg.add("flarum-flags","forum/components/FlagsPage",$);const tt=flarum.reg.get("core","common/Model");var at=t.n(tt);class et extends(at()){type(){return at().attribute("type").call(this)}reason(){return at().attribute("reason").call(this)}reasonDetail(){return at().attribute("reasonDetail").call(this)}createdAt(){return at().attribute("createdAt",at().transformDate).call(this)}post(){return at().hasOne("post").call(this)}user(){return at().hasOne("user").call(this)}}flarum.reg.add("flarum-flags","forum/models/Flag",et);const st=[(new(Q().Routes)).add("flags","/flags",$),(new(Q().Store)).add("flags",et),new(Q().Model)(X()).hasMany("flags").attribute("canFlag")];s().initializers.add("flarum-flags",(()=>{s().flags=new l(s()),(0,i.extend)(c(),"userControls",(function(t,a){!a.isHidden()&&"comment"===a.contentType()&&a.canFlag()&&t.add("flag",m(g(),{icon:"fas fa-flag",onclick:()=>s().modal.show(P,{post:a})},s().translator.trans("flarum-flags.forum.post_controls.flag_button")))})),(0,i.extend)(k().prototype,"items",(function(t){s().forum.attribute("canViewFlags")&&t.add("flags",m(G,{state:s().flags}),15)})),(0,i.extend)(z().prototype,"elementAttrs",(function(t){this.attrs.post.flags().length&&(t.className+=" Post--flagged")})),z().prototype.dismissFlag=function(t){const a=this.attrs.post;return delete a.data.relationships.flags,this.subtree.invalidate(),s().flags.cache&&s().flags.cache.some(((t,e)=>{if(t.post()===a){if(s().flags.cache.splice(e,1),s().flags.index===a){let t=s().flags.cache[e];if(t||(t=s().flags.cache[0]),t){const a=t.post();s().flags.index=a,m.route.set(s().route.post(a))}}return!0}})),s().request({url:s().forum.attribute("apiUrl")+a.apiEndpoint()+"/flags",method:"DELETE",body:t})},z().prototype.flagActionItems=function(){const t=new(F()),a=c().destructiveControls(this.attrs.post);return Object.keys(a.toObject()).forEach((t=>{const e=a.get(t).attrs;e.className="Button",(0,i.extend)(e,"onclick",(()=>this.dismissFlag()))})),t.add("controls",m("div",{className:"ButtonGroup"},a.toArray())),t.add("dismiss",m(g(),{className:"Button",icon:"far fa-eye-slash",onclick:this.dismissFlag.bind(this)},s().translator.trans("flarum-flags.forum.post.dismiss_flag_button")),-100),t},(0,i.override)(z().prototype,"header",(function(t){const a=this.attrs.post,e=a.flags();if(e.length)return a.isHidden()&&(this.revealContent=!0),m("div",{className:"Post-flagged"},m("div",{className:"Post-flagged-flags"},e.map((t=>m("div",{className:"Post-flagged-flag"},this.flagReason(t))))),m("div",{className:"Post-flagged-actions"},this.flagActionItems().toArray()))})),z().prototype.flagReason=function(t){if("user"===t.type()){const a=t.user(),e=t.reason()?s().translator.trans("flarum-flags.forum.flag_post.reason_".concat(t.reason(),"_label")):null,r=t.reasonDetail(),o=J()(t.createdAt());return[s().translator.trans(e?"flarum-flags.forum.post.flagged_by_with_reason_text":"flarum-flags.forum.post.flagged_by_text",{time:o,user:a,reason:e}),!!r&&m("span",{className:"Post-flagged-detail"},r)]}}}))})(),module.exports=a})(); +(()=>{var t={n:a=>{var e=a&&a.__esModule?()=>a.default:()=>a;return t.d(e,{a:e}),e},d:(a,e)=>{for(var s in e)t.o(e,s)&&!t.o(a,s)&&Object.defineProperty(a,s,{enumerable:!0,get:e[s]})},o:(t,a)=>Object.prototype.hasOwnProperty.call(t,a),r:t=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})}},a={};(()=>{"use strict";t.r(a),t.d(a,{extend:()=>st});const e=flarum.reg.get("core","forum/app");var s=t.n(e);function r(t){return r="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},r(t)}const o=flarum.reg.get("core","common/states/PaginatedListState");var n=t.n(o);class l extends(n()){constructor(t){var a,e,s;super({},1,null),a=this,s=void 0,(e=function(t){var a=function(t,a){if("object"!==r(t)||null===t)return t;var e=t[Symbol.toPrimitive];if(void 0!==e){var s=e.call(t,a);if("object"!==r(s))return s;throw new TypeError("@@toPrimitive must return a primitive value.")}return String(t)}(t,"string");return"symbol"===r(a)?a:String(a)}(e="app"))in a?Object.defineProperty(a,e,{value:s,enumerable:!0,configurable:!0,writable:!0}):a[e]=s,this.app=t}get type(){return"flags"}load(){var t;return null!=(t=this.app.session.user)&&t.attribute("newFlagCount")&&(this.pages=[],this.location={page:1}),this.pages.length>0?Promise.resolve():super.loadNext()}}flarum.reg.add("flarum-flags","forum/states/FlagListState",l);const i=flarum.reg.get("core","common/extend"),u=flarum.reg.get("core","forum/utils/PostControls");var c=t.n(u);const f=flarum.reg.get("core","common/components/Button");var g=t.n(f);const d=flarum.reg.get("core","common/components/FormModal");var p=t.n(d);const h=flarum.reg.get("core","common/components/Form");var v=t.n(h);const b=flarum.reg.get("core","common/utils/Stream");var _=t.n(b);const y=flarum.reg.get("core","common/utils/withAttr");var N=t.n(y);const x=flarum.reg.get("core","common/utils/ItemList");var F=t.n(x);class P extends(p()){oninit(t){super.oninit(t),this.success=!1,this.reason=_()(""),this.reasonDetail=_()("")}className(){return"FlagPostModal Modal--medium"}title(){return s().translator.trans("flarum-flags.forum.flag_post.title")}content(){return this.success?m("div",{className:"Modal-body"},m(v(),{className:"Form--centered"},m("p",{className:"helpText"},s().translator.trans("flarum-flags.forum.flag_post.confirmation_message")),m("div",{className:"Form-group Form-controls"},m(g(),{className:"Button Button--primary Button--block",onclick:this.hide.bind(this)},s().translator.trans("flarum-flags.forum.flag_post.dismiss_button"))))):m("div",{className:"Modal-body"},m(v(),{className:"Form--centered"},m("div",{className:"Form-group"},m("div",null,this.flagReasons().toArray())),m("div",{className:"Form-group Form-controls"},m(g(),{className:"Button Button--primary Button--block",type:"submit",loading:this.loading,disabled:!this.reason()},s().translator.trans("flarum-flags.forum.flag_post.submit_button")))))}flagReasons(){const t=new(F()),a=s().forum.attribute("guidelinesUrl");return t.add("off-topic",m("label",{className:"checkbox"},m("input",{type:"radio",name:"reason",checked:"off_topic"===this.reason(),value:"off_topic",onclick:N()("value",this.reason)}),m("strong",null,s().translator.trans("flarum-flags.forum.flag_post.reason_off_topic_label")),s().translator.trans("flarum-flags.forum.flag_post.reason_off_topic_text"),"off_topic"===this.reason()&&m("textarea",{className:"FormControl",placeholder:s().translator.trans("flarum-flags.forum.flag_post.reason_details_placeholder"),value:this.reasonDetail(),oninput:N()("value",this.reasonDetail)})),70),t.add("inappropriate",m("label",{className:"checkbox"},m("input",{type:"radio",name:"reason",checked:"inappropriate"===this.reason(),value:"inappropriate",onclick:N()("value",this.reason)}),m("strong",null,s().translator.trans("flarum-flags.forum.flag_post.reason_inappropriate_label")),s().translator.trans("flarum-flags.forum.flag_post.reason_inappropriate_text",{a:a?m("a",{href:a,target:"_blank"}):void 0}),"inappropriate"===this.reason()&&m("textarea",{className:"FormControl",placeholder:s().translator.trans("flarum-flags.forum.flag_post.reason_details_placeholder"),value:this.reasonDetail(),oninput:N()("value",this.reasonDetail)})),60),t.add("spam",m("label",{className:"checkbox"},m("input",{type:"radio",name:"reason",checked:"spam"===this.reason(),value:"spam",onclick:N()("value",this.reason)}),m("strong",null,s().translator.trans("flarum-flags.forum.flag_post.reason_spam_label")),s().translator.trans("flarum-flags.forum.flag_post.reason_spam_text"),"spam"===this.reason()&&m("textarea",{className:"FormControl",placeholder:s().translator.trans("flarum-flags.forum.flag_post.reason_details_placeholder"),value:this.reasonDetail(),oninput:N()("value",this.reasonDetail)})),50),t.add("other",m("label",{className:"checkbox"},m("input",{type:"radio",name:"reason",checked:"other"===this.reason(),value:"other",onclick:N()("value",this.reason)}),m("strong",null,s().translator.trans("flarum-flags.forum.flag_post.reason_other_label")),"other"===this.reason()&&m("textarea",{className:"FormControl",value:this.reasonDetail(),oninput:N()("value",this.reasonDetail)})),10),t}onsubmit(t){t.preventDefault(),this.loading=!0,s().store.createRecord("flags").save({reason:"other"===this.reason()?null:this.reason(),reasonDetail:this.reasonDetail(),relationships:{post:this.attrs.post}},{errorHandler:this.onerror.bind(this)}).then((()=>this.success=!0)).catch((()=>{})).then(this.loaded.bind(this))}}flarum.reg.add("flarum-flags","forum/components/FlagPostModal",P);const w=flarum.reg.get("core","forum/components/HeaderSecondary");var k=t.n(w);const D=flarum.reg.get("core","forum/components/HeaderDropdown");var S=t.n(D);const C=flarum.reg.get("core","common/utils/classList");var A=t.n(C);const L=flarum.reg.get("core","common/Component");var M=t.n(L);const B=flarum.reg.get("core","common/components/Avatar");var O=t.n(B);const j=flarum.reg.get("core","common/helpers/username");var T=t.n(j);const H=flarum.reg.get("core","forum/components/HeaderList");var I=t.n(H);const R=flarum.reg.get("core","forum/components/HeaderListItem");var E=t.n(R);class U extends(M()){oninit(t){super.oninit(t)}view(){const t=this.attrs.state;return m(I(),{className:"FlagList",title:s().translator.trans("flarum-flags.forum.flagged_posts.title"),hasItems:t.hasItems(),loading:t.isLoading(),emptyText:s().translator.trans("flarum-flags.forum.flagged_posts.empty_text"),loadMore:()=>t.hasNext()&&!t.isLoadingNext()&&t.loadNext()},m("ul",{className:"HeaderListGroup-content"},this.content(t)))}content(t){return!t.isLoading()&&t.hasItems()?t.getPages().map((t=>t.items.map((t=>{const a=t.post();return m("li",null,m(E(),{className:"Flag",avatar:m(O(),{user:a.user()||null}),icon:"fas fa-flag",content:s().translator.trans("flarum-flags.forum.flagged_posts.item_text",{username:T()(a.user()),em:m("em",null),discussion:a.discussion().title()}),excerpt:a.contentPlain(),datetime:t.createdAt(),href:s().route.post(a),onclick:t=>{t.redraw=!1}}))})))):null}}flarum.reg.add("flarum-flags","forum/components/FlagList",U);class G extends(S()){static initAttrs(t){t.className=A()("FlagsDropdown",t.className),t.label=t.label||s().translator.trans("flarum-flags.forum.flagged_posts.tooltip"),t.icon=t.icon||"fas fa-flag",super.initAttrs(t)}getContent(){return m(U,{state:this.attrs.state})}goToRoute(){m.route.set(s().route("flags"))}getUnreadCount(){return s().forum.attribute("flagCount")}getNewCount(){return s().session.user.attribute("newFlagCount")}}flarum.reg.add("flarum-flags","forum/components/FlagsDropdown",G);const q=flarum.reg.get("core","forum/components/Post");var z=t.n(q);const V=flarum.reg.get("core","common/utils/humanTime");var J=t.n(V);const K=flarum.reg.get("core","common/extenders");var Q=t.n(K);const W=flarum.reg.get("core","common/models/Post");var X=t.n(W);const Y=flarum.reg.get("core","common/components/Page");var Z=t.n(Y);class $ extends(Z()){oninit(t){super.oninit(t),s().history.push("flags"),s().flags.load(),this.bodyClass="App--flags"}view(){return m("div",{className:"FlagsPage"},m(U,{state:s().flags}))}}flarum.reg.add("flarum-flags","forum/components/FlagsPage",$);const tt=flarum.reg.get("core","common/Model");var at=t.n(tt);class et extends(at()){type(){return at().attribute("type").call(this)}reason(){return at().attribute("reason").call(this)}reasonDetail(){return at().attribute("reasonDetail").call(this)}createdAt(){return at().attribute("createdAt",at().transformDate).call(this)}post(){return at().hasOne("post").call(this)}user(){return at().hasOne("user").call(this)}}flarum.reg.add("flarum-flags","forum/models/Flag",et);const st=[(new(Q().Routes)).add("flags","/flags",$),(new(Q().Store)).add("flags",et),new(Q().Model)(X()).hasMany("flags").attribute("canFlag")];s().initializers.add("flarum-flags",(()=>{s().flags=new l(s()),(0,i.extend)(c(),"userControls",(function(t,a){!a.isHidden()&&"comment"===a.contentType()&&a.canFlag()&&t.add("flag",m(g(),{icon:"fas fa-flag",onclick:()=>s().modal.show(P,{post:a})},s().translator.trans("flarum-flags.forum.post_controls.flag_button")))})),(0,i.extend)(k().prototype,"items",(function(t){s().forum.attribute("canViewFlags")&&t.add("flags",m(G,{state:s().flags}),15)})),(0,i.extend)(z().prototype,"elementAttrs",(function(t){this.attrs.post.flags().length&&(t.className+=" Post--flagged")})),z().prototype.dismissFlag=function(t){const a=this.attrs.post;return delete a.data.relationships.flags,this.subtree.invalidate(),s().flags.cache&&s().flags.cache.some(((t,e)=>{if(t.post()===a){if(s().flags.cache.splice(e,1),s().flags.index===a){let t=s().flags.cache[e];if(t||(t=s().flags.cache[0]),t){const a=t.post();s().flags.index=a,m.route.set(s().route.post(a))}}return!0}})),s().request({url:s().forum.attribute("apiUrl")+a.apiEndpoint()+"/flags",method:"DELETE",body:t})},z().prototype.flagActionItems=function(){const t=new(F()),a=c().destructiveControls(this.attrs.post);return Object.keys(a.toObject()).forEach((t=>{const e=a.get(t).attrs;e.className="Button",(0,i.extend)(e,"onclick",(()=>this.dismissFlag()))})),t.add("controls",m("div",{className:"ButtonGroup"},a.toArray())),t.add("dismiss",m(g(),{className:"Button",icon:"far fa-eye-slash",onclick:this.dismissFlag.bind(this)},s().translator.trans("flarum-flags.forum.post.dismiss_flag_button")),-100),t},(0,i.override)(z().prototype,"header",(function(t){const a=this.attrs.post,e=a.flags();if(e.length)return a.isHidden()&&(this.revealContent=!0),m("div",{className:"Post-flagged"},m("div",{className:"Post-flagged-flags"},e.map((t=>m("div",{className:"Post-flagged-flag"},this.flagReason(t))))),m("div",{className:"Post-flagged-actions"},this.flagActionItems().toArray()))})),z().prototype.flagReason=function(t){if("user"===t.type()){const a=t.user(),e=t.reason()?s().translator.trans("flarum-flags.forum.flag_post.reason_".concat(t.reason(),"_label")):null,r=t.reasonDetail(),o=J()(t.createdAt());return[s().translator.trans(e?"flarum-flags.forum.post.flagged_by_with_reason_text":"flarum-flags.forum.post.flagged_by_text",{time:o,user:a,reason:e}),!!r&&m("span",{className:"Post-flagged-detail"},r)]}}}))})(),module.exports=a})(); //# sourceMappingURL=forum.js.map \ No newline at end of file diff --git a/extensions/flags/js/dist/forum.js.map b/extensions/flags/js/dist/forum.js.map index b12fc74d7..0138391fc 100644 --- a/extensions/flags/js/dist/forum.js.map +++ b/extensions/flags/js/dist/forum.js.map @@ -1 +1 @@ -{"version":3,"file":"forum.js","mappings":"MACA,IAAIA,EAAsB,CCA1BA,EAAyBC,IACxB,IAAIC,EAASD,GAAUA,EAAOE,WAC7B,IAAOF,EAAiB,QACxB,IAAM,EAEP,OADAD,EAAoBI,EAAEF,EAAQ,CAAEG,EAAGH,IAC5BA,CAAM,ECLdF,EAAwB,CAACM,EAASC,KACjC,IAAI,IAAIC,KAAOD,EACXP,EAAoBS,EAAEF,EAAYC,KAASR,EAAoBS,EAAEH,EAASE,IAC5EE,OAAOC,eAAeL,EAASE,EAAK,CAAEI,YAAY,EAAMC,IAAKN,EAAWC,IAE1E,ECNDR,EAAwB,CAACc,EAAKC,IAAUL,OAAOM,UAAUC,eAAeC,KAAKJ,EAAKC,GCClFf,EAAyBM,IACH,oBAAXa,QAA0BA,OAAOC,aAC1CV,OAAOC,eAAeL,EAASa,OAAOC,YAAa,CAAEC,MAAO,WAE7DX,OAAOC,eAAeL,EAAS,aAAc,CAAEe,OAAO,GAAO,G,sDCL9D,MAAM,EAA+BC,OAAOC,IAAIV,IAAI,OAAQ,a,aCA7C,SAASW,EAAQV,GAG9B,OAAOU,EAAU,mBAAqBL,QAAU,iBAAmBA,OAAOM,SAAW,SAAUX,GAC7F,cAAcA,CAChB,EAAI,SAAUA,GACZ,OAAOA,GAAO,mBAAqBK,QAAUL,EAAIY,cAAgBP,QAAUL,IAAQK,OAAOH,UAAY,gBAAkBF,CAC1H,EAAGU,EAAQV,EACb,CCRA,MAAM,EAA+BQ,OAAOC,IAAIV,IAAI,OAAQ,oC,aCE7C,MAAMc,UAAsB,KACzCD,YAAYE,GCFC,IAAyBd,EAAKN,EAAKa,EDG9CQ,MAAM,CAAC,EAAG,EAAG,MCHuBf,EDIpBgB,KCJ8BT,ODIjB,GCH/Bb,ECAa,SAAwBuB,GACrC,IAAIvB,ECFS,SAAsBwB,EAAOC,GAC1C,GAAuB,WAAnBT,EAAQQ,IAAiC,OAAVA,EAAgB,OAAOA,EAC1D,IAAIE,EAAOF,EAAMb,OAAOgB,aACxB,QAAaC,IAATF,EAAoB,CACtB,IAAIG,EAAMH,EAAKhB,KAAKc,EAAOC,GAC3B,GAAqB,WAAjBT,EAAQa,GAAmB,OAAOA,EACtC,MAAM,IAAIC,UAAU,+CACtB,CACA,OAA4BC,OAAiBP,EAC/C,CDPYG,CAAYJ,EAAK,UAC3B,MAAwB,WAAjBP,EAAQhB,GAAoBA,EAAM+B,OAAO/B,EAClD,CDHQgC,CADqChC,EDInB,UCFbM,EACTJ,OAAOC,eAAeG,EAAKN,EAAK,CAC9Ba,MAAOA,EACPT,YAAY,EACZ6B,cAAc,EACdC,UAAU,IAGZ5B,EAAIN,GAAOa,EDLXS,KAAKF,IAAMA,CACb,CACIe,WACF,MAAO,OACT,CAMAC,OACE,IAAIC,EAOJ,OANuD,OAAlDA,EAAwBf,KAAKF,IAAIkB,QAAQC,OAAiBF,EAAsBG,UAAU,kBAC7FlB,KAAKmB,MAAQ,GACbnB,KAAKoB,SAAW,CACdC,KAAM,IAGNrB,KAAKmB,MAAMG,OAAS,EACfC,QAAQC,UAEVzB,MAAM0B,UACf,EAEFjC,OAAOC,IAAIiC,IAAI,eAAgB,6BAA8B7B,GI9B7D,MAAM,EAA+BL,OAAOC,IAAIV,IAAI,OAAQ,iBCAtD,EAA+BS,OAAOC,IAAIV,IAAI,OAAQ,4B,aCA5D,MAAM,EAA+BS,OAAOC,IAAIV,IAAI,OAAQ,4B,aCA5D,MAAM,EAA+BS,OAAOC,IAAIV,IAAI,OAAQ,+B,aCA5D,MAAM,EAA+BS,OAAOC,IAAIV,IAAI,OAAQ,0B,aCA5D,MAAM,EAA+BS,OAAOC,IAAIV,IAAI,OAAQ,uB,aCA5D,MAAM,EAA+BS,OAAOC,IAAIV,IAAI,OAAQ,yB,aCA5D,MAAM,EAA+BS,OAAOC,IAAIV,IAAI,OAAQ,yB,aCO7C,MAAM4C,UAAsB,KACzCC,OAAOC,GACL9B,MAAM6B,OAAOC,GACb7B,KAAK8B,SAAU,EACf9B,KAAK+B,OAAS,IAAO,IACrB/B,KAAKgC,aAAe,IAAO,GAC7B,CACAC,YACE,MAAO,6BACT,CACAC,QACE,OAAO,qBAAqB,qCAC9B,CACAC,UACE,OAAInC,KAAK8B,QACAM,EAAE,MAAO,CACdH,UAAW,cACVG,EAAE,IAAM,CACTH,UAAW,kBACVG,EAAE,IAAK,CACRH,UAAW,YACV,qBAAqB,sDAAuDG,EAAE,MAAO,CACtFH,UAAW,4BACVG,EAAE,IAAQ,CACXH,UAAW,uCACXI,QAASrC,KAAKsC,KAAKC,KAAKvC,OACvB,qBAAqB,mDAEnBoC,EAAE,MAAO,CACdH,UAAW,cACVG,EAAE,IAAM,CACTH,UAAW,kBACVG,EAAE,MAAO,CACVH,UAAW,cACVG,EAAE,MAAO,KAAMpC,KAAKwC,cAAcC,YAAaL,EAAE,MAAO,CACzDH,UAAW,4BACVG,EAAE,IAAQ,CACXH,UAAW,uCACXpB,KAAM,SACN6B,QAAS1C,KAAK0C,QACdC,UAAW3C,KAAK+B,UACf,qBAAqB,iDAC1B,CACAS,cACE,MAAMI,EAAQ,IAAI,KACZC,EAAgB,oBAAoB,iBA6D1C,OA5DAD,EAAMlB,IAAI,YAAaU,EAAE,QAAS,CAChCH,UAAW,YACVG,EAAE,QAAS,CACZvB,KAAM,QACNiC,KAAM,SACNC,QAA2B,cAAlB/C,KAAK+B,SACdxC,MAAO,YACP8C,QAAS,IAAS,QAASrC,KAAK+B,UAC9BK,EAAE,SAAU,KAAM,qBAAqB,wDAAyD,qBAAqB,sDAAyE,cAAlBpC,KAAK+B,UAA4BK,EAAE,WAAY,CAC7NH,UAAW,cACXe,YAAa,qBAAqB,2DAClCzD,MAAOS,KAAKgC,eACZiB,QAAS,IAAS,QAASjD,KAAKgC,iBAC7B,IACLY,EAAMlB,IAAI,gBAAiBU,EAAE,QAAS,CACpCH,UAAW,YACVG,EAAE,QAAS,CACZvB,KAAM,QACNiC,KAAM,SACNC,QAA2B,kBAAlB/C,KAAK+B,SACdxC,MAAO,gBACP8C,QAAS,IAAS,QAASrC,KAAK+B,UAC9BK,EAAE,SAAU,KAAM,qBAAqB,4DAA6D,qBAAqB,yDAA0D,CACrL7D,EAAGsE,EAAgBT,EAAE,IAAK,CACxBc,KAAML,EACNM,OAAQ,gBACL7C,IACe,kBAAlBN,KAAK+B,UAAgCK,EAAE,WAAY,CACrDH,UAAW,cACXe,YAAa,qBAAqB,2DAClCzD,MAAOS,KAAKgC,eACZiB,QAAS,IAAS,QAASjD,KAAKgC,iBAC7B,IACLY,EAAMlB,IAAI,OAAQU,EAAE,QAAS,CAC3BH,UAAW,YACVG,EAAE,QAAS,CACZvB,KAAM,QACNiC,KAAM,SACNC,QAA2B,SAAlB/C,KAAK+B,SACdxC,MAAO,OACP8C,QAAS,IAAS,QAASrC,KAAK+B,UAC9BK,EAAE,SAAU,KAAM,qBAAqB,mDAAoD,qBAAqB,iDAAoE,SAAlBpC,KAAK+B,UAAuBK,EAAE,WAAY,CAC9MH,UAAW,cACXe,YAAa,qBAAqB,2DAClCzD,MAAOS,KAAKgC,eACZiB,QAAS,IAAS,QAASjD,KAAKgC,iBAC7B,IACLY,EAAMlB,IAAI,QAASU,EAAE,QAAS,CAC5BH,UAAW,YACVG,EAAE,QAAS,CACZvB,KAAM,QACNiC,KAAM,SACNC,QAA2B,UAAlB/C,KAAK+B,SACdxC,MAAO,QACP8C,QAAS,IAAS,QAASrC,KAAK+B,UAC9BK,EAAE,SAAU,KAAM,qBAAqB,oDAAuE,UAAlBpC,KAAK+B,UAAwBK,EAAE,WAAY,CACzIH,UAAW,cACX1C,MAAOS,KAAKgC,eACZiB,QAAS,IAAS,QAASjD,KAAKgC,iBAC7B,IACEY,CACT,CACAQ,SAASC,GACPA,EAAEC,iBACFtD,KAAK0C,SAAU,EACf,uBAAuB,SAASa,KAAK,CACnCxB,OAA0B,UAAlB/B,KAAK+B,SAAuB,KAAO/B,KAAK+B,SAChDC,aAAchC,KAAKgC,eACnBwB,cAAe,CACbvC,KAAM,iBACNwC,KAAMzD,KAAK0D,MAAMD,OAElB,CACDE,aAAc3D,KAAK4D,QAAQrB,KAAKvC,QAC/B6D,MAAK,IAAM7D,KAAK8B,SAAU,IAAMgC,OAAM,SAAUD,KAAK7D,KAAK+D,OAAOxB,KAAKvC,MAC3E,EAEFR,OAAOC,IAAIiC,IAAI,eAAgB,iCAAkCC,GClIjE,MAAM,EAA+BnC,OAAOC,IAAIV,IAAI,OAAQ,oC,aCA5D,MAAM,EAA+BS,OAAOC,IAAIV,IAAI,OAAQ,mC,aCA5D,MAAM,EAA+BS,OAAOC,IAAIV,IAAI,OAAQ,0B,aCA5D,MAAM,EAA+BS,OAAOC,IAAIV,IAAI,OAAQ,oB,aCA5D,MAAM,EAA+BS,OAAOC,IAAIV,IAAI,OAAQ,4B,aCA5D,MAAM,EAA+BS,OAAOC,IAAIV,IAAI,OAAQ,2B,aCA5D,MAAM,EAA+BS,OAAOC,IAAIV,IAAI,OAAQ,+B,aCA5D,MAAM,EAA+BS,OAAOC,IAAIV,IAAI,OAAQ,mC,aCM7C,MAAMiF,UAAiB,KACpCpC,OAAOC,GACL9B,MAAM6B,OAAOC,EACf,CACAoC,OACE,MAAMC,EAAQlE,KAAK0D,MAAMQ,MACzB,OAAO9B,EAAE,IAAY,CACnBH,UAAW,WACXC,MAAO,qBAAqB,0CAC5BiC,SAAUD,EAAMC,WAChBzB,QAASwB,EAAME,YACfC,UAAW,qBAAqB,+CAChCC,SAAU,IAAMJ,EAAMK,YAAcL,EAAMM,iBAAmBN,EAAMzC,YAClEW,EAAE,KAAM,CACTH,UAAW,2BACVjC,KAAKmC,QAAQ+B,IAClB,CACA/B,QAAQ+B,GACN,OAAKA,EAAME,aAAeF,EAAMC,WACvBD,EAAMO,WAAWC,KAAIrD,GACnBA,EAAKuB,MAAM8B,KAAIC,IACpB,MAAMlB,EAAOkB,EAAKlB,OAClB,OAAOrB,EAAE,KAAM,KAAMA,EAAE,IAAgB,CACrCH,UAAW,OACX2C,OAAQxC,EAAE,IAAQ,CAChBnB,KAAMwC,EAAKxC,QAAU,OAEvB4D,KAAM,cACN1C,QAAS,qBAAqB,6CAA8C,CAC1E2C,SAAU,IAASrB,EAAKxC,QACxB8D,GAAI3C,EAAE,KAAM,MACZ4C,WAAYvB,EAAKuB,aAAa9C,UAEhC+C,QAASxB,EAAKyB,eACdC,SAAUR,EAAKS,YACflC,KAAM,eAAeO,GACrBpB,QAASgB,IACPA,EAAEgC,QAAS,CAAK,IAEjB,MAIF,IACT,EAEF7F,OAAOC,IAAIiC,IAAI,eAAgB,4BAA6BsC,GChD7C,MAAMsB,UAAsB,KACzCC,iBAAiB7B,GACfA,EAAMzB,UAAY,IAAU,gBAAiByB,EAAMzB,WACnDyB,EAAM8B,MAAQ9B,EAAM8B,OAAS,qBAAqB,4CAClD9B,EAAMmB,KAAOnB,EAAMmB,MAAQ,cAC3B9E,MAAM0F,UAAU/B,EAClB,CACAgC,aACE,OAAOtD,EAAE4B,EAAU,CACjBE,MAAOlE,KAAK0D,MAAMQ,OAEtB,CACAyB,YACEvD,EAAEwD,MAAMC,IAAI,UAAU,SACxB,CACAC,iBACE,OAAO,oBAAoB,YAC7B,CACAC,cACE,OAAO,2BAA2B,eACpC,EAEFvG,OAAOC,IAAIiC,IAAI,eAAgB,iCAAkC4D,GC1BjE,MAAM,EAA+B9F,OAAOC,IAAIV,IAAI,OAAQ,yB,aCA5D,MAAM,EAA+BS,OAAOC,IAAIV,IAAI,OAAQ,0B,aCA5D,MAAM,EAA+BS,OAAOC,IAAIV,IAAI,OAAQ,oB,aCA5D,MAAM,EAA+BS,OAAOC,IAAIV,IAAI,OAAQ,sB,aCA5D,MAAM,EAA+BS,OAAOC,IAAIV,IAAI,OAAQ,0B,aCQ7C,MAAMiH,UAAkB,KACrCpE,OAAOC,GACL9B,MAAM6B,OAAOC,GACb,iBAAiB,SACjB,iBACA7B,KAAKiG,UAAY,YACnB,CACAhC,OACE,OAAO7B,EAAE,MAAO,CACdH,UAAW,aACVG,EAAE4B,EAAU,CACbE,MAAO,YAEX,EAEF1E,OAAOC,IAAIiC,IAAI,eAAgB,6BAA8BsE,GCvB7D,MAAM,GAA+BxG,OAAOC,IAAIV,IAAI,OAAQ,gB,eCC7C,MAAMmH,WAAa,MAChCrF,OACE,OAAO,eAAgB,QAAQzB,KAAKY,KACtC,CACA+B,SACE,OAAO,eAAgB,UAAU3C,KAAKY,KACxC,CACAgC,eACE,OAAO,eAAgB,gBAAgB5C,KAAKY,KAC9C,CACAoF,YACE,OAAO,eAAgB,YAAa,oBAAqBhG,KAAKY,KAChE,CACAyD,OACE,OAAO,YAAa,QAAQrE,KAAKY,KACnC,CACAiB,OACE,OAAO,YAAa,QAAQ7B,KAAKY,KACnC,EAEFR,OAAOC,IAAIiC,IAAI,eAAgB,oBAAqBwE,ICjBpD,WAAgB,IAAI,aACnBxE,IAAI,QAAS,SAAUsE,IAAY,IAAI,YACvCtE,IAAI,QAASwE,IAAO,IAAI,WAAa,KACrCC,QAAQ,SAASjF,UAAU,YCD5B,qBAAqB,gBAAgB,KACnC,UAAY,IAAIrB,EAAc,MCD9B,IAAAuG,QAAO,IAAc,gBAAgB,SAAUxD,EAAOa,IAChDA,EAAK4C,YAAqC,YAAvB5C,EAAK6C,eAAgC7C,EAAK8C,WACjE3D,EAAMlB,IAAI,OAAQU,EAAE,IAAQ,CAC1ByC,KAAM,cACNxC,QAAS,IAAM,eAAeV,EAAe,CAC3C8B,UAED,qBAAqB,iDAC1B,KCTA,IAAA2C,QAAO,cAA2B,SAAS,SAAUxD,GAC/C,oBAAoB,iBACtBA,EAAMlB,IAAI,QAASU,EAAEkD,EAAe,CAClCpB,MAAO,YACL,GAER,KCHA,IAAAkC,QAAO,cAAgB,gBAAgB,SAAU1C,GAC3C1D,KAAK0D,MAAMD,KAAK+C,QAAQlF,SAC1BoC,EAAMzB,WAAa,iBAEvB,IACA,0BAA6B,SAAUwE,GACrC,MAAMhD,EAAOzD,KAAK0D,MAAMD,KAoBxB,cAnBOA,EAAKiD,KAAKlD,cAAcgD,MAC/BxG,KAAK2G,QAAQC,aACT,iBACF,sBAAqB,CAACjC,EAAMkC,KAC1B,GAAIlC,EAAKlB,SAAWA,EAAM,CAExB,GADA,uBAAuBoD,EAAG,GACtB,kBAAoBpD,EAAM,CAC5B,IAAIqD,EAAO,gBAAgBD,GAE3B,GADKC,IAAMA,EAAO,oBACdA,EAAM,CACR,MAAMC,EAAWD,EAAKrD,OACtB,gBAAkBsD,EAClB3E,EAAEwD,MAAMC,IAAI,eAAekB,GAC7B,CACF,CACA,OAAO,CACT,KAGG,YAAY,CACjBC,IAAK,oBAAoB,UAAYvD,EAAKwD,cAAgB,SAC1DC,OAAQ,SACRT,QAEJ,EACA,8BAAiC,WAC/B,MAAM7D,EAAQ,IAAI,KACZuE,EAAW,wBAAiCnH,KAAK0D,MAAMD,MAc7D,OAbA7E,OAAOwI,KAAKD,EAASE,YAAYC,SAAQC,IACvC,MAAM7D,EAAQyD,EAASpI,IAAIwI,GAAG7D,MAC9BA,EAAMzB,UAAY,UAClB,IAAAmE,QAAO1C,EAAO,WAAW,IAAM1D,KAAKwH,eAAc,IAEpD5E,EAAMlB,IAAI,WAAYU,EAAE,MAAO,CAC7BH,UAAW,eACVkF,EAAS1E,YACZG,EAAMlB,IAAI,UAAWU,EAAE,IAAQ,CAC7BH,UAAW,SACX4C,KAAM,mBACNxC,QAASrC,KAAKwH,YAAYjF,KAAKvC,OAC9B,qBAAqB,iDAAkD,KACnE4C,CACT,GACA,IAAA6E,UAAS,cAAgB,UAAU,SAAUC,GAC3C,MAAMjE,EAAOzD,KAAK0D,MAAMD,KAClB+C,EAAQ/C,EAAK+C,QACnB,GAAKA,EAAMlF,OAEX,OADImC,EAAK4C,aAAYrG,KAAK2H,eAAgB,GACnCvF,EAAE,MAAO,CACdH,UAAW,gBACVG,EAAE,MAAO,CACVH,UAAW,sBACVuE,EAAM9B,KAAIC,GAAQvC,EAAE,MAAO,CAC5BH,UAAW,qBACVjC,KAAK4H,WAAWjD,OAAUvC,EAAE,MAAO,CACpCH,UAAW,wBACVjC,KAAK6H,kBAAkBpF,WAC5B,IACA,yBAA4B,SAAUkC,GACpC,GAAoB,SAAhBA,EAAK9D,OAAmB,CAC1B,MAAMI,EAAO0D,EAAK1D,OACZc,EAAS4C,EAAK5C,SAAW,qBAAqB,uCAAuC+F,OAAOnD,EAAK5C,SAAU,WAAa,KACxHgG,EAASpD,EAAK3C,eACdgG,EAAO,IAAUrD,EAAKS,aAC5B,MAAO,CAAC,qBAAqBrD,EAAS,sDAAwD,0CAA2C,CACvIiG,OACA/G,OACAc,aACIgG,GAAU3F,EAAE,OAAQ,CACxBH,UAAW,uBACV8F,GACL,CACF,CH7EiB,G","sources":["webpack://@flarum/flags/webpack/bootstrap","webpack://@flarum/flags/webpack/runtime/compat get default export","webpack://@flarum/flags/webpack/runtime/define property getters","webpack://@flarum/flags/webpack/runtime/hasOwnProperty shorthand","webpack://@flarum/flags/webpack/runtime/make namespace object","webpack://@flarum/flags/external root \"flarum.reg.get('core', 'forum/app')\"","webpack://@flarum/flags/../../../js-packages/webpack-config/node_modules/@babel/runtime/helpers/esm/typeof.js","webpack://@flarum/flags/external root \"flarum.reg.get('core', 'common/states/PaginatedListState')\"","webpack://@flarum/flags/./src/forum/states/FlagListState.tsx","webpack://@flarum/flags/../../../js-packages/webpack-config/node_modules/@babel/runtime/helpers/esm/defineProperty.js","webpack://@flarum/flags/../../../js-packages/webpack-config/node_modules/@babel/runtime/helpers/esm/toPropertyKey.js","webpack://@flarum/flags/../../../js-packages/webpack-config/node_modules/@babel/runtime/helpers/esm/toPrimitive.js","webpack://@flarum/flags/external root \"flarum.reg.get('core', 'common/extend')\"","webpack://@flarum/flags/external root \"flarum.reg.get('core', 'forum/utils/PostControls')\"","webpack://@flarum/flags/external root \"flarum.reg.get('core', 'common/components/Button')\"","webpack://@flarum/flags/external root \"flarum.reg.get('core', 'common/components/FormModal')\"","webpack://@flarum/flags/external root \"flarum.reg.get('core', 'common/components/Form')\"","webpack://@flarum/flags/external root \"flarum.reg.get('core', 'common/utils/Stream')\"","webpack://@flarum/flags/external root \"flarum.reg.get('core', 'common/utils/withAttr')\"","webpack://@flarum/flags/external root \"flarum.reg.get('core', 'common/utils/ItemList')\"","webpack://@flarum/flags/./src/forum/components/FlagPostModal.js","webpack://@flarum/flags/external root \"flarum.reg.get('core', 'forum/components/HeaderSecondary')\"","webpack://@flarum/flags/external root \"flarum.reg.get('core', 'forum/components/HeaderDropdown')\"","webpack://@flarum/flags/external root \"flarum.reg.get('core', 'common/utils/classList')\"","webpack://@flarum/flags/external root \"flarum.reg.get('core', 'common/Component')\"","webpack://@flarum/flags/external root \"flarum.reg.get('core', 'common/components/Avatar')\"","webpack://@flarum/flags/external root \"flarum.reg.get('core', 'common/helpers/username')\"","webpack://@flarum/flags/external root \"flarum.reg.get('core', 'forum/components/HeaderList')\"","webpack://@flarum/flags/external root \"flarum.reg.get('core', 'forum/components/HeaderListItem')\"","webpack://@flarum/flags/./src/forum/components/FlagList.tsx","webpack://@flarum/flags/./src/forum/components/FlagsDropdown.tsx","webpack://@flarum/flags/external root \"flarum.reg.get('core', 'forum/components/Post')\"","webpack://@flarum/flags/external root \"flarum.reg.get('core', 'common/utils/humanTime')\"","webpack://@flarum/flags/external root \"flarum.reg.get('core', 'common/extenders')\"","webpack://@flarum/flags/external root \"flarum.reg.get('core', 'common/models/Post')\"","webpack://@flarum/flags/external root \"flarum.reg.get('core', 'common/components/Page')\"","webpack://@flarum/flags/./src/forum/components/FlagsPage.js","webpack://@flarum/flags/external root \"flarum.reg.get('core', 'common/Model')\"","webpack://@flarum/flags/./src/forum/models/Flag.ts","webpack://@flarum/flags/./src/forum/extend.ts","webpack://@flarum/flags/./src/forum/index.ts","webpack://@flarum/flags/./src/forum/addFlagControl.js","webpack://@flarum/flags/./src/forum/addFlagsDropdown.js","webpack://@flarum/flags/./src/forum/addFlagsToPosts.js"],"sourcesContent":["// The require scope\nvar __webpack_require__ = {};\n\n","// getDefaultExport function for compatibility with non-harmony modules\n__webpack_require__.n = (module) => {\n\tvar getter = module && module.__esModule ?\n\t\t() => (module['default']) :\n\t\t() => (module);\n\t__webpack_require__.d(getter, { a: getter });\n\treturn getter;\n};","// define getter functions for harmony exports\n__webpack_require__.d = (exports, definition) => {\n\tfor(var key in definition) {\n\t\tif(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {\n\t\t\tObject.defineProperty(exports, key, { enumerable: true, get: definition[key] });\n\t\t}\n\t}\n};","__webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))","// define __esModule on exports\n__webpack_require__.r = (exports) => {\n\tif(typeof Symbol !== 'undefined' && Symbol.toStringTag) {\n\t\tObject.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });\n\t}\n\tObject.defineProperty(exports, '__esModule', { value: true });\n};","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'forum/app');","export default function _typeof(obj) {\n \"@babel/helpers - typeof\";\n\n return _typeof = \"function\" == typeof Symbol && \"symbol\" == typeof Symbol.iterator ? function (obj) {\n return typeof obj;\n } : function (obj) {\n return obj && \"function\" == typeof Symbol && obj.constructor === Symbol && obj !== Symbol.prototype ? \"symbol\" : typeof obj;\n }, _typeof(obj);\n}","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/states/PaginatedListState');","import _defineProperty from \"@babel/runtime/helpers/esm/defineProperty\";\nimport PaginatedListState from 'flarum/common/states/PaginatedListState';\nexport default class FlagListState extends PaginatedListState {\n constructor(app) {\n super({}, 1, null);\n _defineProperty(this, \"app\", void 0);\n this.app = app;\n }\n get type() {\n return 'flags';\n }\n\n /**\n * Load flags into the application's cache if they haven't already\n * been loaded.\n */\n load() {\n var _this$app$session$use;\n if ((_this$app$session$use = this.app.session.user) != null && _this$app$session$use.attribute('newFlagCount')) {\n this.pages = [];\n this.location = {\n page: 1\n };\n }\n if (this.pages.length > 0) {\n return Promise.resolve();\n }\n return super.loadNext();\n }\n}\nflarum.reg.add('flarum-flags', 'forum/states/FlagListState', FlagListState);","import toPropertyKey from \"./toPropertyKey.js\";\nexport default function _defineProperty(obj, key, value) {\n key = toPropertyKey(key);\n if (key in obj) {\n Object.defineProperty(obj, key, {\n value: value,\n enumerable: true,\n configurable: true,\n writable: true\n });\n } else {\n obj[key] = value;\n }\n return obj;\n}","import _typeof from \"./typeof.js\";\nimport toPrimitive from \"./toPrimitive.js\";\nexport default function _toPropertyKey(arg) {\n var key = toPrimitive(arg, \"string\");\n return _typeof(key) === \"symbol\" ? key : String(key);\n}","import _typeof from \"./typeof.js\";\nexport default function _toPrimitive(input, hint) {\n if (_typeof(input) !== \"object\" || input === null) return input;\n var prim = input[Symbol.toPrimitive];\n if (prim !== undefined) {\n var res = prim.call(input, hint || \"default\");\n if (_typeof(res) !== \"object\") return res;\n throw new TypeError(\"@@toPrimitive must return a primitive value.\");\n }\n return (hint === \"string\" ? String : Number)(input);\n}","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/extend');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'forum/utils/PostControls');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/components/Button');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/components/FormModal');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/components/Form');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/utils/Stream');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/utils/withAttr');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/utils/ItemList');","import app from 'flarum/forum/app';\nimport FormModal from 'flarum/common/components/FormModal';\nimport Form from 'flarum/common/components/Form';\nimport Button from 'flarum/common/components/Button';\nimport Stream from 'flarum/common/utils/Stream';\nimport withAttr from 'flarum/common/utils/withAttr';\nimport ItemList from 'flarum/common/utils/ItemList';\nexport default class FlagPostModal extends FormModal {\n oninit(vnode) {\n super.oninit(vnode);\n this.success = false;\n this.reason = Stream('');\n this.reasonDetail = Stream('');\n }\n className() {\n return 'FlagPostModal Modal--medium';\n }\n title() {\n return app.translator.trans('flarum-flags.forum.flag_post.title');\n }\n content() {\n if (this.success) {\n return m(\"div\", {\n className: \"Modal-body\"\n }, m(Form, {\n className: \"Form--centered\"\n }, m(\"p\", {\n className: \"helpText\"\n }, app.translator.trans('flarum-flags.forum.flag_post.confirmation_message')), m(\"div\", {\n className: \"Form-group Form-controls\"\n }, m(Button, {\n className: \"Button Button--primary Button--block\",\n onclick: this.hide.bind(this)\n }, app.translator.trans('flarum-flags.forum.flag_post.dismiss_button')))));\n }\n return m(\"div\", {\n className: \"Modal-body\"\n }, m(Form, {\n className: \"Form--centered\"\n }, m(\"div\", {\n className: \"Form-group\"\n }, m(\"div\", null, this.flagReasons().toArray())), m(\"div\", {\n className: \"Form-group Form-controls\"\n }, m(Button, {\n className: \"Button Button--primary Button--block\",\n type: \"submit\",\n loading: this.loading,\n disabled: !this.reason()\n }, app.translator.trans('flarum-flags.forum.flag_post.submit_button')))));\n }\n flagReasons() {\n const items = new ItemList();\n const guidelinesUrl = app.forum.attribute('guidelinesUrl');\n items.add('off-topic', m(\"label\", {\n className: \"checkbox\"\n }, m(\"input\", {\n type: \"radio\",\n name: \"reason\",\n checked: this.reason() === 'off_topic',\n value: \"off_topic\",\n onclick: withAttr('value', this.reason)\n }), m(\"strong\", null, app.translator.trans('flarum-flags.forum.flag_post.reason_off_topic_label')), app.translator.trans('flarum-flags.forum.flag_post.reason_off_topic_text'), this.reason() === 'off_topic' && m(\"textarea\", {\n className: \"FormControl\",\n placeholder: app.translator.trans('flarum-flags.forum.flag_post.reason_details_placeholder'),\n value: this.reasonDetail(),\n oninput: withAttr('value', this.reasonDetail)\n })), 70);\n items.add('inappropriate', m(\"label\", {\n className: \"checkbox\"\n }, m(\"input\", {\n type: \"radio\",\n name: \"reason\",\n checked: this.reason() === 'inappropriate',\n value: \"inappropriate\",\n onclick: withAttr('value', this.reason)\n }), m(\"strong\", null, app.translator.trans('flarum-flags.forum.flag_post.reason_inappropriate_label')), app.translator.trans('flarum-flags.forum.flag_post.reason_inappropriate_text', {\n a: guidelinesUrl ? m(\"a\", {\n href: guidelinesUrl,\n target: \"_blank\"\n }) : undefined\n }), this.reason() === 'inappropriate' && m(\"textarea\", {\n className: \"FormControl\",\n placeholder: app.translator.trans('flarum-flags.forum.flag_post.reason_details_placeholder'),\n value: this.reasonDetail(),\n oninput: withAttr('value', this.reasonDetail)\n })), 60);\n items.add('spam', m(\"label\", {\n className: \"checkbox\"\n }, m(\"input\", {\n type: \"radio\",\n name: \"reason\",\n checked: this.reason() === 'spam',\n value: \"spam\",\n onclick: withAttr('value', this.reason)\n }), m(\"strong\", null, app.translator.trans('flarum-flags.forum.flag_post.reason_spam_label')), app.translator.trans('flarum-flags.forum.flag_post.reason_spam_text'), this.reason() === 'spam' && m(\"textarea\", {\n className: \"FormControl\",\n placeholder: app.translator.trans('flarum-flags.forum.flag_post.reason_details_placeholder'),\n value: this.reasonDetail(),\n oninput: withAttr('value', this.reasonDetail)\n })), 50);\n items.add('other', m(\"label\", {\n className: \"checkbox\"\n }, m(\"input\", {\n type: \"radio\",\n name: \"reason\",\n checked: this.reason() === 'other',\n value: \"other\",\n onclick: withAttr('value', this.reason)\n }), m(\"strong\", null, app.translator.trans('flarum-flags.forum.flag_post.reason_other_label')), this.reason() === 'other' && m(\"textarea\", {\n className: \"FormControl\",\n value: this.reasonDetail(),\n oninput: withAttr('value', this.reasonDetail)\n })), 10);\n return items;\n }\n onsubmit(e) {\n e.preventDefault();\n this.loading = true;\n app.store.createRecord('flags').save({\n reason: this.reason() === 'other' ? null : this.reason(),\n reasonDetail: this.reasonDetail(),\n relationships: {\n user: app.session.user,\n post: this.attrs.post\n }\n }, {\n errorHandler: this.onerror.bind(this)\n }).then(() => this.success = true).catch(() => {}).then(this.loaded.bind(this));\n }\n}\nflarum.reg.add('flarum-flags', 'forum/components/FlagPostModal', FlagPostModal);","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'forum/components/HeaderSecondary');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'forum/components/HeaderDropdown');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/utils/classList');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/Component');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/components/Avatar');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/helpers/username');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'forum/components/HeaderList');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'forum/components/HeaderListItem');","import app from 'flarum/forum/app';\nimport Component from 'flarum/common/Component';\nimport Avatar from 'flarum/common/components/Avatar';\nimport username from 'flarum/common/helpers/username';\nimport HeaderList from 'flarum/forum/components/HeaderList';\nimport HeaderListItem from 'flarum/forum/components/HeaderListItem';\nexport default class FlagList extends Component {\n oninit(vnode) {\n super.oninit(vnode);\n }\n view() {\n const state = this.attrs.state;\n return m(HeaderList, {\n className: \"FlagList\",\n title: app.translator.trans('flarum-flags.forum.flagged_posts.title'),\n hasItems: state.hasItems(),\n loading: state.isLoading(),\n emptyText: app.translator.trans('flarum-flags.forum.flagged_posts.empty_text'),\n loadMore: () => state.hasNext() && !state.isLoadingNext() && state.loadNext()\n }, m(\"ul\", {\n className: \"HeaderListGroup-content\"\n }, this.content(state)));\n }\n content(state) {\n if (!state.isLoading() && state.hasItems()) {\n return state.getPages().map(page => {\n return page.items.map(flag => {\n const post = flag.post();\n return m(\"li\", null, m(HeaderListItem, {\n className: \"Flag\",\n avatar: m(Avatar, {\n user: post.user() || null\n }),\n icon: \"fas fa-flag\",\n content: app.translator.trans('flarum-flags.forum.flagged_posts.item_text', {\n username: username(post.user()),\n em: m(\"em\", null),\n discussion: post.discussion().title()\n }),\n excerpt: post.contentPlain(),\n datetime: flag.createdAt(),\n href: app.route.post(post),\n onclick: e => {\n e.redraw = false;\n }\n }));\n });\n });\n }\n return null;\n }\n}\nflarum.reg.add('flarum-flags', 'forum/components/FlagList', FlagList);","import app from 'flarum/forum/app';\nimport HeaderDropdown from 'flarum/forum/components/HeaderDropdown';\nimport classList from 'flarum/common/utils/classList';\nimport FlagList from './FlagList';\nexport default class FlagsDropdown extends HeaderDropdown {\n static initAttrs(attrs) {\n attrs.className = classList('FlagsDropdown', attrs.className);\n attrs.label = attrs.label || app.translator.trans('flarum-flags.forum.flagged_posts.tooltip');\n attrs.icon = attrs.icon || 'fas fa-flag';\n super.initAttrs(attrs);\n }\n getContent() {\n return m(FlagList, {\n state: this.attrs.state\n });\n }\n goToRoute() {\n m.route.set(app.route('flags'));\n }\n getUnreadCount() {\n return app.forum.attribute('flagCount');\n }\n getNewCount() {\n return app.session.user.attribute('newFlagCount');\n }\n}\nflarum.reg.add('flarum-flags', 'forum/components/FlagsDropdown', FlagsDropdown);","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'forum/components/Post');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/utils/humanTime');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/extenders');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/models/Post');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/components/Page');","import app from 'flarum/forum/app';\nimport Page from 'flarum/common/components/Page';\nimport FlagList from './FlagList';\n\n/**\n * The `FlagsPage` component shows the flags list. It is only\n * used on mobile devices where the flags dropdown is within the drawer.\n */\nexport default class FlagsPage extends Page {\n oninit(vnode) {\n super.oninit(vnode);\n app.history.push('flags');\n app.flags.load();\n this.bodyClass = 'App--flags';\n }\n view() {\n return m(\"div\", {\n className: \"FlagsPage\"\n }, m(FlagList, {\n state: app.flags\n }));\n }\n}\nflarum.reg.add('flarum-flags', 'forum/components/FlagsPage', FlagsPage);","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/Model');","import Model from 'flarum/common/Model';\nexport default class Flag extends Model {\n type() {\n return Model.attribute('type').call(this);\n }\n reason() {\n return Model.attribute('reason').call(this);\n }\n reasonDetail() {\n return Model.attribute('reasonDetail').call(this);\n }\n createdAt() {\n return Model.attribute('createdAt', Model.transformDate).call(this);\n }\n post() {\n return Model.hasOne('post').call(this);\n }\n user() {\n return Model.hasOne('user').call(this);\n }\n}\nflarum.reg.add('flarum-flags', 'forum/models/Flag', Flag);","import Extend from 'flarum/common/extenders';\nimport Post from 'flarum/common/models/Post';\nimport FlagsPage from './components/FlagsPage';\nimport Flag from './models/Flag';\nexport default [new Extend.Routes() //\n.add('flags', '/flags', FlagsPage), new Extend.Store() //\n.add('flags', Flag), new Extend.Model(Post) //\n.hasMany('flags').attribute('canFlag')];","import app from 'flarum/forum/app';\nimport FlagListState from './states/FlagListState';\nimport addFlagControl from './addFlagControl';\nimport addFlagsDropdown from './addFlagsDropdown';\nimport addFlagsToPosts from './addFlagsToPosts';\nexport { default as extend } from './extend';\napp.initializers.add('flarum-flags', () => {\n app.flags = new FlagListState(app);\n addFlagControl();\n addFlagsDropdown();\n addFlagsToPosts();\n});\nimport './forum';","import { extend } from 'flarum/common/extend';\nimport app from 'flarum/forum/app';\nimport PostControls from 'flarum/forum/utils/PostControls';\nimport Button from 'flarum/common/components/Button';\nimport FlagPostModal from './components/FlagPostModal';\nexport default function () {\n extend(PostControls, 'userControls', function (items, post) {\n if (post.isHidden() || post.contentType() !== 'comment' || !post.canFlag()) return;\n items.add('flag', m(Button, {\n icon: \"fas fa-flag\",\n onclick: () => app.modal.show(FlagPostModal, {\n post\n })\n }, app.translator.trans('flarum-flags.forum.post_controls.flag_button')));\n });\n}","import { extend } from 'flarum/common/extend';\nimport app from 'flarum/forum/app';\nimport HeaderSecondary from 'flarum/forum/components/HeaderSecondary';\nimport FlagsDropdown from './components/FlagsDropdown';\nexport default function () {\n extend(HeaderSecondary.prototype, 'items', function (items) {\n if (app.forum.attribute('canViewFlags')) {\n items.add('flags', m(FlagsDropdown, {\n state: app.flags\n }), 15);\n }\n });\n}","import { extend, override } from 'flarum/common/extend';\nimport app from 'flarum/forum/app';\nimport Post from 'flarum/forum/components/Post';\nimport Button from 'flarum/common/components/Button';\nimport ItemList from 'flarum/common/utils/ItemList';\nimport PostControls from 'flarum/forum/utils/PostControls';\nimport humanTime from 'flarum/common/utils/humanTime';\nexport default function () {\n extend(Post.prototype, 'elementAttrs', function (attrs) {\n if (this.attrs.post.flags().length) {\n attrs.className += ' Post--flagged';\n }\n });\n Post.prototype.dismissFlag = function (body) {\n const post = this.attrs.post;\n delete post.data.relationships.flags;\n this.subtree.invalidate();\n if (app.flags.cache) {\n app.flags.cache.some((flag, i) => {\n if (flag.post() === post) {\n app.flags.cache.splice(i, 1);\n if (app.flags.index === post) {\n let next = app.flags.cache[i];\n if (!next) next = app.flags.cache[0];\n if (next) {\n const nextPost = next.post();\n app.flags.index = nextPost;\n m.route.set(app.route.post(nextPost));\n }\n }\n return true;\n }\n });\n }\n return app.request({\n url: app.forum.attribute('apiUrl') + post.apiEndpoint() + '/flags',\n method: 'DELETE',\n body\n });\n };\n Post.prototype.flagActionItems = function () {\n const items = new ItemList();\n const controls = PostControls.destructiveControls(this.attrs.post);\n Object.keys(controls.toObject()).forEach(k => {\n const attrs = controls.get(k).attrs;\n attrs.className = 'Button';\n extend(attrs, 'onclick', () => this.dismissFlag());\n });\n items.add('controls', m(\"div\", {\n className: \"ButtonGroup\"\n }, controls.toArray()));\n items.add('dismiss', m(Button, {\n className: \"Button\",\n icon: \"far fa-eye-slash\",\n onclick: this.dismissFlag.bind(this)\n }, app.translator.trans('flarum-flags.forum.post.dismiss_flag_button')), -100);\n return items;\n };\n override(Post.prototype, 'header', function (vdom) {\n const post = this.attrs.post;\n const flags = post.flags();\n if (!flags.length) return;\n if (post.isHidden()) this.revealContent = true;\n return m(\"div\", {\n className: \"Post-flagged\"\n }, m(\"div\", {\n className: \"Post-flagged-flags\"\n }, flags.map(flag => m(\"div\", {\n className: \"Post-flagged-flag\"\n }, this.flagReason(flag)))), m(\"div\", {\n className: \"Post-flagged-actions\"\n }, this.flagActionItems().toArray()));\n });\n Post.prototype.flagReason = function (flag) {\n if (flag.type() === 'user') {\n const user = flag.user();\n const reason = flag.reason() ? app.translator.trans(\"flarum-flags.forum.flag_post.reason_\".concat(flag.reason(), \"_label\")) : null;\n const detail = flag.reasonDetail();\n const time = humanTime(flag.createdAt());\n return [app.translator.trans(reason ? 'flarum-flags.forum.post.flagged_by_with_reason_text' : 'flarum-flags.forum.post.flagged_by_text', {\n time,\n user,\n reason\n }), !!detail && m(\"span\", {\n className: \"Post-flagged-detail\"\n }, detail)];\n }\n };\n}"],"names":["__webpack_require__","module","getter","__esModule","d","a","exports","definition","key","o","Object","defineProperty","enumerable","get","obj","prop","prototype","hasOwnProperty","call","Symbol","toStringTag","value","flarum","reg","_typeof","iterator","constructor","FlagListState","app","super","this","arg","input","hint","prim","toPrimitive","undefined","res","TypeError","String","toPropertyKey","configurable","writable","type","load","_this$app$session$use","session","user","attribute","pages","location","page","length","Promise","resolve","loadNext","add","FlagPostModal","oninit","vnode","success","reason","reasonDetail","className","title","content","m","onclick","hide","bind","flagReasons","toArray","loading","disabled","items","guidelinesUrl","name","checked","placeholder","oninput","href","target","onsubmit","e","preventDefault","save","relationships","post","attrs","errorHandler","onerror","then","catch","loaded","FlagList","view","state","hasItems","isLoading","emptyText","loadMore","hasNext","isLoadingNext","getPages","map","flag","avatar","icon","username","em","discussion","excerpt","contentPlain","datetime","createdAt","redraw","FlagsDropdown","static","label","initAttrs","getContent","goToRoute","route","set","getUnreadCount","getNewCount","FlagsPage","bodyClass","Flag","hasMany","extend","isHidden","contentType","canFlag","flags","body","data","subtree","invalidate","i","next","nextPost","url","apiEndpoint","method","controls","keys","toObject","forEach","k","dismissFlag","override","vdom","revealContent","flagReason","flagActionItems","concat","detail","time"],"sourceRoot":""} \ No newline at end of file +{"version":3,"file":"forum.js","mappings":"MACA,IAAIA,EAAsB,CCA1BA,EAAyBC,IACxB,IAAIC,EAASD,GAAUA,EAAOE,WAC7B,IAAOF,EAAiB,QACxB,IAAM,EAEP,OADAD,EAAoBI,EAAEF,EAAQ,CAAEG,EAAGH,IAC5BA,CAAM,ECLdF,EAAwB,CAACM,EAASC,KACjC,IAAI,IAAIC,KAAOD,EACXP,EAAoBS,EAAEF,EAAYC,KAASR,EAAoBS,EAAEH,EAASE,IAC5EE,OAAOC,eAAeL,EAASE,EAAK,CAAEI,YAAY,EAAMC,IAAKN,EAAWC,IAE1E,ECNDR,EAAwB,CAACc,EAAKC,IAAUL,OAAOM,UAAUC,eAAeC,KAAKJ,EAAKC,GCClFf,EAAyBM,IACH,oBAAXa,QAA0BA,OAAOC,aAC1CV,OAAOC,eAAeL,EAASa,OAAOC,YAAa,CAAEC,MAAO,WAE7DX,OAAOC,eAAeL,EAAS,aAAc,CAAEe,OAAO,GAAO,G,sDCL9D,MAAM,EAA+BC,OAAOC,IAAIV,IAAI,OAAQ,a,aCA7C,SAASW,EAAQV,GAG9B,OAAOU,EAAU,mBAAqBL,QAAU,iBAAmBA,OAAOM,SAAW,SAAUX,GAC7F,cAAcA,CAChB,EAAI,SAAUA,GACZ,OAAOA,GAAO,mBAAqBK,QAAUL,EAAIY,cAAgBP,QAAUL,IAAQK,OAAOH,UAAY,gBAAkBF,CAC1H,EAAGU,EAAQV,EACb,CCRA,MAAM,EAA+BQ,OAAOC,IAAIV,IAAI,OAAQ,oC,aCE7C,MAAMc,UAAsB,KACzCD,YAAYE,GCFC,IAAyBd,EAAKN,EAAKa,EDG9CQ,MAAM,CAAC,EAAG,EAAG,MCHuBf,EDIpBgB,KCJ8BT,ODIjB,GCH/Bb,ECAa,SAAwBuB,GACrC,IAAIvB,ECFS,SAAsBwB,EAAOC,GAC1C,GAAuB,WAAnBT,EAAQQ,IAAiC,OAAVA,EAAgB,OAAOA,EAC1D,IAAIE,EAAOF,EAAMb,OAAOgB,aACxB,QAAaC,IAATF,EAAoB,CACtB,IAAIG,EAAMH,EAAKhB,KAAKc,EAAOC,GAC3B,GAAqB,WAAjBT,EAAQa,GAAmB,OAAOA,EACtC,MAAM,IAAIC,UAAU,+CACtB,CACA,OAA4BC,OAAiBP,EAC/C,CDPYG,CAAYJ,EAAK,UAC3B,MAAwB,WAAjBP,EAAQhB,GAAoBA,EAAM+B,OAAO/B,EAClD,CDHQgC,CADqChC,EDInB,UCFbM,EACTJ,OAAOC,eAAeG,EAAKN,EAAK,CAC9Ba,MAAOA,EACPT,YAAY,EACZ6B,cAAc,EACdC,UAAU,IAGZ5B,EAAIN,GAAOa,EDLXS,KAAKF,IAAMA,CACb,CACIe,WACF,MAAO,OACT,CAMAC,OACE,IAAIC,EAOJ,OANuD,OAAlDA,EAAwBf,KAAKF,IAAIkB,QAAQC,OAAiBF,EAAsBG,UAAU,kBAC7FlB,KAAKmB,MAAQ,GACbnB,KAAKoB,SAAW,CACdC,KAAM,IAGNrB,KAAKmB,MAAMG,OAAS,EACfC,QAAQC,UAEVzB,MAAM0B,UACf,EAEFjC,OAAOC,IAAIiC,IAAI,eAAgB,6BAA8B7B,GI9B7D,MAAM,EAA+BL,OAAOC,IAAIV,IAAI,OAAQ,iBCAtD,EAA+BS,OAAOC,IAAIV,IAAI,OAAQ,4B,aCA5D,MAAM,EAA+BS,OAAOC,IAAIV,IAAI,OAAQ,4B,aCA5D,MAAM,EAA+BS,OAAOC,IAAIV,IAAI,OAAQ,+B,aCA5D,MAAM,EAA+BS,OAAOC,IAAIV,IAAI,OAAQ,0B,aCA5D,MAAM,EAA+BS,OAAOC,IAAIV,IAAI,OAAQ,uB,aCA5D,MAAM,EAA+BS,OAAOC,IAAIV,IAAI,OAAQ,yB,aCA5D,MAAM,EAA+BS,OAAOC,IAAIV,IAAI,OAAQ,yB,aCO7C,MAAM4C,UAAsB,KACzCC,OAAOC,GACL9B,MAAM6B,OAAOC,GACb7B,KAAK8B,SAAU,EACf9B,KAAK+B,OAAS,IAAO,IACrB/B,KAAKgC,aAAe,IAAO,GAC7B,CACAC,YACE,MAAO,6BACT,CACAC,QACE,OAAO,qBAAqB,qCAC9B,CACAC,UACE,OAAInC,KAAK8B,QACAM,EAAE,MAAO,CACdH,UAAW,cACVG,EAAE,IAAM,CACTH,UAAW,kBACVG,EAAE,IAAK,CACRH,UAAW,YACV,qBAAqB,sDAAuDG,EAAE,MAAO,CACtFH,UAAW,4BACVG,EAAE,IAAQ,CACXH,UAAW,uCACXI,QAASrC,KAAKsC,KAAKC,KAAKvC,OACvB,qBAAqB,mDAEnBoC,EAAE,MAAO,CACdH,UAAW,cACVG,EAAE,IAAM,CACTH,UAAW,kBACVG,EAAE,MAAO,CACVH,UAAW,cACVG,EAAE,MAAO,KAAMpC,KAAKwC,cAAcC,YAAaL,EAAE,MAAO,CACzDH,UAAW,4BACVG,EAAE,IAAQ,CACXH,UAAW,uCACXpB,KAAM,SACN6B,QAAS1C,KAAK0C,QACdC,UAAW3C,KAAK+B,UACf,qBAAqB,iDAC1B,CACAS,cACE,MAAMI,EAAQ,IAAI,KACZC,EAAgB,oBAAoB,iBA6D1C,OA5DAD,EAAMlB,IAAI,YAAaU,EAAE,QAAS,CAChCH,UAAW,YACVG,EAAE,QAAS,CACZvB,KAAM,QACNiC,KAAM,SACNC,QAA2B,cAAlB/C,KAAK+B,SACdxC,MAAO,YACP8C,QAAS,IAAS,QAASrC,KAAK+B,UAC9BK,EAAE,SAAU,KAAM,qBAAqB,wDAAyD,qBAAqB,sDAAyE,cAAlBpC,KAAK+B,UAA4BK,EAAE,WAAY,CAC7NH,UAAW,cACXe,YAAa,qBAAqB,2DAClCzD,MAAOS,KAAKgC,eACZiB,QAAS,IAAS,QAASjD,KAAKgC,iBAC7B,IACLY,EAAMlB,IAAI,gBAAiBU,EAAE,QAAS,CACpCH,UAAW,YACVG,EAAE,QAAS,CACZvB,KAAM,QACNiC,KAAM,SACNC,QAA2B,kBAAlB/C,KAAK+B,SACdxC,MAAO,gBACP8C,QAAS,IAAS,QAASrC,KAAK+B,UAC9BK,EAAE,SAAU,KAAM,qBAAqB,4DAA6D,qBAAqB,yDAA0D,CACrL7D,EAAGsE,EAAgBT,EAAE,IAAK,CACxBc,KAAML,EACNM,OAAQ,gBACL7C,IACe,kBAAlBN,KAAK+B,UAAgCK,EAAE,WAAY,CACrDH,UAAW,cACXe,YAAa,qBAAqB,2DAClCzD,MAAOS,KAAKgC,eACZiB,QAAS,IAAS,QAASjD,KAAKgC,iBAC7B,IACLY,EAAMlB,IAAI,OAAQU,EAAE,QAAS,CAC3BH,UAAW,YACVG,EAAE,QAAS,CACZvB,KAAM,QACNiC,KAAM,SACNC,QAA2B,SAAlB/C,KAAK+B,SACdxC,MAAO,OACP8C,QAAS,IAAS,QAASrC,KAAK+B,UAC9BK,EAAE,SAAU,KAAM,qBAAqB,mDAAoD,qBAAqB,iDAAoE,SAAlBpC,KAAK+B,UAAuBK,EAAE,WAAY,CAC9MH,UAAW,cACXe,YAAa,qBAAqB,2DAClCzD,MAAOS,KAAKgC,eACZiB,QAAS,IAAS,QAASjD,KAAKgC,iBAC7B,IACLY,EAAMlB,IAAI,QAASU,EAAE,QAAS,CAC5BH,UAAW,YACVG,EAAE,QAAS,CACZvB,KAAM,QACNiC,KAAM,SACNC,QAA2B,UAAlB/C,KAAK+B,SACdxC,MAAO,QACP8C,QAAS,IAAS,QAASrC,KAAK+B,UAC9BK,EAAE,SAAU,KAAM,qBAAqB,oDAAuE,UAAlBpC,KAAK+B,UAAwBK,EAAE,WAAY,CACzIH,UAAW,cACX1C,MAAOS,KAAKgC,eACZiB,QAAS,IAAS,QAASjD,KAAKgC,iBAC7B,IACEY,CACT,CACAQ,SAASC,GACPA,EAAEC,iBACFtD,KAAK0C,SAAU,EACf,uBAAuB,SAASa,KAAK,CACnCxB,OAA0B,UAAlB/B,KAAK+B,SAAuB,KAAO/B,KAAK+B,SAChDC,aAAchC,KAAKgC,eACnBwB,cAAe,CACbC,KAAMzD,KAAK0D,MAAMD,OAElB,CACDE,aAAc3D,KAAK4D,QAAQrB,KAAKvC,QAC/B6D,MAAK,IAAM7D,KAAK8B,SAAU,IAAMgC,OAAM,SAAUD,KAAK7D,KAAK+D,OAAOxB,KAAKvC,MAC3E,EAEFR,OAAOC,IAAIiC,IAAI,eAAgB,iCAAkCC,GCjIjE,MAAM,EAA+BnC,OAAOC,IAAIV,IAAI,OAAQ,oC,aCA5D,MAAM,EAA+BS,OAAOC,IAAIV,IAAI,OAAQ,mC,aCA5D,MAAM,EAA+BS,OAAOC,IAAIV,IAAI,OAAQ,0B,aCA5D,MAAM,EAA+BS,OAAOC,IAAIV,IAAI,OAAQ,oB,aCA5D,MAAM,EAA+BS,OAAOC,IAAIV,IAAI,OAAQ,4B,aCA5D,MAAM,EAA+BS,OAAOC,IAAIV,IAAI,OAAQ,2B,aCA5D,MAAM,EAA+BS,OAAOC,IAAIV,IAAI,OAAQ,+B,aCA5D,MAAM,EAA+BS,OAAOC,IAAIV,IAAI,OAAQ,mC,aCM7C,MAAMiF,UAAiB,KACpCpC,OAAOC,GACL9B,MAAM6B,OAAOC,EACf,CACAoC,OACE,MAAMC,EAAQlE,KAAK0D,MAAMQ,MACzB,OAAO9B,EAAE,IAAY,CACnBH,UAAW,WACXC,MAAO,qBAAqB,0CAC5BiC,SAAUD,EAAMC,WAChBzB,QAASwB,EAAME,YACfC,UAAW,qBAAqB,+CAChCC,SAAU,IAAMJ,EAAMK,YAAcL,EAAMM,iBAAmBN,EAAMzC,YAClEW,EAAE,KAAM,CACTH,UAAW,2BACVjC,KAAKmC,QAAQ+B,IAClB,CACA/B,QAAQ+B,GACN,OAAKA,EAAME,aAAeF,EAAMC,WACvBD,EAAMO,WAAWC,KAAIrD,GACnBA,EAAKuB,MAAM8B,KAAIC,IACpB,MAAMlB,EAAOkB,EAAKlB,OAClB,OAAOrB,EAAE,KAAM,KAAMA,EAAE,IAAgB,CACrCH,UAAW,OACX2C,OAAQxC,EAAE,IAAQ,CAChBnB,KAAMwC,EAAKxC,QAAU,OAEvB4D,KAAM,cACN1C,QAAS,qBAAqB,6CAA8C,CAC1E2C,SAAU,IAASrB,EAAKxC,QACxB8D,GAAI3C,EAAE,KAAM,MACZ4C,WAAYvB,EAAKuB,aAAa9C,UAEhC+C,QAASxB,EAAKyB,eACdC,SAAUR,EAAKS,YACflC,KAAM,eAAeO,GACrBpB,QAASgB,IACPA,EAAEgC,QAAS,CAAK,IAEjB,MAIF,IACT,EAEF7F,OAAOC,IAAIiC,IAAI,eAAgB,4BAA6BsC,GChD7C,MAAMsB,UAAsB,KACzCC,iBAAiB7B,GACfA,EAAMzB,UAAY,IAAU,gBAAiByB,EAAMzB,WACnDyB,EAAM8B,MAAQ9B,EAAM8B,OAAS,qBAAqB,4CAClD9B,EAAMmB,KAAOnB,EAAMmB,MAAQ,cAC3B9E,MAAM0F,UAAU/B,EAClB,CACAgC,aACE,OAAOtD,EAAE4B,EAAU,CACjBE,MAAOlE,KAAK0D,MAAMQ,OAEtB,CACAyB,YACEvD,EAAEwD,MAAMC,IAAI,UAAU,SACxB,CACAC,iBACE,OAAO,oBAAoB,YAC7B,CACAC,cACE,OAAO,2BAA2B,eACpC,EAEFvG,OAAOC,IAAIiC,IAAI,eAAgB,iCAAkC4D,GC1BjE,MAAM,EAA+B9F,OAAOC,IAAIV,IAAI,OAAQ,yB,aCA5D,MAAM,EAA+BS,OAAOC,IAAIV,IAAI,OAAQ,0B,aCA5D,MAAM,EAA+BS,OAAOC,IAAIV,IAAI,OAAQ,oB,aCA5D,MAAM,EAA+BS,OAAOC,IAAIV,IAAI,OAAQ,sB,aCA5D,MAAM,EAA+BS,OAAOC,IAAIV,IAAI,OAAQ,0B,aCQ7C,MAAMiH,UAAkB,KACrCpE,OAAOC,GACL9B,MAAM6B,OAAOC,GACb,iBAAiB,SACjB,iBACA7B,KAAKiG,UAAY,YACnB,CACAhC,OACE,OAAO7B,EAAE,MAAO,CACdH,UAAW,aACVG,EAAE4B,EAAU,CACbE,MAAO,YAEX,EAEF1E,OAAOC,IAAIiC,IAAI,eAAgB,6BAA8BsE,GCvB7D,MAAM,GAA+BxG,OAAOC,IAAIV,IAAI,OAAQ,gB,eCC7C,MAAMmH,WAAa,MAChCrF,OACE,OAAO,eAAgB,QAAQzB,KAAKY,KACtC,CACA+B,SACE,OAAO,eAAgB,UAAU3C,KAAKY,KACxC,CACAgC,eACE,OAAO,eAAgB,gBAAgB5C,KAAKY,KAC9C,CACAoF,YACE,OAAO,eAAgB,YAAa,oBAAqBhG,KAAKY,KAChE,CACAyD,OACE,OAAO,YAAa,QAAQrE,KAAKY,KACnC,CACAiB,OACE,OAAO,YAAa,QAAQ7B,KAAKY,KACnC,EAEFR,OAAOC,IAAIiC,IAAI,eAAgB,oBAAqBwE,ICjBpD,WAAgB,IAAI,aACnBxE,IAAI,QAAS,SAAUsE,IAAY,IAAI,YACvCtE,IAAI,QAASwE,IAAO,IAAI,WAAa,KACrCC,QAAQ,SAASjF,UAAU,YCD5B,qBAAqB,gBAAgB,KACnC,UAAY,IAAIrB,EAAc,MCD9B,IAAAuG,QAAO,IAAc,gBAAgB,SAAUxD,EAAOa,IAChDA,EAAK4C,YAAqC,YAAvB5C,EAAK6C,eAAgC7C,EAAK8C,WACjE3D,EAAMlB,IAAI,OAAQU,EAAE,IAAQ,CAC1ByC,KAAM,cACNxC,QAAS,IAAM,eAAeV,EAAe,CAC3C8B,UAED,qBAAqB,iDAC1B,KCTA,IAAA2C,QAAO,cAA2B,SAAS,SAAUxD,GAC/C,oBAAoB,iBACtBA,EAAMlB,IAAI,QAASU,EAAEkD,EAAe,CAClCpB,MAAO,YACL,GAER,KCHA,IAAAkC,QAAO,cAAgB,gBAAgB,SAAU1C,GAC3C1D,KAAK0D,MAAMD,KAAK+C,QAAQlF,SAC1BoC,EAAMzB,WAAa,iBAEvB,IACA,0BAA6B,SAAUwE,GACrC,MAAMhD,EAAOzD,KAAK0D,MAAMD,KAoBxB,cAnBOA,EAAKiD,KAAKlD,cAAcgD,MAC/BxG,KAAK2G,QAAQC,aACT,iBACF,sBAAqB,CAACjC,EAAMkC,KAC1B,GAAIlC,EAAKlB,SAAWA,EAAM,CAExB,GADA,uBAAuBoD,EAAG,GACtB,kBAAoBpD,EAAM,CAC5B,IAAIqD,EAAO,gBAAgBD,GAE3B,GADKC,IAAMA,EAAO,oBACdA,EAAM,CACR,MAAMC,EAAWD,EAAKrD,OACtB,gBAAkBsD,EAClB3E,EAAEwD,MAAMC,IAAI,eAAekB,GAC7B,CACF,CACA,OAAO,CACT,KAGG,YAAY,CACjBC,IAAK,oBAAoB,UAAYvD,EAAKwD,cAAgB,SAC1DC,OAAQ,SACRT,QAEJ,EACA,8BAAiC,WAC/B,MAAM7D,EAAQ,IAAI,KACZuE,EAAW,wBAAiCnH,KAAK0D,MAAMD,MAc7D,OAbA7E,OAAOwI,KAAKD,EAASE,YAAYC,SAAQC,IACvC,MAAM7D,EAAQyD,EAASpI,IAAIwI,GAAG7D,MAC9BA,EAAMzB,UAAY,UAClB,IAAAmE,QAAO1C,EAAO,WAAW,IAAM1D,KAAKwH,eAAc,IAEpD5E,EAAMlB,IAAI,WAAYU,EAAE,MAAO,CAC7BH,UAAW,eACVkF,EAAS1E,YACZG,EAAMlB,IAAI,UAAWU,EAAE,IAAQ,CAC7BH,UAAW,SACX4C,KAAM,mBACNxC,QAASrC,KAAKwH,YAAYjF,KAAKvC,OAC9B,qBAAqB,iDAAkD,KACnE4C,CACT,GACA,IAAA6E,UAAS,cAAgB,UAAU,SAAUC,GAC3C,MAAMjE,EAAOzD,KAAK0D,MAAMD,KAClB+C,EAAQ/C,EAAK+C,QACnB,GAAKA,EAAMlF,OAEX,OADImC,EAAK4C,aAAYrG,KAAK2H,eAAgB,GACnCvF,EAAE,MAAO,CACdH,UAAW,gBACVG,EAAE,MAAO,CACVH,UAAW,sBACVuE,EAAM9B,KAAIC,GAAQvC,EAAE,MAAO,CAC5BH,UAAW,qBACVjC,KAAK4H,WAAWjD,OAAUvC,EAAE,MAAO,CACpCH,UAAW,wBACVjC,KAAK6H,kBAAkBpF,WAC5B,IACA,yBAA4B,SAAUkC,GACpC,GAAoB,SAAhBA,EAAK9D,OAAmB,CAC1B,MAAMI,EAAO0D,EAAK1D,OACZc,EAAS4C,EAAK5C,SAAW,qBAAqB,uCAAuC+F,OAAOnD,EAAK5C,SAAU,WAAa,KACxHgG,EAASpD,EAAK3C,eACdgG,EAAO,IAAUrD,EAAKS,aAC5B,MAAO,CAAC,qBAAqBrD,EAAS,sDAAwD,0CAA2C,CACvIiG,OACA/G,OACAc,aACIgG,GAAU3F,EAAE,OAAQ,CACxBH,UAAW,uBACV8F,GACL,CACF,CH7EiB,G","sources":["webpack://@flarum/flags/webpack/bootstrap","webpack://@flarum/flags/webpack/runtime/compat get default export","webpack://@flarum/flags/webpack/runtime/define property getters","webpack://@flarum/flags/webpack/runtime/hasOwnProperty shorthand","webpack://@flarum/flags/webpack/runtime/make namespace object","webpack://@flarum/flags/external root \"flarum.reg.get('core', 'forum/app')\"","webpack://@flarum/flags/../../../js-packages/webpack-config/node_modules/@babel/runtime/helpers/esm/typeof.js","webpack://@flarum/flags/external root \"flarum.reg.get('core', 'common/states/PaginatedListState')\"","webpack://@flarum/flags/./src/forum/states/FlagListState.tsx","webpack://@flarum/flags/../../../js-packages/webpack-config/node_modules/@babel/runtime/helpers/esm/defineProperty.js","webpack://@flarum/flags/../../../js-packages/webpack-config/node_modules/@babel/runtime/helpers/esm/toPropertyKey.js","webpack://@flarum/flags/../../../js-packages/webpack-config/node_modules/@babel/runtime/helpers/esm/toPrimitive.js","webpack://@flarum/flags/external root \"flarum.reg.get('core', 'common/extend')\"","webpack://@flarum/flags/external root \"flarum.reg.get('core', 'forum/utils/PostControls')\"","webpack://@flarum/flags/external root \"flarum.reg.get('core', 'common/components/Button')\"","webpack://@flarum/flags/external root \"flarum.reg.get('core', 'common/components/FormModal')\"","webpack://@flarum/flags/external root \"flarum.reg.get('core', 'common/components/Form')\"","webpack://@flarum/flags/external root \"flarum.reg.get('core', 'common/utils/Stream')\"","webpack://@flarum/flags/external root \"flarum.reg.get('core', 'common/utils/withAttr')\"","webpack://@flarum/flags/external root \"flarum.reg.get('core', 'common/utils/ItemList')\"","webpack://@flarum/flags/./src/forum/components/FlagPostModal.js","webpack://@flarum/flags/external root \"flarum.reg.get('core', 'forum/components/HeaderSecondary')\"","webpack://@flarum/flags/external root \"flarum.reg.get('core', 'forum/components/HeaderDropdown')\"","webpack://@flarum/flags/external root \"flarum.reg.get('core', 'common/utils/classList')\"","webpack://@flarum/flags/external root \"flarum.reg.get('core', 'common/Component')\"","webpack://@flarum/flags/external root \"flarum.reg.get('core', 'common/components/Avatar')\"","webpack://@flarum/flags/external root \"flarum.reg.get('core', 'common/helpers/username')\"","webpack://@flarum/flags/external root \"flarum.reg.get('core', 'forum/components/HeaderList')\"","webpack://@flarum/flags/external root \"flarum.reg.get('core', 'forum/components/HeaderListItem')\"","webpack://@flarum/flags/./src/forum/components/FlagList.tsx","webpack://@flarum/flags/./src/forum/components/FlagsDropdown.tsx","webpack://@flarum/flags/external root \"flarum.reg.get('core', 'forum/components/Post')\"","webpack://@flarum/flags/external root \"flarum.reg.get('core', 'common/utils/humanTime')\"","webpack://@flarum/flags/external root \"flarum.reg.get('core', 'common/extenders')\"","webpack://@flarum/flags/external root \"flarum.reg.get('core', 'common/models/Post')\"","webpack://@flarum/flags/external root \"flarum.reg.get('core', 'common/components/Page')\"","webpack://@flarum/flags/./src/forum/components/FlagsPage.js","webpack://@flarum/flags/external root \"flarum.reg.get('core', 'common/Model')\"","webpack://@flarum/flags/./src/forum/models/Flag.ts","webpack://@flarum/flags/./src/forum/extend.ts","webpack://@flarum/flags/./src/forum/index.ts","webpack://@flarum/flags/./src/forum/addFlagControl.js","webpack://@flarum/flags/./src/forum/addFlagsDropdown.js","webpack://@flarum/flags/./src/forum/addFlagsToPosts.js"],"sourcesContent":["// The require scope\nvar __webpack_require__ = {};\n\n","// getDefaultExport function for compatibility with non-harmony modules\n__webpack_require__.n = (module) => {\n\tvar getter = module && module.__esModule ?\n\t\t() => (module['default']) :\n\t\t() => (module);\n\t__webpack_require__.d(getter, { a: getter });\n\treturn getter;\n};","// define getter functions for harmony exports\n__webpack_require__.d = (exports, definition) => {\n\tfor(var key in definition) {\n\t\tif(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {\n\t\t\tObject.defineProperty(exports, key, { enumerable: true, get: definition[key] });\n\t\t}\n\t}\n};","__webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))","// define __esModule on exports\n__webpack_require__.r = (exports) => {\n\tif(typeof Symbol !== 'undefined' && Symbol.toStringTag) {\n\t\tObject.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });\n\t}\n\tObject.defineProperty(exports, '__esModule', { value: true });\n};","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'forum/app');","export default function _typeof(obj) {\n \"@babel/helpers - typeof\";\n\n return _typeof = \"function\" == typeof Symbol && \"symbol\" == typeof Symbol.iterator ? function (obj) {\n return typeof obj;\n } : function (obj) {\n return obj && \"function\" == typeof Symbol && obj.constructor === Symbol && obj !== Symbol.prototype ? \"symbol\" : typeof obj;\n }, _typeof(obj);\n}","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/states/PaginatedListState');","import _defineProperty from \"@babel/runtime/helpers/esm/defineProperty\";\nimport PaginatedListState from 'flarum/common/states/PaginatedListState';\nexport default class FlagListState extends PaginatedListState {\n constructor(app) {\n super({}, 1, null);\n _defineProperty(this, \"app\", void 0);\n this.app = app;\n }\n get type() {\n return 'flags';\n }\n\n /**\n * Load flags into the application's cache if they haven't already\n * been loaded.\n */\n load() {\n var _this$app$session$use;\n if ((_this$app$session$use = this.app.session.user) != null && _this$app$session$use.attribute('newFlagCount')) {\n this.pages = [];\n this.location = {\n page: 1\n };\n }\n if (this.pages.length > 0) {\n return Promise.resolve();\n }\n return super.loadNext();\n }\n}\nflarum.reg.add('flarum-flags', 'forum/states/FlagListState', FlagListState);","import toPropertyKey from \"./toPropertyKey.js\";\nexport default function _defineProperty(obj, key, value) {\n key = toPropertyKey(key);\n if (key in obj) {\n Object.defineProperty(obj, key, {\n value: value,\n enumerable: true,\n configurable: true,\n writable: true\n });\n } else {\n obj[key] = value;\n }\n return obj;\n}","import _typeof from \"./typeof.js\";\nimport toPrimitive from \"./toPrimitive.js\";\nexport default function _toPropertyKey(arg) {\n var key = toPrimitive(arg, \"string\");\n return _typeof(key) === \"symbol\" ? key : String(key);\n}","import _typeof from \"./typeof.js\";\nexport default function _toPrimitive(input, hint) {\n if (_typeof(input) !== \"object\" || input === null) return input;\n var prim = input[Symbol.toPrimitive];\n if (prim !== undefined) {\n var res = prim.call(input, hint || \"default\");\n if (_typeof(res) !== \"object\") return res;\n throw new TypeError(\"@@toPrimitive must return a primitive value.\");\n }\n return (hint === \"string\" ? String : Number)(input);\n}","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/extend');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'forum/utils/PostControls');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/components/Button');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/components/FormModal');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/components/Form');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/utils/Stream');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/utils/withAttr');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/utils/ItemList');","import app from 'flarum/forum/app';\nimport FormModal from 'flarum/common/components/FormModal';\nimport Form from 'flarum/common/components/Form';\nimport Button from 'flarum/common/components/Button';\nimport Stream from 'flarum/common/utils/Stream';\nimport withAttr from 'flarum/common/utils/withAttr';\nimport ItemList from 'flarum/common/utils/ItemList';\nexport default class FlagPostModal extends FormModal {\n oninit(vnode) {\n super.oninit(vnode);\n this.success = false;\n this.reason = Stream('');\n this.reasonDetail = Stream('');\n }\n className() {\n return 'FlagPostModal Modal--medium';\n }\n title() {\n return app.translator.trans('flarum-flags.forum.flag_post.title');\n }\n content() {\n if (this.success) {\n return m(\"div\", {\n className: \"Modal-body\"\n }, m(Form, {\n className: \"Form--centered\"\n }, m(\"p\", {\n className: \"helpText\"\n }, app.translator.trans('flarum-flags.forum.flag_post.confirmation_message')), m(\"div\", {\n className: \"Form-group Form-controls\"\n }, m(Button, {\n className: \"Button Button--primary Button--block\",\n onclick: this.hide.bind(this)\n }, app.translator.trans('flarum-flags.forum.flag_post.dismiss_button')))));\n }\n return m(\"div\", {\n className: \"Modal-body\"\n }, m(Form, {\n className: \"Form--centered\"\n }, m(\"div\", {\n className: \"Form-group\"\n }, m(\"div\", null, this.flagReasons().toArray())), m(\"div\", {\n className: \"Form-group Form-controls\"\n }, m(Button, {\n className: \"Button Button--primary Button--block\",\n type: \"submit\",\n loading: this.loading,\n disabled: !this.reason()\n }, app.translator.trans('flarum-flags.forum.flag_post.submit_button')))));\n }\n flagReasons() {\n const items = new ItemList();\n const guidelinesUrl = app.forum.attribute('guidelinesUrl');\n items.add('off-topic', m(\"label\", {\n className: \"checkbox\"\n }, m(\"input\", {\n type: \"radio\",\n name: \"reason\",\n checked: this.reason() === 'off_topic',\n value: \"off_topic\",\n onclick: withAttr('value', this.reason)\n }), m(\"strong\", null, app.translator.trans('flarum-flags.forum.flag_post.reason_off_topic_label')), app.translator.trans('flarum-flags.forum.flag_post.reason_off_topic_text'), this.reason() === 'off_topic' && m(\"textarea\", {\n className: \"FormControl\",\n placeholder: app.translator.trans('flarum-flags.forum.flag_post.reason_details_placeholder'),\n value: this.reasonDetail(),\n oninput: withAttr('value', this.reasonDetail)\n })), 70);\n items.add('inappropriate', m(\"label\", {\n className: \"checkbox\"\n }, m(\"input\", {\n type: \"radio\",\n name: \"reason\",\n checked: this.reason() === 'inappropriate',\n value: \"inappropriate\",\n onclick: withAttr('value', this.reason)\n }), m(\"strong\", null, app.translator.trans('flarum-flags.forum.flag_post.reason_inappropriate_label')), app.translator.trans('flarum-flags.forum.flag_post.reason_inappropriate_text', {\n a: guidelinesUrl ? m(\"a\", {\n href: guidelinesUrl,\n target: \"_blank\"\n }) : undefined\n }), this.reason() === 'inappropriate' && m(\"textarea\", {\n className: \"FormControl\",\n placeholder: app.translator.trans('flarum-flags.forum.flag_post.reason_details_placeholder'),\n value: this.reasonDetail(),\n oninput: withAttr('value', this.reasonDetail)\n })), 60);\n items.add('spam', m(\"label\", {\n className: \"checkbox\"\n }, m(\"input\", {\n type: \"radio\",\n name: \"reason\",\n checked: this.reason() === 'spam',\n value: \"spam\",\n onclick: withAttr('value', this.reason)\n }), m(\"strong\", null, app.translator.trans('flarum-flags.forum.flag_post.reason_spam_label')), app.translator.trans('flarum-flags.forum.flag_post.reason_spam_text'), this.reason() === 'spam' && m(\"textarea\", {\n className: \"FormControl\",\n placeholder: app.translator.trans('flarum-flags.forum.flag_post.reason_details_placeholder'),\n value: this.reasonDetail(),\n oninput: withAttr('value', this.reasonDetail)\n })), 50);\n items.add('other', m(\"label\", {\n className: \"checkbox\"\n }, m(\"input\", {\n type: \"radio\",\n name: \"reason\",\n checked: this.reason() === 'other',\n value: \"other\",\n onclick: withAttr('value', this.reason)\n }), m(\"strong\", null, app.translator.trans('flarum-flags.forum.flag_post.reason_other_label')), this.reason() === 'other' && m(\"textarea\", {\n className: \"FormControl\",\n value: this.reasonDetail(),\n oninput: withAttr('value', this.reasonDetail)\n })), 10);\n return items;\n }\n onsubmit(e) {\n e.preventDefault();\n this.loading = true;\n app.store.createRecord('flags').save({\n reason: this.reason() === 'other' ? null : this.reason(),\n reasonDetail: this.reasonDetail(),\n relationships: {\n post: this.attrs.post\n }\n }, {\n errorHandler: this.onerror.bind(this)\n }).then(() => this.success = true).catch(() => {}).then(this.loaded.bind(this));\n }\n}\nflarum.reg.add('flarum-flags', 'forum/components/FlagPostModal', FlagPostModal);","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'forum/components/HeaderSecondary');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'forum/components/HeaderDropdown');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/utils/classList');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/Component');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/components/Avatar');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/helpers/username');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'forum/components/HeaderList');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'forum/components/HeaderListItem');","import app from 'flarum/forum/app';\nimport Component from 'flarum/common/Component';\nimport Avatar from 'flarum/common/components/Avatar';\nimport username from 'flarum/common/helpers/username';\nimport HeaderList from 'flarum/forum/components/HeaderList';\nimport HeaderListItem from 'flarum/forum/components/HeaderListItem';\nexport default class FlagList extends Component {\n oninit(vnode) {\n super.oninit(vnode);\n }\n view() {\n const state = this.attrs.state;\n return m(HeaderList, {\n className: \"FlagList\",\n title: app.translator.trans('flarum-flags.forum.flagged_posts.title'),\n hasItems: state.hasItems(),\n loading: state.isLoading(),\n emptyText: app.translator.trans('flarum-flags.forum.flagged_posts.empty_text'),\n loadMore: () => state.hasNext() && !state.isLoadingNext() && state.loadNext()\n }, m(\"ul\", {\n className: \"HeaderListGroup-content\"\n }, this.content(state)));\n }\n content(state) {\n if (!state.isLoading() && state.hasItems()) {\n return state.getPages().map(page => {\n return page.items.map(flag => {\n const post = flag.post();\n return m(\"li\", null, m(HeaderListItem, {\n className: \"Flag\",\n avatar: m(Avatar, {\n user: post.user() || null\n }),\n icon: \"fas fa-flag\",\n content: app.translator.trans('flarum-flags.forum.flagged_posts.item_text', {\n username: username(post.user()),\n em: m(\"em\", null),\n discussion: post.discussion().title()\n }),\n excerpt: post.contentPlain(),\n datetime: flag.createdAt(),\n href: app.route.post(post),\n onclick: e => {\n e.redraw = false;\n }\n }));\n });\n });\n }\n return null;\n }\n}\nflarum.reg.add('flarum-flags', 'forum/components/FlagList', FlagList);","import app from 'flarum/forum/app';\nimport HeaderDropdown from 'flarum/forum/components/HeaderDropdown';\nimport classList from 'flarum/common/utils/classList';\nimport FlagList from './FlagList';\nexport default class FlagsDropdown extends HeaderDropdown {\n static initAttrs(attrs) {\n attrs.className = classList('FlagsDropdown', attrs.className);\n attrs.label = attrs.label || app.translator.trans('flarum-flags.forum.flagged_posts.tooltip');\n attrs.icon = attrs.icon || 'fas fa-flag';\n super.initAttrs(attrs);\n }\n getContent() {\n return m(FlagList, {\n state: this.attrs.state\n });\n }\n goToRoute() {\n m.route.set(app.route('flags'));\n }\n getUnreadCount() {\n return app.forum.attribute('flagCount');\n }\n getNewCount() {\n return app.session.user.attribute('newFlagCount');\n }\n}\nflarum.reg.add('flarum-flags', 'forum/components/FlagsDropdown', FlagsDropdown);","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'forum/components/Post');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/utils/humanTime');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/extenders');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/models/Post');","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/components/Page');","import app from 'flarum/forum/app';\nimport Page from 'flarum/common/components/Page';\nimport FlagList from './FlagList';\n\n/**\n * The `FlagsPage` component shows the flags list. It is only\n * used on mobile devices where the flags dropdown is within the drawer.\n */\nexport default class FlagsPage extends Page {\n oninit(vnode) {\n super.oninit(vnode);\n app.history.push('flags');\n app.flags.load();\n this.bodyClass = 'App--flags';\n }\n view() {\n return m(\"div\", {\n className: \"FlagsPage\"\n }, m(FlagList, {\n state: app.flags\n }));\n }\n}\nflarum.reg.add('flarum-flags', 'forum/components/FlagsPage', FlagsPage);","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.reg.get('core', 'common/Model');","import Model from 'flarum/common/Model';\nexport default class Flag extends Model {\n type() {\n return Model.attribute('type').call(this);\n }\n reason() {\n return Model.attribute('reason').call(this);\n }\n reasonDetail() {\n return Model.attribute('reasonDetail').call(this);\n }\n createdAt() {\n return Model.attribute('createdAt', Model.transformDate).call(this);\n }\n post() {\n return Model.hasOne('post').call(this);\n }\n user() {\n return Model.hasOne('user').call(this);\n }\n}\nflarum.reg.add('flarum-flags', 'forum/models/Flag', Flag);","import Extend from 'flarum/common/extenders';\nimport Post from 'flarum/common/models/Post';\nimport FlagsPage from './components/FlagsPage';\nimport Flag from './models/Flag';\nexport default [new Extend.Routes() //\n.add('flags', '/flags', FlagsPage), new Extend.Store() //\n.add('flags', Flag), new Extend.Model(Post) //\n.hasMany('flags').attribute('canFlag')];","import app from 'flarum/forum/app';\nimport FlagListState from './states/FlagListState';\nimport addFlagControl from './addFlagControl';\nimport addFlagsDropdown from './addFlagsDropdown';\nimport addFlagsToPosts from './addFlagsToPosts';\nexport { default as extend } from './extend';\napp.initializers.add('flarum-flags', () => {\n app.flags = new FlagListState(app);\n addFlagControl();\n addFlagsDropdown();\n addFlagsToPosts();\n});\nimport './forum';","import { extend } from 'flarum/common/extend';\nimport app from 'flarum/forum/app';\nimport PostControls from 'flarum/forum/utils/PostControls';\nimport Button from 'flarum/common/components/Button';\nimport FlagPostModal from './components/FlagPostModal';\nexport default function () {\n extend(PostControls, 'userControls', function (items, post) {\n if (post.isHidden() || post.contentType() !== 'comment' || !post.canFlag()) return;\n items.add('flag', m(Button, {\n icon: \"fas fa-flag\",\n onclick: () => app.modal.show(FlagPostModal, {\n post\n })\n }, app.translator.trans('flarum-flags.forum.post_controls.flag_button')));\n });\n}","import { extend } from 'flarum/common/extend';\nimport app from 'flarum/forum/app';\nimport HeaderSecondary from 'flarum/forum/components/HeaderSecondary';\nimport FlagsDropdown from './components/FlagsDropdown';\nexport default function () {\n extend(HeaderSecondary.prototype, 'items', function (items) {\n if (app.forum.attribute('canViewFlags')) {\n items.add('flags', m(FlagsDropdown, {\n state: app.flags\n }), 15);\n }\n });\n}","import { extend, override } from 'flarum/common/extend';\nimport app from 'flarum/forum/app';\nimport Post from 'flarum/forum/components/Post';\nimport Button from 'flarum/common/components/Button';\nimport ItemList from 'flarum/common/utils/ItemList';\nimport PostControls from 'flarum/forum/utils/PostControls';\nimport humanTime from 'flarum/common/utils/humanTime';\nexport default function () {\n extend(Post.prototype, 'elementAttrs', function (attrs) {\n if (this.attrs.post.flags().length) {\n attrs.className += ' Post--flagged';\n }\n });\n Post.prototype.dismissFlag = function (body) {\n const post = this.attrs.post;\n delete post.data.relationships.flags;\n this.subtree.invalidate();\n if (app.flags.cache) {\n app.flags.cache.some((flag, i) => {\n if (flag.post() === post) {\n app.flags.cache.splice(i, 1);\n if (app.flags.index === post) {\n let next = app.flags.cache[i];\n if (!next) next = app.flags.cache[0];\n if (next) {\n const nextPost = next.post();\n app.flags.index = nextPost;\n m.route.set(app.route.post(nextPost));\n }\n }\n return true;\n }\n });\n }\n return app.request({\n url: app.forum.attribute('apiUrl') + post.apiEndpoint() + '/flags',\n method: 'DELETE',\n body\n });\n };\n Post.prototype.flagActionItems = function () {\n const items = new ItemList();\n const controls = PostControls.destructiveControls(this.attrs.post);\n Object.keys(controls.toObject()).forEach(k => {\n const attrs = controls.get(k).attrs;\n attrs.className = 'Button';\n extend(attrs, 'onclick', () => this.dismissFlag());\n });\n items.add('controls', m(\"div\", {\n className: \"ButtonGroup\"\n }, controls.toArray()));\n items.add('dismiss', m(Button, {\n className: \"Button\",\n icon: \"far fa-eye-slash\",\n onclick: this.dismissFlag.bind(this)\n }, app.translator.trans('flarum-flags.forum.post.dismiss_flag_button')), -100);\n return items;\n };\n override(Post.prototype, 'header', function (vdom) {\n const post = this.attrs.post;\n const flags = post.flags();\n if (!flags.length) return;\n if (post.isHidden()) this.revealContent = true;\n return m(\"div\", {\n className: \"Post-flagged\"\n }, m(\"div\", {\n className: \"Post-flagged-flags\"\n }, flags.map(flag => m(\"div\", {\n className: \"Post-flagged-flag\"\n }, this.flagReason(flag)))), m(\"div\", {\n className: \"Post-flagged-actions\"\n }, this.flagActionItems().toArray()));\n });\n Post.prototype.flagReason = function (flag) {\n if (flag.type() === 'user') {\n const user = flag.user();\n const reason = flag.reason() ? app.translator.trans(\"flarum-flags.forum.flag_post.reason_\".concat(flag.reason(), \"_label\")) : null;\n const detail = flag.reasonDetail();\n const time = humanTime(flag.createdAt());\n return [app.translator.trans(reason ? 'flarum-flags.forum.post.flagged_by_with_reason_text' : 'flarum-flags.forum.post.flagged_by_text', {\n time,\n user,\n reason\n }), !!detail && m(\"span\", {\n className: \"Post-flagged-detail\"\n }, detail)];\n }\n };\n}"],"names":["__webpack_require__","module","getter","__esModule","d","a","exports","definition","key","o","Object","defineProperty","enumerable","get","obj","prop","prototype","hasOwnProperty","call","Symbol","toStringTag","value","flarum","reg","_typeof","iterator","constructor","FlagListState","app","super","this","arg","input","hint","prim","toPrimitive","undefined","res","TypeError","String","toPropertyKey","configurable","writable","type","load","_this$app$session$use","session","user","attribute","pages","location","page","length","Promise","resolve","loadNext","add","FlagPostModal","oninit","vnode","success","reason","reasonDetail","className","title","content","m","onclick","hide","bind","flagReasons","toArray","loading","disabled","items","guidelinesUrl","name","checked","placeholder","oninput","href","target","onsubmit","e","preventDefault","save","relationships","post","attrs","errorHandler","onerror","then","catch","loaded","FlagList","view","state","hasItems","isLoading","emptyText","loadMore","hasNext","isLoadingNext","getPages","map","flag","avatar","icon","username","em","discussion","excerpt","contentPlain","datetime","createdAt","redraw","FlagsDropdown","static","label","initAttrs","getContent","goToRoute","route","set","getUnreadCount","getNewCount","FlagsPage","bodyClass","Flag","hasMany","extend","isHidden","contentType","canFlag","flags","body","data","subtree","invalidate","i","next","nextPost","url","apiEndpoint","method","controls","keys","toObject","forEach","k","dismissFlag","override","vdom","revealContent","flagReason","flagActionItems","concat","detail","time"],"sourceRoot":""} \ No newline at end of file diff --git a/extensions/flags/js/src/forum/components/FlagPostModal.js b/extensions/flags/js/src/forum/components/FlagPostModal.js index 560b99ec2..9c2e58954 100644 --- a/extensions/flags/js/src/forum/components/FlagPostModal.js +++ b/extensions/flags/js/src/forum/components/FlagPostModal.js @@ -151,7 +151,6 @@ export default class FlagPostModal extends FormModal { reason: this.reason() === 'other' ? null : this.reason(), reasonDetail: this.reasonDetail(), relationships: { - user: app.session.user, post: this.attrs.post, }, }, diff --git a/extensions/flags/src/Access/ScopeFlagVisibility.php b/extensions/flags/src/Access/ScopeFlagVisibility.php index 8add2307f..6b9f40e54 100644 --- a/extensions/flags/src/Access/ScopeFlagVisibility.php +++ b/extensions/flags/src/Access/ScopeFlagVisibility.php @@ -10,7 +10,6 @@ namespace Flarum\Flags\Access; use Flarum\Extension\ExtensionManager; -use Flarum\Tags\Tag; use Flarum\User\User; use Illuminate\Database\Eloquent\Builder; @@ -23,31 +22,26 @@ class ScopeFlagVisibility public function __invoke(User $actor, Builder $query): void { - if ($this->extensions->isEnabled('flarum-tags')) { - $query - ->select('flags.*') - ->leftJoin('posts', 'posts.id', '=', 'flags.post_id') - ->leftJoin('discussions', 'discussions.id', '=', 'posts.discussion_id') - ->whereNotExists(function ($query) use ($actor) { - return $query->selectRaw('1') - ->from('discussion_tag') - ->whereNotIn('tag_id', function ($query) use ($actor) { - Tag::query()->setQuery($query->from('tags'))->whereHasPermission($actor, 'discussion.viewFlags')->select('tags.id'); - }) - ->whereColumn('discussions.id', 'discussion_id'); - }); + $query + ->whereHas('post', function (Builder $query) use ($actor) { + $query->whereVisibleTo($actor); + }) + ->where(function (Builder $query) use ($actor) { + if ($this->extensions->isEnabled('flarum-tags')) { + $query + ->select('flags.*') + ->whereHas('post.discussion.tags', function ($query) use ($actor) { + $query->whereHasPermission($actor, 'discussion.viewFlags'); + }); - if (! $actor->hasPermission('discussion.viewFlags')) { - $query->whereExists(function ($query) { - return $query->selectRaw('1') - ->from('discussion_tag') - ->whereColumn('discussions.id', 'discussion_id'); - }); - } - } + if ($actor->hasPermission('discussion.viewFlags')) { + $query->orWhereDoesntHave('post.discussion.tags'); + } + } - if (! $actor->hasPermission('discussion.viewFlags')) { - $query->orWhere('flags.user_id', $actor->id); - } + if (! $actor->hasPermission('discussion.viewFlags')) { + $query->orWhere('flags.user_id', $actor->id); + } + }); } } diff --git a/extensions/flags/src/AddCanFlagAttribute.php b/extensions/flags/src/AddCanFlagAttribute.php deleted file mode 100644 index 1e1e6793c..000000000 --- a/extensions/flags/src/AddCanFlagAttribute.php +++ /dev/null @@ -1,39 +0,0 @@ -getActor()->can('flag', $post) && $this->checkFlagOwnPostSetting($serializer->getActor(), $post); - } - - protected function checkFlagOwnPostSetting(User $actor, Post $post): bool - { - if ($actor->id === $post->user_id) { - // If $actor is the post author, check to see if the setting is enabled - return (bool) $this->settings->get('flarum-flags.can_flag_own'); - } - - // $actor is not the post author - return true; - } -} diff --git a/extensions/flags/src/AddFlagsApiAttributes.php b/extensions/flags/src/AddFlagsApiAttributes.php deleted file mode 100755 index f8a2f1292..000000000 --- a/extensions/flags/src/AddFlagsApiAttributes.php +++ /dev/null @@ -1,40 +0,0 @@ - $serializer->getActor()->hasPermissionLike('discussion.viewFlags') - ]; - - if ($attributes['canViewFlags']) { - $attributes['flagCount'] = (int) $this->getFlagCount($serializer->getActor()); - } - - return $attributes; - } - - protected function getFlagCount(User $actor): int - { - return Flag::whereVisibleTo($actor)->distinct()->count('flags.post_id'); - } -} diff --git a/extensions/flags/src/AddNewFlagCountAttribute.php b/extensions/flags/src/AddNewFlagCountAttribute.php deleted file mode 100644 index 45a1fd829..000000000 --- a/extensions/flags/src/AddNewFlagCountAttribute.php +++ /dev/null @@ -1,32 +0,0 @@ -getNewFlagCount($user); - } - - protected function getNewFlagCount(User $actor): int - { - $query = Flag::whereVisibleTo($actor); - - if ($time = $actor->read_flags_at) { - $query->where('flags.created_at', '>', $time); - } - - return $query->distinct()->count('flags.post_id'); - } -} diff --git a/extensions/flags/src/Api/Controller/CreateFlagController.php b/extensions/flags/src/Api/Controller/CreateFlagController.php deleted file mode 100644 index 7d5b5c376..000000000 --- a/extensions/flags/src/Api/Controller/CreateFlagController.php +++ /dev/null @@ -1,43 +0,0 @@ -bus->dispatch( - new CreateFlag(RequestUtil::getActor($request), Arr::get($request->getParsedBody(), 'data', [])) - ); - } -} diff --git a/extensions/flags/src/Api/Controller/ListFlagsController.php b/extensions/flags/src/Api/Controller/ListFlagsController.php deleted file mode 100644 index 534d3abe5..000000000 --- a/extensions/flags/src/Api/Controller/ListFlagsController.php +++ /dev/null @@ -1,81 +0,0 @@ -assertRegistered(); - - $actor->read_flags_at = Carbon::now(); - $actor->save(); - - $limit = $this->extractLimit($request); - $offset = $this->extractOffset($request); - $include = $this->extractInclude($request); - - if (in_array('post.user', $include)) { - $include[] = 'post.user.groups'; - } - - $flags = Flag::whereVisibleTo($actor) - ->latest('flags.created_at') - ->groupBy('post_id') - ->limit($limit + 1) - ->offset($offset) - ->get(); - - $this->loadRelations($flags, $include, $request); - - $flags = $flags->all(); - - $areMoreResults = false; - - if (count($flags) > $limit) { - array_pop($flags); - $areMoreResults = true; - } - - $this->addPaginationData( - $document, - $request, - $this->url->to('api')->route('flags.index'), - $areMoreResults ? null : 0 - ); - - return $flags; - } -} diff --git a/extensions/flags/src/Api/ForumResourceFields.php b/extensions/flags/src/Api/ForumResourceFields.php new file mode 100644 index 000000000..9438535e3 --- /dev/null +++ b/extensions/flags/src/Api/ForumResourceFields.php @@ -0,0 +1,32 @@ +get(function (object $model, Context $context) { + return $context->getActor()->hasPermissionLike('discussion.viewFlags'); + }), + Schema\Integer::make('flagCount') + ->visible(fn (object $model, Context $context) => $context->getActor()->hasPermissionLike('discussion.viewFlags')) + ->get(function (object $model, Context $context) { + return Flag::whereVisibleTo($context->getActor())->distinct()->count('flags.post_id'); + }), + ]; + } +} diff --git a/extensions/flags/src/Api/PostResourceFields.php b/extensions/flags/src/Api/PostResourceFields.php new file mode 100644 index 000000000..f4863e0f2 --- /dev/null +++ b/extensions/flags/src/Api/PostResourceFields.php @@ -0,0 +1,42 @@ +get(function (Post $post, Context $context) { + $actor = $context->getActor(); + + return $actor->can('flag', $post) && ( + // $actor is not the post author + $actor->id !== $post->user_id + // If $actor is the post author, check to see if the setting is enabled + || ((bool) $this->settings->get('flarum-flags.can_flag_own')) + ); + }), + Schema\Relationship\ToMany::make('flags') + ->includable(), + ]; + } +} diff --git a/extensions/flags/src/Api/Resource/FlagResource.php b/extensions/flags/src/Api/Resource/FlagResource.php new file mode 100644 index 000000000..a124abd52 --- /dev/null +++ b/extensions/flags/src/Api/Resource/FlagResource.php @@ -0,0 +1,165 @@ + + */ +class FlagResource extends AbstractDatabaseResource +{ + public function __construct( + protected PostRepository $posts, + protected TranslatorInterface $translator, + protected SettingsRepositoryInterface $settings, + ) { + } + + public function type(): string + { + return 'flags'; + } + + public function model(): string + { + return Flag::class; + } + + public function query(Context $context): object + { + if ($context->listing(self::class)) { + $query = Flag::query()->groupBy('post_id'); + + $this->scope($query, $context); + + return $query; + } + + return parent::query($context); + } + + public function scope(Builder $query, Context $context): void + { + $query->whereVisibleTo($context->getActor()); + } + + public function newModel(Context $context): object + { + if ($context->creating(self::class)) { + Flag::unguard(); + + return Flag::query()->firstOrNew([ + 'post_id' => (int) Arr::get($context->body(), 'data.relationships.post.data.id'), + 'user_id' => $context->getActor()->id + ], [ + 'type' => 'user', + ]); + } + + return parent::newModel($context); + } + + public function endpoints(): array + { + return [ + Endpoint\Create::make() + ->authenticated() + ->defaultInclude(['post', 'post.flags', 'user']), + Endpoint\Index::make() + ->authenticated() + ->defaultInclude(['user', 'post', 'post.user', 'post.discussion']) + ->defaultSort('-createdAt') + ->paginate() + ->after(function (FlarumContext $context, $data) { + $actor = $context->getActor(); + + $actor->read_flags_at = Carbon::now(); + $actor->save(); + + return $data; + }), + ]; + } + + public function fields(): array + { + return [ + Schema\Str::make('type'), + Schema\Str::make('reason') + ->writableOnCreate() + ->nullable() + ->requiredOnCreateWithout(['reasonDetail']) + ->validationMessages([ + 'reason.required_without' => $this->translator->trans('flarum-flags.forum.flag_post.reason_missing_message'), + ]), + Schema\Str::make('reasonDetail') + ->writableOnCreate() + ->nullable() + ->requiredOnCreateWithout(['reason']) + ->validationMessages([ + 'reasonDetail.required_without' => $this->translator->trans('flarum-flags.forum.flag_post.reason_missing_message'), + ]), + Schema\DateTime::make('createdAt'), + + Schema\Relationship\ToOne::make('post') + ->includable() + ->writable(fn (Flag $flag, FlarumContext $context) => $context->creating()) + ->set(function (Flag $flag, Post $post, FlarumContext $context) { + if (! ($post instanceof CommentPost)) { + throw new InvalidParameterException; + } + + $actor = $context->getActor(); + + $actor->assertCan('flag', $post); + + if ($actor->id === $post->user_id && ! $this->settings->get('flarum-flags.can_flag_own')) { + throw new PermissionDeniedException; + } + + $flag->post_id = $post->id; + }), + Schema\Relationship\ToOne::make('user') + ->includable(), + ]; + } + + public function sorts(): array + { + return [ + SortColumn::make('createdAt'), + ]; + } + + public function created(object $model, Context $context): ?object + { + $this->events->dispatch(new Created($model, $context->getActor(), $context->body())); + + return parent::created($model, $context); + } +} diff --git a/extensions/flags/src/Api/Serializer/FlagSerializer.php b/extensions/flags/src/Api/Serializer/FlagSerializer.php deleted file mode 100644 index 83e8e7d3c..000000000 --- a/extensions/flags/src/Api/Serializer/FlagSerializer.php +++ /dev/null @@ -1,48 +0,0 @@ - $model->type, - 'reason' => $model->reason, - 'reasonDetail' => $model->reason_detail, - 'createdAt' => $this->formatDate($model->created_at), - ]; - } - - protected function post(Flag $flag): ?Relationship - { - return $this->hasOne($flag, PostSerializer::class); - } - - protected function user(Flag $flag): ?Relationship - { - return $this->hasOne($flag, BasicUserSerializer::class); - } -} diff --git a/extensions/flags/src/Api/UserResourceFields.php b/extensions/flags/src/Api/UserResourceFields.php new file mode 100644 index 000000000..3bb50ec67 --- /dev/null +++ b/extensions/flags/src/Api/UserResourceFields.php @@ -0,0 +1,36 @@ +visible(fn (User $user, Context $context) => $context->getActor()->id === $user->id) + ->get(function (User $user, Context $context) { + $actor = $context->getActor(); + $query = Flag::whereVisibleTo($actor); + + if ($time = $actor->read_flags_at) { + $query->where('flags.created_at', '>', $time); + } + + return $query->distinct()->count('flags.post_id'); + }), + ]; + } +} diff --git a/extensions/flags/src/Command/CreateFlagHandler.php b/extensions/flags/src/Command/CreateFlagHandler.php deleted file mode 100644 index 3fbb79aac..000000000 --- a/extensions/flags/src/Command/CreateFlagHandler.php +++ /dev/null @@ -1,79 +0,0 @@ -actor; - $data = $command->data; - - $postId = Arr::get($data, 'relationships.post.data.id'); - $post = $this->posts->findOrFail($postId, $actor); - - if (! ($post instanceof CommentPost)) { - throw new InvalidParameterException; - } - - $actor->assertCan('flag', $post); - - if ($actor->id === $post->user_id && ! $this->settings->get('flarum-flags.can_flag_own')) { - throw new PermissionDeniedException(); - } - - if (Arr::get($data, 'attributes.reason') === null && Arr::get($data, 'attributes.reasonDetail') === '') { - throw new ValidationException([ - 'message' => $this->translator->trans('flarum-flags.forum.flag_post.reason_missing_message') - ]); - } - - Flag::unguard(); - - $flag = Flag::firstOrNew([ - 'post_id' => $post->id, - 'user_id' => $actor->id - ]); - - $flag->post_id = $post->id; - $flag->user_id = $actor->id; - $flag->type = 'user'; - $flag->reason = Arr::get($data, 'attributes.reason'); - $flag->reason_detail = Arr::get($data, 'attributes.reasonDetail'); - $flag->created_at = Carbon::now(); - - $flag->save(); - - $this->events->dispatch(new Created($flag, $actor, $data)); - - return $flag; - } -} diff --git a/extensions/flags/src/Flag.php b/extensions/flags/src/Flag.php index c9050a8ba..ee197e79e 100644 --- a/extensions/flags/src/Flag.php +++ b/extensions/flags/src/Flag.php @@ -33,6 +33,10 @@ class Flag extends AbstractModel use ScopeVisibilityTrait; use HasFactory; + public $timestamps = true; + + public const UPDATED_AT = null; + protected $casts = ['created_at' => 'datetime']; public function post(): BelongsTo diff --git a/extensions/flags/src/PrepareFlagsApiData.php b/extensions/flags/src/PrepareFlagsApiData.php deleted file mode 100755 index 4442255d2..000000000 --- a/extensions/flags/src/PrepareFlagsApiData.php +++ /dev/null @@ -1,64 +0,0 @@ -relationLoaded('posts')) { - $posts = $data->getRelation('posts'); - } - } - - if ($controller instanceof Controller\ListPostsController) { - $posts = $data->all(); - } - - if ($controller instanceof Controller\ShowPostController) { - $posts = [$data]; - } - - if ($controller instanceof CreateFlagController) { - $posts = [$data->post]; - } - - if (isset($posts)) { - $actor = RequestUtil::getActor($request); - $postsWithPermission = []; - - foreach ($posts as $post) { - if (is_object($post)) { - $post->setRelation('flags', null); - - if ($actor->can('viewFlags', $post->discussion)) { - $postsWithPermission[] = $post; - } - } - } - - if (count($postsWithPermission)) { - (new Collection($postsWithPermission)) - ->load('flags', 'flags.user'); - } - } - } -} diff --git a/extensions/flags/tests/integration/api/flags/ListTest.php b/extensions/flags/tests/integration/api/flags/ListTest.php index ef4f17b29..6f137c265 100644 --- a/extensions/flags/tests/integration/api/flags/ListTest.php +++ b/extensions/flags/tests/integration/api/flags/ListTest.php @@ -55,6 +55,7 @@ class ListTest extends TestCase ['id' => 1, 'discussion_id' => 1, 'user_id' => 1, 'type' => 'comment', 'content' => '

'], ['id' => 2, 'discussion_id' => 1, 'user_id' => 1, 'type' => 'comment', 'content' => '

'], ['id' => 3, 'discussion_id' => 1, 'user_id' => 1, 'type' => 'comment', 'content' => '

'], + ['id' => 4, 'discussion_id' => 1, 'user_id' => 1, 'type' => 'comment', 'content' => '

', 'is_private' => true], ], Flag::class => [ ['id' => 1, 'post_id' => 1, 'user_id' => 1], @@ -62,6 +63,7 @@ class ListTest extends TestCase ['id' => 3, 'post_id' => 1, 'user_id' => 3], ['id' => 4, 'post_id' => 2, 'user_id' => 2], ['id' => 5, 'post_id' => 3, 'user_id' => 1], + ['id' => 6, 'post_id' => 4, 'user_id' => 1], ] ]); } @@ -69,7 +71,7 @@ class ListTest extends TestCase /** * @test */ - public function admin_can_see_one_flag_per_post() + public function admin_can_see_one_flag_per_visible_post() { $response = $this->send( $this->request('GET', '/api/flags', [ @@ -77,9 +79,9 @@ class ListTest extends TestCase ]) ); - $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals(200, $response->getStatusCode(), $body = $response->getBody()->getContents()); - $data = json_decode($response->getBody()->getContents(), true)['data']; + $data = json_decode($body, true)['data']; $ids = Arr::pluck($data, 'id'); $this->assertEqualsCanonicalizing(['1', '4', '5'], $ids); @@ -88,7 +90,7 @@ class ListTest extends TestCase /** * @test */ - public function regular_user_sees_own_flags() + public function regular_user_sees_own_flags_of_visible_posts() { $response = $this->send( $this->request('GET', '/api/flags', [ @@ -107,7 +109,7 @@ class ListTest extends TestCase /** * @test */ - public function mod_can_see_one_flag_per_post() + public function mod_can_see_one_flag_per_visible_post() { $response = $this->send( $this->request('GET', '/api/flags', [ diff --git a/extensions/flags/tests/integration/api/flags/ListWithTagsTest.php b/extensions/flags/tests/integration/api/flags/ListWithTagsTest.php index ab65c66fe..dda8f27ed 100644 --- a/extensions/flags/tests/integration/api/flags/ListWithTagsTest.php +++ b/extensions/flags/tests/integration/api/flags/ListWithTagsTest.php @@ -55,9 +55,9 @@ class ListWithTagsTest extends TestCase ], 'group_permission' => [ ['group_id' => Group::MODERATOR_ID, 'permission' => 'discussion.viewFlags'], - ['group_id' => Group::MODERATOR_ID, 'permission' => 'tag2.viewDiscussions'], + ['group_id' => Group::MODERATOR_ID, 'permission' => 'tag2.viewForum'], ['group_id' => Group::MODERATOR_ID, 'permission' => 'tag3.discussion.viewFlags'], - ['group_id' => Group::MODERATOR_ID, 'permission' => 'tag4.viewDiscussions'], + ['group_id' => Group::MODERATOR_ID, 'permission' => 'tag4.viewForum'], ['group_id' => Group::MODERATOR_ID, 'permission' => 'tag4.discussion.viewFlags'], ], Discussion::class => [ @@ -154,9 +154,7 @@ class ListWithTagsTest extends TestCase $data = json_decode($response->getBody()->getContents(), true)['data']; $ids = Arr::pluck($data, 'id'); - // 7 is included, even though mods can't view discussions. - // This is because the UI doesnt allow discussions.viewFlags without viewDiscussions. - $this->assertEqualsCanonicalizing(['1', '4', '5', '7', '8', '9'], $ids); + $this->assertEqualsCanonicalizing(['1', '4', '5', '8', '9'], $ids); } /** diff --git a/extensions/flags/tests/integration/api/posts/IncludeFlagsVisibilityTest.php b/extensions/flags/tests/integration/api/posts/IncludeFlagsVisibilityTest.php new file mode 100644 index 000000000..7ae42b513 --- /dev/null +++ b/extensions/flags/tests/integration/api/posts/IncludeFlagsVisibilityTest.php @@ -0,0 +1,145 @@ +extension('flarum-tags', 'flarum-flags'); + + $this->prepareDatabase([ + 'users' => [ + $this->normalUser(), + [ + 'id' => 3, + 'username' => 'mod', + 'password' => '$2y$10$LO59tiT7uggl6Oe23o/O6.utnF6ipngYjvMvaxo1TciKqBttDNKim', // BCrypt hash for "too-obscure" + 'email' => 'normal2@machine.local', + 'is_email_confirmed' => 1, + ], + [ + 'id' => 4, + 'username' => 'tod', + 'password' => '$2y$10$LO59tiT7uggl6Oe23o/O6.utnF6ipngYjvMvaxo1TciKqBttDNKim', // BCrypt hash for "too-obscure" + 'email' => 'tod@machine.local', + 'is_email_confirmed' => 1, + ], + [ + 'id' => 5, + 'username' => 'ted', + 'password' => '$2y$10$LO59tiT7uggl6Oe23o/O6.utnF6ipngYjvMvaxo1TciKqBttDNKim', // BCrypt hash for "too-obscure" + 'email' => 'ted@machine.local', + 'is_email_confirmed' => 1, + ], + ], + 'group_user' => [ + ['group_id' => 5, 'user_id' => 2], + ['group_id' => 6, 'user_id' => 3], + ], + 'groups' => [ + ['id' => 5, 'name_singular' => 'group5', 'name_plural' => 'group5', 'color' => null, 'icon' => 'fas fa-crown', 'is_hidden' => false], + ['id' => 6, 'name_singular' => 'group1', 'name_plural' => 'group1', 'color' => null, 'icon' => 'fas fa-cog', 'is_hidden' => false], + ], + 'group_permission' => [ + ['group_id' => Group::MEMBER_ID, 'permission' => 'tag1.viewForum'], + ['group_id' => 5, 'permission' => 'tag1.viewForum'], + ['group_id' => 5, 'permission' => 'discussion.viewFlags'], + ['group_id' => 6, 'permission' => 'tag1.discussion.viewFlags'], + ['group_id' => 6, 'permission' => 'tag1.viewForum'], + ], + 'tags' => [ + ['id' => 1, 'name' => 'Tag 1', 'slug' => 'tag-1', 'is_primary' => false, 'position' => null, 'parent_id' => null, 'is_restricted' => true], + ['id' => 2, 'name' => 'Tag 2', 'slug' => 'tag-2', 'is_primary' => true, 'position' => 2, 'parent_id' => null, 'is_restricted' => false], + ], + 'discussions' => [ + ['id' => 1, 'title' => 'Test1', 'user_id' => 1, 'comment_count' => 1], + ['id' => 2, 'title' => 'Test2', 'user_id' => 1, 'comment_count' => 1], + ], + 'discussion_tag' => [ + ['discussion_id' => 1, 'tag_id' => 1], + ['discussion_id' => 2, 'tag_id' => 2], + ], + 'posts' => [ + ['id' => 1, 'discussion_id' => 1, 'user_id' => 1, 'type' => 'comment', 'content' => '

'], + ['id' => 2, 'discussion_id' => 1, 'user_id' => 1, 'type' => 'comment', 'content' => '

'], + ['id' => 3, 'discussion_id' => 1, 'user_id' => 1, 'type' => 'comment', 'content' => '

'], + + ['id' => 4, 'discussion_id' => 2, 'user_id' => 1, 'type' => 'comment', 'content' => '

'], + ['id' => 5, 'discussion_id' => 2, 'user_id' => 1, 'type' => 'comment', 'content' => '

'], + ], + 'flags' => [ + ['id' => 1, 'post_id' => 1, 'user_id' => 1], + ['id' => 2, 'post_id' => 1, 'user_id' => 5], + ['id' => 3, 'post_id' => 1, 'user_id' => 3], + ['id' => 4, 'post_id' => 2, 'user_id' => 5], + ['id' => 5, 'post_id' => 3, 'user_id' => 1], + + ['id' => 6, 'post_id' => 4, 'user_id' => 1], + ['id' => 7, 'post_id' => 5, 'user_id' => 5], + ['id' => 8, 'post_id' => 5, 'user_id' => 5], + ], + ]); + } + + /** + * @dataProvider listFlagsIncludesDataProvider + * @test + */ + public function user_sees_where_allowed_with_included_tags(int $actorId, array $expectedIncludes) + { + $response = $this->send( + $this->request('GET', '/api/posts', [ + 'authenticatedAs' => $actorId, + ])->withQueryParams([ + 'include' => 'flags' + ]) + ); + + $this->assertEquals(200, $response->getStatusCode()); + + $responseBody = json_decode($response->getBody()->getContents(), true); + + $data = $responseBody['data']; + + $this->assertEquals(['1', '2', '3', '4', '5'], Arr::pluck($data, 'id')); + $this->assertEqualsCanonicalizing( + $expectedIncludes, + collect($responseBody['included'] ?? []) + ->filter(fn ($include) => $include['type'] === 'flags') + ->pluck('id') + ->map(strval(...)) + ->all() + ); + } + + public function listFlagsIncludesDataProvider(): array + { + return [ + 'admin_sees_all' => [1, [1, 2, 3, 4, 5, 6, 7, 8]], + 'user_with_general_permission_sees_where_unrestricted_tag' => [2, [6, 7, 8]], + 'user_with_tag1_permission_sees_tag1_flags' => [3, [1, 2, 3, 4, 5]], + 'normal_user_sees_none' => [4, []], + 'normal_user_sees_own' => [5, [2, 7, 4, 8]], + ]; + } +} diff --git a/extensions/likes/extend.php b/extensions/likes/extend.php index 95ee5d1ef..56c147dfe 100644 --- a/extensions/likes/extend.php +++ b/extensions/likes/extend.php @@ -9,16 +9,16 @@ namespace Flarum\Likes; -use Flarum\Api\Controller; -use Flarum\Api\Serializer\BasicUserSerializer; -use Flarum\Api\Serializer\PostSerializer; +use Flarum\Api\Endpoint; +use Flarum\Api\Resource; use Flarum\Extend; -use Flarum\Likes\Api\LoadLikesRelationship; +use Flarum\Likes\Api\PostResourceFields; use Flarum\Likes\Event\PostWasLiked; use Flarum\Likes\Event\PostWasUnliked; use Flarum\Likes\Notification\PostLikedBlueprint; use Flarum\Likes\Query\LikedByFilter; use Flarum\Likes\Query\LikedFilter; +use Flarum\Post\Event\Deleted; use Flarum\Post\Filter\PostSearcher; use Flarum\Post\Post; use Flarum\Search\Database\DatabaseSearchDriver; @@ -39,43 +39,28 @@ return [ new Extend\Locales(__DIR__.'/locale'), (new Extend\Notification()) - ->type(PostLikedBlueprint::class, PostSerializer::class, ['alert']), + ->type(PostLikedBlueprint::class, ['alert']), - (new Extend\ApiSerializer(PostSerializer::class)) - ->hasMany('likes', BasicUserSerializer::class) - ->attribute('canLike', function (PostSerializer $serializer, $model) { - return (bool) $serializer->getActor()->can('like', $model); - }) - ->attribute('likesCount', function (PostSerializer $serializer, $model) { - return $model->getAttribute('likes_count') ?: 0; + (new Extend\ApiResource(Resource\PostResource::class)) + ->fields(PostResourceFields::class) + ->endpoint( + [Endpoint\Index::class, Endpoint\Show::class, Endpoint\Create::class, Endpoint\Update::class], + function (Endpoint\Index|Endpoint\Show|Endpoint\Create|Endpoint\Update $endpoint): Endpoint\EndpointInterface { + return $endpoint->addDefaultInclude(['likes']); + } + ), + + (new Extend\ApiResource(Resource\DiscussionResource::class)) + ->endpoint(Endpoint\Show::class, function (Endpoint\Show $endpoint): Endpoint\EndpointInterface { + return $endpoint->addDefaultInclude(['posts.likes']); }), - (new Extend\ApiController(Controller\ShowDiscussionController::class)) - ->addInclude('posts.likes') - ->loadWhere('posts.likes', LoadLikesRelationship::mutateRelation(...)) - ->prepareDataForSerialization(LoadLikesRelationship::countRelation(...)), - - (new Extend\ApiController(Controller\ListPostsController::class)) - ->addInclude('likes') - ->loadWhere('likes', LoadLikesRelationship::mutateRelation(...)) - ->prepareDataForSerialization(LoadLikesRelationship::countRelation(...)), - (new Extend\ApiController(Controller\ShowPostController::class)) - ->addInclude('likes') - ->loadWhere('likes', LoadLikesRelationship::mutateRelation(...)) - ->prepareDataForSerialization(LoadLikesRelationship::countRelation(...)), - (new Extend\ApiController(Controller\CreatePostController::class)) - ->addInclude('likes') - ->loadWhere('likes', LoadLikesRelationship::mutateRelation(...)) - ->prepareDataForSerialization(LoadLikesRelationship::countRelation(...)), - (new Extend\ApiController(Controller\UpdatePostController::class)) - ->addInclude('likes') - ->loadWhere('likes', LoadLikesRelationship::mutateRelation(...)) - ->prepareDataForSerialization(LoadLikesRelationship::countRelation(...)), - (new Extend\Event()) ->listen(PostWasLiked::class, Listener\SendNotificationWhenPostIsLiked::class) ->listen(PostWasUnliked::class, Listener\SendNotificationWhenPostIsUnliked::class) - ->subscribe(Listener\SaveLikesToDatabase::class), + ->listen(Deleted::class, function (Deleted $event) { + $event->post->likes()->detach(); + }), (new Extend\SearchDriver(DatabaseSearchDriver::class)) ->addFilter(PostSearcher::class, LikedByFilter::class) diff --git a/extensions/likes/src/Api/LoadLikesRelationship.php b/extensions/likes/src/Api/LoadLikesRelationship.php deleted file mode 100644 index 4f9b62619..000000000 --- a/extensions/likes/src/Api/LoadLikesRelationship.php +++ /dev/null @@ -1,65 +0,0 @@ -getQuery()->getGrammar(); - - $query - // So that we can tell if the current user has liked the post. - ->orderBy(new Expression($grammar->wrap('user_id').' = '.$actor->id), 'desc') - // Limiting a relationship results is only possible because - // the Post model uses the \Staudenmeir\EloquentEagerLimit\HasEagerLimit - // trait. - ->limit(self::$maxLikes); - } - - /** - * Called using the @see ApiController::prepareDataForSerialization extender. - */ - public static function countRelation(AbstractSerializeController $controller, mixed $data): array - { - $loadable = null; - - if ($data instanceof Discussion) { - // We do this because the ShowDiscussionController manipulates the posts - // in a way that some of them are just ids. - $loadable = $data->posts->filter(function ($post) { - return $post instanceof Post; - }); - } elseif ($data instanceof Collection) { - $loadable = $data; - } elseif ($data instanceof Post) { - $loadable = $data->newCollection([$data]); - } - - if ($loadable) { - $loadable->loadCount('likes'); - } - - return []; - } -} diff --git a/extensions/likes/src/Api/PostResourceFields.php b/extensions/likes/src/Api/PostResourceFields.php new file mode 100644 index 000000000..1bf5d8900 --- /dev/null +++ b/extensions/likes/src/Api/PostResourceFields.php @@ -0,0 +1,65 @@ +visible(false) + ->writable(fn (Post $post, Context $context) => $context->getActor()->can('like', $post)) + ->set(function (Post $post, bool $liked, Context $context) { + $actor = $context->getActor(); + + $currentlyLiked = $post->likes()->where('user_id', $actor->id)->exists(); + + if ($liked && ! $currentlyLiked) { + $post->likes()->attach($actor->id); + + $post->raise(new PostWasLiked($post, $actor)); + } elseif ($currentlyLiked) { + $post->likes()->detach($actor->id); + + $post->raise(new PostWasUnliked($post, $actor)); + } + }), + + Schema\Boolean::make('canLike') + ->get(fn (Post $post, Context $context) => $context->getActor()->can('like', $post)), + Schema\Integer::make('likesCount') + ->countRelation('likes'), + + Schema\Relationship\ToMany::make('likes') + ->type('users') + ->includable() + ->constrain(function (Builder $query, Context $context) { + $actor = $context->getActor(); + $grammar = $query->getQuery()->getGrammar(); + + // So that we can tell if the current user has liked the post. + $query + ->orderBy(new Expression($grammar->wrap('user_id').' = '.$actor->id), 'desc') + ->limit(static::$maxLikes); + }), + ]; + } +} diff --git a/extensions/likes/src/Listener/SaveLikesToDatabase.php b/extensions/likes/src/Listener/SaveLikesToDatabase.php deleted file mode 100755 index 8be625e9b..000000000 --- a/extensions/likes/src/Listener/SaveLikesToDatabase.php +++ /dev/null @@ -1,55 +0,0 @@ -listen(Saving::class, $this->whenPostIsSaving(...)); - $events->listen(Deleted::class, $this->whenPostIsDeleted(...)); - } - - public function whenPostIsSaving(Saving $event): void - { - $post = $event->post; - $data = $event->data; - - if ($post->exists && isset($data['attributes']['isLiked'])) { - $actor = $event->actor; - $liked = (bool) $data['attributes']['isLiked']; - - $actor->assertCan('like', $post); - - $currentlyLiked = $post->likes()->where('user_id', $actor->id)->exists(); - - if ($liked && ! $currentlyLiked) { - $post->likes()->attach($actor->id); - - $post->raise(new PostWasLiked($post, $actor)); - } elseif ($currentlyLiked) { - $post->likes()->detach($actor->id); - - $post->raise(new PostWasUnliked($post, $actor)); - } - } - } - - public function whenPostIsDeleted(Deleted $event): void - { - $event->post->likes()->detach(); - } -} diff --git a/extensions/likes/tests/integration/api/LikePostTest.php b/extensions/likes/tests/integration/api/LikePostTest.php index 1c60fa511..570081e1f 100644 --- a/extensions/likes/tests/integration/api/LikePostTest.php +++ b/extensions/likes/tests/integration/api/LikePostTest.php @@ -76,7 +76,7 @@ class LikePostTest extends TestCase $post = CommentPost::query()->find($postId); - $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals(200, $response->getStatusCode(), $response->getBody()->getContents()); $this->assertNotNull($post->likes->where('id', $authenticatedAs)->first(), $message); } @@ -96,7 +96,7 @@ class LikePostTest extends TestCase $post = CommentPost::query()->find($postId); - $this->assertEquals(403, $response->getStatusCode(), $message); + $this->assertContainsEquals($response->getStatusCode(), [401, 403], $message); $this->assertNull($post->likes->where('id', $authenticatedAs)->first()); } diff --git a/extensions/likes/tests/integration/api/ListPostsTest.php b/extensions/likes/tests/integration/api/ListPostsTest.php index 767de463c..5c8df77b9 100644 --- a/extensions/likes/tests/integration/api/ListPostsTest.php +++ b/extensions/likes/tests/integration/api/ListPostsTest.php @@ -12,7 +12,7 @@ namespace Flarum\Likes\Tests\integration\api\discussions; use Carbon\Carbon; use Flarum\Discussion\Discussion; use Flarum\Group\Group; -use Flarum\Likes\Api\LoadLikesRelationship; +use Flarum\Likes\Api\PostResourceFields; use Flarum\Post\Post; use Flarum\Testing\integration\RetrievesAuthorizedUsers; use Flarum\Testing\integration\TestCase; @@ -135,7 +135,7 @@ class ListPostsTest extends TestCase $likes = $data['relationships']['likes']['data']; // Only displays a limited amount of likes - $this->assertCount(LoadLikesRelationship::$maxLikes, $likes); + $this->assertCount(PostResourceFields::$maxLikes, $likes); // Displays the correct count of likes $this->assertEquals(11, $data['attributes']['likesCount']); // Of the limited amount of likes, the actor always appears @@ -162,7 +162,7 @@ class ListPostsTest extends TestCase $likes = $data[0]['relationships']['likes']['data']; // Only displays a limited amount of likes - $this->assertCount(LoadLikesRelationship::$maxLikes, $likes); + $this->assertCount(PostResourceFields::$maxLikes, $likes); // Displays the correct count of likes $this->assertEquals(11, $data[0]['attributes']['likesCount']); // Of the limited amount of likes, the actor always appears @@ -173,7 +173,7 @@ class ListPostsTest extends TestCase * @dataProvider likesIncludeProvider * @test */ - public function likes_relation_returns_limited_results_and_shows_only_visible_posts_in_show_discussion_endpoint(string $include) + public function likes_relation_returns_limited_results_and_shows_only_visible_posts_in_show_discussion_endpoint(?string $include) { // Show discussion endpoint $response = $this->send( @@ -184,22 +184,27 @@ class ListPostsTest extends TestCase ]) ); - $included = json_decode($response->getBody()->getContents(), true)['included']; + $body = $response->getBody()->getContents(); + + $this->assertEquals(200, $response->getStatusCode(), $body); + + $included = json_decode($body, true)['included'] ?? []; $likes = collect($included) ->where('type', 'posts') ->where('id', 101) - ->first()['relationships']['likes']['data']; + ->first()['relationships']['likes']['data'] ?? null; // Only displays a limited amount of likes - $this->assertCount(LoadLikesRelationship::$maxLikes, $likes); + $this->assertNotNull($likes, $body); + $this->assertCount(PostResourceFields::$maxLikes, $likes); // Displays the correct count of likes $this->assertEquals(11, collect($included) ->where('type', 'posts') ->where('id', 101) - ->first()['attributes']['likesCount']); + ->first()['attributes']['likesCount'] ?? null, $body); // Of the limited amount of likes, the actor always appears - $this->assertEquals([2, 102, 104, 105], Arr::pluck($likes, 'id')); + $this->assertEquals([2, 102, 104, 105], Arr::pluck($likes, 'id'), $body); } public function likesIncludeProvider(): array @@ -207,7 +212,7 @@ class ListPostsTest extends TestCase return [ ['posts,posts.likes'], ['posts.likes'], - [''], + [null], ]; } } diff --git a/extensions/lock/extend.php b/extensions/lock/extend.php index 26000af97..ad902dcd4 100644 --- a/extensions/lock/extend.php +++ b/extensions/lock/extend.php @@ -7,10 +7,10 @@ * LICENSE file that was distributed with this source code. */ -use Flarum\Api\Serializer\BasicDiscussionSerializer; -use Flarum\Api\Serializer\DiscussionSerializer; +use Flarum\Api\Context; +use Flarum\Api\Resource; +use Flarum\Api\Schema; use Flarum\Discussion\Discussion; -use Flarum\Discussion\Event\Saving; use Flarum\Discussion\Search\DiscussionSearcher; use Flarum\Extend; use Flarum\Lock\Access; @@ -33,24 +33,38 @@ return [ new Extend\Locales(__DIR__.'/locale'), (new Extend\Notification()) - ->type(DiscussionLockedBlueprint::class, BasicDiscussionSerializer::class, ['alert']), + ->type(DiscussionLockedBlueprint::class, ['alert']), (new Extend\Model(Discussion::class)) ->cast('is_locked', 'bool'), - (new Extend\ApiSerializer(DiscussionSerializer::class)) - ->attribute('isLocked', function (DiscussionSerializer $serializer, Discussion $discussion) { - return $discussion->is_locked; - }) - ->attribute('canLock', function (DiscussionSerializer $serializer, Discussion $discussion) { - return $serializer->getActor()->can('lock', $discussion); - }), + (new Extend\ApiResource(Resource\DiscussionResource::class)) + ->fields(fn () => [ + Schema\Boolean::make('isLocked') + ->writable(fn (Discussion $discussion, Context $context) => $context->getActor()->can('lock', $discussion)) + ->set(function (Discussion $discussion, bool $isLocked, Context $context) { + $actor = $context->getActor(); + + if ($discussion->is_locked === $isLocked) { + return; + } + + $discussion->is_locked = $isLocked; + + $discussion->raise( + $discussion->is_locked + ? new DiscussionWasLocked($discussion, $actor) + : new DiscussionWasUnlocked($discussion, $actor) + ); + }), + Schema\Boolean::make('canLock') + ->get(fn (Discussion $discussion, Context $context) => $context->getActor()->can('lock', $discussion)), + ]), (new Extend\Post()) ->type(DiscussionLockedPost::class), (new Extend\Event()) - ->listen(Saving::class, Listener\SaveLockedToDatabase::class) ->listen(DiscussionWasLocked::class, Listener\CreatePostWhenDiscussionIsLocked::class) ->listen(DiscussionWasUnlocked::class, Listener\CreatePostWhenDiscussionIsUnlocked::class), diff --git a/extensions/lock/src/Listener/SaveLockedToDatabase.php b/extensions/lock/src/Listener/SaveLockedToDatabase.php deleted file mode 100755 index e3c107a4f..000000000 --- a/extensions/lock/src/Listener/SaveLockedToDatabase.php +++ /dev/null @@ -1,40 +0,0 @@ -data['attributes']['isLocked'])) { - $isLocked = (bool) $event->data['attributes']['isLocked']; - $discussion = $event->discussion; - $actor = $event->actor; - - $actor->assertCan('lock', $discussion); - - if ((bool) $discussion->is_locked === $isLocked) { - return; - } - - $discussion->is_locked = $isLocked; - - $discussion->raise( - $discussion->is_locked - ? new DiscussionWasLocked($discussion, $actor) - : new DiscussionWasUnlocked($discussion, $actor) - ); - } - } -} diff --git a/extensions/mentions/extend.php b/extensions/mentions/extend.php index a85d04dab..4ef314e48 100644 --- a/extensions/mentions/extend.php +++ b/extensions/mentions/extend.php @@ -9,16 +9,14 @@ namespace Flarum\Mentions; -use Flarum\Api\Controller; -use Flarum\Api\Serializer\BasicPostSerializer; -use Flarum\Api\Serializer\BasicUserSerializer; -use Flarum\Api\Serializer\CurrentUserSerializer; -use Flarum\Api\Serializer\GroupSerializer; -use Flarum\Api\Serializer\PostSerializer; +use Flarum\Api\Context; +use Flarum\Api\Endpoint; +use Flarum\Api\Resource; +use Flarum\Api\Schema; use Flarum\Approval\Event\PostWasApproved; use Flarum\Extend; use Flarum\Group\Group; -use Flarum\Mentions\Api\LoadMentionedByRelationship; +use Flarum\Mentions\Api\PostResourceFields; use Flarum\Post\Event\Deleted; use Flarum\Post\Event\Hidden; use Flarum\Post\Event\Posted; @@ -27,7 +25,6 @@ use Flarum\Post\Event\Revised; use Flarum\Post\Filter\PostSearcher; use Flarum\Post\Post; use Flarum\Search\Database\DatabaseSearchDriver; -use Flarum\Tags\Api\Serializer\TagSerializer; use Flarum\User\User; return [ @@ -60,50 +57,49 @@ return [ ->namespace('flarum-mentions', __DIR__.'/views'), (new Extend\Notification()) - ->type(Notification\PostMentionedBlueprint::class, PostSerializer::class, ['alert']) - ->type(Notification\UserMentionedBlueprint::class, PostSerializer::class, ['alert']) - ->type(Notification\GroupMentionedBlueprint::class, PostSerializer::class, ['alert']), + ->type(Notification\PostMentionedBlueprint::class, ['alert']) + ->type(Notification\UserMentionedBlueprint::class, ['alert']) + ->type(Notification\GroupMentionedBlueprint::class, ['alert']), - (new Extend\ApiSerializer(BasicPostSerializer::class)) - ->hasMany('mentionedBy', BasicPostSerializer::class) - ->hasMany('mentionsPosts', BasicPostSerializer::class) - ->hasMany('mentionsUsers', BasicUserSerializer::class) - ->hasMany('mentionsGroups', GroupSerializer::class) - ->attribute('mentionedByCount', function (BasicPostSerializer $serializer, Post $post) { - // Only if it was eager loaded. - return $post->getAttribute('mentioned_by_count') ?? 0; + (new Extend\ApiResource(Resource\PostResource::class)) + ->fields(PostResourceFields::class) + ->endpoint([Endpoint\Index::class, Endpoint\Show::class], function (Endpoint\Index|Endpoint\Show $endpoint): Endpoint\EndpointInterface { + return $endpoint->addDefaultInclude(['mentionedBy', 'mentionedBy.user', 'mentionedBy.discussion']); + }) + ->endpoint(Endpoint\Index::class, function (Endpoint\Index $endpoint): Endpoint\Index { + return $endpoint->eagerLoad(['mentionsUsers', 'mentionsPosts', 'mentionsPosts.user', 'mentionsPosts.discussion', 'mentionsGroups']); }), - (new Extend\ApiController(Controller\ShowDiscussionController::class)) - ->addInclude(['posts.mentionedBy', 'posts.mentionedBy.user', 'posts.mentionedBy.discussion']) - ->load([ - 'posts.mentionsUsers', 'posts.mentionsPosts', 'posts.mentionsPosts.user', - 'posts.mentionsPosts.discussion', 'posts.mentionsGroups' - ]) - ->loadWhere('posts.mentionedBy', LoadMentionedByRelationship::mutateRelation(...)) - ->prepareDataForSerialization(LoadMentionedByRelationship::countRelation(...)), + (new Extend\ApiResource(Resource\DiscussionResource::class)) + ->endpoint(Endpoint\Index::class, function (Endpoint\Index $endpoint): Endpoint\Index { + return $endpoint->eagerLoadWhenIncluded([ + 'firstPost' => [ + 'firstPost.mentionsUsers', 'firstPost.mentionsPosts', + 'firstPost.mentionsPosts.user', 'firstPost.mentionsPosts.discussion', 'firstPost.mentionsGroups', + ], + 'lastPost' => [ + 'lastPost.mentionsUsers', 'lastPost.mentionsPosts', + 'lastPost.mentionsPosts.user', 'lastPost.mentionsPosts.discussion', 'lastPost.mentionsGroups', + ], + ]); + }) + ->endpoint(Endpoint\Show::class, function (Endpoint\Show $endpoint): Endpoint\Show { + return $endpoint->addDefaultInclude(['posts.mentionedBy', 'posts.mentionedBy.user', 'posts.mentionedBy.discussion']) + ->eagerLoadWhenIncluded([ + 'posts' => [ + 'posts.mentionsUsers', 'posts.mentionsPosts', 'posts.mentionsPosts.user', + 'posts.mentionsPosts.discussion', 'posts.mentionsGroups' + ], + ]); + }), - (new Extend\ApiController(Controller\ListDiscussionsController::class)) - ->load([ - 'firstPost.mentionsUsers', 'firstPost.mentionsPosts', - 'firstPost.mentionsPosts.user', 'firstPost.mentionsPosts.discussion', 'firstPost.mentionsGroups', - 'lastPost.mentionsUsers', 'lastPost.mentionsPosts', - 'lastPost.mentionsPosts.user', 'lastPost.mentionsPosts.discussion', 'lastPost.mentionsGroups', + (new Extend\ApiResource(Resource\UserResource::class)) + ->fields(fn () => [ + Schema\Boolean::make('canMentionGroups') + ->visible(fn (User $user, Context $context) => $context->getActor()->id === $user->id) + ->get(fn (User $user) => $user->can('mentionGroups')), ]), - (new Extend\ApiController(Controller\ShowPostController::class)) - ->addInclude(['mentionedBy', 'mentionedBy.user', 'mentionedBy.discussion']) - // We wouldn't normally need to eager load on a single model, - // but we do so here for visibility scoping. - ->loadWhere('mentionedBy', LoadMentionedByRelationship::mutateRelation(...)) - ->prepareDataForSerialization(LoadMentionedByRelationship::countRelation(...)), - - (new Extend\ApiController(Controller\ListPostsController::class)) - ->addInclude(['mentionedBy', 'mentionedBy.user', 'mentionedBy.discussion']) - ->load(['mentionsUsers', 'mentionsPosts', 'mentionsPosts.user', 'mentionsPosts.discussion', 'mentionsGroups']) - ->loadWhere('mentionedBy', LoadMentionedByRelationship::mutateRelation(...)) - ->prepareDataForSerialization(LoadMentionedByRelationship::countRelation(...)), - (new Extend\Settings) ->serializeToForum('allowUsernameMentionFormat', 'flarum-mentions.allow_username_format', 'boolval'), @@ -119,11 +115,6 @@ return [ ->addFilter(PostSearcher::class, Filter\MentionedFilter::class) ->addFilter(PostSearcher::class, Filter\MentionedPostFilter::class), - (new Extend\ApiSerializer(CurrentUserSerializer::class)) - ->attribute('canMentionGroups', function (CurrentUserSerializer $serializer, User $user): bool { - return $user->can('mentionGroups'); - }), - // Tag mentions (new Extend\Conditional()) ->whenExtensionEnabled('flarum-tags', fn () => [ @@ -131,18 +122,23 @@ return [ ->render(Formatter\FormatTagMentions::class) ->unparse(Formatter\UnparseTagMentions::class), - (new Extend\ApiSerializer(BasicPostSerializer::class)) - ->hasMany('mentionsTags', TagSerializer::class), - - (new Extend\ApiController(Controller\ShowDiscussionController::class)) - ->load(['posts.mentionsTags']), - - (new Extend\ApiController(Controller\ListDiscussionsController::class)) - ->load([ - 'firstPost.mentionsTags', 'lastPost.mentionsTags', + (new Extend\ApiResource(Resource\PostResource::class)) + ->fields(fn () => [ + Schema\Relationship\ToMany::make('mentionsTags') + ->type('tags'), ]), - (new Extend\ApiController(Controller\ListPostsController::class)) - ->load(['mentionsTags']), + (new Extend\ApiResource(Resource\DiscussionResource::class)) + ->endpoint(Endpoint\Show::class, function (Endpoint\Show $endpoint): Endpoint\Show { + return $endpoint->eagerLoadWhenIncluded(['posts' => ['posts.mentionsTags']]); + }) + ->endpoint(Endpoint\Index::class, function (Endpoint\Index $endpoint): Endpoint\Index { + return $endpoint->eagerLoadWhenIncluded(['firstPost' => ['firstPost.mentionsTags'], 'lastPost' => ['lastPost.mentionsTags']]); + }), + + (new Extend\ApiResource(Resource\PostResource::class)) + ->endpoint([Endpoint\Index::class, Endpoint\Show::class], function (Endpoint\Index|Endpoint\Show $endpoint): Endpoint\EndpointInterface { + return $endpoint->eagerLoad(['mentionsTags']); + }), ]), ]; diff --git a/extensions/mentions/src/Api/LoadMentionedByRelationship.php b/extensions/mentions/src/Api/LoadMentionedByRelationship.php deleted file mode 100644 index 905d7d48c..000000000 --- a/extensions/mentions/src/Api/LoadMentionedByRelationship.php +++ /dev/null @@ -1,82 +0,0 @@ -with(['mentionsPosts', 'mentionsPosts.user', 'mentionsPosts.discussion', 'mentionsUsers']) - ->whereVisibleTo($actor) - ->oldest('posts.created_at') - // Limiting a relationship results is only possible because - // the Post model uses the \Staudenmeir\EloquentEagerLimit\HasEagerLimit - // trait. - ->limit(self::$maxMentionedBy); - } - - /** - * Called using the @see ApiController::prepareDataForSerialization extender. - */ - public static function countRelation(AbstractSerializeController $controller, mixed $data, ServerRequestInterface $request): array - { - $actor = RequestUtil::getActor($request); - $loadable = null; - - if ($data instanceof Discussion) { - // We do this because the ShowDiscussionController manipulates the posts - // in a way that some of them are just ids. - $loadable = $data->posts->filter(function ($post) { - return $post instanceof Post; - }); - - // firstPost and lastPost might have been included in the API response, - // so we have to make sure counts are also loaded for them. - if ($data->firstPost) { - $loadable->push($data->firstPost); - } - - if ($data->lastPost) { - $loadable->push($data->lastPost); - } - } elseif ($data instanceof Collection) { - $loadable = $data; - } elseif ($data instanceof Post) { - $loadable = $data->newCollection([$data]); - } - - if ($loadable) { - $loadable->loadCount([ - 'mentionedBy' => function ($query) use ($actor) { - return $query->whereVisibleTo($actor); - } - ]); - } - - return []; - } -} diff --git a/extensions/mentions/src/Api/PostResourceFields.php b/extensions/mentions/src/Api/PostResourceFields.php new file mode 100644 index 000000000..e27376ac2 --- /dev/null +++ b/extensions/mentions/src/Api/PostResourceFields.php @@ -0,0 +1,37 @@ +countRelation('mentionedBy'), + + Schema\Relationship\ToMany::make('mentionedBy') + ->type('posts') + ->includable() + ->constrain(fn (Builder $query) => $query->oldest('id')->limit(static::$maxMentionedBy)), + Schema\Relationship\ToMany::make('mentionsPosts') + ->type('posts'), + Schema\Relationship\ToMany::make('mentionsUsers') + ->type('users'), + Schema\Relationship\ToMany::make('mentionsGroups') + ->type('groups'), + ]; + } +} diff --git a/extensions/mentions/tests/integration/api/GroupMentionsTest.php b/extensions/mentions/tests/integration/api/GroupMentionsTest.php index 02f882fe1..0256e947d 100644 --- a/extensions/mentions/tests/integration/api/GroupMentionsTest.php +++ b/extensions/mentions/tests/integration/api/GroupMentionsTest.php @@ -93,11 +93,12 @@ class GroupMentionsTest extends TestCase 'authenticatedAs' => 1, 'json' => [ 'data' => [ + 'type' => 'posts', 'attributes' => [ 'content' => '@"InvalidGroup"#g99', ], 'relationships' => [ - 'discussion' => ['data' => ['id' => 2]], + 'discussion' => ['data' => ['type' => 'discussions', 'id' => 2]], ] ], ], @@ -168,11 +169,12 @@ class GroupMentionsTest extends TestCase 'authenticatedAs' => 1, 'json' => [ 'data' => [ + 'type' => 'posts', 'attributes' => [ 'content' => '@"Mods"#g4', ], 'relationships' => [ - 'discussion' => ['data' => ['id' => 2]], + 'discussion' => ['data' => ['type' => 'discussions', 'id' => 2]], ] ] ] @@ -200,11 +202,12 @@ class GroupMentionsTest extends TestCase 'authenticatedAs' => 1, 'json' => [ 'data' => [ + 'type' => 'posts', 'attributes' => [ 'content' => '@"Admins"#g1 @"Mods"#g4', ], 'relationships' => [ - 'discussion' => ['data' => ['id' => 2]], + 'discussion' => ['data' => ['type' => 'discussions', 'id' => 2]], ] ] ] @@ -234,11 +237,12 @@ class GroupMentionsTest extends TestCase 'authenticatedAs' => 1, 'json' => [ 'data' => [ + 'type' => 'posts', 'attributes' => [ 'content' => '@"Members"#g3 @"Guests"#g2', ], 'relationships' => [ - 'discussion' => ['data' => ['id' => 2]], + 'discussion' => ['data' => ['type' => 'discussions', 'id' => 2]], ] ] ] @@ -290,11 +294,12 @@ class GroupMentionsTest extends TestCase 'authenticatedAs' => 3, 'json' => [ 'data' => [ + 'type' => 'posts', 'attributes' => [ 'content' => '@"Mods"#g4', ], 'relationships' => [ - 'discussion' => ['data' => ['id' => 2]], + 'discussion' => ['data' => ['type' => 'discussions', 'id' => 2]], ], ], ], @@ -321,11 +326,12 @@ class GroupMentionsTest extends TestCase 'authenticatedAs' => 4, 'json' => [ 'data' => [ + 'type' => 'posts', 'attributes' => [ 'content' => '@"Mods"#g4', ], 'relationships' => [ - 'discussion' => ['data' => ['id' => 2]], + 'discussion' => ['data' => ['type' => 'discussions', 'id' => 2]], ], ], ], @@ -352,11 +358,12 @@ class GroupMentionsTest extends TestCase 'authenticatedAs' => 4, 'json' => [ 'data' => [ + 'type' => 'posts', 'attributes' => [ 'content' => '@"Ninjas"#g10', ], 'relationships' => [ - 'discussion' => ['data' => ['id' => 2]], + 'discussion' => ['data' => ['type' => 'discussions', 'id' => 2]], ], ], ], @@ -383,6 +390,7 @@ class GroupMentionsTest extends TestCase 'authenticatedAs' => 1, 'json' => [ 'data' => [ + 'type' => 'posts', 'attributes' => [ 'content' => 'New content with @"Mods"#g4 mention', ], diff --git a/extensions/mentions/tests/integration/api/ListPostsTest.php b/extensions/mentions/tests/integration/api/ListPostsTest.php index c70c1a638..f33abc2fc 100644 --- a/extensions/mentions/tests/integration/api/ListPostsTest.php +++ b/extensions/mentions/tests/integration/api/ListPostsTest.php @@ -11,7 +11,7 @@ namespace Flarum\Mentions\Tests\integration\api\discussions; use Carbon\Carbon; use Flarum\Discussion\Discussion; -use Flarum\Mentions\Api\LoadMentionedByRelationship; +use Flarum\Mentions\Api\PostResourceFields; use Flarum\Post\Post; use Flarum\Testing\integration\RetrievesAuthorizedUsers; use Flarum\Testing\integration\TestCase; @@ -170,7 +170,7 @@ class ListPostsTest extends TestCase $mentionedBy = $data['relationships']['mentionedBy']['data']; // Only displays a limited amount of mentioned by posts - $this->assertCount(LoadMentionedByRelationship::$maxMentionedBy, $mentionedBy); + $this->assertCount(PostResourceFields::$maxMentionedBy, $mentionedBy); // Of the limited amount of mentioned by posts, they must be visible to the actor $this->assertEquals([102, 104, 105, 106], Arr::pluck($mentionedBy, 'id')); } @@ -190,14 +190,14 @@ class ListPostsTest extends TestCase ]) ); - $data = json_decode($response->getBody()->getContents(), true)['data']; + $data = json_decode($body = $response->getBody()->getContents(), true)['data'] ?? []; - $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals(200, $response->getStatusCode(), $body); $mentionedBy = $data[0]['relationships']['mentionedBy']['data']; // Only displays a limited amount of mentioned by posts - $this->assertCount(LoadMentionedByRelationship::$maxMentionedBy, $mentionedBy); + $this->assertCount(PostResourceFields::$maxMentionedBy, $mentionedBy); // Of the limited amount of mentioned by posts, they must be visible to the actor $this->assertEquals([102, 104, 105, 106], Arr::pluck($mentionedBy, 'id')); } @@ -206,7 +206,7 @@ class ListPostsTest extends TestCase * @dataProvider mentionedByIncludeProvider * @test */ - public function mentioned_by_relation_returns_limited_results_and_shows_only_visible_posts_in_show_discussion_endpoint(string $include) + public function mentioned_by_relation_returns_limited_results_and_shows_only_visible_posts_in_show_discussion_endpoint(?string $include) { $this->prepareMentionedByData(); @@ -219,15 +219,18 @@ class ListPostsTest extends TestCase ]) ); - $included = json_decode($response->getBody()->getContents(), true)['included']; + $included = json_decode($body = $response->getBody()->getContents(), true)['included'] ?? []; + + $this->assertEquals(200, $response->getStatusCode(), $body); $mentionedBy = collect($included) ->where('type', 'posts') ->where('id', 101) - ->first()['relationships']['mentionedBy']['data']; + ->first()['relationships']['mentionedBy']['data'] ?? null; + $this->assertNotNull($mentionedBy, 'Mentioned by relation not included'); // Only displays a limited amount of mentioned by posts - $this->assertCount(LoadMentionedByRelationship::$maxMentionedBy, $mentionedBy); + $this->assertCount(PostResourceFields::$maxMentionedBy, $mentionedBy); // Of the limited amount of mentioned by posts, they must be visible to the actor $this->assertEquals([102, 104, 105, 106], Arr::pluck($mentionedBy, 'id')); } @@ -237,7 +240,7 @@ class ListPostsTest extends TestCase return [ ['posts,posts.mentionedBy'], ['posts.mentionedBy'], - [''], + [null], ]; } @@ -253,10 +256,54 @@ class ListPostsTest extends TestCase ]) ); - $data = json_decode($response->getBody()->getContents(), true)['data']; + $data = json_decode($body = $response->getBody()->getContents(), true)['data'] ?? []; - $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals(200, $response->getStatusCode(), $body); $this->assertEquals(0, $data['attributes']['mentionedByCount']); } + + /** @test */ + public function mentioned_by_count_works_on_show_endpoint() + { + $this->prepareMentionedByData(); + + // List posts endpoint + $response = $this->send( + $this->request('GET', '/api/posts/101', [ + 'authenticatedAs' => 1, + ]) + ); + + $data = json_decode($body = $response->getBody()->getContents(), true)['data'] ?? []; + + $this->assertEquals(200, $response->getStatusCode(), $body); + + $this->assertEquals(10, $data['attributes']['mentionedByCount']); + } + + /** @test */ + public function mentioned_by_count_works_on_list_endpoint() + { + $this->prepareMentionedByData(); + + // List posts endpoint + $response = $this->send( + $this->request('GET', '/api/posts', [ + 'authenticatedAs' => 1, + ])->withQueryParams([ + 'filter' => ['discussion' => 100], + ]) + ); + + $data = json_decode($body = $response->getBody()->getContents(), true)['data'] ?? []; + + $this->assertEquals(200, $response->getStatusCode(), $body); + + $post101 = collect($data)->where('id', 101)->first(); + $post112 = collect($data)->where('id', 112)->first(); + + $this->assertEquals(10, $post101['attributes']['mentionedByCount']); + $this->assertEquals(0, $post112['attributes']['mentionedByCount']); + } } diff --git a/extensions/mentions/tests/integration/api/PostMentionsTest.php b/extensions/mentions/tests/integration/api/PostMentionsTest.php index 3d0304c45..822eaf1ad 100644 --- a/extensions/mentions/tests/integration/api/PostMentionsTest.php +++ b/extensions/mentions/tests/integration/api/PostMentionsTest.php @@ -83,11 +83,12 @@ class PostMentionsTest extends TestCase 'authenticatedAs' => 1, 'json' => [ 'data' => [ + 'type' => 'posts', 'attributes' => [ 'content' => '@potato#4', ], 'relationships' => [ - 'discussion' => ['data' => ['id' => 2]], + 'discussion' => ['data' => ['type' => 'discussions', 'id' => 2]], ], ], ], @@ -114,11 +115,12 @@ class PostMentionsTest extends TestCase 'authenticatedAs' => 1, 'json' => [ 'data' => [ + 'type' => 'posts', 'attributes' => [ 'content' => '@"POTATO$"#p4', ], 'relationships' => [ - 'discussion' => ['data' => ['id' => 2]], + 'discussion' => ['data' => ['type' => 'discussions', 'id' => 2]], ], ], ], @@ -145,11 +147,12 @@ class PostMentionsTest extends TestCase 'authenticatedAs' => 1, 'json' => [ 'data' => [ + 'type' => 'posts', 'attributes' => [ 'content' => '@"potato"#p50', ], 'relationships' => [ - 'discussion' => ['data' => ['id' => 2]], + 'discussion' => ['data' => ['type' => 'discussions', 'id' => 2]], ], ], ], @@ -176,11 +179,12 @@ class PostMentionsTest extends TestCase 'authenticatedAs' => 1, 'json' => [ 'data' => [ + 'type' => 'posts', 'attributes' => [ 'content' => '@“POTATO$”#p4', ], 'relationships' => [ - 'discussion' => ['data' => ['id' => 2]], + 'discussion' => ['data' => ['type' => 'discussions', 'id' => 2]], ], ], ], @@ -207,11 +211,12 @@ class PostMentionsTest extends TestCase 'authenticatedAs' => 1, 'json' => [ 'data' => [ + 'type' => 'posts', 'attributes' => [ 'content' => '@"franzofflarum"#p215', ], 'relationships' => [ - 'discussion' => ['data' => ['id' => 2]], + 'discussion' => ['data' => ['type' => 'discussions', 'id' => 2]], ], ], ], @@ -238,11 +243,12 @@ class PostMentionsTest extends TestCase 'authenticatedAs' => 1, 'json' => [ 'data' => [ + 'type' => 'posts', 'attributes' => [ 'content' => '@"TOBY$"#p5 @"flarum"#2015 @"franzofflarum"#220 @"POTATO$"#3 @"POTATO$"#p4', ], 'relationships' => [ - 'discussion' => ['data' => ['id' => 2]], + 'discussion' => ['data' => ['type' => 'discussions', 'id' => 2]], ], ], ], @@ -385,11 +391,12 @@ class PostMentionsTest extends TestCase 'authenticatedAs' => 1, 'json' => [ 'data' => [ + 'type' => 'posts', 'attributes' => [ 'content' => '@"Bad "#p6 User"#p9', ], 'relationships' => [ - 'discussion' => ['data' => ['id' => 2]], + 'discussion' => ['data' => ['type' => 'discussions', 'id' => 2]], ], ], ], @@ -437,11 +444,12 @@ class PostMentionsTest extends TestCase 'authenticatedAs' => 1, 'json' => [ 'data' => [ + 'type' => 'posts', 'attributes' => [ 'content' => '@"Bad _ User"#p9', ], 'relationships' => [ - 'discussion' => ['data' => ['id' => 2]], + 'discussion' => ['data' => ['type' => 'discussions', 'id' => 2]], ], ], ], @@ -468,6 +476,7 @@ class PostMentionsTest extends TestCase 'authenticatedAs' => 1, 'json' => [ 'data' => [ + 'type' => 'posts', 'attributes' => [ 'content' => '@"Bad _ User"#p9', ], @@ -496,6 +505,7 @@ class PostMentionsTest extends TestCase 'authenticatedAs' => 1, 'json' => [ 'data' => [ + 'type' => 'posts', 'attributes' => [ 'content' => '@"Bad _ User"#p9', ], @@ -524,6 +534,7 @@ class PostMentionsTest extends TestCase 'authenticatedAs' => 1, 'json' => [ 'data' => [ + 'type' => 'posts', 'attributes' => [ 'content' => '@"acme"#p11', ], diff --git a/extensions/mentions/tests/integration/api/TagMentionsTest.php b/extensions/mentions/tests/integration/api/TagMentionsTest.php index 8bf30a007..54b4615c1 100644 --- a/extensions/mentions/tests/integration/api/TagMentionsTest.php +++ b/extensions/mentions/tests/integration/api/TagMentionsTest.php @@ -72,11 +72,12 @@ class TagMentionsTest extends TestCase 'authenticatedAs' => 1, 'json' => [ 'data' => [ + 'type' => 'posts', 'attributes' => [ 'content' => '#flarum', ], 'relationships' => [ - 'discussion' => ['data' => ['id' => 2]], + 'discussion' => ['data' => ['type' => 'discussions', 'id' => 2]], ], ], ], @@ -100,11 +101,12 @@ class TagMentionsTest extends TestCase 'authenticatedAs' => 1, 'json' => [ 'data' => [ + 'type' => 'posts', 'attributes' => [ 'content' => '#戦い', ], 'relationships' => [ - 'discussion' => ['data' => ['id' => 2]], + 'discussion' => ['data' => ['type' => 'discussions', 'id' => 2]], ], ], ], @@ -129,11 +131,12 @@ class TagMentionsTest extends TestCase 'authenticatedAs' => 1, 'json' => [ 'data' => [ + 'type' => 'posts', 'attributes' => [ 'content' => '#franzofflarum', ], 'relationships' => [ - 'discussion' => ['data' => ['id' => 2]], + 'discussion' => ['data' => ['type' => 'discussions', 'id' => 2]], ], ], ], @@ -159,11 +162,12 @@ class TagMentionsTest extends TestCase 'authenticatedAs' => 1, 'json' => [ 'data' => [ + 'type' => 'posts', 'attributes' => [ 'content' => '#test', ], 'relationships' => [ - 'discussion' => ['data' => ['id' => 2]], + 'discussion' => ['data' => ['type' => 'discussions', 'id' => 2]], ], ], ], @@ -187,11 +191,12 @@ class TagMentionsTest extends TestCase 'authenticatedAs' => 3, 'json' => [ 'data' => [ + 'type' => 'posts', 'attributes' => [ 'content' => '#dev', ], 'relationships' => [ - 'discussion' => ['data' => ['id' => 2]], + 'discussion' => ['data' => ['type' => 'discussions', 'id' => 2]], ], ], ], @@ -215,11 +220,12 @@ class TagMentionsTest extends TestCase 'authenticatedAs' => 1, 'json' => [ 'data' => [ + 'type' => 'posts', 'attributes' => [ 'content' => '#dev', ], 'relationships' => [ - 'discussion' => ['data' => ['id' => 2]], + 'discussion' => ['data' => ['type' => 'discussions', 'id' => 2]], ], ], ], @@ -243,11 +249,12 @@ class TagMentionsTest extends TestCase 'authenticatedAs' => 1, 'json' => [ 'data' => [ + 'type' => 'posts', 'attributes' => [ 'content' => '#test #flarum #support #laravel #franzofflarum', ], 'relationships' => [ - 'discussion' => ['data' => ['id' => 2]], + 'discussion' => ['data' => ['type' => 'discussions', 'id' => 2]], ], ], ], @@ -369,6 +376,7 @@ class TagMentionsTest extends TestCase 'authenticatedAs' => 1, 'json' => [ 'data' => [ + 'type' => 'posts', 'attributes' => [ 'content' => '#laravel', ], diff --git a/extensions/mentions/tests/integration/api/UserMentionsTest.php b/extensions/mentions/tests/integration/api/UserMentionsTest.php index 6566c8f87..050e8275b 100644 --- a/extensions/mentions/tests/integration/api/UserMentionsTest.php +++ b/extensions/mentions/tests/integration/api/UserMentionsTest.php @@ -74,11 +74,12 @@ class UserMentionsTest extends TestCase 'authenticatedAs' => 1, 'json' => [ 'data' => [ + 'type' => 'posts', 'attributes' => [ 'content' => '@potato', ], 'relationships' => [ - 'discussion' => ['data' => ['id' => 2]], + 'discussion' => ['data' => ['type' => 'discussions', 'id' => 2]], ], ], ], @@ -107,11 +108,12 @@ class UserMentionsTest extends TestCase 'authenticatedAs' => 1, 'json' => [ 'data' => [ + 'type' => 'posts', 'attributes' => [ 'content' => '@potato', ], 'relationships' => [ - 'discussion' => ['data' => ['id' => 2]], + 'discussion' => ['data' => ['type' => 'discussions', 'id' => 2]], ], ], ], @@ -138,6 +140,7 @@ class UserMentionsTest extends TestCase 'authenticatedAs' => 1, 'json' => [ 'data' => [ + 'type' => 'posts', 'attributes' => [ 'content' => '@"POTATO$"#3', ], @@ -169,6 +172,7 @@ class UserMentionsTest extends TestCase 'authenticatedAs' => 1, 'json' => [ 'data' => [ + 'type' => 'posts', 'attributes' => [ 'content' => '@“POTATO$”#3', ], @@ -200,6 +204,7 @@ class UserMentionsTest extends TestCase 'authenticatedAs' => 1, 'json' => [ 'data' => [ + 'type' => 'posts', 'attributes' => [ 'content' => '@"franzofflarum"#82', ], @@ -231,6 +236,7 @@ class UserMentionsTest extends TestCase 'authenticatedAs' => 1, 'json' => [ 'data' => [ + 'type' => 'posts', 'attributes' => [ 'content' => '@"TOBY$"#4 @"POTATO$"#p4 @"franzofflarum"#82 @"POTATO$"#3', ], @@ -284,6 +290,7 @@ class UserMentionsTest extends TestCase 'authenticatedAs' => 1, 'json' => [ 'data' => [ + 'type' => 'posts', 'attributes' => [ 'content' => '@"potato_"#3', ], @@ -314,6 +321,7 @@ class UserMentionsTest extends TestCase 'authenticatedAs' => 1, 'json' => [ 'data' => [ + 'type' => 'posts', 'attributes' => [ 'content' => '@"potato_"#3', ], @@ -369,6 +377,7 @@ class UserMentionsTest extends TestCase 'authenticatedAs' => 1, 'json' => [ 'data' => [ + 'type' => 'posts', 'attributes' => [ 'content' => '@"Bad "#p6 User"#5', ], @@ -421,6 +430,7 @@ class UserMentionsTest extends TestCase 'authenticatedAs' => 1, 'json' => [ 'data' => [ + 'type' => 'posts', 'attributes' => [ 'content' => '@"Bad _ User"#5', ], @@ -452,6 +462,7 @@ class UserMentionsTest extends TestCase 'authenticatedAs' => 1, 'json' => [ 'data' => [ + 'type' => 'posts', 'attributes' => [ 'content' => '@"Bad _ User"#5', ], @@ -480,6 +491,7 @@ class UserMentionsTest extends TestCase 'authenticatedAs' => 1, 'json' => [ 'data' => [ + 'type' => 'posts', 'attributes' => [ 'content' => '@"Bad _ User"#5', ], diff --git a/extensions/nicknames/extend.php b/extensions/nicknames/extend.php index c2c5239ec..c38f586c3 100644 --- a/extensions/nicknames/extend.php +++ b/extensions/nicknames/extend.php @@ -9,14 +9,13 @@ namespace Flarum\Nicknames; -use Flarum\Api\Serializer\UserSerializer; +use Flarum\Api\Resource; use Flarum\Extend; use Flarum\Nicknames\Access\UserPolicy; +use Flarum\Nicknames\Api\UserResourceFields; use Flarum\Search\Database\DatabaseSearchDriver; -use Flarum\User\Event\Saving; use Flarum\User\Search\UserSearcher; use Flarum\User\User; -use Flarum\User\UserValidator; return [ (new Extend\Frontend('forum')) @@ -33,13 +32,9 @@ return [ (new Extend\User()) ->displayNameDriver('nickname', NicknameDriver::class), - (new Extend\Event()) - ->listen(Saving::class, SaveNicknameToDatabase::class), - - (new Extend\ApiSerializer(UserSerializer::class)) - ->attribute('canEditNickname', function (UserSerializer $serializer, User $user) { - return $serializer->getActor()->can('editNickname', $user); - }), + (new Extend\ApiResource(Resource\UserResource::class)) + ->fields(UserResourceFields::class) + ->field('username', UserResourceFields::username(...)), (new Extend\Settings()) ->default('flarum-nicknames.set_on_registration', true) @@ -50,9 +45,6 @@ return [ ->serializeToForum('setNicknameOnRegistration', 'flarum-nicknames.set_on_registration', 'boolval') ->serializeToForum('randomizeUsernameOnRegistration', 'flarum-nicknames.random_username', 'boolval'), - (new Extend\Validator(UserValidator::class)) - ->configure(AddNicknameValidation::class), - (new Extend\SearchDriver(DatabaseSearchDriver::class)) ->setFulltext(UserSearcher::class, NicknameFullTextFilter::class), diff --git a/extensions/nicknames/src/AddNicknameValidation.php b/extensions/nicknames/src/AddNicknameValidation.php deleted file mode 100644 index ed2350e30..000000000 --- a/extensions/nicknames/src/AddNicknameValidation.php +++ /dev/null @@ -1,50 +0,0 @@ -getUser() ? ','.$flarumValidator->getUser()->id : ''; - $rules = $validator->getRules(); - - $rules['nickname'] = [ - function ($attribute, $value, $fail) { - $regex = $this->settings->get('flarum-nicknames.regex'); - if ($regex && ! preg_match_all("/$regex/", $value)) { - $fail($this->translator->trans('flarum-nicknames.api.invalid_nickname_message')); - } - }, - 'min:'.$this->settings->get('flarum-nicknames.min'), - 'max:'.$this->settings->get('flarum-nicknames.max'), - 'nullable' - ]; - - if ($this->settings->get('flarum-nicknames.unique')) { - $rules['nickname'][] = 'unique:users,username'.$idSuffix; - $rules['nickname'][] = 'unique:users,nickname'.$idSuffix; - $rules['username'][] = 'unique:users,nickname'.$idSuffix; - } - - $validator->setRules($rules); - } -} diff --git a/extensions/nicknames/src/Api/UserResourceFields.php b/extensions/nicknames/src/Api/UserResourceFields.php new file mode 100644 index 000000000..cd0993f61 --- /dev/null +++ b/extensions/nicknames/src/Api/UserResourceFields.php @@ -0,0 +1,61 @@ +settings->get('flarum-nicknames.regex'); + + if (! empty($regex)) { + $regex = "/$regex/"; + } + + return [ + Schema\Str::make('nickname') + ->visible(false) + ->writable(function (User $user, Context $context) { + return $context->getActor()->can('editNickname', $user); + }) + ->nullable() + ->regex($regex ?? '', ! empty($regex)) + ->minLength($this->settings->get('flarum-nicknames.min')) + ->maxLength($this->settings->get('flarum-nicknames.max')) + ->unique('users', 'nickname', true, (bool) $this->settings->get('flarum-nicknames.unique')) + ->unique('users', 'username', true, (bool) $this->settings->get('flarum-nicknames.unique')) + ->validationMessages([ + 'nickname.regex' => $this->translator->trans('flarum-nicknames.api.invalid_nickname_message'), + ]) + ->set(function (User $user, ?string $nickname) { + $user->nickname = $user->username === $nickname ? null : $nickname; + }), + Schema\Boolean::make('canEditNickname') + ->get(fn (User $user, Context $context) => $context->getActor()->can('editNickname', $user)), + ]; + } + + public static function username(Schema\Str $field): Schema\Str + { + return $field->unique('users', 'nickname', true, (bool) resolve(SettingsRepositoryInterface::class)->get('flarum-nicknames.unique')); + } +} diff --git a/extensions/nicknames/src/SaveNicknameToDatabase.php b/extensions/nicknames/src/SaveNicknameToDatabase.php deleted file mode 100644 index 4a2cdb593..000000000 --- a/extensions/nicknames/src/SaveNicknameToDatabase.php +++ /dev/null @@ -1,40 +0,0 @@ -user; - $data = $event->data; - $actor = $event->actor; - $attributes = Arr::get($data, 'attributes', []); - - if (isset($attributes['nickname'])) { - $actor->assertCan('editNickname', $user); - - $nickname = $attributes['nickname']; - - // If the user sets their nickname back to the username - // set the nickname to null so that it just falls back to the username - $user->nickname = $user->username === $nickname ? null : $nickname; - } - } -} diff --git a/extensions/nicknames/tests/integration/api/EditUserTest.php b/extensions/nicknames/tests/integration/api/EditUserTest.php index 24222b740..8b92949eb 100644 --- a/extensions/nicknames/tests/integration/api/EditUserTest.php +++ b/extensions/nicknames/tests/integration/api/EditUserTest.php @@ -10,6 +10,7 @@ namespace Flarum\Nicknames\Tests\integration; use Flarum\Group\Group; +use Flarum\Locale\TranslatorInterface; use Flarum\Testing\integration\RetrievesAuthorizedUsers; use Flarum\Testing\integration\TestCase; use Flarum\User\User; @@ -45,6 +46,7 @@ class UpdateTest extends TestCase 'authenticatedAs' => 2, 'json' => [ 'data' => [ + 'type' => 'users', 'attributes' => [ 'nickname' => 'new nickname', ], @@ -53,7 +55,7 @@ class UpdateTest extends TestCase ]) ); - $this->assertEquals(403, $response->getStatusCode()); + $this->assertEquals(403, $response->getStatusCode(), $response->getBody()->getContents()); } /** @@ -72,6 +74,7 @@ class UpdateTest extends TestCase 'authenticatedAs' => 2, 'json' => [ 'data' => [ + 'type' => 'users', 'attributes' => [ 'nickname' => 'new nickname', ], @@ -80,8 +83,36 @@ class UpdateTest extends TestCase ]) ); - $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals(200, $response->getStatusCode(), $response->getBody()->getContents()); $this->assertEquals('new nickname', User::find(2)->nickname); } + + /** + * @test + */ + public function cant_edit_nickname_if_invalid_regex() + { + $this->setting('flarum-nicknames.set_on_registration', true); + $this->setting('flarum-nicknames.regex', '^[A-z]+$'); + + $response = $this->send( + $this->request('PATCH', '/api/users/2', [ + 'authenticatedAs' => 2, + 'json' => [ + 'data' => [ + 'type' => 'users', + 'attributes' => [ + 'nickname' => '007', + ], + ], + ], + ]) + ); + + $body = $response->getBody()->getContents(); + + $this->assertEquals(422, $response->getStatusCode(), $body); + $this->assertStringContainsString($this->app()->getContainer()->make(TranslatorInterface::class)->trans('flarum-nicknames.api.invalid_nickname_message'), $body); + } } diff --git a/extensions/nicknames/tests/integration/api/RegisterTest.php b/extensions/nicknames/tests/integration/api/RegisterTest.php index af050ab3f..fbf411e46 100644 --- a/extensions/nicknames/tests/integration/api/RegisterTest.php +++ b/extensions/nicknames/tests/integration/api/RegisterTest.php @@ -44,7 +44,7 @@ class RegisterTest extends TestCase ]) ); - $this->assertEquals(201, $response->getStatusCode()); + $this->assertEquals(201, $response->getStatusCode(), $response->getBody()->getContents()); /** @var User $user */ $user = User::where('username', 'test')->firstOrFail(); @@ -72,7 +72,7 @@ class RegisterTest extends TestCase ]) ); - $this->assertEquals(403, $response->getStatusCode()); + $this->assertEquals(403, $response->getStatusCode(), $response->getBody()->getContents()); } /** @@ -94,7 +94,7 @@ class RegisterTest extends TestCase ]) ); - $this->assertEquals(422, $response->getStatusCode()); + $this->assertEquals(422, $response->getStatusCode(), $response->getBody()->getContents()); } /** @@ -116,6 +116,6 @@ class RegisterTest extends TestCase ]) ); - $this->assertEquals(201, $response->getStatusCode()); + $this->assertEquals(201, $response->getStatusCode(), $response->getBody()->getContents()); } } diff --git a/extensions/package-manager/extend.php b/extensions/package-manager/extend.php index 85a491f66..871b2619d 100755 --- a/extensions/package-manager/extend.php +++ b/extensions/package-manager/extend.php @@ -10,6 +10,7 @@ namespace Flarum\ExtensionManager; use Flarum\Extend; +use Flarum\ExtensionManager\Api\Resource\TaskResource; use Flarum\Foundation\Paths; use Flarum\Frontend\Document; use Illuminate\Contracts\Queue\Queue; @@ -25,9 +26,10 @@ return [ ->post('/extension-manager/minor-update', 'extension-manager.minor-update', Api\Controller\MinorUpdateController::class) ->post('/extension-manager/major-update', 'extension-manager.major-update', Api\Controller\MajorUpdateController::class) ->post('/extension-manager/global-update', 'extension-manager.global-update', Api\Controller\GlobalUpdateController::class) - ->get('/extension-manager-tasks', 'extension-manager.tasks.index', Api\Controller\ListTasksController::class) ->post('/extension-manager/composer', 'extension-manager.composer', Api\Controller\ConfigureComposerController::class), + new Extend\ApiResource(TaskResource::class), + (new Extend\Frontend('admin')) ->css(__DIR__.'/less/admin.less') ->js(__DIR__.'/js/dist/admin.js') diff --git a/extensions/package-manager/js/src/admin/components/ConfigureJson.tsx b/extensions/package-manager/js/src/admin/components/ConfigureJson.tsx index 5c8ffc6b2..fc2408229 100644 --- a/extensions/package-manager/js/src/admin/components/ConfigureJson.tsx +++ b/extensions/package-manager/js/src/admin/components/ConfigureJson.tsx @@ -1,7 +1,8 @@ import app from 'flarum/admin/app'; import type Mithril from 'mithril'; import Component, { type ComponentAttrs } from 'flarum/common/Component'; -import { CommonSettingsItemOptions, type SettingsComponentOptions } from '@flarum/core/src/admin/components/AdminPage'; +import { type SettingsComponentOptions } from '@flarum/core/src/admin/components/AdminPage'; +import FormGroup, { type CommonFieldOptions } from 'flarum/common/components/FormGroup'; import AdminPage from 'flarum/admin/components/AdminPage'; import type ItemList from 'flarum/common/utils/ItemList'; import Stream from 'flarum/common/utils/Stream'; @@ -49,8 +50,8 @@ export default abstract class ConfigureJson Mithril.Children> { - return AdminPage.prototype.customSettingComponents(); + customSettingComponents(): ItemList<(attributes: CommonFieldOptions) => Mithril.Children> { + return FormGroup.prototype.customFieldComponents(); } setting(key: string) { diff --git a/extensions/package-manager/js/src/admin/states/QueueState.ts b/extensions/package-manager/js/src/admin/states/QueueState.ts index a29a484f4..5b54efffd 100644 --- a/extensions/package-manager/js/src/admin/states/QueueState.ts +++ b/extensions/package-manager/js/src/admin/states/QueueState.ts @@ -22,7 +22,7 @@ export default class QueueState { return app.store.find('extension-manager-tasks', params || {}).then((data) => { this.tasks = data; - this.total = data.payload.meta?.total; + this.total = data.payload.meta?.total || 0; m.redraw(); diff --git a/extensions/package-manager/src/Api/Controller/ListTasksController.php b/extensions/package-manager/src/Api/Controller/ListTasksController.php deleted file mode 100644 index ab27fe9d1..000000000 --- a/extensions/package-manager/src/Api/Controller/ListTasksController.php +++ /dev/null @@ -1,58 +0,0 @@ -assertAdmin(); - - $limit = $this->extractLimit($request); - $offset = $this->extractOffset($request); - - $results = Task::query() - ->latest('id') - ->offset($offset) - ->limit($limit) - ->get(); - - $total = Task::query()->count(); - - $document->addMeta('total', (string) $total); - - $document->addPaginationLinks( - $this->url->to('api')->route('extension-manager.tasks.index'), - $request->getQueryParams(), - $offset, - $limit, - $total - ); - - return $results; - } -} diff --git a/extensions/package-manager/src/Api/Resource/TaskResource.php b/extensions/package-manager/src/Api/Resource/TaskResource.php new file mode 100644 index 000000000..d132d29f7 --- /dev/null +++ b/extensions/package-manager/src/Api/Resource/TaskResource.php @@ -0,0 +1,60 @@ +defaultSort('-createdAt') + ->paginate(), + ]; + } + + public function fields(): array + { + return [ + Schema\Str::make('status'), + Schema\Str::make('operation'), + Schema\Str::make('command'), + Schema\Str::make('package'), + Schema\Str::make('output'), + Schema\DateTime::make('createdAt'), + Schema\DateTime::make('startedAt'), + Schema\DateTime::make('finishedAt'), + Schema\Number::make('peakMemoryUsed'), + ]; + } + + public function sorts(): array + { + return [ + SortColumn::make('createdAt'), + ]; + } +} diff --git a/extensions/package-manager/src/Api/Serializer/TaskSerializer.php b/extensions/package-manager/src/Api/Serializer/TaskSerializer.php deleted file mode 100644 index 3781fca9a..000000000 --- a/extensions/package-manager/src/Api/Serializer/TaskSerializer.php +++ /dev/null @@ -1,50 +0,0 @@ - $model->status, - 'operation' => $model->operation, - 'command' => $model->command, - 'package' => $model->package, - 'output' => $model->output, - 'guessedCause' => $model->guessed_cause, - 'createdAt' => $model->created_at, - 'startedAt' => $model->started_at, - 'finishedAt' => $model->finished_at, - 'peakMemoryUsed' => $model->peak_memory_used, - ]; - } -} diff --git a/extensions/package-manager/src/Task/TaskRepository.php b/extensions/package-manager/src/Task/TaskRepository.php deleted file mode 100644 index dfe54e76c..000000000 --- a/extensions/package-manager/src/Task/TaskRepository.php +++ /dev/null @@ -1,34 +0,0 @@ -js(__DIR__.'/js/dist/forum.js') ->css(__DIR__.'/less/forum.less'), + (new Extend\Frontend('admin')) + ->js(__DIR__.'/js/dist/admin.js'), + + new Extend\Locales(__DIR__.'/locale'), + (new Extend\Model(Discussion::class)) ->cast('is_sticky', 'bool'), (new Extend\Post()) ->type(DiscussionStickiedPost::class), - (new Extend\ApiSerializer(DiscussionSerializer::class)) - ->attribute('isSticky', function (DiscussionSerializer $serializer, Discussion $discussion) { - return (bool) $discussion->is_sticky; - }) - ->attribute('canSticky', function (DiscussionSerializer $serializer, $discussion) { - return (bool) $serializer->getActor()->can('sticky', $discussion); + (new Extend\ApiResource(Resource\DiscussionResource::class)) + ->fields(DiscussionResourceFields::class) + ->endpoint(Endpoint\Index::class, function (Endpoint\Index $endpoint): Endpoint\Index { + return $endpoint->addDefaultInclude(['firstPost']); }), - (new Extend\ApiController(ListDiscussionsController::class)) - ->addInclude('firstPost'), - - (new Extend\Frontend('admin')) - ->js(__DIR__.'/js/dist/admin.js'), - - new Extend\Locales(__DIR__.'/locale'), - (new Extend\Event()) - ->listen(Saving::class, SaveStickyToDatabase::class) ->listen(DiscussionWasStickied::class, [Listener\CreatePostWhenDiscussionIsStickied::class, 'whenDiscussionWasStickied']) ->listen(DiscussionWasUnstickied::class, [Listener\CreatePostWhenDiscussionIsStickied::class, 'whenDiscussionWasUnstickied']), diff --git a/extensions/sticky/src/Api/DiscussionResourceFields.php b/extensions/sticky/src/Api/DiscussionResourceFields.php new file mode 100644 index 000000000..ca4987d90 --- /dev/null +++ b/extensions/sticky/src/Api/DiscussionResourceFields.php @@ -0,0 +1,48 @@ +writable(function (Discussion $discussion, Context $context) { + return $context->endpoint instanceof Update + && $context->getActor()->can('sticky', $discussion); + }) + ->set(function (Discussion $discussion, bool $isSticky, Context $context) { + $actor = $context->getActor(); + + if ($discussion->is_sticky === $isSticky) { + return; + } + + $discussion->is_sticky = $isSticky; + + $discussion->raise( + $discussion->is_sticky + ? new DiscussionWasStickied($discussion, $actor) + : new DiscussionWasUnstickied($discussion, $actor) + ); + }), + Schema\Boolean::make('canSticky') + ->get(fn (Discussion $discussion, Context $context) => $context->getActor()->can('sticky', $discussion)), + ]; + } +} diff --git a/extensions/sticky/src/Listener/SaveStickyToDatabase.php b/extensions/sticky/src/Listener/SaveStickyToDatabase.php deleted file mode 100755 index 719c92cdf..000000000 --- a/extensions/sticky/src/Listener/SaveStickyToDatabase.php +++ /dev/null @@ -1,40 +0,0 @@ -data['attributes']['isSticky'])) { - $isSticky = (bool) $event->data['attributes']['isSticky']; - $discussion = $event->discussion; - $actor = $event->actor; - - $actor->assertCan('sticky', $discussion); - - if ((bool) $discussion->is_sticky === $isSticky) { - return; - } - - $discussion->is_sticky = $isSticky; - - $discussion->raise( - $discussion->is_sticky - ? new DiscussionWasStickied($discussion, $actor) - : new DiscussionWasUnstickied($discussion, $actor) - ); - } - } -} diff --git a/extensions/sticky/tests/integration/api/ListDiscussionsTest.php b/extensions/sticky/tests/integration/api/ListDiscussionsTest.php index 5647c50f4..85ab86c27 100644 --- a/extensions/sticky/tests/integration/api/ListDiscussionsTest.php +++ b/extensions/sticky/tests/integration/api/ListDiscussionsTest.php @@ -62,11 +62,11 @@ class ListDiscussionsTest extends TestCase $this->request('GET', '/api/discussions') ); - $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals(200, $response->getStatusCode(), $body = $response->getBody()->getContents()); - $data = json_decode($response->getBody()->getContents(), true); + $data = json_decode($body, true); - $this->assertEquals([3, 1, 2, 4], Arr::pluck($data['data'], 'id')); + $this->assertEqualsCanonicalizing([3, 1, 2, 4], Arr::pluck($data['data'], 'id')); } /** @test */ @@ -78,11 +78,11 @@ class ListDiscussionsTest extends TestCase ]) ); - $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals(200, $response->getStatusCode(), $body = $response->getBody()->getContents()); - $data = json_decode($response->getBody()->getContents(), true); + $data = json_decode($body, true); - $this->assertEquals([3, 1, 2, 4], Arr::pluck($data['data'], 'id')); + $this->assertEqualsCanonicalizing([3, 1, 2, 4], Arr::pluck($data['data'], 'id')); } /** @test */ @@ -94,11 +94,11 @@ class ListDiscussionsTest extends TestCase ]) ); - $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals(200, $response->getStatusCode(), $body = $response->getBody()->getContents()); - $data = json_decode($response->getBody()->getContents(), true); + $data = json_decode($body, true); - $this->assertEquals([2, 4, 3, 1], Arr::pluck($data['data'], 'id')); + $this->assertEqualsCanonicalizing([2, 4, 3, 1], Arr::pluck($data['data'], 'id')); } /** @test */ @@ -114,10 +114,10 @@ class ListDiscussionsTest extends TestCase ]) ); - $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals(200, $response->getStatusCode(), $body = $response->getBody()->getContents()); - $data = json_decode($response->getBody()->getContents(), true); + $data = json_decode($body, true); - $this->assertEquals([3, 1, 2, 4], Arr::pluck($data['data'], 'id')); + $this->assertEqualsCanonicalizing([3, 1, 2, 4], Arr::pluck($data['data'], 'id')); } } diff --git a/extensions/sticky/tests/integration/api/StickyDiscussionsTest.php b/extensions/sticky/tests/integration/api/StickyDiscussionsTest.php new file mode 100644 index 000000000..1554ec37a --- /dev/null +++ b/extensions/sticky/tests/integration/api/StickyDiscussionsTest.php @@ -0,0 +1,91 @@ +extension('flarum-sticky'); + + $this->prepareDatabase([ + 'users' => [ + ['id' => 1, 'username' => 'Muralf', 'email' => 'muralf@machine.local', 'is_email_confirmed' => 1], + $this->normalUser(), + ['id' => 3, 'username' => 'Muralf_', 'email' => 'muralf_@machine.local', 'is_email_confirmed' => 1], + ], + 'discussions' => [ + ['id' => 1, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1, 'is_sticky' => true, 'last_post_number' => 1], + ['id' => 2, 'title' => __CLASS__, 'created_at' => Carbon::now()->addMinutes(2), 'last_posted_at' => Carbon::now()->addMinutes(5), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1, 'is_sticky' => false, 'last_post_number' => 1], + ['id' => 3, 'title' => __CLASS__, 'created_at' => Carbon::now()->addMinutes(3), 'last_posted_at' => Carbon::now()->addMinute(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1, 'is_sticky' => true, 'last_post_number' => 1], + ['id' => 4, 'title' => __CLASS__, 'created_at' => Carbon::now()->addMinutes(4), 'last_posted_at' => Carbon::now()->addMinutes(2), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1, 'is_sticky' => false, 'last_post_number' => 1], + ], + 'groups' => [ + ['id' => 5, 'name_singular' => 'Group', 'name_plural' => 'Groups', 'color' => 'blue'], + ], + 'group_user' => [ + ['user_id' => 2, 'group_id' => 5] + ], + 'group_permission' => [ + ['group_id' => 5, 'permission' => 'discussion.sticky'], + ], + ]); + } + + /** + * @dataProvider stickyDataProvider + * @test + */ + public function can_sticky_if_allowed(int $actorId, bool $allowed, bool $sticky) + { + $response = $this->send( + $this->request('PATCH', '/api/discussions/1', [ + 'authenticatedAs' => $actorId, + 'json' => [ + 'data' => [ + 'attributes' => [ + 'isSticky' => $sticky + ] + ] + ] + ]) + ); + + $body = $response->getBody()->getContents(); + $json = json_decode($body, true); + + if ($allowed) { + $this->assertEquals(200, $response->getStatusCode(), $body); + $this->assertEquals($sticky, $json['data']['attributes']['isSticky']); + } else { + $this->assertEquals(403, $response->getStatusCode(), $body); + } + } + + public static function stickyDataProvider(): array + { + return [ + [1, true, true], + [1, true, false], + [2, true, true], + [2, true, false], + [3, false, true], + [3, false, false], + ]; + } +} diff --git a/extensions/subscriptions/extend.php b/extensions/subscriptions/extend.php index f114e11fe..8bb29b544 100644 --- a/extensions/subscriptions/extend.php +++ b/extensions/subscriptions/extend.php @@ -7,10 +7,8 @@ * LICENSE file that was distributed with this source code. */ -use Flarum\Api\Serializer\BasicDiscussionSerializer; -use Flarum\Api\Serializer\DiscussionSerializer; +use Flarum\Api\Resource; use Flarum\Approval\Event\PostWasApproved; -use Flarum\Discussion\Discussion; use Flarum\Discussion\Event\Saving; use Flarum\Discussion\Search\DiscussionSearcher; use Flarum\Discussion\UserState; @@ -20,6 +18,7 @@ use Flarum\Post\Event\Hidden; use Flarum\Post\Event\Posted; use Flarum\Post\Event\Restored; use Flarum\Search\Database\DatabaseSearchDriver; +use Flarum\Subscriptions\Api\UserResourceFields; use Flarum\Subscriptions\Filter\SubscriptionFilter; use Flarum\Subscriptions\HideIgnoredFromAllDiscussionsPage; use Flarum\Subscriptions\Listener; @@ -48,18 +47,11 @@ return [ ->namespace('flarum-subscriptions', __DIR__.'/views'), (new Extend\Notification()) - ->type(NewPostBlueprint::class, BasicDiscussionSerializer::class, ['alert', 'email']) + ->type(NewPostBlueprint::class, ['alert', 'email']) ->beforeSending(FilterVisiblePostsBeforeSending::class), - (new Extend\ApiSerializer(DiscussionSerializer::class)) - ->attribute('subscription', function (DiscussionSerializer $serializer, Discussion $discussion) { - if ($state = $discussion->state) { - return $state->subscription; - } - }), - - (new Extend\User()) - ->registerPreference('followAfterReply', 'boolval', false), + (new Extend\ApiResource(Resource\DiscussionResource::class)) + ->fields(UserResourceFields::class), (new Extend\Event()) ->listen(Saving::class, Listener\SaveSubscriptionToDatabase::class) @@ -75,5 +67,6 @@ return [ ->addMutator(DiscussionSearcher::class, HideIgnoredFromAllDiscussionsPage::class), (new Extend\User()) + ->registerPreference('followAfterReply', 'boolval', false) ->registerPreference('flarum-subscriptions.notify_for_all_posts', 'boolval', false), ]; diff --git a/extensions/subscriptions/src/Api/UserResourceFields.php b/extensions/subscriptions/src/Api/UserResourceFields.php new file mode 100644 index 000000000..c1773cf7f --- /dev/null +++ b/extensions/subscriptions/src/Api/UserResourceFields.php @@ -0,0 +1,37 @@ +writable(fn (Discussion $discussion, Context $context) => $context->updating()) + ->nullable() + ->get(fn (Discussion $discussion) => $discussion->state?->subscription) + ->set(function (Discussion $discussion, ?string $subscription, Context $context) { + $actor = $context->getActor(); + $state = $discussion->stateFor($actor); + + if (! in_array($subscription, ['follow', 'ignore'])) { + $subscription = null; + } + + $state->subscription = $subscription; + }), + ]; + } +} diff --git a/extensions/subscriptions/tests/integration/api/discussions/ReplyNotificationTest.php b/extensions/subscriptions/tests/integration/api/discussions/ReplyNotificationTest.php index 540bcfb98..55e7476a5 100644 --- a/extensions/subscriptions/tests/integration/api/discussions/ReplyNotificationTest.php +++ b/extensions/subscriptions/tests/integration/api/discussions/ReplyNotificationTest.php @@ -120,6 +120,7 @@ class ReplyNotificationTest extends TestCase 'authenticatedAs' => 1, 'json' => [ 'data' => [ + 'type' => 'discussions', 'attributes' => [ 'title' => 'ACME', ], @@ -134,6 +135,7 @@ class ReplyNotificationTest extends TestCase 'authenticatedAs' => 1, 'json' => [ 'data' => [ + 'type' => 'discussions', 'attributes' => [ 'lastReadPostNumber' => 2, ], @@ -149,6 +151,7 @@ class ReplyNotificationTest extends TestCase 'authenticatedAs' => 2, 'json' => [ 'data' => [ + 'type' => 'posts', 'attributes' => [ 'content' => 'reply with predetermined content for automated testing - too-obscure', ], @@ -204,6 +207,7 @@ class ReplyNotificationTest extends TestCase 'authenticatedAs' => 3, 'json' => [ 'data' => [ + 'type' => 'posts', 'attributes' => [ 'content' => 'reply with predetermined content for automated testing - too-obscure', ], @@ -250,6 +254,7 @@ class ReplyNotificationTest extends TestCase 'authenticatedAs' => 4, 'json' => [ 'data' => [ + 'type' => 'posts', 'attributes' => [ 'content' => 'reply with predetermined content for automated testing - too-obscure', ], @@ -271,6 +276,7 @@ class ReplyNotificationTest extends TestCase 'authenticatedAs' => 1, 'json' => [ 'data' => [ + 'type' => 'posts', 'attributes' => [ 'isApproved' => 1, ], @@ -310,6 +316,7 @@ class ReplyNotificationTest extends TestCase 'authenticatedAs' => 3, 'json' => [ 'data' => [ + 'type' => 'posts', 'attributes' => [ 'content' => 'restricted-test-post', ], diff --git a/extensions/subscriptions/tests/integration/api/discussions/SubscribeTest.php b/extensions/subscriptions/tests/integration/api/discussions/SubscribeTest.php new file mode 100644 index 000000000..21a243867 --- /dev/null +++ b/extensions/subscriptions/tests/integration/api/discussions/SubscribeTest.php @@ -0,0 +1,94 @@ +extension('flarum-subscriptions'); + + $this->prepareDatabase([ + 'users' => [ + $this->normalUser(), + ['id' => 3, 'username' => 'acme', 'email' => 'acme@machine.local', 'is_email_confirmed' => 1, 'preferences' => json_encode(['flarum-subscriptions.notify_for_all_posts' => true])], + ['id' => 4, 'username' => 'acme2', 'email' => 'acme2@machine.local', 'is_email_confirmed' => 1], + ], + 'discussions' => [ + ['id' => 1, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1, 'last_post_number' => 1, 'last_post_id' => 1], + ['id' => 2, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 2, 'comment_count' => 1, 'last_post_number' => 1, 'last_post_id' => 2], + + ['id' => 33, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 33, 'comment_count' => 6, 'last_post_number' => 6, 'last_post_id' => 38], + ], + 'posts' => [ + ['id' => 1, 'discussion_id' => 1, 'created_at' => Carbon::createFromDate(1975, 5, 21)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '

foo bar

', 'number' => 1], + ['id' => 2, 'discussion_id' => 2, 'created_at' => Carbon::createFromDate(1975, 5, 21)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '

foo bar

', 'number' => 1], + + ['id' => 33, 'discussion_id' => 33, 'created_at' => Carbon::createFromDate(1975, 5, 21)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '

foo bar

', 'number' => 1], + ['id' => 34, 'discussion_id' => 33, 'created_at' => Carbon::createFromDate(1975, 5, 21)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '

foo bar

', 'number' => 2], + ['id' => 35, 'discussion_id' => 33, 'created_at' => Carbon::createFromDate(1975, 5, 21)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '

foo bar

', 'number' => 3], + ['id' => 36, 'discussion_id' => 33, 'created_at' => Carbon::createFromDate(1975, 5, 21)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '

foo bar

', 'number' => 4], + ['id' => 37, 'discussion_id' => 33, 'created_at' => Carbon::createFromDate(1975, 5, 21)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '

foo bar

', 'number' => 5], + ['id' => 38, 'discussion_id' => 33, 'created_at' => Carbon::createFromDate(1975, 5, 21)->toDateTimeString(), 'user_id' => 1, 'type' => 'comment', 'content' => '

foo bar

', 'number' => 6], + ], + 'discussion_user' => [ + ['discussion_id' => 1, 'user_id' => 1, 'last_read_post_number' => 1, 'subscription' => 'follow'], + ['discussion_id' => 1, 'user_id' => 2, 'last_read_post_number' => 1, 'subscription' => null], + ['discussion_id' => 2, 'user_id' => 1, 'last_read_post_number' => 1, 'subscription' => 'follow'], + + ['discussion_id' => 33, 'user_id' => 2, 'last_read_post_number' => 1, 'subscription' => 'follow'], + ['discussion_id' => 33, 'user_id' => 3, 'last_read_post_number' => 1, 'subscription' => 'ignore'], + ] + ]); + } + + /** + * @test + * @dataProvider provideStates + */ + public function can_subscribe_to_a_discussion(int $actorId, int $discussionId, ?string $newState) + { + $this->app(); + + $response = $this->send( + $this->request('PATCH', '/api/discussions/'.$discussionId, [ + 'authenticatedAs' => $actorId, + 'json' => [ + 'data' => [ + 'type' => 'discussions', + 'attributes' => [ + 'subscription' => $newState, + ], + ], + ], + ]) + ); + + $this->assertEquals(200, $response->getStatusCode(), $response->getBody()->getContents()); + $this->assertEquals($newState, $this->database()->table('discussion_user')->where('discussion_id', $discussionId)->where('user_id', $actorId)->value('subscription')); + } + + public static function provideStates() + { + return [ + 'follow' => [2, 1, 'follow'], + 'ignore' => [2, 1, 'ignore'], + 'null' => [2, 1, null], + ]; + } +} diff --git a/extensions/suspend/extend.php b/extensions/suspend/extend.php index 57670b38b..0aab8298b 100644 --- a/extensions/suspend/extend.php +++ b/extensions/suspend/extend.php @@ -7,13 +7,13 @@ * LICENSE file that was distributed with this source code. */ -use Flarum\Api\Serializer\BasicUserSerializer; -use Flarum\Api\Serializer\ForumSerializer; -use Flarum\Api\Serializer\UserSerializer; +use Flarum\Api\Context; +use Flarum\Api\Resource; +use Flarum\Api\Schema; use Flarum\Extend; use Flarum\Search\Database\DatabaseSearchDriver; use Flarum\Suspend\Access\UserPolicy; -use Flarum\Suspend\AddUserSuspendAttributes; +use Flarum\Suspend\Api\UserResourceFields; use Flarum\Suspend\Event\Suspended; use Flarum\Suspend\Event\Unsuspended; use Flarum\Suspend\Listener; @@ -39,22 +39,23 @@ return [ ->cast('suspend_reason', 'string') ->cast('suspend_message', 'string'), - (new Extend\ApiSerializer(UserSerializer::class)) - ->attributes(AddUserSuspendAttributes::class), + (new Extend\ApiResource(Resource\UserResource::class)) + ->fields(UserResourceFields::class), - (new Extend\ApiSerializer(ForumSerializer::class)) - ->attribute('canSuspendUsers', function (ForumSerializer $serializer) { - return $serializer->getActor()->hasPermission('user.suspend'); - }), + (new Extend\ApiResource(Resource\ForumResource::class)) + ->fields(fn () => [ + Schema\Boolean::make('canSuspendUsers') + ->get(fn (object $model, Context $context) => $context->getActor()->hasPermission('user.suspend')), + ]), new Extend\Locales(__DIR__.'/locale'), (new Extend\Notification()) - ->type(UserSuspendedBlueprint::class, BasicUserSerializer::class, ['alert', 'email']) - ->type(UserUnsuspendedBlueprint::class, BasicUserSerializer::class, ['alert', 'email']), + ->type(UserSuspendedBlueprint::class, ['alert', 'email']) + ->type(UserUnsuspendedBlueprint::class, ['alert', 'email']), (new Extend\Event()) - ->listen(Saving::class, Listener\SaveSuspensionToDatabase::class) + ->listen(Saving::class, Listener\SavingUser::class) ->listen(Suspended::class, Listener\SendNotificationWhenUserIsSuspended::class) ->listen(Unsuspended::class, Listener\SendNotificationWhenUserIsUnsuspended::class), diff --git a/extensions/suspend/src/AddUserSuspendAttributes.php b/extensions/suspend/src/AddUserSuspendAttributes.php deleted file mode 100755 index 44d63a5cb..000000000 --- a/extensions/suspend/src/AddUserSuspendAttributes.php +++ /dev/null @@ -1,35 +0,0 @@ -getActor()->can('suspend', $user); - - if ($canSuspend) { - $attributes['suspendReason'] = $user->suspend_reason; - } - - if ($serializer->getActor()->id === $user->id || $canSuspend) { - $attributes['suspendMessage'] = $user->suspend_message; - $attributes['suspendedUntil'] = $serializer->formatDate($user->suspended_until); - } - - $attributes['canSuspend'] = $canSuspend; - - return $attributes; - } -} diff --git a/extensions/suspend/src/Api/UserResourceFields.php b/extensions/suspend/src/Api/UserResourceFields.php new file mode 100644 index 000000000..ab360c899 --- /dev/null +++ b/extensions/suspend/src/Api/UserResourceFields.php @@ -0,0 +1,35 @@ +get($canSuspend = fn (User $user, Context $context) => $context->getActor()->can('suspend', $user)), + Schema\Str::make('suspendReason') + ->writable($canSuspend) + ->visible($canSuspend), + Schema\Str::make('suspendMessage') + ->writable($canSuspend) + ->visible(fn (User $user, Context $context) => $context->getActor()->id === $user->id || $canSuspend($user, $context)), + Schema\Date::make('suspendedUntil') + ->writable($canSuspend) + ->visible(fn (User $user, Context $context) => $context->getActor()->id === $user->id || $canSuspend($user, $context)) + ->nullable(), + ]; + } +} diff --git a/extensions/suspend/src/Listener/SaveSuspensionToDatabase.php b/extensions/suspend/src/Listener/SaveSuspensionToDatabase.php deleted file mode 100755 index 34e40e7ac..000000000 --- a/extensions/suspend/src/Listener/SaveSuspensionToDatabase.php +++ /dev/null @@ -1,60 +0,0 @@ -data, 'attributes', []); - - if (array_key_exists('suspendedUntil', $attributes)) { - $this->validator->assertValid($attributes); - - $user = $event->user; - $actor = $event->actor; - - $actor->assertCan('suspend', $user); - - if ($attributes['suspendedUntil']) { - $user->suspended_until = Carbon::createFromTimestamp((new DateTime($attributes['suspendedUntil']))->getTimestamp()); - $user->suspend_reason = empty($attributes['suspendReason']) ? null : $attributes['suspendReason']; - $user->suspend_message = empty($attributes['suspendMessage']) ? null : $attributes['suspendMessage']; - } else { - $user->suspended_until = null; - $user->suspend_reason = null; - $user->suspend_message = null; - } - - if ($user->isDirty(['suspended_until', 'suspend_reason', 'suspend_message'])) { - $this->events->dispatch( - $user->suspended_until === null ? - new Unsuspended($user, $actor) : - new Suspended($user, $actor) - ); - } - } - } -} diff --git a/extensions/suspend/src/Listener/SavingUser.php b/extensions/suspend/src/Listener/SavingUser.php new file mode 100755 index 000000000..87ea07ad7 --- /dev/null +++ b/extensions/suspend/src/Listener/SavingUser.php @@ -0,0 +1,37 @@ +user; + $actor = $event->actor; + + if ($user->isDirty(['suspended_until', 'suspend_reason', 'suspend_message'])) { + $this->events->dispatch( + $user->suspended_until === null ? + new Unsuspended($user, $actor) : + new Suspended($user, $actor) + ); + } + } +} diff --git a/extensions/suspend/src/SuspendValidator.php b/extensions/suspend/src/SuspendValidator.php deleted file mode 100644 index db91666ea..000000000 --- a/extensions/suspend/src/SuspendValidator.php +++ /dev/null @@ -1,19 +0,0 @@ - ['nullable', 'date'], - ]; -} diff --git a/extensions/suspend/tests/integration/api/UseForumTest.php b/extensions/suspend/tests/integration/api/UseForumTest.php index 3475c524e..f640b4a39 100644 --- a/extensions/suspend/tests/integration/api/UseForumTest.php +++ b/extensions/suspend/tests/integration/api/UseForumTest.php @@ -48,6 +48,7 @@ class UseForumTest extends TestCase 'authenticatedAs' => 2, 'json' => [ 'data' => [ + 'type' => 'discussions', 'attributes' => [ 'title' => 'Test post', 'content' => '

Hello, world!

' @@ -68,6 +69,7 @@ class UseForumTest extends TestCase 'authenticatedAs' => 2, 'json' => [ 'data' => [ + 'type' => 'posts', 'attributes' => [ 'content' => '

Hello, world!

' ], diff --git a/extensions/suspend/tests/integration/api/users/SuspendUserTest.php b/extensions/suspend/tests/integration/api/users/SuspendUserTest.php index d398ab497..92eff1d5d 100644 --- a/extensions/suspend/tests/integration/api/users/SuspendUserTest.php +++ b/extensions/suspend/tests/integration/api/users/SuspendUserTest.php @@ -93,6 +93,7 @@ class SuspendUserTest extends TestCase 'authenticatedAs' => $authenticatedAs, 'json' => [ 'data' => [ + 'type' => 'users', 'attributes' => [ 'suspendedUntil' => Carbon::now()->addDay(), 'suspendReason' => 'Suspended for acme reasons.', diff --git a/extensions/tags/extend.php b/extensions/tags/extend.php index b4fffbec4..b656961df 100644 --- a/extensions/tags/extend.php +++ b/extensions/tags/extend.php @@ -7,26 +7,22 @@ * LICENSE file that was distributed with this source code. */ -use Flarum\Api\Controller as FlarumController; -use Flarum\Api\Serializer\BasicPostSerializer; -use Flarum\Api\Serializer\DiscussionSerializer; -use Flarum\Api\Serializer\ForumSerializer; +use Flarum\Api\Context; +use Flarum\Api\Endpoint; +use Flarum\Api\Resource; +use Flarum\Api\Schema; use Flarum\Discussion\Discussion; -use Flarum\Discussion\Event\Saving; use Flarum\Discussion\Search\DiscussionSearcher; use Flarum\Extend; -use Flarum\Flags\Api\Controller\ListFlagsController; -use Flarum\Http\RequestUtil; +use Flarum\Flags\Api\Resource\FlagResource; use Flarum\Post\Filter\PostSearcher; use Flarum\Post\Post; use Flarum\Search\Database\DatabaseSearchDriver; use Flarum\Tags\Access; -use Flarum\Tags\Api\Controller; -use Flarum\Tags\Api\Serializer\TagSerializer; +use Flarum\Tags\Api; use Flarum\Tags\Content; use Flarum\Tags\Event\DiscussionWasTagged; use Flarum\Tags\Listener; -use Flarum\Tags\LoadForumTagsRelationship; use Flarum\Tags\Post\DiscussionTaggedPost; use Flarum\Tags\Search\Filter\PostTagFilter; use Flarum\Tags\Search\Filter\TagFilter; @@ -37,13 +33,6 @@ use Flarum\Tags\Tag; use Flarum\Tags\Utf8SlugDriver; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\Relation; -use Psr\Http\Message\ServerRequestInterface; - -$eagerLoadTagState = function ($query, ?ServerRequestInterface $request, array $relations) { - if ($request && in_array('tags.state', $relations, true)) { - $query->withStateFor(RequestUtil::getActor($request)); - } -}; return [ (new Extend\Frontend('forum')) @@ -61,49 +50,71 @@ return [ ->css(__DIR__.'/less/admin.less'), (new Extend\Routes('api')) - ->get('/tags', 'tags.index', Controller\ListTagsController::class) - ->post('/tags', 'tags.create', Controller\CreateTagController::class) - ->post('/tags/order', 'tags.order', Controller\OrderTagsController::class) - ->get('/tags/{slug}', 'tags.show', Controller\ShowTagController::class) - ->patch('/tags/{id}', 'tags.update', Controller\UpdateTagController::class) - ->delete('/tags/{id}', 'tags.delete', Controller\DeleteTagController::class), + ->post('/tags/order', 'tags.order', Api\Controller\OrderTagsController::class), (new Extend\Model(Discussion::class)) ->belongsToMany('tags', Tag::class, 'discussion_tag'), - (new Extend\ApiSerializer(ForumSerializer::class)) - ->hasMany('tags', TagSerializer::class) - ->attribute('canBypassTagCounts', function (ForumSerializer $serializer) { - return $serializer->getActor()->can('bypassTagCounts'); + new Extend\ApiResource(Api\Resource\TagResource::class), + + (new Extend\ApiResource(Resource\ForumResource::class)) + ->fields(fn () => [ + Schema\Relationship\ToMany::make('tags') + ->includable() + ->get(function ($model, Context $context) { + $actor = $context->getActor(); + + return Tag::query() + ->where(function ($query) { + $query + ->whereNull('parent_id') + ->whereNotNull('position'); + }) + ->union( + Tag::whereVisibleTo($actor) + ->whereNull('parent_id') + ->whereNull('position') + ->orderBy('discussion_count', 'desc') + ->limit(4) // We get one more than we need so the "more" link can be shown. + ) + ->whereVisibleTo($actor) + ->withStateFor($actor) + ->get() + ->all(); + }), + Schema\Boolean::make('canBypassTagCounts') + ->get(fn ($model, Context $context) => $context->getActor()->can('bypassTagCounts')), + ]) + ->endpoint(Endpoint\Show::class, function (Endpoint\Show $endpoint) { + return $endpoint->addDefaultInclude(['tags', 'tags.parent']); }), - (new Extend\ApiSerializer(DiscussionSerializer::class)) - ->hasMany('tags', TagSerializer::class) - ->attribute('canTag', function (DiscussionSerializer $serializer, $model) { - return $serializer->getActor()->can('tag', $model); + (new Extend\ApiResource(Resource\PostResource::class)) + ->endpoint(Endpoint\Index::class, function (Endpoint\Index $endpoint) { + return $endpoint->eagerLoadWhenIncluded(['discussion' => ['discussion.tags']]); }), - (new Extend\ApiController(FlarumController\ListPostsController::class)) - ->load('discussion.tags'), + (new Extend\Conditional()) + ->whenExtensionEnabled('flarum-flags', fn () => [ + (new Extend\ApiResource(FlagResource::class)) + ->endpoint(Endpoint\Index::class, function (Endpoint\Index $endpoint) { + return $endpoint->eagerLoadWhenIncluded(['post.discussion' => ['post.discussion.tags']]); + }), + ]), - (new Extend\ApiController(ListFlagsController::class)) - ->load('post.discussion.tags'), - - (new Extend\ApiController(FlarumController\ListDiscussionsController::class)) - ->addInclude(['tags', 'tags.state', 'tags.parent']) - ->loadWhere('tags', $eagerLoadTagState), - - (new Extend\ApiController(FlarumController\ShowDiscussionController::class)) - ->addInclude(['tags', 'tags.state', 'tags.parent']) - ->loadWhere('tags', $eagerLoadTagState), - - (new Extend\ApiController(FlarumController\CreateDiscussionController::class)) - ->addInclude(['tags', 'tags.state', 'tags.parent']) - ->loadWhere('tags', $eagerLoadTagState), - - (new Extend\ApiController(FlarumController\ShowForumController::class)) - ->addInclude(['tags', 'tags.parent']) - ->prepareDataForSerialization(LoadForumTagsRelationship::class), + (new Extend\ApiResource(Resource\DiscussionResource::class)) + ->fields(Api\DiscussionResourceFields::class) + ->endpoint( + [Endpoint\Index::class, Endpoint\Show::class, Endpoint\Create::class], + function (Endpoint\Index|Endpoint\Show|Endpoint\Create $endpoint) { + return $endpoint + ->addDefaultInclude(['tags', 'tags.parent']) + ->eagerLoadWhere('tags', function (Builder|Relation $query, Context $context) { + /** @var Builder|Relation $query */ + $query->withStateFor($context->getActor()); + }); + } + ), (new Extend\Settings()) ->serializeToForum('minPrimaryTags', 'flarum-tags.min_primary_tags') @@ -131,7 +142,6 @@ return [ ->type(DiscussionTaggedPost::class), (new Extend\Event()) - ->listen(Saving::class, Listener\SaveTagsToDatabase::class) ->listen(DiscussionWasTagged::class, Listener\CreatePostWhenTagsAreChanged::class) ->subscribe(Listener\UpdateTagMetadata::class), @@ -158,27 +168,20 @@ return [ return $model->mentionsTags(); }), - (new Extend\ApiSerializer(BasicPostSerializer::class)) - ->relationship('eventPostMentionsTags', function (BasicPostSerializer $serializer, Post $model) { - if ($model instanceof DiscussionTaggedPost) { - return $serializer->hasMany($model, TagSerializer::class, 'eventPostMentionsTags'); - } - - return null; - }) - ->hasMany('eventPostMentionsTags', TagSerializer::class), - - (new Extend\ApiController(FlarumController\ListPostsController::class)) - ->addInclude('eventPostMentionsTags') - // Restricted tags should still appear as `deleted` to unauthorized users. - ->loadWhere('eventPostMentionsTags', $restrictMentionedTags = function (Relation|Builder $query, ?ServerRequestInterface $request) { - if ($request) { - $actor = RequestUtil::getActor($request); - $query->whereVisibleTo($actor); - } + (new Extend\ApiResource(Resource\PostResource::class)) + ->fields(fn () => [ + Schema\Relationship\ToMany::make('eventPostMentionsTags') + ->type('tags') + ->includable(), + ]) + ->endpoint(Endpoint\Index::class, function (Endpoint\Index $endpoint) { + return $endpoint + ->addDefaultInclude(['eventPostMentionsTags']); }), - (new Extend\ApiController(FlarumController\ShowDiscussionController::class)) - ->addInclude('posts.eventPostMentionsTags') - ->loadWhere('posts.eventPostMentionsTags', $restrictMentionedTags), + (new Extend\ApiResource(Resource\DiscussionResource::class)) + ->endpoint(Endpoint\Show::class, function (Endpoint\Show $endpoint) { + return $endpoint + ->addDefaultInclude(['posts.eventPostMentionsTags']); + }), ]; diff --git a/extensions/tags/js/src/admin/components/EditTagModal.tsx b/extensions/tags/js/src/admin/components/EditTagModal.tsx index ffc76c288..d53ba2fa1 100644 --- a/extensions/tags/js/src/admin/components/EditTagModal.tsx +++ b/extensions/tags/js/src/admin/components/EditTagModal.tsx @@ -30,7 +30,7 @@ export default class EditTagModal extends FormModal { color!: Stream; icon!: Stream; isHidden!: Stream; - primary!: Stream; + isPrimary!: Stream; oninit(vnode: Mithril.Vnode) { super.oninit(vnode); @@ -43,7 +43,7 @@ export default class EditTagModal extends FormModal { this.color = Stream(this.tag.color() || ''); this.icon = Stream(this.tag.icon() || ''); this.isHidden = Stream(this.tag.isHidden() || false); - this.primary = Stream(this.attrs.primary || false); + this.isPrimary = Stream(this.attrs.primary || false); } className() { @@ -164,7 +164,7 @@ export default class EditTagModal extends FormModal { color: this.color(), icon: this.icon(), isHidden: this.isHidden(), - primary: this.primary(), + isPrimary: this.isPrimary(), }; } @@ -189,8 +189,6 @@ export default class EditTagModal extends FormModal { children.forEach((tag) => tag.pushData({ attributes: { isChild: false }, - // @deprecated. Temporary hack for type safety, remove before v1.3. - relationships: { parent: null as any as [] }, }) ); m.redraw(); diff --git a/extensions/tags/js/src/admin/components/SelectTagsSettingComponent.tsx b/extensions/tags/js/src/admin/components/SelectTagsSettingComponent.tsx index acf01fcd4..5eaa01db4 100644 --- a/extensions/tags/js/src/admin/components/SelectTagsSettingComponent.tsx +++ b/extensions/tags/js/src/admin/components/SelectTagsSettingComponent.tsx @@ -4,12 +4,12 @@ import Button from 'flarum/common/components/Button'; import LoadingIndicator from 'flarum/common/components/LoadingIndicator'; import tagsLabel from '../../common/helpers/tagsLabel'; -import type { CommonSettingsItemOptions } from 'flarum/admin/components/AdminPage'; +import type { CommonFieldOptions } from 'flarum/common/components/FormGroup'; import type Stream from 'flarum/common/utils/Stream'; import type { ITagSelectionModalAttrs } from '../../common/components/TagSelectionModal'; import type Tag from '../../common/models/Tag'; -export interface SelectTagsSettingComponentOptions extends CommonSettingsItemOptions { +export interface SelectTagsSettingComponentOptions extends CommonFieldOptions { type: 'flarum-tags.select-tags'; options?: ITagSelectionModalAttrs; } diff --git a/extensions/tags/js/src/common/components/TagSelectionModal.tsx b/extensions/tags/js/src/common/components/TagSelectionModal.tsx index 68e8affb4..d8b47ab1d 100644 --- a/extensions/tags/js/src/common/components/TagSelectionModal.tsx +++ b/extensions/tags/js/src/common/components/TagSelectionModal.tsx @@ -252,10 +252,10 @@ export default class TagSelectionModal< // we'll filter out all other tags of that type. else { if (primaryCount >= this.attrs.limits!.max!.primary!) { - tags = tags.filter((tag) => !tag.isPrimary() || this.selected.includes(tag)); + tags = tags.filter((tag) => !tag.isPrimaryParent() || this.selected.includes(tag)); } if (secondaryCount >= this.attrs.limits!.max!.secondary!) { - tags = tags.filter((tag) => tag.isPrimary() || this.selected.includes(tag)); + tags = tags.filter((tag) => tag.isPrimaryParent() || this.selected.includes(tag)); } } } @@ -275,14 +275,14 @@ export default class TagSelectionModal< * Counts the number of selected primary tags. */ protected primaryCount(): number { - return this.selected.filter((tag) => tag.isPrimary()).length; + return this.selected.filter((tag) => tag.isPrimaryParent()).length; } /** * Counts the number of selected secondary tags. */ protected secondaryCount(): number { - return this.selected.filter((tag) => !tag.isPrimary()).length; + return this.selected.filter((tag) => !tag.isPrimaryParent()).length; } /** diff --git a/extensions/tags/js/src/common/models/Tag.ts b/extensions/tags/js/src/common/models/Tag.ts index d68c8698e..46a023bec 100644 --- a/extensions/tags/js/src/common/models/Tag.ts +++ b/extensions/tags/js/src/common/models/Tag.ts @@ -44,6 +44,9 @@ export default class Tag extends Model { isHidden() { return Model.attribute('isHidden').call(this); } + isPrimary() { + return Model.attribute('isPrimary').call(this); + } discussionCount() { return Model.attribute('discussionCount').call(this); @@ -65,7 +68,7 @@ export default class Tag extends Model { return Model.attribute('canAddToDiscussion').call(this); } - isPrimary() { + isPrimaryParent() { return computed('position', 'parent', (position, parent) => position !== null && parent === false).call(this); } } diff --git a/extensions/tags/js/src/forum/addTagFilter.tsx b/extensions/tags/js/src/forum/addTagFilter.tsx index 34fee4628..13875b15e 100644 --- a/extensions/tags/js/src/forum/addTagFilter.tsx +++ b/extensions/tags/js/src/forum/addTagFilter.tsx @@ -39,7 +39,7 @@ export default function addTagFilter() { // - We loaded in that child tag (and its siblings) in the API document // - We first navigated to the current tag's parent, which would have loaded in the current tag's siblings. this.store - .find('tags', slug, { include: 'children,children.parent,parent,state' }) + .find('tags', slug, { include: 'children,children.parent,parent' }) .then(() => { this.currentActiveTag = findTag(slug); diff --git a/extensions/tags/migrations/2024_02_23_000000_add_is_primary_column_to_tags.php b/extensions/tags/migrations/2024_02_23_000000_add_is_primary_column_to_tags.php new file mode 100644 index 000000000..f68c148e1 --- /dev/null +++ b/extensions/tags/migrations/2024_02_23_000000_add_is_primary_column_to_tags.php @@ -0,0 +1,29 @@ + function (Builder $schema) { + $schema->table('tags', function (Blueprint $table) { + $table->boolean('is_primary')->default(false)->after('background_mode'); + }); + + $schema->getConnection() + ->table('tags') + ->whereNotNull('position') + ->update(['is_primary' => true]); + }, + 'down' => function (Builder $schema) { + $schema->table('tags', function (Blueprint $table) { + $table->dropColumn('is_primary'); + }); + } +]; diff --git a/extensions/tags/src/Api/Controller/CreateTagController.php b/extensions/tags/src/Api/Controller/CreateTagController.php deleted file mode 100644 index 7a5fcd6f7..000000000 --- a/extensions/tags/src/Api/Controller/CreateTagController.php +++ /dev/null @@ -1,39 +0,0 @@ -bus->dispatch( - new CreateTag(RequestUtil::getActor($request), Arr::get($request->getParsedBody(), 'data', [])) - ); - } -} diff --git a/extensions/tags/src/Api/Controller/DeleteTagController.php b/extensions/tags/src/Api/Controller/DeleteTagController.php deleted file mode 100644 index db75abeb8..000000000 --- a/extensions/tags/src/Api/Controller/DeleteTagController.php +++ /dev/null @@ -1,32 +0,0 @@ -bus->dispatch( - new DeleteTag(Arr::get($request->getQueryParams(), 'id'), RequestUtil::getActor($request)) - ); - } -} diff --git a/extensions/tags/src/Api/Controller/ListTagsController.php b/extensions/tags/src/Api/Controller/ListTagsController.php deleted file mode 100644 index 2f23267a6..000000000 --- a/extensions/tags/src/Api/Controller/ListTagsController.php +++ /dev/null @@ -1,78 +0,0 @@ -extractInclude($request); - $filters = $this->extractFilter($request); - $limit = $this->extractLimit($request); - $offset = $this->extractOffset($request); - - if (in_array('lastPostedDiscussion', $include)) { - $include = array_merge($include, ['lastPostedDiscussion.tags', 'lastPostedDiscussion.state']); - } - - if (array_key_exists('q', $filters)) { - $results = $this->search->query(Tag::class, new SearchCriteria($actor, $filters, $limit, $offset)); - - $tags = $results->getResults(); - - $document->addPaginationLinks( - $this->url->to('api')->route('tags.index'), - $request->getQueryParams(), - $offset, - $limit, - $results->areMoreResults() ? null : 0 - ); - } else { - $tags = $this->tags - ->with($include, $actor) - ->whereVisibleTo($actor) - ->withStateFor($actor) - ->get(); - } - - return $tags; - } -} diff --git a/extensions/tags/src/Api/Controller/OrderTagsController.php b/extensions/tags/src/Api/Controller/OrderTagsController.php index 9cb4e125e..4dbfd1370 100644 --- a/extensions/tags/src/Api/Controller/OrderTagsController.php +++ b/extensions/tags/src/Api/Controller/OrderTagsController.php @@ -31,19 +31,21 @@ class OrderTagsController implements RequestHandlerInterface Tag::query()->update([ 'position' => null, - 'parent_id' => null + 'parent_id' => null, + 'is_primary' => false, ]); foreach ($order as $i => $parent) { $parentId = Arr::get($parent, 'id'); - Tag::where('id', $parentId)->update(['position' => $i]); + Tag::where('id', $parentId)->update(['position' => $i, 'is_primary' => true]); if (isset($parent['children']) && is_array($parent['children'])) { foreach ($parent['children'] as $j => $childId) { Tag::where('id', $childId)->update([ 'position' => $j, - 'parent_id' => $parentId + 'parent_id' => $parentId, + 'is_primary' => true, ]); } } diff --git a/extensions/tags/src/Api/Controller/ShowTagController.php b/extensions/tags/src/Api/Controller/ShowTagController.php deleted file mode 100644 index ae34d51de..000000000 --- a/extensions/tags/src/Api/Controller/ShowTagController.php +++ /dev/null @@ -1,69 +0,0 @@ -getQueryParams(), 'slug'); - $actor = RequestUtil::getActor($request); - $include = $this->extractInclude($request); - $setParentOnChildren = false; - - if (in_array('parent.children.parent', $include, true)) { - $setParentOnChildren = true; - $include[] = 'parent.children'; - $include = array_unique(array_diff($include, ['parent.children.parent'])); - } - - $tag = $this->slugger - ->forResource(Tag::class) - ->fromSlug($slug, $actor); - - $tag->load($this->tags->getAuthorizedRelations($include, $actor)); - - if ($setParentOnChildren && $tag->parent) { - foreach ($tag->parent->children as $child) { - $child->parent = $tag->parent; - } - } - - return $tag; - } -} diff --git a/extensions/tags/src/Api/Controller/UpdateTagController.php b/extensions/tags/src/Api/Controller/UpdateTagController.php deleted file mode 100644 index 2f5776446..000000000 --- a/extensions/tags/src/Api/Controller/UpdateTagController.php +++ /dev/null @@ -1,41 +0,0 @@ -getQueryParams(), 'id'); - $actor = RequestUtil::getActor($request); - $data = Arr::get($request->getParsedBody(), 'data', []); - - return $this->bus->dispatch( - new EditTag($id, $actor, $data) - ); - } -} diff --git a/extensions/tags/src/Api/DiscussionResourceFields.php b/extensions/tags/src/Api/DiscussionResourceFields.php new file mode 100644 index 000000000..1bc643ae0 --- /dev/null +++ b/extensions/tags/src/Api/DiscussionResourceFields.php @@ -0,0 +1,114 @@ +get(fn (Discussion $discussion, Context $context) => $context->getActor()->can('tag', $discussion)), + Schema\Relationship\ToMany::make('tags') + ->includable() + ->writable() + ->required(fn (Context $context, Discussion $discussion) => ! $context->getActor()->can('bypassTagCounts', $discussion)) + ->set(function (Discussion $discussion, array $newTags, Context $context) { + $actor = $context->getActor(); + + $newTagIds = array_map(fn (Tag $tag) => $tag->id, $newTags); + + $primaryParentCount = 0; + $secondaryOrPrimaryChildCount = 0; + + if ($discussion->exists) { + $actor->assertCan('tag', $discussion); + + $oldTags = $discussion->tags()->get(); + $oldTagIds = $oldTags->pluck('id')->all(); + + if ($oldTagIds == $newTagIds) { + return; + } + + foreach ($newTags as $tag) { + if (! in_array($tag->id, $oldTagIds) && $actor->cannot('addToDiscussion', $tag)) { + throw new PermissionDeniedException; + } + } + + $discussion->raise( + new DiscussionWasTagged($discussion, $actor, $oldTags->all()) + ); + } + + foreach ($newTags as $tag) { + if (! $discussion->exists && $actor->cannot('startDiscussion', $tag)) { + throw new PermissionDeniedException; + } + + if ($tag->position !== null && $tag->parent_id === null) { + $primaryParentCount++; + } else { + $secondaryOrPrimaryChildCount++; + } + } + + if (! $discussion->exists && $primaryParentCount === 0 && $secondaryOrPrimaryChildCount === 0 && ! $actor->hasPermission('startDiscussion')) { + throw new PermissionDeniedException; + } + + if (! $actor->can('bypassTagCounts', $discussion)) { + $this->validateTagCount('primary', $primaryParentCount); + $this->validateTagCount('secondary', $secondaryOrPrimaryChildCount); + } + + $discussion->afterSave(function ($discussion) use ($newTagIds) { + $discussion->tags()->sync($newTagIds); + $discussion->unsetRelation('tags'); + }); + }), + ]; + } + + protected function validateTagCount(string $type, int $count): void + { + $min = $this->settings->get('flarum-tags.min_'.$type.'_tags'); + $max = $this->settings->get('flarum-tags.max_'.$type.'_tags'); + $key = 'tag_count_'.$type; + + $validator = $this->validator->make( + [$key => $count], + [$key => ['numeric', $min === $max ? "size:$min" : "between:$min,$max"]] + ); + + if ($validator->fails()) { + throw new ValidationException([], ['tags' => $validator->getMessageBag()->first($key)]); + } + } +} diff --git a/extensions/tags/src/Api/Resource/TagResource.php b/extensions/tags/src/Api/Resource/TagResource.php new file mode 100644 index 000000000..75ccbbb8c --- /dev/null +++ b/extensions/tags/src/Api/Resource/TagResource.php @@ -0,0 +1,168 @@ + + */ +class TagResource extends AbstractDatabaseResource +{ + public function __construct( + protected SlugManager $slugManager + ) { + } + + public function type(): string + { + return 'tags'; + } + + public function model(): string + { + return Tag::class; + } + + public function scope(Builder $query, Context $context): void + { + $query->whereVisibleTo($context->getActor()); + + if ($context->listing(self::class) || $context->showing(self::class)) { + $query->withStateFor($context->getActor()); + } + } + + public function find(string $id, Context $context): ?object + { + $actor = $context->getActor(); + + if (is_numeric($id) && $tag = $this->query($context)->find($id)) { + return $tag; + } + + return $this->slugManager->forResource(Tag::class)->fromSlug($id, $actor); + } + + public function endpoints(): array + { + return [ + Endpoint\Show::make(), + Endpoint\Create::make() + ->authenticated() + ->can('createTag'), + Endpoint\Update::make() + ->authenticated() + ->can('edit'), + Endpoint\Delete::make() + ->authenticated() + ->can('delete'), + Endpoint\Index::make() + ->defaultInclude(['parent']), + ]; + } + + public function fields(): array + { + return [ + Schema\Str::make('name') + ->requiredOnCreate() + ->writable(), + Schema\Str::make('description') + ->writable() + ->maxLength(700) + ->nullable(), + Schema\Str::make('slug') + ->requiredOnCreate() + ->writable() + ->unique('tags', 'slug', true) + ->regex('/^[^\/\\ ]*$/i') + ->get(function (Tag $tag) { + return $this->slugManager->forResource($tag::class)->toSlug($tag); + }), + Schema\Str::make('color') + ->writable() + ->nullable() + ->rule('hex_color'), + Schema\Str::make('icon') + ->writable() + ->nullable(), + Schema\Boolean::make('isHidden') + ->writable(), + Schema\Boolean::make('isPrimary') + ->writable(), + Schema\Boolean::make('isRestricted') + ->writableOnUpdate() + ->visible(fn (Tag $tag, FlarumContext $context) => $context->getActor()->isAdmin()), + Schema\Str::make('backgroundUrl') + ->get(fn (Tag $tag) => $tag->background_path), + Schema\Str::make('backgroundMode'), + Schema\Integer::make('discussionCount'), + Schema\Integer::make('position') + ->nullable(), + Schema\Str::make('defaultSort') + ->nullable(), + Schema\Boolean::make('isChild') + ->get(fn (Tag $tag) => (bool) $tag->parent_id), + Schema\DateTime::make('lastPostedAt'), + Schema\Boolean::make('canStartDiscussion') + ->get(fn (Tag $tag, FlarumContext $context) => $context->getActor()->can('startDiscussion', $tag)), + Schema\Boolean::make('canAddToDiscussion') + ->get(fn (Tag $tag, FlarumContext $context) => $context->getActor()->can('addToDiscussion', $tag)), + + Schema\Relationship\ToOne::make('parent') + ->type('tags') + ->includable() + ->writable(fn (Tag $tag, FlarumContext $context) => (bool) Arr::get($context->body(), 'attributes.isPrimary')), + Schema\Relationship\ToMany::make('children') + ->type('tags') + ->includable(), + Schema\Relationship\ToOne::make('lastPostedDiscussion') + ->type('discussions') + ->includable(), + ]; + } + + public function creating(object $model, Context $context): ?object + { + $this->events->dispatch( + new Creating($model, $context->getActor(), $context->body()) + ); + + return $model; + } + + public function saving(object $model, Context $context): ?object + { + if (! $context->creating(self::class)) { + $this->events->dispatch( + new Saving($model, $context->getActor(), $context->body()) + ); + } + + return $model; + } + + public function deleting(object $model, Context $context): void + { + $this->events->dispatch(new Deleting($model, $context->getActor())); + } +} diff --git a/extensions/tags/src/Api/Serializer/TagSerializer.php b/extensions/tags/src/Api/Serializer/TagSerializer.php deleted file mode 100644 index f3f14bce1..000000000 --- a/extensions/tags/src/Api/Serializer/TagSerializer.php +++ /dev/null @@ -1,75 +0,0 @@ - $model->name, - 'description' => $model->description, - 'slug' => $this->slugManager->forResource(Tag::class)->toSlug($model), - 'color' => $model->color, - 'backgroundUrl' => $model->background_path, - 'backgroundMode' => $model->background_mode, - 'icon' => $model->icon, - 'discussionCount' => (int) $model->discussion_count, - 'position' => $model->position === null ? null : (int) $model->position, - 'defaultSort' => $model->default_sort, - 'isChild' => (bool) $model->parent_id, - 'isHidden' => (bool) $model->is_hidden, - 'lastPostedAt' => $this->formatDate($model->last_posted_at), - 'canStartDiscussion' => $this->actor->can('startDiscussion', $model), - 'canAddToDiscussion' => $this->actor->can('addToDiscussion', $model) - ]; - - if ($this->actor->isAdmin()) { - $attributes['isRestricted'] = (bool) $model->is_restricted; - } - - return $attributes; - } - - protected function parent(Tag $tag): ?Relationship - { - return $this->hasOne($tag, self::class); - } - - protected function children(Tag $tag): ?Relationship - { - return $this->hasMany($tag, self::class); - } - - protected function lastPostedDiscussion(Tag $tag): ?Relationship - { - return $this->hasOne($tag, DiscussionSerializer::class); - } -} diff --git a/extensions/tags/src/Command/CreateTagHandler.php b/extensions/tags/src/Command/CreateTagHandler.php deleted file mode 100644 index f51cd46f7..000000000 --- a/extensions/tags/src/Command/CreateTagHandler.php +++ /dev/null @@ -1,66 +0,0 @@ -actor; - $data = $command->data; - - $actor->assertCan('createTag'); - - $tag = Tag::build( - Arr::get($data, 'attributes.name'), - Arr::get($data, 'attributes.slug'), - Arr::get($data, 'attributes.description'), - Arr::get($data, 'attributes.color'), - Arr::get($data, 'attributes.icon'), - Arr::get($data, 'attributes.isHidden') - ); - - $parentId = Arr::get($data, 'relationships.parent.data.id'); - $primary = Arr::get($data, 'attributes.primary'); - - if ($parentId !== null || $primary) { - $rootTags = Tag::whereNull('parent_id')->whereNotNull('position'); - - if ($parentId === 0 || $primary) { - $tag->position = $rootTags->max('position') + 1; - } elseif ($rootTags->find($parentId)) { - $position = Tag::where('parent_id', $parentId)->max('position'); - - $tag->parent()->associate($parentId); - $tag->position = $position === null ? 0 : $position + 1; - } - } - - $this->events->dispatch(new Creating($tag, $actor, $data)); - - $this->validator->assertValid($tag->getAttributes()); - - $tag->save(); - - return $tag; - } -} diff --git a/extensions/tags/src/Command/DeleteTag.php b/extensions/tags/src/Command/DeleteTag.php deleted file mode 100644 index 48d239088..000000000 --- a/extensions/tags/src/Command/DeleteTag.php +++ /dev/null @@ -1,22 +0,0 @@ -actor; - - $tag = $this->tags->findOrFail($command->tagId, $actor); - - $actor->assertCan('delete', $tag); - - $this->events->dispatch(new Deleting($tag, $actor)); - - $tag->delete(); - - return $tag; - } -} diff --git a/extensions/tags/src/Command/EditTag.php b/extensions/tags/src/Command/EditTag.php deleted file mode 100644 index a52d43586..000000000 --- a/extensions/tags/src/Command/EditTag.php +++ /dev/null @@ -1,22 +0,0 @@ -actor; - $data = $command->data; - - $tag = $this->tags->findOrFail($command->tagId, $actor); - - $actor->assertCan('edit', $tag); - - $attributes = Arr::get($data, 'attributes', []); - - if (isset($attributes['name'])) { - $tag->name = $attributes['name']; - } - - if (isset($attributes['slug'])) { - $tag->slug = $attributes['slug']; - } - - if (isset($attributes['description'])) { - $tag->description = $attributes['description']; - } - - if (isset($attributes['color'])) { - $tag->color = $attributes['color']; - } - - if (isset($attributes['icon'])) { - $tag->icon = $attributes['icon']; - } - - if (isset($attributes['isHidden'])) { - $tag->is_hidden = (bool) $attributes['isHidden']; - } - - if (isset($attributes['isRestricted'])) { - $tag->is_restricted = (bool) $attributes['isRestricted']; - } - - $this->events->dispatch(new Saving($tag, $actor, $data)); - - $this->validator->assertValid($tag->getDirty()); - - $tag->save(); - - return $tag; - } -} diff --git a/extensions/tags/src/Content/Tag.php b/extensions/tags/src/Content/Tag.php index 0d2afa426..b7c7500fa 100644 --- a/extensions/tags/src/Content/Tag.php +++ b/extensions/tags/src/Content/Tag.php @@ -10,6 +10,7 @@ namespace Flarum\Tags\Content; use Flarum\Api\Client; +use Flarum\Api\Resource\DiscussionResource; use Flarum\Frontend\Document; use Flarum\Http\RequestUtil; use Flarum\Http\SlugManager; @@ -27,7 +28,8 @@ class Tag protected Factory $view, protected TagRepository $tags, protected TranslatorInterface $translator, - protected SlugManager $slugger + protected SlugManager $slugger, + protected DiscussionResource $resource ) { } @@ -42,7 +44,7 @@ class Tag $page = Arr::pull($queryParams, 'page', 1); $filters = Arr::pull($queryParams, 'filter', []); - $sortMap = $this->getSortMap(); + $sortMap = $this->resource->sortMap(); $tag = $this->slugger->forResource(TagModel::class)->fromSlug($slug, $actor); @@ -78,14 +80,6 @@ class Tag return $document; } - /** - * Get a map of sort query param values and their API sort params. - */ - protected function getSortMap(): array - { - return resolve('flarum.forum.discussions.sortmap'); - } - /** * Get the result of an API request to list discussions. */ @@ -101,7 +95,7 @@ class Tag ->withoutErrorHandling() ->withParentRequest($request) ->withQueryParams([ - 'include' => 'children,children.parent,parent,parent.children.parent,state' + 'include' => 'children,children.parent,parent,parent.children.parent' ]) ->get("/tags/$slug") ->getBody() diff --git a/extensions/tags/src/Listener/SaveTagsToDatabase.php b/extensions/tags/src/Listener/SaveTagsToDatabase.php deleted file mode 100755 index c1eba2ae8..000000000 --- a/extensions/tags/src/Listener/SaveTagsToDatabase.php +++ /dev/null @@ -1,116 +0,0 @@ -discussion; - $actor = $event->actor; - - $newTagIds = []; - $newTags = []; - - $primaryCount = 0; - $secondaryCount = 0; - - if (isset($event->data['relationships']['tags']['data'])) { - $linkage = (array) $event->data['relationships']['tags']['data']; - - foreach ($linkage as $link) { - $newTagIds[] = (int) $link['id']; - } - - $newTags = Tag::whereIn('id', $newTagIds)->get(); - } - - if ($discussion->exists && isset($event->data['relationships']['tags']['data'])) { - $actor->assertCan('tag', $discussion); - - $oldTags = $discussion->tags()->get(); - $oldTagIds = $oldTags->pluck('id')->all(); - - if ($oldTagIds == $newTagIds) { - return; - } - - foreach ($newTags as $tag) { - if (! in_array($tag->id, $oldTagIds) && $actor->cannot('addToDiscussion', $tag)) { - throw new PermissionDeniedException; - } - } - - $discussion->raise( - new DiscussionWasTagged($discussion, $actor, $oldTags->all()) - ); - } - - if (! $discussion->exists || isset($event->data['relationships']['tags']['data'])) { - foreach ($newTags as $tag) { - if (! $discussion->exists && $actor->cannot('startDiscussion', $tag)) { - throw new PermissionDeniedException; - } - - if ($tag->position !== null && $tag->parent_id === null) { - $primaryCount++; - } else { - $secondaryCount++; - } - } - - if (! $discussion->exists && $primaryCount === 0 && $secondaryCount === 0 && ! $actor->hasPermission('startDiscussion')) { - throw new PermissionDeniedException; - } - - if (! $actor->can('bypassTagCounts', $discussion)) { - $this->validateTagCount('primary', $primaryCount); - $this->validateTagCount('secondary', $secondaryCount); - } - - $discussion->afterSave(function ($discussion) use ($newTagIds) { - $discussion->tags()->sync($newTagIds); - $discussion->unsetRelation('tags'); - }); - } - } - - protected function validateTagCount(string $type, int $count): void - { - $min = $this->settings->get('flarum-tags.min_'.$type.'_tags'); - $max = $this->settings->get('flarum-tags.max_'.$type.'_tags'); - $key = 'tag_count_'.$type; - - $validator = $this->validator->make( - [$key => $count], - [$key => ['numeric', $min === $max ? "size:$min" : "between:$min,$max"]] - ); - - if ($validator->fails()) { - throw new ValidationException([], ['tags' => $validator->getMessageBag()->first($key)]); - } - } -} diff --git a/extensions/tags/src/LoadForumTagsRelationship.php b/extensions/tags/src/LoadForumTagsRelationship.php deleted file mode 100755 index e0d8c7453..000000000 --- a/extensions/tags/src/LoadForumTagsRelationship.php +++ /dev/null @@ -1,43 +0,0 @@ -where(function ($query) { - $query - ->whereNull('parent_id') - ->whereNotNull('position'); - }) - ->union( - Tag::whereVisibleTo($actor) - ->whereNull('parent_id') - ->whereNull('position') - ->orderBy('discussion_count', 'desc') - ->limit(4) // We get one more than we need so the "more" link can be shown. - ) - ->whereVisibleTo($actor) - ->withStateFor($actor) - ->get(); - } -} diff --git a/extensions/tags/src/Tag.php b/extensions/tags/src/Tag.php index 5d423e711..e647c118b 100644 --- a/extensions/tags/src/Tag.php +++ b/extensions/tags/src/Tag.php @@ -31,9 +31,10 @@ use Illuminate\Database\Query\Builder as QueryBuilder; * @property string $color * @property string $background_path * @property string $background_mode + * @property bool $is_primary * @property int $position * @property int $parent_id - * @property string $default_sort + * @property string|null $default_sort * @property bool $is_restricted * @property bool $is_hidden * @property int $discussion_count @@ -59,6 +60,7 @@ class Tag extends AbstractModel protected $casts = [ 'is_hidden' => 'bool', 'is_restricted' => 'bool', + 'is_primary' => 'bool', 'last_posted_at' => 'datetime', 'created_at' => 'datetime', 'updated_at' => 'datetime', @@ -74,6 +76,15 @@ class Tag extends AbstractModel } }); + static::creating(function (self $tag) { + if ($tag->is_primary) { + $tag->position = static::query() + ->when($tag->parent_id, fn ($query) => $query->where('parent_id', $tag->parent_id)) + ->where('is_primary', true) + ->max('position') + 1; + } + }); + static::deleted(function (self $tag) { $tag->deletePermissions(); }); diff --git a/extensions/tags/src/TagRepository.php b/extensions/tags/src/TagRepository.php index 6c74d0d91..063941ed2 100644 --- a/extensions/tags/src/TagRepository.php +++ b/extensions/tags/src/TagRepository.php @@ -15,8 +15,6 @@ use Illuminate\Database\Eloquent\Collection; class TagRepository { - private const TAG_RELATIONS = ['children', 'parent', 'parent.children']; - /** * @return Builder */ @@ -30,32 +28,6 @@ class TagRepository return $this->scopeVisibleTo($this->query(), $actor); } - /** - * @return Builder - */ - public function with(array|string $relations, User $actor): Builder - { - return $this->query()->with($this->getAuthorizedRelations($relations, $actor)); - } - - public function getAuthorizedRelations(array|string $relations, User $actor): array - { - $relations = is_string($relations) ? explode(',', $relations) : $relations; - $relationsArray = []; - - foreach ($relations as $relation) { - if (in_array($relation, self::TAG_RELATIONS, true)) { - $relationsArray[$relation] = function ($query) use ($actor) { - $query->whereVisibleTo($actor); - }; - } else { - $relationsArray[] = $relation; - } - } - - return $relationsArray; - } - /** * Find a tag by ID, optionally making sure it is visible to a certain * user, or throw an exception. diff --git a/extensions/tags/src/TagValidator.php b/extensions/tags/src/TagValidator.php deleted file mode 100644 index cf1d9c528..000000000 --- a/extensions/tags/src/TagValidator.php +++ /dev/null @@ -1,23 +0,0 @@ - ['required'], - 'slug' => ['required', 'unique:tags', 'regex:/^[^\/\\ ]*$/i'], - 'is_hidden' => ['bool'], - 'description' => ['string', 'max:700'], - 'color' => ['hex_color'], - ]; -} diff --git a/extensions/tags/tests/integration/RetrievesRepresentativeTags.php b/extensions/tags/tests/integration/RetrievesRepresentativeTags.php index 778ab040f..6c666064a 100644 --- a/extensions/tags/tests/integration/RetrievesRepresentativeTags.php +++ b/extensions/tags/tests/integration/RetrievesRepresentativeTags.php @@ -14,20 +14,20 @@ trait RetrievesRepresentativeTags protected function tags() { return [ - ['id' => 1, 'name' => 'Primary 1', 'slug' => 'primary-1', 'position' => 0, 'parent_id' => null], - ['id' => 2, 'name' => 'Primary 2', 'slug' => 'primary-2', 'position' => 1, 'parent_id' => null], - ['id' => 3, 'name' => 'Primary 2 Child 1', 'slug' => 'primary-2-child-1', 'position' => 2, 'parent_id' => 2], - ['id' => 4, 'name' => 'Primary 2 Child 2', 'slug' => 'primary-2-child-2', 'position' => 3, 'parent_id' => 2], - ['id' => 5, 'name' => 'Primary 2 Child Restricted', 'slug' => 'primary-2-child-restricted', 'position' => 4, 'parent_id' => 2, 'is_restricted' => true], - ['id' => 6, 'name' => 'Primary Restricted', 'slug' => 'primary-restricted', 'position' => 5, 'parent_id' => null, 'is_restricted' => true], - ['id' => 7, 'name' => 'Primary Restricted Child 1', 'slug' => 'primary-restricted-child-1', 'position' => 6, 'parent_id' => 6], - ['id' => 8, 'name' => 'Primary Restricted Child Restricted', 'slug' => 'primary-restricted-child-restricted', 'position' => 7, 'parent_id' => 6, 'is_restricted' => true], - ['id' => 9, 'name' => 'Secondary 1', 'slug' => 'secondary-1', 'position' => null, 'parent_id' => null], - ['id' => 10, 'name' => 'Secondary 2', 'slug' => 'secondary-2', 'position' => null, 'parent_id' => null], - ['id' => 11, 'name' => 'Secondary Restricted', 'slug' => 'secondary-restricted', 'position' => null, 'parent_id' => null, 'is_restricted' => true], - ['id' => 12, 'name' => 'Primary Restricted 2', 'slug' => 'primary-2-restricted', 'position' => 100, 'parent_id' => null, 'is_restricted' => true], - ['id' => 13, 'name' => 'Primary Restricted 2 Child 1', 'slug' => 'primary-2-restricted-child-1', 'position' => 101, 'parent_id' => 12], - ['id' => 14, 'name' => 'Primary Restricted 3', 'slug' => 'primary-3-restricted', 'position' => 102, 'parent_id' => null, 'is_restricted' => true], + ['id' => 1, 'name' => 'Primary 1', 'slug' => 'primary-1', 'is_primary' => true, 'position' => 0, 'parent_id' => null], + ['id' => 2, 'name' => 'Primary 2', 'slug' => 'primary-2', 'is_primary' => true, 'position' => 1, 'parent_id' => null], + ['id' => 3, 'name' => 'Primary 2 Child 1', 'slug' => 'primary-2-child-1', 'is_primary' => true, 'position' => 2, 'parent_id' => 2], + ['id' => 4, 'name' => 'Primary 2 Child 2', 'slug' => 'primary-2-child-2', 'is_primary' => true, 'position' => 3, 'parent_id' => 2], + ['id' => 5, 'name' => 'Primary 2 Child Restricted', 'slug' => 'primary-2-child-restricted', 'is_primary' => true, 'position' => 4, 'parent_id' => 2, 'is_restricted' => true], + ['id' => 6, 'name' => 'Primary Restricted', 'slug' => 'primary-restricted', 'is_primary' => true, 'position' => 5, 'parent_id' => null, 'is_restricted' => true], + ['id' => 7, 'name' => 'Primary Restricted Child 1', 'slug' => 'primary-restricted-child-1', 'is_primary' => true, 'position' => 6, 'parent_id' => 6], + ['id' => 8, 'name' => 'Primary Restricted Child Restricted', 'slug' => 'primary-restricted-child-restricted', 'is_primary' => true, 'position' => 7, 'parent_id' => 6, 'is_restricted' => true], + ['id' => 9, 'name' => 'Secondary 1', 'slug' => 'secondary-1', 'is_primary' => false, 'position' => null, 'parent_id' => null], + ['id' => 10, 'name' => 'Secondary 2', 'slug' => 'secondary-2', 'is_primary' => false, 'position' => null, 'parent_id' => null], + ['id' => 11, 'name' => 'Secondary Restricted', 'slug' => 'secondary-restricted', 'is_primary' => false, 'position' => null, 'parent_id' => null, 'is_restricted' => true], + ['id' => 12, 'name' => 'Primary Restricted 2', 'slug' => 'primary-2-restricted', 'is_primary' => true, 'position' => 100, 'parent_id' => null, 'is_restricted' => true], + ['id' => 13, 'name' => 'Primary Restricted 2 Child 1', 'slug' => 'primary-2-restricted-child-1', 'is_primary' => true, 'position' => 101, 'parent_id' => 12], + ['id' => 14, 'name' => 'Primary Restricted 3', 'slug' => 'primary-3-restricted', 'is_primary' => true, 'position' => 102, 'parent_id' => null, 'is_restricted' => true], ]; } } diff --git a/extensions/tags/tests/integration/api/discussions/CreateTest.php b/extensions/tags/tests/integration/api/discussions/CreateTest.php index ef7a0bd24..19d24b3f0 100644 --- a/extensions/tags/tests/integration/api/discussions/CreateTest.php +++ b/extensions/tags/tests/integration/api/discussions/CreateTest.php @@ -55,6 +55,7 @@ class CreateTest extends TestCase 'authenticatedAs' => 1, 'json' => [ 'data' => [ + 'type' => 'discussions', 'attributes' => [ 'title' => 'test - too-obscure', 'content' => 'predetermined content for automated testing - too-obscure', @@ -77,6 +78,7 @@ class CreateTest extends TestCase 'authenticatedAs' => 2, 'json' => [ 'data' => [ + 'type' => 'discussions', 'attributes' => [ 'title' => 'test - too-obscure', 'content' => 'predetermined content for automated testing - too-obscure', @@ -87,6 +89,28 @@ class CreateTest extends TestCase ); $this->assertEquals(422, $response->getStatusCode()); + + $response = $this->send( + $this->request('POST', '/api/discussions', [ + 'authenticatedAs' => 2, + 'json' => [ + 'data' => [ + 'type' => 'discussions', + 'attributes' => [ + 'title' => 'test - too-obscure', + 'content' => 'predetermined content for automated testing - too-obscure', + ], + 'relationships' => [ + 'tags' => [ + 'data' => [] + ] + ] + ] + ], + ]) + ); + + $this->assertEquals(422, $response->getStatusCode()); } /** @@ -105,6 +129,7 @@ class CreateTest extends TestCase 'authenticatedAs' => 2, 'json' => [ 'data' => [ + 'type' => 'discussions', 'attributes' => [ 'title' => 'test - too-obscure', 'content' => 'predetermined content for automated testing - too-obscure', @@ -127,6 +152,7 @@ class CreateTest extends TestCase 'authenticatedAs' => 2, 'json' => [ 'data' => [ + 'type' => 'discussions', 'attributes' => [ 'title' => 'test - too-obscure', 'content' => 'predetermined content for automated testing - too-obscure', @@ -143,7 +169,7 @@ class CreateTest extends TestCase ]) ); - $this->assertEquals(201, $response->getStatusCode()); + $this->assertEquals(201, $response->getStatusCode(), (string) $response->getBody()); } /** @@ -156,6 +182,7 @@ class CreateTest extends TestCase 'authenticatedAs' => 2, 'json' => [ 'data' => [ + 'type' => 'discussions', 'attributes' => [ 'title' => 'test - too-obscure', 'content' => 'predetermined content for automated testing - too-obscure', @@ -191,6 +218,7 @@ class CreateTest extends TestCase 'authenticatedAs' => 2, 'json' => [ 'data' => [ + 'type' => 'discussions', 'attributes' => [ 'title' => 'test - too-obscure', 'content' => 'predetermined content for automated testing - too-obscure', @@ -220,6 +248,7 @@ class CreateTest extends TestCase 'authenticatedAs' => 2, 'json' => [ 'data' => [ + 'type' => 'discussions', 'attributes' => [ 'title' => 'test - too-obscure', 'content' => 'predetermined content for automated testing - too-obscure', @@ -250,6 +279,7 @@ class CreateTest extends TestCase 'authenticatedAs' => 2, 'json' => [ 'data' => [ + 'type' => 'discussions', 'attributes' => [ 'title' => 'test - too-obscure', 'content' => 'predetermined content for automated testing - too-obscure', @@ -279,6 +309,7 @@ class CreateTest extends TestCase 'authenticatedAs' => 2, 'json' => [ 'data' => [ + 'type' => 'discussions', 'attributes' => [ 'title' => 'test - too-obscure', 'content' => 'predetermined content for automated testing - too-obscure', @@ -309,6 +340,7 @@ class CreateTest extends TestCase 'authenticatedAs' => 2, 'json' => [ 'data' => [ + 'type' => 'discussions', 'attributes' => [ 'title' => 'test - too-obscure', 'content' => 'predetermined content for automated testing - too-obscure', diff --git a/extensions/tags/tests/integration/api/discussions/UpdateTest.php b/extensions/tags/tests/integration/api/discussions/UpdateTest.php index a865b0d85..bcdcd3d39 100644 --- a/extensions/tags/tests/integration/api/discussions/UpdateTest.php +++ b/extensions/tags/tests/integration/api/discussions/UpdateTest.php @@ -230,6 +230,7 @@ class UpdateTest extends TestCase 'authenticatedAs' => 2, 'json' => [ 'data' => [ + 'type' => 'discussions', 'attributes' => [ 'title' => 'test - too-obscure', 'content' => 'predetermined content for automated testing - too-obscure', diff --git a/extensions/tags/tests/integration/api/posts/ListTest.php b/extensions/tags/tests/integration/api/posts/ListTest.php index bc4032bb9..c0a4c0605 100644 --- a/extensions/tags/tests/integration/api/posts/ListTest.php +++ b/extensions/tags/tests/integration/api/posts/ListTest.php @@ -86,9 +86,11 @@ class ListTest extends TestCase ]) ); - $this->assertEquals(200, $response->getStatusCode()); + $body = $response->getBody()->getContents(); - $data = json_decode($response->getBody()->getContents(), true); + $this->assertEquals(200, $response->getStatusCode(), $body); + + $data = json_decode($body, true); $tagIds = array_map(function ($tag) { return $tag['id']; @@ -96,7 +98,7 @@ class ListTest extends TestCase return $item['type'] === 'tags'; })); - $this->assertEqualsCanonicalizing([1, 5], $tagIds); + $this->assertEqualsCanonicalizing([1, 5], $tagIds, $body); } /** diff --git a/extensions/tags/tests/integration/api/tags/CreateTest.php b/extensions/tags/tests/integration/api/tags/CreateTest.php index a31187dfc..881f7d579 100644 --- a/extensions/tags/tests/integration/api/tags/CreateTest.php +++ b/extensions/tags/tests/integration/api/tags/CreateTest.php @@ -59,11 +59,13 @@ class CreateTest extends TestCase $response = $this->send( $this->request('POST', '/api/tags', [ 'authenticatedAs' => 1, - 'json' => [], + 'json' => [ + 'data' => [] + ], ]) ); - $this->assertEquals(422, $response->getStatusCode()); + $this->assertEquals(422, $response->getStatusCode(), (string) $response->getBody()); } /** @@ -76,6 +78,7 @@ class CreateTest extends TestCase 'authenticatedAs' => 1, 'json' => [ 'data' => [ + 'type' => 'tags', 'attributes' => [ 'name' => 'Dev Blog', 'slug' => 'dev-blog', @@ -87,10 +90,10 @@ class CreateTest extends TestCase ]) ); - $this->assertEquals(201, $response->getStatusCode()); + $this->assertEquals(201, $response->getStatusCode(), $body = (string) $response->getBody()); // Verify API response body - $data = json_decode($response->getBody(), true); + $data = json_decode($body, true); $this->assertEquals('Dev Blog', Arr::get($data, 'data.attributes.name')); $this->assertEquals('dev-blog', Arr::get($data, 'data.attributes.slug')); $this->assertEquals('Follow Flarum development!', Arr::get($data, 'data.attributes.description')); diff --git a/extensions/tags/tests/integration/api/tags/ListTest.php b/extensions/tags/tests/integration/api/tags/ListTest.php index d8e710d16..ee813ae3d 100644 --- a/extensions/tags/tests/integration/api/tags/ListTest.php +++ b/extensions/tags/tests/integration/api/tags/ListTest.php @@ -103,13 +103,22 @@ class ListTest extends TestCase $responseBody = json_decode($response->getBody()->getContents(), true); $data = $responseBody['data']; - $included = $responseBody['included']; // 5 isnt included because parent access doesnt necessarily give child access // 6, 7, 8 aren't included because child access shouldnt work unless parent // access is also given. $this->assertEquals(['1', '2', '3', '4', '9', '10', '11'], Arr::pluck($data, 'id')); - $this->assertEquals($expectedIncludes, Arr::pluck($included, 'id')); + $this->assertEquals( + $expectedIncludes, + collect($data) + ->pluck('relationships.'.$include.'.data') + ->filter(fn ($data) => ! empty($data)) + ->values() + ->flatMap(fn (array $data) => isset($data['type']) ? [$data] : $data) + ->pluck('id') + ->unique() + ->all() + ); } /** diff --git a/framework/core/composer.json b/framework/core/composer.json index 60619d9cd..aeba06447 100644 --- a/framework/core/composer.json +++ b/framework/core/composer.json @@ -79,7 +79,6 @@ "psr/http-server-middleware": "^1.0.2", "s9e/text-formatter": "^2.13", "staudenmeir/eloquent-eager-limit": "^1.8.2", - "sycho/json-api": "^0.5.0", "sycho/sourcemap": "^2.0.0", "symfony/config": "^6.3", "symfony/console": "^6.3", @@ -92,6 +91,7 @@ "symfony/translation": "^6.3", "symfony/translation-contracts": "^2.5", "symfony/yaml": "^6.3", + "flarum/json-api-server": "^1.0.0", "wikimedia/less.php": "^4.1" }, "require-dev": { diff --git a/framework/core/js/src/admin/components/CreateUserModal.tsx b/framework/core/js/src/admin/components/CreateUserModal.tsx index a351364c4..98c2cac38 100644 --- a/framework/core/js/src/admin/components/CreateUserModal.tsx +++ b/framework/core/js/src/admin/components/CreateUserModal.tsx @@ -207,11 +207,9 @@ export default class CreateUserModal { diff --git a/framework/core/js/src/common/states/PaginatedListState.ts b/framework/core/js/src/common/states/PaginatedListState.ts index 6d7e83baf..4b8efe984 100644 --- a/framework/core/js/src/common/states/PaginatedListState.ts +++ b/framework/core/js/src/common/states/PaginatedListState.ts @@ -121,6 +121,10 @@ export default abstract class PaginatedListState(this.type, params).then((results) => { /* * If this state does not rely on a preloaded API document to know the page size, diff --git a/framework/core/js/tests/unit/common/GambitManager.test.ts b/framework/core/js/tests/unit/common/GambitManager.test.ts index d27e1a614..138f20aff 100644 --- a/framework/core/js/tests/unit/common/GambitManager.test.ts +++ b/framework/core/js/tests/unit/common/GambitManager.test.ts @@ -7,7 +7,7 @@ test('gambits are converted to filters', function () { q: 'lorem', created: '2023-07-07', hidden: true, - author: ['behz'], + author: 'behz', }); }); @@ -16,7 +16,7 @@ test('gambits are negated when prefixed with a dash', function () { q: 'lorem', '-created': '2023-07-07', '-hidden': true, - '-author': ['behz'], + '-author': 'behz', }); }); @@ -29,6 +29,6 @@ test('gambits are only applied for the correct resource type', function () { q: 'lorem email:behz@machine.local', created: '2023-07-07..2023-10-18', hidden: true, - '-author': ['behz'], + '-author': 'behz', }); }); diff --git a/framework/core/locale/validation.yml b/framework/core/locale/validation.yml index 7c2b40189..f07455473 100644 --- a/framework/core/locale/validation.yml +++ b/framework/core/locale/validation.yml @@ -1,107 +1,139 @@ validation: - accepted: "The :attribute must be accepted." - active_url: "The :attribute is not a valid URL." - after: "The :attribute must be a date after :date." - after_or_equal: "The :attribute must be a date after or equal to :date." - alpha: "The :attribute must only contain letters." - alpha_dash: "The :attribute must only contain letters, numbers, dashes and underscores." - alpha_num: "The :attribute must only contain letters and numbers." - array: "The :attribute must be an array." - before: "The :attribute must be a date before :date." - before_or_equal: "The :attribute must be a date before or equal to :date." + accepted: "The :attribute field must be accepted." + accepted_if: "The :attribute field must be accepted when :other is :value." + active_url: "The :attribute field must be a valid URL." + after: "The :attribute field must be a date after :date." + after_or_equal: "The :attribute field must be a date after or equal to :date." + alpha: "The :attribute field must only contain letters." + alpha_dash: "The :attribute field must only contain letters, numbers, dashes, and underscores." + alpha_num: "The :attribute field must only contain letters and numbers." + array: "The :attribute field must be an array." + ascii: "The :attribute field must only contain single-byte alphanumeric characters and symbols." + before: "The :attribute field must be a date before :date." + before_or_equal: "The :attribute field must be a date before or equal to :date." between: - numeric: "The :attribute must be between :min and :max." - file: "The :attribute must be between :min and :max kilobytes." - string: "The :attribute must be between :min and :max characters." - array: "The :attribute must have between :min and :max items." + array: "The :attribute field must contain between :min and :max items." + file: "The :attribute field must be between :min and :max kilobytes." + numeric: "The :attribute field must be between :min and :max." + string: "The :attribute field must be between :min and :max characters." boolean: "The :attribute field must be true or false." - confirmed: "The :attribute confirmation does not match." - date: "The :attribute is not a valid date." - date_equals: "The :attribute must be a date equal to :date." - date_format: "The :attribute does not match the format :format." - different: "The :attribute and :other must be different." - digits: "The :attribute must be :digits digits." - digits_between: "The :attribute must be between :min and :max digits." - dimensions: "The :attribute has invalid image dimensions." + can: "The :attribute field contains an unauthorized value." + confirmed: "The :attribute field confirmation does not match." + current_password: "The password is incorrect." + date: "The :attribute field must be a valid date." + date_equals: "The :attribute field must be a date equal to :date." + date_format: "The :attribute field must match the format :format." + decimal: "The :attribute field must have :decimal decimal places." + declined: "The :attribute field must be declined." + declined_if: "The :attribute field must be declined when :other is :value." + different: "The :attribute field and :other must be different." + digits: "The :attribute field must be :digits digits." + digits_between: "The :attribute field must be between :min and :max digits." + dimensions: "The :attribute field has invalid image dimensions." distinct: "The :attribute field has a duplicate value." - email: "The :attribute must be a valid email address." - ends_with: "The :attribute must end with one of the following: :values." + doesnt_end_with: "The :attribute field must not end with one of the following: :values." + doesnt_start_with: "The :attribute field must not start with one of the following: :values." + email: "The :attribute field must be a valid email address." + ends_with: "The :attribute field must end with one of the following: :values." + enum: "The selected :attribute is invalid." exists: "The selected :attribute is invalid." - file: "The :attribute must be a file." - file_too_large: "The :attribute is too large." - file_upload_failed: "The :attribute failed to upload." + extensions: "The :attribute field must have one of the following extensions: :values." + file: "The :attribute field must be a file." filled: "The :attribute field must have a value." gt: - numeric: "The :attribute must be greater than :value." - file: "The :attribute must be greater than :value kilobytes." - string: "The :attribute must be greater than :value characters." - array: "The :attribute must have more than :value items." + array: "The :attribute field must have more than :value items." + file: "The :attribute field must be greater than :value kilobytes." + numeric: "The :attribute field must be greater than :value." + string: "The :attribute field must be greater than :value characters." gte: - numeric: "The :attribute must be greater than or equal :value." - file: "The :attribute must be greater than or equal :value kilobytes." - string: "The :attribute must be greater than or equal :value characters." - array: "The :attribute must have :value items or more." + array: "The :attribute field must have :value items or more." + file: "The :attribute field must be greater than or equal to :value kilobytes." + numeric: "The :attribute field must be greater than or equal to :value." + string: "The :attribute field must be greater than or equal to :value characters." hex_color: "The :attribute field must be a valid hexadecimal color." - image: "The :attribute must be an image." + image: "The :attribute field must be an image." in: "The selected :attribute is invalid." - in_array: "The :attribute field does not exist in :other." - integer: "The :attribute must be an integer." - ip: "The :attribute must be a valid IP address." - ipv4: "The :attribute must be a valid IPv4 address." - ipv6: "The :attribute must be a valid IPv6 address." - json: "The :attribute must be a valid JSON string." + in_array: "The :attribute field must exist in :other." + integer: "The :attribute field must be an integer." + ip: "The :attribute field must be a valid IP address." + ipv4: "The :attribute field must be a valid IPv4 address." + ipv6: "The :attribute field must be a valid IPv6 address." + json: "The :attribute field must be a valid JSON string." + lowercase: "The :attribute field must be lowercase." lt: - numeric: "The :attribute must be less than :value." - file: "The :attribute must be less than :value kilobytes." - string: "The :attribute must be less than :value characters." - array: "The :attribute must have less than :value items." + array: "The :attribute field must have less than :value items." + file: "The :attribute field must be less than :value kilobytes." + numeric: "The :attribute field must be less than :value." + string: "The :attribute field must be less than :value characters." lte: - numeric: "The :attribute must be less than or equal :value." - file: "The :attribute must be less than or equal :value kilobytes." - string: "The :attribute must be less than or equal :value characters." - array: "The :attribute must not have more than :value items." + array: "The :attribute field must not have more than :value items." + file: "The :attribute field must be less than or equal to :value kilobytes." + numeric: "The :attribute field must be less than or equal to :value." + string: "The :attribute field must be less than or equal to :value characters." + mac_address: "The :attribute field must be a valid MAC address." max: - numeric: "The :attribute must not be greater than :max." - file: "The :attribute must not be greater than :max kilobytes." - string: "The :attribute must not be greater than :max characters." - array: "The :attribute must not have more than :max items." - mimes: "The :attribute must be a file of type: :values." - mimetypes: "The :attribute must be a file of type: :values." + array: "The :attribute field must not have more than :max items." + file: "The :attribute field must not be greater than :max kilobytes." + numeric: "The :attribute field must not be greater than :max." + string: "The :attribute field must not be greater than :max characters." + max_digits: "The :attribute field must not have more than :max digits." + mimes: "The :attribute field must be a file of type: :values." + mimetypes: "The :attribute field must be a file of type: :values." min: - numeric: "The :attribute must be at least :min." - file: "The :attribute must be at least :min kilobytes." - string: "The :attribute must be at least :min characters." - array: "The :attribute must have at least :min items." - multiple_of: "The :attribute must be a multiple of :value." + array: "The :attribute field must have at least :min items." + file: "The :attribute field must be at least :min kilobytes." + numeric: "The :attribute field must be at least :min." + string: "The :attribute field must be at least :min characters." + min_digits: "The :attribute field must have at least :min digits." + missing: "The :attribute field must be missing." + missing_if: "The :attribute field must be missing when :other is :value." + missing_unless: "The :attribute field must be missing unless :other is :value." + missing_with: "The :attribute field must be missing when :values is present." + missing_with_all: "The :attribute field must be missing when :values are present." + multiple_of: "The :attribute field must be a multiple of :value." not_in: "The selected :attribute is invalid." - not_regex: "The :attribute format is invalid." - numeric: "The :attribute must be a number." - password: "The password is incorrect." + not_regex: "The :attribute field format is invalid." + numeric: "The :attribute field must be a number." + password: + letters: "The :attribute field must contain at least one letter." + mixed: "The :attribute field must contain at least one uppercase and one lowercase letter." + numbers: "The :attribute field must contain at least one number." + symbols: "The :attribute field must contain at least one symbol." + uncompromised: "The given :attribute has appeared in a data leak. Please choose a different :attribute." present: "The :attribute field must be present." - regex: "The :attribute format is invalid." + present_if: "The :attribute field must be present when :other is :value." + present_unless: "The :attribute field must be present unless :other is :value." + present_with: "The :attribute field must be present when :values is present." + present_with_all: "The :attribute field must be present when :values are present." + prohibited: "The :attribute field is prohibited." + prohibited_if: "The :attribute field is prohibited when :other is :value." + prohibited_unless: "The :attribute field is prohibited unless :other is in :values." + prohibits: "The :attribute field prohibits :other from being present." + regex: "The :attribute field format is invalid." required: "The :attribute field is required." + required_array_keys: "The :attribute field must contain entries for: :values." required_if: "The :attribute field is required when :other is :value." + required_if_accepted: "The :attribute field is required when :other is accepted." required_unless: "The :attribute field is required unless :other is in :values." required_with: "The :attribute field is required when :values is present." required_with_all: "The :attribute field is required when :values are present." required_without: "The :attribute field is required when :values is not present." required_without_all: "The :attribute field is required when none of :values are present." - prohibited: "The :attribute field is prohibited." - prohibited_if: "The :attribute field is prohibited when :other is :value." - prohibited_unless: "The :attribute field is prohibited unless :other is in :values." - same: "The :attribute and :other must match." + same: "The :attribute field must match :other." size: - numeric: "The :attribute must be :size." - file: "The :attribute must be :size kilobytes." - string: "The :attribute must be :size characters." - array: "The :attribute must contain :size items." - starts_with: "The :attribute must start with one of the following: :values." - string: "The :attribute must be a string." - timezone: "The :attribute must be a valid zone." + array: "The :attribute field must contain :size items." + file: "The :attribute field must be :size kilobytes." + numeric: "The :attribute field must be :size." + string: "The :attribute field must be :size characters." + starts_with: "The :attribute field must start with one of the following: :values." + string: "The :attribute field must be a string." + timezone: "The :attribute field must be a valid timezone." unique: "The :attribute has already been taken." uploaded: "The :attribute failed to upload." - url: "The :attribute format is invalid." - uuid: "The :attribute must be a valid UUID." + uppercase: "The :attribute field must be uppercase." + url: "The :attribute field must be a valid URL." + ulid: "The :attribute field must be a valid ULID." + uuid: "The :attribute field must be a valid UUID." attributes: username: username diff --git a/framework/core/src/Api/ApiServiceProvider.php b/framework/core/src/Api/ApiServiceProvider.php index aeca45dbe..4fda34143 100644 --- a/framework/core/src/Api/ApiServiceProvider.php +++ b/framework/core/src/Api/ApiServiceProvider.php @@ -9,10 +9,7 @@ namespace Flarum\Api; -use Flarum\Api\Controller\AbstractSerializeController; -use Flarum\Api\Serializer\AbstractSerializer; -use Flarum\Api\Serializer\BasicDiscussionSerializer; -use Flarum\Api\Serializer\NotificationSerializer; +use Flarum\Api\Endpoint\EndpointInterface; use Flarum\Foundation\AbstractServiceProvider; use Flarum\Foundation\ErrorHandling\JsonApiFormatter; use Flarum\Foundation\ErrorHandling\Registry; @@ -24,6 +21,8 @@ use Flarum\Http\RouteHandlerFactory; use Flarum\Http\UrlGenerator; use Illuminate\Contracts\Container\Container; use Laminas\Stratigility\MiddlewarePipe; +use ReflectionClass; +use Tobyz\JsonApiServer\Endpoint\Endpoint; class ApiServiceProvider extends AbstractServiceProvider { @@ -33,9 +32,40 @@ class ApiServiceProvider extends AbstractServiceProvider return $url->addCollection('api', $container->make('flarum.api.routes'), 'api'); }); - $this->container->singleton('flarum.api.routes', function () { + $this->container->singleton('flarum.api.resources', function () { + return [ + Resource\ForumResource::class, + Resource\UserResource::class, + Resource\GroupResource::class, + Resource\PostResource::class, + Resource\DiscussionResource::class, + Resource\NotificationResource::class, + Resource\AccessTokenResource::class, + Resource\MailSettingResource::class, + Resource\ExtensionReadmeResource::class, + ]; + }); + + $this->container->singleton('flarum.api.resource_handler', function (Container $container) { + $resources = $this->container->make('flarum.api.resources'); + + $api = new JsonApi('/'); + $api->container($container); + + foreach ($resources as $resourceClass) { + /** @var \Flarum\Api\Resource\AbstractResource|\Flarum\Api\Resource\AbstractDatabaseResource $resource */ + $resource = $container->make($resourceClass); + $api->resource($resource->boot($api)); + } + + return $api; + }); + + $this->container->alias('flarum.api.resource_handler', JsonApi::class); + + $this->container->singleton('flarum.api.routes', function (Container $container) { $routes = new RouteCollection; - $this->populateRoutes($routes); + $this->populateRoutes($routes, $container); return $routes; }); @@ -68,7 +98,8 @@ class ApiServiceProvider extends AbstractServiceProvider 'flarum.api.route_resolver', 'flarum.api.check_for_maintenance', HttpMiddleware\CheckCsrfToken::class, - Middleware\ThrottleApi::class + Middleware\ThrottleApi::class, + HttpMiddleware\PopulateWithActor::class, ]; }); @@ -107,12 +138,6 @@ class ApiServiceProvider extends AbstractServiceProvider return $pipe; }); - $this->container->singleton('flarum.api.notification_serializers', function () { - return [ - 'discussionRenamed' => BasicDiscussionSerializer::class - ]; - }); - $this->container->singleton('flarum.api_client.exclude_middleware', function () { return [ HttpMiddleware\InjectActorReference::class, @@ -144,27 +169,47 @@ class ApiServiceProvider extends AbstractServiceProvider public function boot(Container $container): void { - $this->setNotificationSerializers(); - - AbstractSerializeController::setContainer($container); - - AbstractSerializer::setContainer($container); + // } - protected function setNotificationSerializers(): void + protected function populateRoutes(RouteCollection $routes, Container $container): void { - $serializers = $this->container->make('flarum.api.notification_serializers'); - - foreach ($serializers as $type => $serializer) { - NotificationSerializer::setSubjectSerializer($type, $serializer); - } - } - - protected function populateRoutes(RouteCollection $routes): void - { - $factory = $this->container->make(RouteHandlerFactory::class); + /** @var RouteHandlerFactory $factory */ + $factory = $container->make(RouteHandlerFactory::class); $callback = include __DIR__.'/routes.php'; $callback($routes, $factory); + + $resources = $this->container->make('flarum.api.resources'); + + foreach ($resources as $resourceClass) { + /** + * This is an empty shell instance, + * we only need it to get the endpoint routes and types. + * + * We avoid dependency injection here to avoid early resolution. + * + * @var \Flarum\Api\Resource\AbstractResource|\Flarum\Api\Resource\AbstractDatabaseResource $resource + */ + $resource = (new ReflectionClass($resourceClass))->newInstanceWithoutConstructor(); + + $type = $resource->type(); + + /** + * None of the injected dependencies should be directly used within + * the `endpoints` method. Encourage using callbacks. + * + * @var array $endpoints + */ + $endpoints = $resource->resolveEndpoints(true); + + foreach ($endpoints as $endpoint) { + $method = $endpoint->method; + $path = rtrim("/$type$endpoint->path", '/'); + $name = "$type.$endpoint->name"; + + $routes->addRoute($method, $path, $name, $factory->toApiResource($resource::class, $endpoint->name)); + } + } } } diff --git a/framework/core/src/Api/Context.php b/framework/core/src/Api/Context.php new file mode 100644 index 000000000..2d48124d6 --- /dev/null +++ b/framework/core/src/Api/Context.php @@ -0,0 +1,99 @@ +search = $search; + + return $new; + } + + public function withInternal(string $key, mixed $value): static + { + $new = clone $this; + $new->internal[$key] = $value; + + return $new; + } + + public function getSearchResults(): ?SearchResults + { + return $this->search; + } + + public function internal(string $key, mixed $default = null): mixed + { + return $this->internal[$key] ?? $default; + } + + public function getActor(): User + { + return RequestUtil::getActor($this->request); + } + + public function setParam(string $key, mixed $default = null): static + { + $this->parameters[$key] = $default; + + return $this; + } + + public function getParam(string $key, mixed $default = null): mixed + { + return $this->parameters[$key] ?? $default; + } + + public function creating(string|null $resource = null): bool + { + return $this->endpoint instanceof Endpoint\Create && (! $resource || is_a($this->collection, $resource)); + } + + public function updating(string|null $resource = null): bool + { + return $this->endpoint instanceof Endpoint\Update && (! $resource || is_a($this->collection, $resource)); + } + + public function deleting(string|null $resource = null): bool + { + return $this->endpoint instanceof Endpoint\Delete && (! $resource || is_a($this->collection, $resource)); + } + + public function showing(string|null $resource = null): bool + { + return $this->endpoint instanceof Endpoint\Show && (! $resource || is_a($this->collection, $resource)); + } + + public function listing(string|null $resource = null): bool + { + return $this->endpoint instanceof Endpoint\Index && (! $resource || is_a($this->collection, $resource)); + } +} diff --git a/framework/core/src/Api/Controller/AbstractCreateController.php b/framework/core/src/Api/Controller/AbstractCreateController.php deleted file mode 100644 index 00b9c774b..000000000 --- a/framework/core/src/Api/Controller/AbstractCreateController.php +++ /dev/null @@ -1,21 +0,0 @@ -withStatus(201); - } -} diff --git a/framework/core/src/Api/Controller/AbstractListController.php b/framework/core/src/Api/Controller/AbstractListController.php deleted file mode 100644 index d5aa7f740..000000000 --- a/framework/core/src/Api/Controller/AbstractListController.php +++ /dev/null @@ -1,46 +0,0 @@ -extractLimit($request); - $offset = $this->extractOffset($request); - - $document->addPaginationLinks( - $url, - $request->getQueryParams(), - $offset, - $limit, - $total, - ); - - $document->setMeta([ - 'total' => $total, - 'perPage' => $limit, - 'page' => $offset / $limit + 1, - ]); - } -} diff --git a/framework/core/src/Api/Controller/AbstractSerializeController.php b/framework/core/src/Api/Controller/AbstractSerializeController.php deleted file mode 100644 index 2c65d74f0..000000000 --- a/framework/core/src/Api/Controller/AbstractSerializeController.php +++ /dev/null @@ -1,432 +0,0 @@ -|null - */ - public ?string $serializer; - - /** - * The relationships that are included by default. - * - * @var string[] - */ - public array $include = []; - - /** - * The relationships that are available to be included. - * - * @var string[] - */ - public array $optionalInclude = []; - - /** - * The maximum number of records that can be requested. - */ - public int $maxLimit = 50; - - /** - * The number of records included by default. - */ - public int $limit = 20; - - /** - * The fields that are available to be sorted by. - * - * @var string[] - */ - public array $sortFields = []; - - /** - * The default sort field and order to use. - * - * @var array|null - */ - public ?array $sort = null; - - protected static Container $container; - - /** - * @var array, callable[]> - */ - protected static array $beforeDataCallbacks = []; - - /** - * @var array, callable[]> - */ - protected static array $beforeSerializationCallbacks = []; - - /** - * @var string[][] - */ - protected static array $loadRelations = []; - - /** - * @var array - */ - protected static array $loadRelationCallables = []; - - public function handle(ServerRequestInterface $request): ResponseInterface - { - $document = new Document; - - foreach (array_reverse(array_merge([static::class], class_parents($this))) as $class) { - if (isset(static::$beforeDataCallbacks[$class])) { - foreach (static::$beforeDataCallbacks[$class] as $callback) { - $callback($this); - } - } - } - - $data = $this->data($request, $document); - - foreach (array_reverse(array_merge([static::class], class_parents($this))) as $class) { - if (isset(static::$beforeSerializationCallbacks[$class])) { - foreach (static::$beforeSerializationCallbacks[$class] as $callback) { - $callback($this, $data, $request, $document); - } - } - } - - if (empty($this->serializer)) { - throw new InvalidArgumentException('Serializer required for controller: '.static::class); - } - - $serializer = static::$container->make($this->serializer); - $serializer->setRequest($request); - - $element = $this->createElement($data, $serializer) - ->with($this->extractInclude($request)) - ->fields($this->extractFields($request)); - - $document->setData($element); - - return new JsonApiResponse($document); - } - - /** - * Get the data to be serialized and assigned to the response document. - */ - abstract protected function data(ServerRequestInterface $request, Document $document): mixed; - - /** - * Create a PHP JSON-API Element for output in the document. - */ - abstract protected function createElement(mixed $data, SerializerInterface $serializer): ElementInterface; - - /** - * Returns the relations to load added by extenders. - * - * @return string[] - */ - protected function getRelationsToLoad(Collection $models): array - { - $addedRelations = []; - - foreach (array_reverse(array_merge([static::class], class_parents($this))) as $class) { - if (isset(static::$loadRelations[$class])) { - $addedRelations = array_merge($addedRelations, static::$loadRelations[$class]); - } - } - - return $addedRelations; - } - - /** - * Returns the relation callables to load added by extenders. - * - * @return array - */ - protected function getRelationCallablesToLoad(Collection $models): array - { - $addedRelationCallables = []; - - foreach (array_reverse(array_merge([static::class], class_parents($this))) as $class) { - if (isset(static::$loadRelationCallables[$class])) { - $addedRelationCallables = array_merge($addedRelationCallables, static::$loadRelationCallables[$class]); - } - } - - return $addedRelationCallables; - } - - /** - * Eager loads the required relationships. - */ - protected function loadRelations(Collection $models, array $relations, ServerRequestInterface $request = null): void - { - $addedRelations = $this->getRelationsToLoad($models); - $addedRelationCallables = $this->getRelationCallablesToLoad($models); - - foreach ($addedRelationCallables as $name => $relation) { - $addedRelations[] = $name; - } - - if (! empty($addedRelations)) { - usort($addedRelations, function ($a, $b) { - return substr_count($a, '.') - substr_count($b, '.'); - }); - - foreach ($addedRelations as $relation) { - if (str_contains($relation, '.')) { - $parentRelation = Str::beforeLast($relation, '.'); - - if (! in_array($parentRelation, $relations, true)) { - continue; - } - } - - $relations[] = $relation; - } - } - - if (! empty($relations)) { - $relations = array_unique($relations); - } - - $callableRelations = []; - $nonCallableRelations = []; - - foreach ($relations as $relation) { - if (isset($addedRelationCallables[$relation])) { - $load = $addedRelationCallables[$relation]; - - $callableRelations[$relation] = function ($query) use ($load, $request, $relations) { - $load($query, $request, $relations); - }; - } else { - $nonCallableRelations[] = $relation; - } - } - - if (! empty($callableRelations)) { - $models->loadMissing($callableRelations); - } - - if (! empty($nonCallableRelations)) { - $models->loadMissing($nonCallableRelations); - } - } - - /** - * @throws \Tobscure\JsonApi\Exception\InvalidParameterException - */ - protected function extractInclude(ServerRequestInterface $request): array - { - $available = array_merge($this->include, $this->optionalInclude); - - return $this->buildParameters($request)->getInclude($available) ?: $this->include; - } - - protected function extractFields(ServerRequestInterface $request): array - { - return $this->buildParameters($request)->getFields(); - } - - /** - * @throws \Tobscure\JsonApi\Exception\InvalidParameterException - */ - protected function extractSort(ServerRequestInterface $request): ?array - { - return $this->buildParameters($request)->getSort($this->sortFields) ?: $this->sort; - } - - /** - * @throws \Tobscure\JsonApi\Exception\InvalidParameterException - */ - protected function extractOffset(ServerRequestInterface $request): int - { - return (int) $this->buildParameters($request)->getOffset($this->extractLimit($request)) ?: 0; - } - - /** - * @throws \Tobscure\JsonApi\Exception\InvalidParameterException - */ - protected function extractLimit(ServerRequestInterface $request): int - { - return (int) $this->buildParameters($request)->getLimit($this->maxLimit) ?: $this->limit; - } - - protected function extractFilter(ServerRequestInterface $request): array - { - return $this->buildParameters($request)->getFilter() ?: []; - } - - protected function buildParameters(ServerRequestInterface $request): Parameters - { - return new Parameters($request->getQueryParams()); - } - - protected function sortIsDefault(ServerRequestInterface $request): bool - { - return ! Arr::get($request->getQueryParams(), 'sort'); - } - - /** - * Set the serializer that will serialize data for the endpoint. - */ - public function setSerializer(string $serializer): void - { - $this->serializer = $serializer; - } - - /** - * Include the given relationship by default. - */ - public function addInclude(array|string $name): void - { - $this->include = array_merge($this->include, (array) $name); - } - - /** - * Don't include the given relationship by default. - */ - public function removeInclude(array|string $name): void - { - $this->include = array_diff($this->include, (array) $name); - } - - /** - * Make the given relationship available for inclusion. - */ - public function addOptionalInclude(array|string $name): void - { - $this->optionalInclude = array_merge($this->optionalInclude, (array) $name); - } - - /** - * Don't allow the given relationship to be included. - */ - public function removeOptionalInclude(array|string $name): void - { - $this->optionalInclude = array_diff($this->optionalInclude, (array) $name); - } - - /** - * Set the default number of results. - */ - public function setLimit(int $limit): void - { - $this->limit = $limit; - } - - /** - * Set the maximum number of results. - */ - public function setMaxLimit(int $max): void - { - $this->maxLimit = $max; - } - - /** - * Allow sorting results by the given field. - */ - public function addSortField(array|string $field): void - { - $this->sortFields = array_merge($this->sortFields, (array) $field); - } - - /** - * Disallow sorting results by the given field. - */ - public function removeSortField(array|string $field): void - { - $this->sortFields = array_diff($this->sortFields, (array) $field); - } - - /** - * Set the default sort order for the results. - */ - public function setSort(array $sort): void - { - $this->sort = $sort; - } - - public static function getContainer(): Container - { - return static::$container; - } - - /** - * @internal - */ - public static function setContainer(Container $container): void - { - static::$container = $container; - } - - /** - * @internal - */ - public static function addDataPreparationCallback(string $controllerClass, callable $callback): void - { - if (! isset(static::$beforeDataCallbacks[$controllerClass])) { - static::$beforeDataCallbacks[$controllerClass] = []; - } - - static::$beforeDataCallbacks[$controllerClass][] = $callback; - } - - /** - * @internal - */ - public static function addSerializationPreparationCallback(string $controllerClass, callable $callback): void - { - if (! isset(static::$beforeSerializationCallbacks[$controllerClass])) { - static::$beforeSerializationCallbacks[$controllerClass] = []; - } - - static::$beforeSerializationCallbacks[$controllerClass][] = $callback; - } - - /** - * @internal - */ - public static function setLoadRelations(string $controllerClass, array $relations): void - { - if (! isset(static::$loadRelations[$controllerClass])) { - static::$loadRelations[$controllerClass] = []; - } - - static::$loadRelations[$controllerClass] = array_merge(static::$loadRelations[$controllerClass], $relations); - } - - /** - * @internal - */ - public static function setLoadRelationCallables(string $controllerClass, array $relations): void - { - if (! isset(static::$loadRelationCallables[$controllerClass])) { - static::$loadRelationCallables[$controllerClass] = []; - } - - static::$loadRelationCallables[$controllerClass] = array_merge(static::$loadRelationCallables[$controllerClass], $relations); - } -} diff --git a/framework/core/src/Api/Controller/AbstractShowController.php b/framework/core/src/Api/Controller/AbstractShowController.php deleted file mode 100644 index b87b5c594..000000000 --- a/framework/core/src/Api/Controller/AbstractShowController.php +++ /dev/null @@ -1,21 +0,0 @@ -assertRegistered(); - $actor->assertCan('createAccessToken'); - - $title = Arr::get($request->getParsedBody(), 'data.attributes.title'); - - $this->validation->make(compact('title'), [ - 'title' => 'required|string|max:255', - ])->validate(); - - $token = DeveloperAccessToken::generate($actor->id); - - $token->title = $title; - $token->last_activity_at = null; - - $token->save(); - - $this->events->dispatch(new DeveloperTokenCreated($token)); - - return $token; - } -} diff --git a/framework/core/src/Api/Controller/CreateDiscussionController.php b/framework/core/src/Api/Controller/CreateDiscussionController.php deleted file mode 100644 index fe5a28ee6..000000000 --- a/framework/core/src/Api/Controller/CreateDiscussionController.php +++ /dev/null @@ -1,62 +0,0 @@ -getAttribute('ipAddress'); - - $discussion = $this->bus->dispatch( - new StartDiscussion($actor, Arr::get($request->getParsedBody(), 'data', []), $ipAddress) - ); - - // After creating the discussion, we assume that the user has seen all - // the posts in the discussion; thus, we will mark the discussion - // as read if they are logged in. - if ($actor->exists) { - $this->bus->dispatch( - new ReadDiscussion($discussion->id, $actor, 1) - ); - } - - $this->loadRelations(new Collection([$discussion]), $this->extractInclude($request), $request); - - return $discussion; - } -} diff --git a/framework/core/src/Api/Controller/CreateGroupController.php b/framework/core/src/Api/Controller/CreateGroupController.php deleted file mode 100644 index 1d8ff85f3..000000000 --- a/framework/core/src/Api/Controller/CreateGroupController.php +++ /dev/null @@ -1,36 +0,0 @@ -bus->dispatch( - new CreateGroup(RequestUtil::getActor($request), Arr::get($request->getParsedBody(), 'data', [])) - ); - } -} diff --git a/framework/core/src/Api/Controller/CreatePostController.php b/framework/core/src/Api/Controller/CreatePostController.php deleted file mode 100644 index 717b5ab4d..000000000 --- a/framework/core/src/Api/Controller/CreatePostController.php +++ /dev/null @@ -1,66 +0,0 @@ -getParsedBody(), 'data', []); - $discussionId = (int) Arr::get($data, 'relationships.discussion.data.id'); - $ipAddress = $request->getAttribute('ipAddress'); - - /** @var CommentPost $post */ - $post = $this->bus->dispatch( - new PostReply($discussionId, $actor, $data, $ipAddress) - ); - - // After replying, we assume that the user has seen all of the posts - // in the discussion; thus, we will mark the discussion as read if - // they are logged in. - if ($actor->exists) { - $this->bus->dispatch( - new ReadDiscussion($discussionId, $actor, $post->number) - ); - } - - $discussion = $post->discussion; - $discussion->setRelation('posts', $discussion->posts()->whereVisibleTo($actor)->orderBy('created_at')->pluck('id')); - - $this->loadRelations($post->newCollection([$post]), $this->extractInclude($request), $request); - - return $post; - } -} diff --git a/framework/core/src/Api/Controller/CreateUserController.php b/framework/core/src/Api/Controller/CreateUserController.php deleted file mode 100644 index 03c091607..000000000 --- a/framework/core/src/Api/Controller/CreateUserController.php +++ /dev/null @@ -1,36 +0,0 @@ -bus->dispatch( - new RegisterUser(RequestUtil::getActor($request), Arr::get($request->getParsedBody(), 'data', [])) - ); - } -} diff --git a/framework/core/src/Api/Controller/DeleteAccessTokenController.php b/framework/core/src/Api/Controller/DeleteAccessTokenController.php deleted file mode 100644 index af2b224ec..000000000 --- a/framework/core/src/Api/Controller/DeleteAccessTokenController.php +++ /dev/null @@ -1,46 +0,0 @@ -getQueryParams(), 'id'); - - $actor->assertRegistered(); - - $token = AccessToken::query()->findOrFail($id); - - /** @var Session|null $session */ - $session = $request->getAttribute('session'); - - // Current session should only be terminated through logout. - if ($session && $token->token === $session->get('access_token')) { - throw new PermissionDeniedException(); - } - - // Don't give away the existence of the token. - if ($actor->cannot('revoke', $token)) { - throw new ModelNotFoundException(); - } - - $token->delete(); - } -} diff --git a/framework/core/src/Api/Controller/DeleteAvatarController.php b/framework/core/src/Api/Controller/DeleteAvatarController.php deleted file mode 100644 index 33baf6348..000000000 --- a/framework/core/src/Api/Controller/DeleteAvatarController.php +++ /dev/null @@ -1,35 +0,0 @@ -bus->dispatch( - new DeleteAvatar(Arr::get($request->getQueryParams(), 'id'), RequestUtil::getActor($request)) - ); - } -} diff --git a/framework/core/src/Api/Controller/DeleteDiscussionController.php b/framework/core/src/Api/Controller/DeleteDiscussionController.php deleted file mode 100644 index 99319e4bd..000000000 --- a/framework/core/src/Api/Controller/DeleteDiscussionController.php +++ /dev/null @@ -1,35 +0,0 @@ -getQueryParams(), 'id'); - $actor = RequestUtil::getActor($request); - $input = $request->getParsedBody(); - - $this->bus->dispatch( - new DeleteDiscussion($id, $actor, $input) - ); - } -} diff --git a/framework/core/src/Api/Controller/DeleteGroupController.php b/framework/core/src/Api/Controller/DeleteGroupController.php deleted file mode 100644 index 4acc0afea..000000000 --- a/framework/core/src/Api/Controller/DeleteGroupController.php +++ /dev/null @@ -1,31 +0,0 @@ -bus->dispatch( - new DeleteGroup(Arr::get($request->getQueryParams(), 'id'), RequestUtil::getActor($request)) - ); - } -} diff --git a/framework/core/src/Api/Controller/DeletePostController.php b/framework/core/src/Api/Controller/DeletePostController.php deleted file mode 100644 index 3e61c55f8..000000000 --- a/framework/core/src/Api/Controller/DeletePostController.php +++ /dev/null @@ -1,31 +0,0 @@ -bus->dispatch( - new DeletePost(Arr::get($request->getQueryParams(), 'id'), RequestUtil::getActor($request)) - ); - } -} diff --git a/framework/core/src/Api/Controller/DeleteUserController.php b/framework/core/src/Api/Controller/DeleteUserController.php deleted file mode 100644 index 07329b6bc..000000000 --- a/framework/core/src/Api/Controller/DeleteUserController.php +++ /dev/null @@ -1,31 +0,0 @@ -bus->dispatch( - new DeleteUser(Arr::get($request->getQueryParams(), 'id'), RequestUtil::getActor($request)) - ); - } -} diff --git a/framework/core/src/Api/Controller/ListAccessTokensController.php b/framework/core/src/Api/Controller/ListAccessTokensController.php deleted file mode 100644 index 98a3eeb14..000000000 --- a/framework/core/src/Api/Controller/ListAccessTokensController.php +++ /dev/null @@ -1,53 +0,0 @@ -assertRegistered(); - - $offset = $this->extractOffset($request); - $limit = $this->extractLimit($request); - $filter = $this->extractFilter($request); - - $tokens = $this->search->query(AccessToken::class, new SearchCriteria($actor, $filter, $limit, $offset)); - - $document->addPaginationLinks( - $this->url->to('api')->route('access-tokens.index'), - $request->getQueryParams(), - $offset, - $limit, - $tokens->areMoreResults() ? null : 0 - ); - - return $tokens->getResults(); - } -} diff --git a/framework/core/src/Api/Controller/ListDiscussionsController.php b/framework/core/src/Api/Controller/ListDiscussionsController.php deleted file mode 100644 index 208f656ae..000000000 --- a/framework/core/src/Api/Controller/ListDiscussionsController.php +++ /dev/null @@ -1,99 +0,0 @@ - 'desc']; - - public array $sortFields = ['lastPostedAt', 'commentCount', 'createdAt']; - - public function __construct( - protected SearchManager $search, - protected UrlGenerator $url - ) { - } - - protected function data(ServerRequestInterface $request, Document $document): iterable - { - $actor = RequestUtil::getActor($request); - $filters = $this->extractFilter($request); - $sort = $this->extractSort($request); - $sortIsDefault = $this->sortIsDefault($request); - - $limit = $this->extractLimit($request); - $offset = $this->extractOffset($request); - $include = array_merge($this->extractInclude($request), ['state']); - - $results = $this->search->query( - Discussion::class, - new SearchCriteria($actor, $filters, $limit, $offset, $sort, $sortIsDefault) - ); - - $this->addPaginationData( - $document, - $request, - $this->url->to('api')->route('discussions.index'), - $results->areMoreResults() ? null : 0 - ); - - Discussion::setStateUser($actor); - - // Eager load groups for use in the policies (isAdmin check) - if (in_array('mostRelevantPost.user', $include)) { - $include[] = 'mostRelevantPost.user.groups'; - - // If the first level of the relationship wasn't explicitly included, - // add it so the code below can look for it - if (! in_array('mostRelevantPost', $include)) { - $include[] = 'mostRelevantPost'; - } - } - - $results = $results->getResults(); - - $this->loadRelations($results, $include, $request); - - if ($relations = array_intersect($include, ['firstPost', 'lastPost', 'mostRelevantPost'])) { - foreach ($results as $discussion) { - foreach ($relations as $relation) { - if ($discussion->$relation) { - $discussion->$relation->discussion = $discussion; - } - } - } - } - - return $results; - } -} diff --git a/framework/core/src/Api/Controller/ListGroupsController.php b/framework/core/src/Api/Controller/ListGroupsController.php deleted file mode 100644 index 935964e3b..000000000 --- a/framework/core/src/Api/Controller/ListGroupsController.php +++ /dev/null @@ -1,65 +0,0 @@ -extractFilter($request); - $sort = $this->extractSort($request); - $sortIsDefault = $this->sortIsDefault($request); - - $limit = $this->extractLimit($request); - $offset = $this->extractOffset($request); - - $queryResults = $this->search->query( - Group::class, - new SearchCriteria($actor, $filters, $limit, $offset, $sort, $sortIsDefault) - ); - - $document->addPaginationLinks( - $this->url->to('api')->route('groups.index'), - $request->getQueryParams(), - $offset, - $limit, - $queryResults->areMoreResults() ? null : 0 - ); - - $results = $queryResults->getResults(); - - $this->loadRelations($results, [], $request); - - return $results; - } -} diff --git a/framework/core/src/Api/Controller/ListNotificationsController.php b/framework/core/src/Api/Controller/ListNotificationsController.php deleted file mode 100644 index 6481274d1..000000000 --- a/framework/core/src/Api/Controller/ListNotificationsController.php +++ /dev/null @@ -1,100 +0,0 @@ -assertRegistered(); - - $actor->markNotificationsAsRead()->save(); - - $limit = $this->extractLimit($request); - $offset = $this->extractOffset($request); - $include = $this->extractInclude($request); - - if (! in_array('subject', $include)) { - $include[] = 'subject'; - } - - $notifications = $this->notifications->findByUser($actor, $limit + 1, $offset); - - $this->loadRelations($notifications, array_diff($include, ['subject.discussion']), $request); - - $notifications = $notifications->all(); - - $areMoreResults = false; - - if (count($notifications) > $limit) { - array_pop($notifications); - $areMoreResults = true; - } - - $this->addPaginationData( - $document, - $request, - $this->url->to('api')->route('notifications.index'), - $areMoreResults ? null : 0 - ); - - if (in_array('subject.discussion', $include)) { - $this->loadSubjectDiscussions($notifications); - } - - return $notifications; - } - - /** - * @param \Flarum\Notification\Notification[] $notifications - */ - private function loadSubjectDiscussions(array $notifications): void - { - $ids = []; - - foreach ($notifications as $notification) { - if ($notification->subject && ($discussionId = $notification->subject->getAttribute('discussion_id'))) { - $ids[] = $discussionId; - } - } - - $discussions = Discussion::query()->find(array_unique($ids)); - - foreach ($notifications as $notification) { - if ($notification->subject && ($discussionId = $notification->subject->getAttribute('discussion_id'))) { - $notification->subject->setRelation('discussion', $discussions->find($discussionId)); - } - } - } -} diff --git a/framework/core/src/Api/Controller/ListPostsController.php b/framework/core/src/Api/Controller/ListPostsController.php deleted file mode 100644 index 4c419bb64..000000000 --- a/framework/core/src/Api/Controller/ListPostsController.php +++ /dev/null @@ -1,124 +0,0 @@ -extractFilter($request); - $sort = $this->extractSort($request); - $sortIsDefault = $this->sortIsDefault($request); - - $limit = $this->extractLimit($request); - $offset = $this->extractOffset($request); - $include = $this->extractInclude($request); - - $results = $this->search->query( - Post::class, - new SearchCriteria($actor, $filters, $limit, $offset, $sort, $sortIsDefault) - ); - - $document->addPaginationLinks( - $this->url->to('api')->route('posts.index'), - $request->getQueryParams(), - $offset, - $limit, - $results->areMoreResults() ? null : 0 - ); - - // Eager load discussion for use in the policies, - // eager loading does not affect the JSON response, - // the response only includes relations included in the request. - if (! in_array('discussion', $include)) { - $include[] = 'discussion'; - } - - if (in_array('user', $include)) { - $include[] = 'user.groups'; - } - - $results = $results->getResults(); - - $this->loadRelations($results, $include, $request); - - return $results; - } - - /** - * @link https://github.com/flarum/framework/pull/3506 - */ - protected function extractSort(ServerRequestInterface $request): ?array - { - $sort = []; - - foreach ((parent::extractSort($request) ?: []) as $field => $direction) { - $sort["posts.$field"] = $direction; - } - - return $sort; - } - - protected function extractOffset(ServerRequestInterface $request): int - { - $actor = RequestUtil::getActor($request); - $queryParams = $request->getQueryParams(); - $sort = $this->extractSort($request); - $limit = $this->extractLimit($request); - $filter = $this->extractFilter($request); - - if (($near = Arr::get($queryParams, 'page.near')) > 1) { - if (count($filter) > 1 || ! isset($filter['discussion']) || $sort) { - throw new InvalidParameterException( - 'You can only use page[near] with filter[discussion] and the default sort order' - ); - } - - $offset = $this->posts->getIndexForNumber((int) $filter['discussion'], $near, $actor); - - return max(0, $offset - $limit / 2); - } - - return parent::extractOffset($request); - } -} diff --git a/framework/core/src/Api/Controller/ListUsersController.php b/framework/core/src/Api/Controller/ListUsersController.php deleted file mode 100644 index 0547afc71..000000000 --- a/framework/core/src/Api/Controller/ListUsersController.php +++ /dev/null @@ -1,81 +0,0 @@ -assertCan('searchUsers'); - - if (! $actor->hasPermission('user.viewLastSeenAt')) { - // If a user cannot see everyone's last online date, we prevent them from sorting by it - // Otherwise this sort field would defeat the privacy setting discloseOnline - // We use remove instead of add so that extensions can still completely disable the sort using the extender - $this->removeSortField('lastSeenAt'); - } - - $filters = $this->extractFilter($request); - $sort = $this->extractSort($request); - $sortIsDefault = $this->sortIsDefault($request); - - $limit = $this->extractLimit($request); - $offset = $this->extractOffset($request); - $include = $this->extractInclude($request); - - $results = $this->search->query( - User::class, - new SearchCriteria($actor, $filters, $limit, $offset, $sort, $sortIsDefault) - ); - - $document->addPaginationLinks( - $this->url->to('api')->route('users.index'), - $request->getQueryParams(), - $offset, - $limit, - $results->areMoreResults() ? null : 0 - ); - - $results = $results->getResults(); - - $this->loadRelations($results, $include, $request); - - return $results; - } -} diff --git a/framework/core/src/Api/Controller/ShowDiscussionController.php b/framework/core/src/Api/Controller/ShowDiscussionController.php deleted file mode 100644 index cabfe79cb..000000000 --- a/framework/core/src/Api/Controller/ShowDiscussionController.php +++ /dev/null @@ -1,178 +0,0 @@ -getQueryParams(), 'id'); - $actor = RequestUtil::getActor($request); - $include = $this->extractInclude($request); - - if (Arr::get($request->getQueryParams(), 'bySlug', false)) { - $discussion = $this->slugManager->forResource(Discussion::class)->fromSlug($discussionId, $actor); - } else { - $discussion = $this->discussions->findOrFail($discussionId, $actor); - } - - // If posts is included or a sub relation of post is included. - if (in_array('posts', $include) || Str::contains(implode(',', $include), 'posts.')) { - $postRelationships = $this->getPostRelationships($include); - - $this->includePosts($discussion, $request, $postRelationships); - } - - $this->loadRelations(new Collection([$discussion]), array_filter($include, function ($relationship) { - return ! Str::startsWith($relationship, 'posts'); - }), $request); - - return $discussion; - } - - private function includePosts(Discussion $discussion, ServerRequestInterface $request, array $include): void - { - $actor = RequestUtil::getActor($request); - $limit = $this->extractLimit($request); - $offset = $this->getPostsOffset($request, $discussion, $limit); - - $allPosts = $this->loadPostIds($discussion, $actor); - $loadedPosts = $this->loadPosts($discussion, $actor, $offset, $limit, $include, $request); - - array_splice($allPosts, $offset, $limit, $loadedPosts); - - $discussion->setRelation('posts', (new Post)->newCollection($allPosts)); - } - - private function loadPostIds(Discussion $discussion, User $actor): array - { - return $discussion->posts()->whereVisibleTo($actor)->orderBy('number')->pluck('id')->all(); - } - - private function getPostRelationships(array $include): array - { - $prefixLength = strlen($prefix = 'posts.'); - $relationships = []; - - foreach ($include as $relationship) { - if (substr($relationship, 0, $prefixLength) === $prefix) { - $relationships[] = substr($relationship, $prefixLength); - } - } - - return $relationships; - } - - private function getPostsOffset(ServerRequestInterface $request, Discussion $discussion, int $limit): int - { - $queryParams = $request->getQueryParams(); - $actor = RequestUtil::getActor($request); - - if (($near = Arr::get($queryParams, 'page.near')) > 1) { - $offset = $this->posts->getIndexForNumber($discussion->id, $near, $actor); - $offset = max(0, $offset - $limit / 2); - } else { - $offset = $this->extractOffset($request); - } - - return $offset; - } - - private function loadPosts(Discussion $discussion, User $actor, int $offset, int $limit, array $include, ServerRequestInterface $request): array - { - /** @var Builder $query */ - $query = $discussion->posts()->whereVisibleTo($actor); - - $query->orderBy('number')->skip($offset)->take($limit); - - $posts = $query->get(); - - /** @var Post $post */ - foreach ($posts as $post) { - $post->setRelation('discussion', $discussion); - } - - $this->loadRelations($posts, $include, $request); - - return $posts->all(); - } - - protected function getRelationsToLoad(Collection $models): array - { - $addedRelations = parent::getRelationsToLoad($models); - - if ($models->first() instanceof Discussion) { - return $addedRelations; - } - - return $this->getPostRelationships($addedRelations); - } - - protected function getRelationCallablesToLoad(Collection $models): array - { - $addedCallableRelations = parent::getRelationCallablesToLoad($models); - - if ($models->first() instanceof Discussion) { - return $addedCallableRelations; - } - - $postCallableRelationships = $this->getPostRelationships(array_keys($addedCallableRelations)); - - $relationCallables = array_intersect_key($addedCallableRelations, array_flip(array_map(function ($relation) { - return "posts.$relation"; - }, $postCallableRelationships))); - - // remove posts. prefix from keys - return array_combine(array_map(function ($relation) { - return substr($relation, 6); - }, array_keys($relationCallables)), array_values($relationCallables)); - } -} diff --git a/framework/core/src/Api/Controller/ShowExtensionReadmeController.php b/framework/core/src/Api/Controller/ShowExtensionReadmeController.php deleted file mode 100644 index 034d6d0e3..000000000 --- a/framework/core/src/Api/Controller/ShowExtensionReadmeController.php +++ /dev/null @@ -1,37 +0,0 @@ -getQueryParams(), 'name'); - - RequestUtil::getActor($request)->assertAdmin(); - - return $this->extensions->getExtension($extensionName); - } -} diff --git a/framework/core/src/Api/Controller/ShowForumController.php b/framework/core/src/Api/Controller/ShowForumController.php index f0c005513..c30e11286 100644 --- a/framework/core/src/Api/Controller/ShowForumController.php +++ b/framework/core/src/Api/Controller/ShowForumController.php @@ -9,25 +9,24 @@ namespace Flarum\Api\Controller; -use Flarum\Api\Serializer\ForumSerializer; -use Flarum\Group\Group; -use Flarum\Http\RequestUtil; +use Flarum\Api\JsonApi; +use Flarum\Api\Resource\ForumResource; +use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -use Tobscure\JsonApi\Document; +use Psr\Http\Server\RequestHandlerInterface; -class ShowForumController extends AbstractShowController +class ShowForumController implements RequestHandlerInterface { - public ?string $serializer = ForumSerializer::class; + public function __construct( + protected JsonApi $api + ) { + } - public array $include = ['groups', 'actor', 'actor.groups']; - - protected function data(ServerRequestInterface $request, Document $document): array + public function handle(ServerRequestInterface $request): ResponseInterface { - $actor = RequestUtil::getActor($request); - - return [ - 'groups' => Group::whereVisibleTo($actor)->get(), - 'actor' => $actor->isGuest() ? null : $actor - ]; + return $this->api + ->forResource(ForumResource::class) + ->forEndpoint('show') + ->handle($request); } } diff --git a/framework/core/src/Api/Controller/ShowGroupController.php b/framework/core/src/Api/Controller/ShowGroupController.php deleted file mode 100644 index 6787be3cc..000000000 --- a/framework/core/src/Api/Controller/ShowGroupController.php +++ /dev/null @@ -1,38 +0,0 @@ -getQueryParams(), 'id'); - $actor = RequestUtil::getActor($request); - - $group = $this->groups->findOrFail($id, $actor); - - return $group; - } -} diff --git a/framework/core/src/Api/Controller/ShowMailSettingsController.php b/framework/core/src/Api/Controller/ShowMailSettingsController.php index 90a4dc654..3a90f8adf 100644 --- a/framework/core/src/Api/Controller/ShowMailSettingsController.php +++ b/framework/core/src/Api/Controller/ShowMailSettingsController.php @@ -9,36 +9,24 @@ namespace Flarum\Api\Controller; -use Flarum\Api\Serializer\MailSettingsSerializer; -use Flarum\Http\RequestUtil; -use Flarum\Settings\SettingsRepositoryInterface; -use Illuminate\Contracts\Validation\Factory; +use Flarum\Api\JsonApi; +use Flarum\Api\Resource\MailSettingResource; +use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -use Tobscure\JsonApi\Document; +use Psr\Http\Server\RequestHandlerInterface; -class ShowMailSettingsController extends AbstractShowController +class ShowMailSettingsController implements RequestHandlerInterface { - public ?string $serializer = MailSettingsSerializer::class; + public function __construct( + protected JsonApi $api + ) { + } - protected function data(ServerRequestInterface $request, Document $document): array + public function handle(ServerRequestInterface $request): ResponseInterface { - RequestUtil::getActor($request)->assertAdmin(); - - $drivers = array_map(function ($driver) { - return self::$container->make($driver); - }, self::$container->make('mail.supported_drivers')); - - $settings = self::$container->make(SettingsRepositoryInterface::class); - $configured = self::$container->make('flarum.mail.configured_driver'); - $actual = self::$container->make('mail.driver'); - $validator = self::$container->make(Factory::class); - - $errors = $configured->validate($settings, $validator); - - return [ - 'drivers' => $drivers, - 'sending' => $actual->canSend(), - 'errors' => $errors, - ]; + return $this->api + ->forResource(MailSettingResource::class) + ->forEndpoint('show') + ->handle($request); } } diff --git a/framework/core/src/Api/Controller/ShowPostController.php b/framework/core/src/Api/Controller/ShowPostController.php deleted file mode 100644 index 25f7715b0..000000000 --- a/framework/core/src/Api/Controller/ShowPostController.php +++ /dev/null @@ -1,48 +0,0 @@ -posts->findOrFail(Arr::get($request->getQueryParams(), 'id'), RequestUtil::getActor($request)); - - $include = $this->extractInclude($request); - - $this->loadRelations(new Collection([$post]), $include, $request); - - return $post; - } -} diff --git a/framework/core/src/Api/Controller/ShowUserController.php b/framework/core/src/Api/Controller/ShowUserController.php deleted file mode 100644 index 779d5e5f0..000000000 --- a/framework/core/src/Api/Controller/ShowUserController.php +++ /dev/null @@ -1,51 +0,0 @@ -getQueryParams(), 'id'); - $actor = RequestUtil::getActor($request); - - if (Arr::get($request->getQueryParams(), 'bySlug', false)) { - $user = $this->slugManager->forResource(User::class)->fromSlug($id, $actor); - } else { - $user = $this->users->findOrFail($id, $actor); - } - - if ($actor->id === $user->id) { - $this->serializer = CurrentUserSerializer::class; - } - - return $user; - } -} diff --git a/framework/core/src/Api/Controller/UpdateDiscussionController.php b/framework/core/src/Api/Controller/UpdateDiscussionController.php deleted file mode 100644 index 24516159c..000000000 --- a/framework/core/src/Api/Controller/UpdateDiscussionController.php +++ /dev/null @@ -1,74 +0,0 @@ -getQueryParams(), 'id'); - $data = Arr::get($request->getParsedBody(), 'data', []); - - /** @var Discussion $discussion */ - $discussion = $this->bus->dispatch( - new EditDiscussion($discussionId, $actor, $data) - ); - - // TODO: Refactor the ReadDiscussion (state) command into EditDiscussion? - // That's what extensions will do anyway. - if ($readNumber = Arr::get($data, 'attributes.lastReadPostNumber')) { - $state = $this->bus->dispatch( - new ReadDiscussion($discussionId, $actor, $readNumber) - ); - - $discussion = $state->discussion; - } - - if ($posts = $discussion->getModifiedPosts()) { - /** @var Collection $posts */ - $posts = (new Collection($posts))->load('discussion', 'user'); - $discussionPosts = $discussion->posts()->whereVisibleTo($actor)->oldest()->pluck('id')->all(); - - foreach ($discussionPosts as &$id) { - foreach ($posts as $post) { - if ($id == $post->id) { - $id = $post; - } - } - } - - $discussion->setRelation('posts', $discussionPosts); - - $this->include = array_merge($this->include, ['posts', 'posts.discussion', 'posts.user']); - } - - return $discussion; - } -} diff --git a/framework/core/src/Api/Controller/UpdateGroupController.php b/framework/core/src/Api/Controller/UpdateGroupController.php deleted file mode 100644 index aa59e8970..000000000 --- a/framework/core/src/Api/Controller/UpdateGroupController.php +++ /dev/null @@ -1,40 +0,0 @@ -getQueryParams(), 'id'); - $actor = RequestUtil::getActor($request); - $data = Arr::get($request->getParsedBody(), 'data', []); - - return $this->bus->dispatch( - new EditGroup($id, $actor, $data) - ); - } -} diff --git a/framework/core/src/Api/Controller/UpdateNotificationController.php b/framework/core/src/Api/Controller/UpdateNotificationController.php deleted file mode 100644 index 153f37ced..000000000 --- a/framework/core/src/Api/Controller/UpdateNotificationController.php +++ /dev/null @@ -1,39 +0,0 @@ -getQueryParams(), 'id'); - $actor = RequestUtil::getActor($request); - - return $this->bus->dispatch( - new ReadNotification($id, $actor) - ); - } -} diff --git a/framework/core/src/Api/Controller/UpdatePostController.php b/framework/core/src/Api/Controller/UpdatePostController.php deleted file mode 100644 index 0935836b9..000000000 --- a/framework/core/src/Api/Controller/UpdatePostController.php +++ /dev/null @@ -1,49 +0,0 @@ -getQueryParams(), 'id'); - $actor = RequestUtil::getActor($request); - $data = Arr::get($request->getParsedBody(), 'data', []); - - $post = $this->bus->dispatch( - new EditPost($id, $actor, $data) - ); - - $this->loadRelations($post->newCollection([$post]), $this->extractInclude($request), $request); - - return $post; - } -} diff --git a/framework/core/src/Api/Controller/UpdateUserController.php b/framework/core/src/Api/Controller/UpdateUserController.php deleted file mode 100644 index 1651880fc..000000000 --- a/framework/core/src/Api/Controller/UpdateUserController.php +++ /dev/null @@ -1,58 +0,0 @@ -getQueryParams(), 'id'); - $actor = RequestUtil::getActor($request); - $data = Arr::get($request->getParsedBody(), 'data', []); - - if ($actor->id == $id) { - $this->serializer = CurrentUserSerializer::class; - } - - // Require the user's current password if they are attempting to change - // their own email address. - if (isset($data['attributes']['email']) && $actor->id == $id) { - $password = (string) Arr::get($request->getParsedBody(), 'meta.password'); - - if (! $actor->checkPassword($password)) { - throw new NotAuthenticatedException; - } - } - - return $this->bus->dispatch( - new EditUser($id, $actor, $data) - ); - } -} diff --git a/framework/core/src/Api/Controller/UploadAvatarController.php b/framework/core/src/Api/Controller/UploadAvatarController.php deleted file mode 100644 index 015fc3e08..000000000 --- a/framework/core/src/Api/Controller/UploadAvatarController.php +++ /dev/null @@ -1,40 +0,0 @@ -getQueryParams(), 'id'); - $actor = RequestUtil::getActor($request); - $file = Arr::get($request->getUploadedFiles(), 'avatar'); - - return $this->bus->dispatch( - new UploadAvatar($id, $file, $actor) - ); - } -} diff --git a/framework/core/src/Api/Controller/UploadFaviconController.php b/framework/core/src/Api/Controller/UploadFaviconController.php index c251a64c0..35c3d1ad1 100644 --- a/framework/core/src/Api/Controller/UploadFaviconController.php +++ b/framework/core/src/Api/Controller/UploadFaviconController.php @@ -9,6 +9,7 @@ namespace Flarum\Api\Controller; +use Flarum\Api\JsonApi; use Flarum\Foundation\ValidationException; use Flarum\Locale\TranslatorInterface; use Flarum\Settings\SettingsRepositoryInterface; @@ -23,12 +24,13 @@ class UploadFaviconController extends UploadImageController protected string $filenamePrefix = 'favicon'; public function __construct( + JsonApi $api, SettingsRepositoryInterface $settings, Factory $filesystemFactory, protected TranslatorInterface $translator, protected ImageManager $imageManager ) { - parent::__construct($settings, $filesystemFactory); + parent::__construct($api, $settings, $filesystemFactory); } protected function makeImage(UploadedFileInterface $file): EncodedImageInterface diff --git a/framework/core/src/Api/Controller/UploadImageController.php b/framework/core/src/Api/Controller/UploadImageController.php index cb1a82268..ac760f164 100644 --- a/framework/core/src/Api/Controller/UploadImageController.php +++ b/framework/core/src/Api/Controller/UploadImageController.php @@ -9,6 +9,7 @@ namespace Flarum\Api\Controller; +use Flarum\Api\JsonApi; use Flarum\Http\RequestUtil; use Flarum\Settings\SettingsRepositoryInterface; use Illuminate\Contracts\Filesystem\Factory; @@ -16,9 +17,9 @@ use Illuminate\Contracts\Filesystem\Filesystem; use Illuminate\Support\Arr; use Illuminate\Support\Str; use Intervention\Image\Interfaces\EncodedImageInterface; +use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\UploadedFileInterface; -use Tobscure\JsonApi\Document; abstract class UploadImageController extends ShowForumController { @@ -28,13 +29,16 @@ abstract class UploadImageController extends ShowForumController protected string $filenamePrefix = ''; public function __construct( + JsonApi $api, protected SettingsRepositoryInterface $settings, Factory $filesystemFactory ) { + parent::__construct($api); + $this->uploadDir = $filesystemFactory->disk('flarum-assets'); } - public function data(ServerRequestInterface $request, Document $document): array + public function handle(ServerRequestInterface $request): ResponseInterface { RequestUtil::getActor($request)->assertAdmin(); @@ -52,7 +56,7 @@ abstract class UploadImageController extends ShowForumController $this->settings->set($this->filePathSettingKey, $uploadName); - return parent::data($request, $document); + return parent::handle($request); } abstract protected function makeImage(UploadedFileInterface $file): EncodedImageInterface; diff --git a/framework/core/src/Api/Controller/UploadLogoController.php b/framework/core/src/Api/Controller/UploadLogoController.php index 1ca056bc6..396e14acb 100644 --- a/framework/core/src/Api/Controller/UploadLogoController.php +++ b/framework/core/src/Api/Controller/UploadLogoController.php @@ -9,6 +9,7 @@ namespace Flarum\Api\Controller; +use Flarum\Api\JsonApi; use Flarum\Settings\SettingsRepositoryInterface; use Illuminate\Contracts\Filesystem\Factory; use Intervention\Image\ImageManager; @@ -21,11 +22,12 @@ class UploadLogoController extends UploadImageController protected string $filenamePrefix = 'logo'; public function __construct( + JsonApi $api, SettingsRepositoryInterface $settings, Factory $filesystemFactory, protected ImageManager $imageManager ) { - parent::__construct($settings, $filesystemFactory); + parent::__construct($api, $settings, $filesystemFactory); } protected function makeImage(UploadedFileInterface $file): EncodedImageInterface diff --git a/framework/core/src/Api/Endpoint/Concerns/ExtractsListingParams.php b/framework/core/src/Api/Endpoint/Concerns/ExtractsListingParams.php new file mode 100644 index 000000000..1b7a44b83 --- /dev/null +++ b/framework/core/src/Api/Endpoint/Concerns/ExtractsListingParams.php @@ -0,0 +1,126 @@ +limit = $limit; + + return $this; + } + + public function maxLimit(int $maxLimit): static + { + $this->maxLimit = $maxLimit; + + return $this; + } + + public function extractFilter(Closure $callback): self + { + $this->extractFilterCallback = $callback; + + return $this; + } + + public function extractSort(Closure $callback): self + { + $this->extractSortCallback = $callback; + + return $this; + } + + public function extractLimit(Closure $callback): self + { + $this->extractLimitCallback = $callback; + + return $this; + } + + public function extractOffset(Closure $callback): self + { + $this->extractOffsetCallback = $callback; + + return $this; + } + + public function extractFilterValue(Context $context, array $defaultExtracts): array + { + return $this->extractFilterCallback + ? ($this->extractFilterCallback)($context, $defaultExtracts) + : $defaultExtracts['filter']; + } + + public function extractSortValue(Context $context, array $defaultExtracts): ?array + { + $visibleSorts = $this->getAvailableSorts($context); + + return $this->extractSortCallback + ? ($this->extractSortCallback)($context, $defaultExtracts, $visibleSorts) + : $defaultExtracts['sort']; + } + + public function extractLimitValue(Context $context, array $defaultExtracts): ?int + { + return $this->extractLimitCallback + ? ($this->extractLimitCallback)($context, $defaultExtracts) + : $defaultExtracts['limit']; + } + + public function extractOffsetValue(Context $context, array $defaultExtracts): int + { + return $this->extractOffsetCallback + ? ($this->extractOffsetCallback)($context, $defaultExtracts) + : $defaultExtracts['offset']; + } + + public function defaultExtracts(Context $context): array + { + return [ + 'filter' => RequestUtil::extractFilter($context->request), + 'sort' => RequestUtil::extractSort($context->request, $this->defaultSort, $this->getAvailableSorts($context)), + 'limit' => $limit = (RequestUtil::extractLimit($context->request, $this->limit, $this->maxLimit) ?? null), + 'offset' => RequestUtil::extractOffset($context->request, $limit), + ]; + } + + public function getAvailableSorts(Context $context): array + { + if (! $context->collection instanceof AbstractResource) { + return []; + } + + $asc = collect($context->collection->resolveSorts()) + ->filter(fn (Sort $field) => $field->isVisible($context)) + ->pluck('name') + ->toArray(); + + $desc = array_map(fn ($field) => "-$field", $asc); + + return array_merge($asc, $desc); + } +} diff --git a/framework/core/src/Api/Endpoint/Concerns/HasAuthorization.php b/framework/core/src/Api/Endpoint/Concerns/HasAuthorization.php new file mode 100644 index 000000000..aa4abfa0b --- /dev/null +++ b/framework/core/src/Api/Endpoint/Concerns/HasAuthorization.php @@ -0,0 +1,91 @@ +authenticated = $condition; + + return $this; + } + + public function can(null|string|Closure $ability): self + { + $this->ability = $ability; + + return $this; + } + + public function admin(bool $admin = true): self + { + $this->admin = $admin; + + return $this; + } + + public function getAuthenticated(Context $context): bool + { + if (is_bool($this->authenticated)) { + return $this->authenticated; + } + + return (bool) (isset($context->model) + ? ($this->authenticated)($context->model, $context) + : ($this->authenticated)($context)); + } + + public function getAuthorized(Context $context): string|null + { + if (! is_callable($this->ability)) { + return $this->ability; + } + + return isset($context->model) + ? ($this->ability)($context->model, $context) + : ($this->ability)($context); + } + + /** + * @throws NotAuthenticatedException + * @throws PermissionDeniedException + */ + public function isVisible(Context $context): bool + { + $actor = RequestUtil::getActor($context->request); + + if ($this->getAuthenticated($context)) { + $actor->assertRegistered(); + } + + if ($this->admin) { + $actor->assertAdmin(); + } + + if ($ability = $this->getAuthorized($context)) { + $actor->assertCan($ability, $context->model); + } + + return parent::isVisible($context); + } +} diff --git a/framework/core/src/Api/Endpoint/Concerns/HasCustomHooks.php b/framework/core/src/Api/Endpoint/Concerns/HasCustomHooks.php new file mode 100644 index 000000000..e866f612a --- /dev/null +++ b/framework/core/src/Api/Endpoint/Concerns/HasCustomHooks.php @@ -0,0 +1,25 @@ +api->getContainer()); + } + + return $callable; + } +} diff --git a/framework/core/src/Api/Endpoint/Create.php b/framework/core/src/Api/Endpoint/Create.php new file mode 100644 index 000000000..443546a72 --- /dev/null +++ b/framework/core/src/Api/Endpoint/Create.php @@ -0,0 +1,25 @@ +query(function ($query, ?Pagination $pagination, Context $context): Context { + // This model has a searcher API, so we'll use that instead of the default. + // The searcher API allows swapping the default search engine for a custom one. + $search = $context->api->getContainer()->make(SearchManager::class); + $modelClass = $query->getModel()::class; + + if ($query instanceof Builder && $search->searchable($modelClass)) { + $actor = $context->getActor(); + + $extracts = $this->defaultExtracts($context); + + $filters = $this->extractFilterValue($context, $extracts); + $sort = $this->extractSortValue($context, $extracts); + $limit = $this->extractLimitValue($context, $extracts); + $offset = $this->extractOffsetValue($context, $extracts); + + $sortIsDefault = ! $context->queryParam('sort'); + + $results = $search->query( + $modelClass, + new SearchCriteria($actor, $filters, $limit, $offset, $sort, $sortIsDefault), + ); + + $context = $context->withSearchResults($results); + } + // If the model doesn't have a searcher API, we'll just use the default logic. + else { + $context = $context->withQuery($query); + + $this->applySorts($query, $context); + $this->applyFilters($query, $context); + + $pagination?->apply($query); + } + + return $context; + }); + } + + public function paginate(int $defaultLimit = 20, int $maxLimit = 50): static + { + $this->limit = $defaultLimit; + $this->maxLimit = $maxLimit; + + $this->paginationResolver = fn (Context $context) => new OffsetPagination( + $context, + $this->limit, + $this->maxLimit, + ); + + return $this; + } +} diff --git a/framework/core/src/Api/Endpoint/Show.php b/framework/core/src/Api/Endpoint/Show.php new file mode 100644 index 000000000..a124ee5f5 --- /dev/null +++ b/framework/core/src/Api/Endpoint/Show.php @@ -0,0 +1,27 @@ +resourceClass = $resourceClass; + + return $this; + } + + public function forEndpoint(string $endpointName): self + { + $this->endpointName = $endpointName; + + return $this; + } + + protected function makeContext(Request $request): Context + { + if (! $this->endpointName || ! $this->resourceClass || ! class_exists($this->resourceClass)) { + throw new BadRequestException('No resource or endpoint specified'); + } + + $collection = $this->getCollection($this->resourceClass); + + return (new Context($this, $request)) + ->withCollection($collection) + ->withEndpoint($this->findEndpoint($collection)); + } + + protected function findEndpoint(?Collection $collection): Endpoint&EndpointInterface + { + /** @var Endpoint&EndpointInterface $endpoint */ + foreach ($collection->resolveEndpoints() as $endpoint) { + if ($endpoint->name === $this->endpointName) { + return $endpoint; + } + } + + throw new BadRequestException('Invalid endpoint specified'); + } + + public function withRequest(Request $request): self + { + $this->baseRequest = $request; + + return $this; + } + + public function handle(Request $request): Response + { + $context = $this->makeContext($request); + + return $context->endpoint->handle($context); + } + + public function process(array $body, array $internal = [], array $options = []): mixed + { + $request = $this->baseRequest ?? ServerRequestFactory::fromGlobals(); + + if (! empty($options['actor'])) { + $request = RequestUtil::withActor($request, $options['actor']); + } + + $resource = $this->getCollection($this->resourceClass); + + $request = $request + ->withParsedBody([ + ...$body, + 'data' => [ + ...($request->getParsedBody()['data'] ?? []), + ...($body['data'] ?? []), + 'type' => $resource instanceof Resource + ? $resource->type() + : $resource->name(), + ], + ]); + + $context = $this->makeContext($request) + ->withModelId($body['data']['id'] ?? null); + + foreach ($internal as $key => $value) { + $context = $context->withInternal($key, $value); + } + + $context = $context->withRequest( + $request + ->withMethod($context->endpoint->method) + ->withUri(new Uri($context->endpoint->path)) + ); + + return $context->endpoint->process($context); + } + + public function validateQueryParameters(Request $request): void + { + foreach ($request->getQueryParams() as $key => $value) { + if ( + ! preg_match('/[^a-z]/', $key) && + ! in_array($key, ['include', 'fields', 'filter', 'page', 'sort']) + ) { + throw (new BadRequestException("Invalid query parameter: $key"))->setSource([ + 'parameter' => $key, + ]); + } + } + } + + public function typeForModel(string $modelClass): ?string + { + foreach ($this->resources as $resource) { + if ($resource instanceof AbstractDatabaseResource && $resource->model() === $modelClass) { + return $resource->type(); + } + } + + return null; + } + + public function typesForModels(array $modelClasses): array + { + return array_values(array_unique(array_map(fn ($modelClass) => $this->typeForModel($modelClass), $modelClasses))); + } + + public function container(Container $container): static + { + $this->container = $container; + + return $this; + } + + public function getContainer(): ?Container + { + return $this->container; + } +} diff --git a/framework/core/src/Api/JsonApiResponse.php b/framework/core/src/Api/JsonApiResponse.php index b05a5dcb6..6ef793d6a 100644 --- a/framework/core/src/Api/JsonApiResponse.php +++ b/framework/core/src/Api/JsonApiResponse.php @@ -10,11 +10,10 @@ namespace Flarum\Api; use Laminas\Diactoros\Response\JsonResponse; -use Tobscure\JsonApi\Document; class JsonApiResponse extends JsonResponse { - public function __construct(Document $document, $status = 200, array $headers = [], $encodingOptions = 15) + public function __construct(array $document, $status = 200, array $headers = [], $encodingOptions = 15) { $headers['content-type'] = 'application/vnd.api+json'; diff --git a/framework/core/src/Api/Resource/AbstractDatabaseResource.php b/framework/core/src/Api/Resource/AbstractDatabaseResource.php new file mode 100644 index 000000000..c65cf4a58 --- /dev/null +++ b/framework/core/src/Api/Resource/AbstractDatabaseResource.php @@ -0,0 +1,205 @@ + + */ +abstract class AbstractDatabaseResource extends BaseResource +{ + use Bootable; + use Extendable; + use HasSortMap; + use DispatchEventsTrait { + dispatchEventsFor as traitDispatchEventsFor; + } + + abstract public function model(): string; + + /** @inheritDoc */ + public function newModel(Context $context): object + { + return new ($this->model()); + } + + public function resource(object $model, Context $context): ?string + { + $baseModel = $this->model(); + + if ($model instanceof $baseModel) { + return $this->type(); + } + + return null; + } + + public function filters(): array + { + throw new RuntimeException('Not supported in Flarum, please use a model searcher instead https://docs.flarum.org/extend/search.'); + } + + public function createAction(object $model, Context $context): object + { + $model = parent::createAction($model, $context); + + $this->dispatchEventsFor($model, $context->getActor()); + + return $model; + } + + public function updateAction(object $model, Context $context): object + { + $model = parent::updateAction($model, $context); + + $this->dispatchEventsFor($model, $context->getActor()); + + return $model; + } + + public function deleteAction(object $model, Context $context): void + { + $this->deleting($model, $context); + + $this->delete($model, $context); + + $this->deleted($model, $context); + + $this->dispatchEventsFor($model, $context->getActor()); + } + + /** + * @param M $model + * @param FlarumContext $context + * @return M|null + */ + public function creating(object $model, Context $context): ?object + { + return $model; + } + + /** + * @param M $model + * @param FlarumContext $context + * @return M|null + */ + public function updating(object $model, Context $context): ?object + { + return $model; + } + + /** + * @param M $model + * @param FlarumContext $context + * @return M|null + */ + public function saving(object $model, Context $context): ?object + { + return $model; + } + + /** + * @param M $model + * @param FlarumContext $context + * @return M|null + */ + public function saved(object $model, Context $context): ?object + { + return $model; + } + + /** + * @param M $model + * @param FlarumContext $context + * @return M|null + */ + public function created(object $model, Context $context): ?object + { + return $model; + } + + /** + * @param M $model + * @param FlarumContext $context + * @return M|null + */ + public function updated(object $model, Context $context): ?object + { + return $model; + } + + /** + * @param M $model + * @param FlarumContext $context + */ + public function deleting(object $model, Context $context): void + { + // + } + + /** + * @param M $model + * @param FlarumContext $context + */ + public function deleted(object $model, Context $context): void + { + // + } + + public function dispatchEventsFor(mixed $entity, User $actor = null): void + { + if (method_exists($entity, 'releaseEvents')) { + $this->traitDispatchEventsFor($entity, $actor); + } + } + + /** + * @param FlarumContext $context + */ + public function mutateDataBeforeValidation(Context $context, array $data): array + { + return $data; + } + + /** + * @param FlarumContext $context + */ + public function results(object $query, Context $context): iterable + { + if ($results = $context->getSearchResults()) { + return $results->getResults(); + } + + return $query->get(); + } + + /** + * @param FlarumContext $context + */ + public function count(object $query, Context $context): ?int + { + if ($results = $context->getSearchResults()) { + return $results->getTotalResults(); + } + + return parent::count($query, $context); + } +} diff --git a/framework/core/src/Api/Resource/AbstractResource.php b/framework/core/src/Api/Resource/AbstractResource.php new file mode 100644 index 000000000..d203c4865 --- /dev/null +++ b/framework/core/src/Api/Resource/AbstractResource.php @@ -0,0 +1,27 @@ + + */ +abstract class AbstractResource extends BaseResource +{ + use Bootable; + use Extendable; + use HasSortMap; +} diff --git a/framework/core/src/Api/Resource/AccessTokenResource.php b/framework/core/src/Api/Resource/AccessTokenResource.php new file mode 100644 index 000000000..e6f487550 --- /dev/null +++ b/framework/core/src/Api/Resource/AccessTokenResource.php @@ -0,0 +1,143 @@ + + */ +class AccessTokenResource extends AbstractDatabaseResource +{ + public function __construct( + protected TranslatorInterface $translator + ) { + } + + public function type(): string + { + return 'access-tokens'; + } + + public function model(): string + { + return AccessToken::class; + } + + public function scope(Builder $query, \Tobyz\JsonApiServer\Context $context): void + { + $query->whereVisibleTo($context->getActor()); + } + + public function newModel(\Tobyz\JsonApiServer\Context $context): object + { + if ($context->creating(self::class)) { + $token = DeveloperAccessToken::make($context->getActor()->id); + $token->last_activity_at = null; + + return $token; + } + + return parent::newModel($context); + } + + public function endpoints(): array + { + return [ + Endpoint\Create::make() + ->authenticated() + ->can('createAccessToken'), + Endpoint\Delete::make() + ->authenticated(), + Endpoint\Index::make() + ->authenticated() + ->paginate(), + ]; + } + + public function fields(): array + { + return [ + Schema\Str::make('token') + ->visible(function (AccessToken $token, Context $context) { + return $context->getActor()->id === $token->user_id && ! in_array('token', $token->getHidden(), true); + }), + Schema\Integer::make('userId'), + Schema\DateTime::make('createdAt'), + Schema\DateTime::make('lastActivityAt'), + Schema\Boolean::make('isCurrent') + ->get(function (AccessToken $token, Context $context) { + return $token->token === $context->request->getAttribute('session')->get('access_token'); + }), + Schema\Boolean::make('isSessionToken') + ->get(function (AccessToken $token) { + return in_array($token->type, [SessionAccessToken::$type, RememberAccessToken::$type], true); + }), + Schema\Str::make('title') + ->writableOnCreate() + ->requiredOnCreate() + ->maxLength(255), + Schema\Str::make('lastIpAddress'), + Schema\Str::make('device') + ->get(function (AccessToken $token) { + $agent = new Agent(); + $agent->setUserAgent($token->last_user_agent); + + return $this->translator->trans('core.forum.security.browser_on_operating_system', [ + 'browser' => $agent->browser(), + 'os' => $agent->platform(), + ]); + }), + ]; + } + + public function created(object $model, \Tobyz\JsonApiServer\Context $context): ?object + { + $this->events->dispatch(new DeveloperTokenCreated($model)); + + return parent::created($model, $context); + } + + /** + * @param AccessToken $model + * @param \Flarum\Api\Context $context + * @throws PermissionDeniedException + */ + public function delete(object $model, \Tobyz\JsonApiServer\Context $context): void + { + /** @var Session|null $session */ + $session = $context->request->getAttribute('session'); + + // Current session should only be terminated through logout. + if ($session && $model->token === $session->get('access_token')) { + throw new PermissionDeniedException(); + } + + // Don't give away the existence of the token. + if ($context->getActor()->cannot('revoke', $model)) { + throw new ModelNotFoundException(); + } + + $model->delete(); + } +} diff --git a/framework/core/src/Api/Resource/Concerns/Bootable.php b/framework/core/src/Api/Resource/Concerns/Bootable.php new file mode 100644 index 000000000..e084f7bad --- /dev/null +++ b/framework/core/src/Api/Resource/Concerns/Bootable.php @@ -0,0 +1,41 @@ +api = $api; + $this->events = $api->getContainer()->make(Dispatcher::class); + $this->validation = $api->getContainer()->make(Factory::class); + + return $this; + } + + /** + * Called by the JSON:API server package to resolve the validation factory. + */ + public function validationFactory(): Factory + { + return $this->validation; + } +} diff --git a/framework/core/src/Api/Resource/Concerns/Extendable.php b/framework/core/src/Api/Resource/Concerns/Extendable.php new file mode 100644 index 000000000..2a9b898da --- /dev/null +++ b/framework/core/src/Api/Resource/Concerns/Extendable.php @@ -0,0 +1,93 @@ +cachedEndpoints) && ! $earlyResolution) { + return $this->cachedEndpoints; + } + + $endpoints = $this->endpoints(); + + foreach (array_reverse(array_merge([static::class], class_parents($this))) as $class) { + if (isset(static::$endpointModifiers[$class])) { + foreach (static::$endpointModifiers[$class] as $modifier) { + $endpoints = $modifier($endpoints, $this); + } + } + } + + return $this->cachedEndpoints = $endpoints; + } + + public function resolveFields(): array + { + if (! is_null($this->cachedFields)) { + return $this->cachedFields; + } + + $fields = $this->fields(); + + foreach (array_reverse(array_merge([static::class], class_parents($this))) as $class) { + if (isset(static::$fieldModifiers[$class])) { + foreach (static::$fieldModifiers[$class] as $modifier) { + $fields = $modifier($fields, $this); + } + } + } + + return $this->cachedFields = $fields; + } + + public function resolveSorts(): array + { + if (! is_null($this->cachedSorts)) { + return $this->cachedSorts; + } + + $sorts = $this->sorts(); + + foreach (array_reverse(array_merge([static::class], class_parents($this))) as $class) { + if (isset(static::$sortModifiers[$class])) { + foreach (static::$sortModifiers[$class] as $modifier) { + $sorts = $modifier($sorts, $this); + } + } + } + + return $this->cachedSorts = $sorts; + } +} diff --git a/framework/core/src/Api/Resource/Concerns/HasSortMap.php b/framework/core/src/Api/Resource/Concerns/HasSortMap.php new file mode 100644 index 000000000..2937b8db8 --- /dev/null +++ b/framework/core/src/Api/Resource/Concerns/HasSortMap.php @@ -0,0 +1,29 @@ +resolveSorts(); + + $map = []; + + foreach ($sorts as $sort) { + $map = array_merge($map, $sort->sortMap()); + } + + return $map; + } +} diff --git a/framework/core/src/Api/Resource/DiscussionResource.php b/framework/core/src/Api/Resource/DiscussionResource.php new file mode 100644 index 000000000..91f45b3d8 --- /dev/null +++ b/framework/core/src/Api/Resource/DiscussionResource.php @@ -0,0 +1,368 @@ + + */ +class DiscussionResource extends AbstractDatabaseResource +{ + public function __construct( + protected Dispatcher $bus, + protected SlugManager $slugManager, + protected PostRepository $posts + ) { + } + + public function type(): string + { + return 'discussions'; + } + + public function model(): string + { + return Discussion::class; + } + + public function scope(Builder $query, \Tobyz\JsonApiServer\Context $context): void + { + $query->whereVisibleTo($context->getActor()); + } + + public function find(string $id, \Tobyz\JsonApiServer\Context $context): ?object + { + $actor = $context->getActor(); + + if (Arr::get($context->request->getQueryParams(), 'bySlug', false)) { + $discussion = $this->slugManager->forResource(Discussion::class)->fromSlug($id, $actor); + } else { + $discussion = $this->query($context)->findOrFail($id); + } + + return $discussion; + } + + public function endpoints(): array + { + return [ + Endpoint\Create::make() + ->authenticated() + ->can('startDiscussion') + ->defaultInclude([ + 'posts', + 'user', + 'lastPostedUser', + 'firstPost', + 'lastPost' + ]), + Endpoint\Update::make() + ->authenticated(), + Endpoint\Delete::make() + ->authenticated() + ->can('delete'), + Endpoint\Show::make() + ->defaultInclude([ + 'user', + 'posts', + 'posts.discussion', + 'posts.user', + 'posts.user.groups', + 'posts.editedUser', + 'posts.hiddenUser' + ]), + Endpoint\Index::make() + ->defaultInclude([ + 'user', + 'lastPostedUser', + 'mostRelevantPost', + 'mostRelevantPost.user' + ]) + ->defaultSort('-lastPostedAt') + ->eagerLoad('state') + ->paginate(), + ]; + } + + public function fields(): array + { + return [ + Schema\Str::make('title') + ->requiredOnCreate() + ->writable(function (Discussion $discussion, Context $context) { + return $context->creating() + || $context->getActor()->can('rename', $discussion); + }) + ->minLength(3) + ->maxLength(80), + Schema\Str::make('content') + ->writableOnCreate() + ->requiredOnCreate() + ->visible(false) + ->maxLength(63000) + // set nothing... + ->set(fn () => null), + Schema\Str::make('slug') + ->get(function (Discussion $discussion) { + return $this->slugManager->forResource(Discussion::class)->toSlug($discussion); + }), + Schema\Integer::make('commentCount'), + Schema\Integer::make('participantCount'), + Schema\DateTime::make('createdAt'), + Schema\DateTime::make('lastPostedAt'), + Schema\Integer::make('lastPostNumber'), + Schema\Boolean::make('canReply') + ->get(function (Discussion $discussion, Context $context) { + return $context->getActor()->can('reply', $discussion); + }), + Schema\Boolean::make('canRename') + ->get(function (Discussion $discussion, Context $context) { + return $context->getActor()->can('rename', $discussion); + }), + Schema\Boolean::make('canDelete') + ->get(function (Discussion $discussion, Context $context) { + return $context->getActor()->can('delete', $discussion); + }), + Schema\Boolean::make('canHide') + ->get(function (Discussion $discussion, Context $context) { + return $context->getActor()->can('hide', $discussion); + }), + Schema\Boolean::make('isHidden') + ->visible(fn (Discussion $discussion) => $discussion->hidden_at !== null) + ->writable(function (Discussion $discussion, Context $context) { + return $context->updating() + && $context->getActor()->can('hide', $discussion); + }) + ->set(function (Discussion $discussion, bool $value, Context $context) { + if ($value) { + $discussion->hide($context->getActor()); + } else { + $discussion->restore(); + } + }), + Schema\DateTime::make('hiddenAt') + ->visible(fn (Discussion $discussion) => $discussion->hidden_at !== null), + Schema\DateTime::make('lastReadAt') + ->visible(fn (Discussion $discussion) => $discussion->state !== null) + ->get(function (Discussion $discussion) { + return $discussion->state->last_read_at; + }), + Schema\Integer::make('lastReadPostNumber') + ->visible(fn (Discussion $discussion) => $discussion->state !== null) + ->get(function (Discussion $discussion) { + return $discussion->state?->last_read_post_number; + }) + ->writable(function (Discussion $discussion, Context $context) { + return $context->updating(); + }) + ->set(function (Discussion $discussion, int $value, Context $context) { + if ($readNumber = Arr::get($context->body(), 'data.attributes.lastReadPostNumber')) { + $discussion->afterSave(function (Discussion $discussion) use ($readNumber, $context) { + $this->bus->dispatch( + new ReadDiscussion($discussion->id, $context->getActor(), $readNumber) + ); + }); + } + }), + + Schema\Relationship\ToOne::make('user') + ->writableOnCreate() + ->includable(), + Schema\Relationship\ToOne::make('firstPost') + ->includable() + ->inverse('discussion') + ->type('posts'), + Schema\Relationship\ToOne::make('lastPostedUser') + ->includable() + ->type('users'), + Schema\Relationship\ToOne::make('lastPost') + ->includable() + ->inverse('discussion') + ->type('posts'), + Schema\Relationship\ToMany::make('posts') + ->withLinkage(function (Context $context) { + return $context->showing(self::class); + }) + ->includable() + ->get(function (Discussion $discussion, Context $context) { + $showingDiscussion = $context->showing(self::class); + + if (! $showingDiscussion) { + return fn () => $discussion->posts->all(); + } + + /** @var Endpoint\Show $endpoint */ + $endpoint = $context->endpoint; + + $actor = $context->getActor(); + + $limit = PostResource::$defaultLimit; + + if (($near = Arr::get($context->request->getQueryParams(), 'page.near')) > 1) { + $offset = $this->posts->getIndexForNumber($discussion->id, $near, $actor); + $offset = max(0, $offset - $limit / 2); + } else { + $offset = $endpoint->extractOffsetValue($context, $endpoint->defaultExtracts($context)); + } + + $posts = $discussion->posts() + ->whereVisibleTo($actor) + ->with($context->endpoint->getEagerLoadsFor('posts', $context)) + ->with($context->endpoint->getWhereEagerLoadsFor('posts', $context)) + ->orderBy('number') + ->skip($offset) + ->take($limit) + ->get(); + + /** @var Post $post */ + foreach ($posts as $post) { + $post->setRelation('discussion', $discussion); + } + + $allPosts = $discussion->posts()->whereVisibleTo($actor)->orderBy('number')->pluck('id')->all(); + $loadedPosts = $posts->all(); + + array_splice($allPosts, $offset, $limit, $loadedPosts); + + return $allPosts; + }), + Schema\Relationship\ToOne::make('mostRelevantPost') + ->visible(fn (Discussion $model, Context $context) => $context->listing()) + ->includable() + ->inverse('discussion') + ->type('posts'), + Schema\Relationship\ToOne::make('hideUser') + ->type('users'), + ]; + } + + public function sorts(): array + { + return [ + SortColumn::make('lastPostedAt') + ->descendingAlias('latest'), + SortColumn::make('commentCount') + ->descendingAlias('top'), + SortColumn::make('createdAt') + ->ascendingAlias('oldest') + ->descendingAlias('newest'), + ]; + } + + /** @param Discussion $model */ + public function creating(object $model, \Tobyz\JsonApiServer\Context $context): ?object + { + $actor = $context->getActor(); + + $model->created_at = Carbon::now(); + $model->user_id = $actor->id; + + $model->setRelation('user', $actor); + + $model->raise(new Started($model)); + + return $model; + } + + /** @param Discussion $model */ + public function created(object $model, \Tobyz\JsonApiServer\Context $context): ?object + { + $actor = $context->getActor(); + + if ($actor->exists) { + $this->bus->dispatch( + new ReadDiscussion($model->id, $actor, 1) + ); + } + + return $model; + } + + /** @param Discussion $model */ + protected function saveModel(Model $model, \Tobyz\JsonApiServer\Context $context): void + { + if ($context->creating()) { + $model->newQuery()->getConnection()->transaction(function () use ($model, $context) { + $model->save(); + + /** @var JsonApi $api */ + $api = $context->api; + + // Now that the discussion has been created, we can add the first post. + // We will do this by running the PostReply command. + /** @var Post $post */ + $post = $api->forResource(PostResource::class) + ->forEndpoint('create') + ->withRequest($context->request) + ->process([ + 'data' => [ + 'attributes' => [ + 'content' => Arr::get($context->body(), 'data.attributes.content'), + ], + 'relationships' => [ + 'discussion' => [ + 'data' => [ + 'type' => 'discussions', + 'id' => (string) $model->id, + ], + ], + ], + ], + ], ['isFirstPost' => true]); + + // Before we dispatch events, refresh our discussion instance's + // attributes as posting the reply will have changed some of them (e.g. + // last_time.) + $model->setRawAttributes($post->discussion->getAttributes(), true); + $model->setFirstPost($post); + $model->setLastPost($post); + + $model->save(); + }); + } + + parent::saveModel($model, $context); + } + + /** @param Discussion $model */ + public function deleting(object $model, \Tobyz\JsonApiServer\Context $context): void + { + $this->events->dispatch( + new Deleting($model, $context->getActor(), []) + ); + } + + public function saving(object $model, \Tobyz\JsonApiServer\Context $context): ?object + { + $this->events->dispatch( + new Saving($model, $context->getActor(), Arr::get($context->body(), 'data', [])) + ); + + return $model; + } +} diff --git a/framework/core/src/Api/Resource/ExtensionReadmeResource.php b/framework/core/src/Api/Resource/ExtensionReadmeResource.php new file mode 100644 index 000000000..580eb88d5 --- /dev/null +++ b/framework/core/src/Api/Resource/ExtensionReadmeResource.php @@ -0,0 +1,64 @@ + + */ +class ExtensionReadmeResource extends AbstractResource implements Findable +{ + public function __construct( + protected ExtensionManager $extensions + ) { + } + + public function type(): string + { + return 'extension-readmes'; + } + + /** + * @param Extension $model + */ + public function getId(object $model, Context $context): string + { + return $model->getId(); + } + + public function find(string $id, Context $context): ?object + { + return $this->extensions->getExtension($id); + } + + public function endpoints(): array + { + return [ + Endpoint\Show::make() + ->admin(), + ]; + } + + public function fields(): array + { + return [ + Schema\Str::make('content') + ->get(fn (Extension $extension) => $extension->getReadme()), + ]; + } +} diff --git a/framework/core/src/Api/Resource/ForumResource.php b/framework/core/src/Api/Resource/ForumResource.php new file mode 100644 index 000000000..2c508d410 --- /dev/null +++ b/framework/core/src/Api/Resource/ForumResource.php @@ -0,0 +1,168 @@ + + */ +class ForumResource extends AbstractResource implements Findable +{ + /** + * @var Filesystem&Cloud + */ + protected Filesystem $assetsFilesystem; + + public function __construct( + protected UrlGenerator $url, + protected SettingsRepositoryInterface $settings, + protected Config $config, + Factory $filesystemFactory + ) { + $this->assetsFilesystem = $filesystemFactory->disk('flarum-assets'); + } + + public function type(): string + { + return 'forums'; + } + + public function getId(object $model, \Tobyz\JsonApiServer\Context $context): string + { + return '1'; + } + + public function id(\Tobyz\JsonApiServer\Context $context): ?string + { + return '1'; + } + + public function find(string $id, \Tobyz\JsonApiServer\Context $context): ?object + { + return new stdClass(); + } + + public function endpoints(): array + { + return [ + Endpoint\Show::make() + ->defaultInclude(['groups', 'actor.groups']) + ->route('GET', '/'), + ]; + } + + public function fields(): array + { + $forumUrl = $this->url->to('forum')->base(); + $path = parse_url($forumUrl, PHP_URL_PATH) ?: ''; + + return [ + Schema\Str::make('title') + ->get(fn () => $this->settings->get('forum_title')), + Schema\Str::make('description') + ->get(fn () => $this->settings->get('forum_description')), + Schema\Boolean::make('showLanguageSelector') + ->get(fn () => $this->settings->get('show_language_selector', true)), + Schema\Str::make('baseUrl') + ->get(fn () => $forumUrl), + Schema\Str::make('basePath') + ->get(fn () => $path), + Schema\Str::make('baseOrigin') + ->get(fn () => substr($forumUrl, 0, strlen($forumUrl) - strlen($path))), + Schema\Str::make('debug') + ->get(fn () => $this->config->inDebugMode()), + Schema\Str::make('apiUrl') + ->get(fn () => $this->url->to('api')->base()), + Schema\Str::make('welcomeTitle') + ->get(fn () => $this->settings->get('welcome_title')), + Schema\Str::make('welcomeMessage') + ->get(fn () => $this->settings->get('welcome_message')), + Schema\Str::make('themePrimaryColor') + ->get(fn () => $this->settings->get('theme_primary_color')), + Schema\Str::make('themeSecondaryColor') + ->get(fn () => $this->settings->get('theme_secondary_color')), + Schema\Str::make('logoUrl') + ->get(fn () => $this->getLogoUrl()), + Schema\Str::make('faviconUrl') + ->get(fn () => $this->getFaviconUrl()), + Schema\Str::make('headerHtml') + ->get(fn () => $this->settings->get('custom_header')), + Schema\Str::make('footerHtml') + ->get(fn () => $this->settings->get('custom_footer')), + Schema\Boolean::make('allowSignUp') + ->get(fn () => $this->settings->get('allow_sign_up')), + Schema\Str::make('defaultRoute') + ->get(fn () => $this->settings->get('default_route')), + Schema\Boolean::make('canViewForum') + ->get(fn ($model, Context $context) => $context->getActor()->can('viewForum')), + Schema\Boolean::make('canStartDiscussion') + ->get(fn ($model, Context $context) => $context->getActor()->can('startDiscussion')), + Schema\Boolean::make('canSearchUsers') + ->get(fn ($model, Context $context) => $context->getActor()->can('searchUsers')), + Schema\Boolean::make('canCreateAccessToken') + ->get(fn ($model, Context $context) => $context->getActor()->can('createAccessToken')), + Schema\Boolean::make('moderateAccessTokens') + ->get(fn ($model, Context $context) => $context->getActor()->can('moderateAccessTokens')), + Schema\Boolean::make('canEditUserCredentials') + ->get(fn ($model, Context $context) => $context->getActor()->hasPermission('user.editCredentials')), + Schema\Str::make('assetsBaseUrl') + ->get(fn () => rtrim($this->assetsFilesystem->url(''), '/')), + Schema\Str::make('jsChunksBaseUrl') + ->get(fn () => $this->assetsFilesystem->url('js')), + + Schema\Str::make('adminUrl') + ->visible(fn ($model, Context $context) => $context->getActor()->can('administrate')) + ->get(fn () => $this->url->to('admin')->base()), + Schema\Str::make('version') + ->visible(fn ($model, Context $context) => $context->getActor()->can('administrate')) + ->get(fn () => Application::VERSION), + + Schema\Relationship\ToMany::make('groups') + ->includable() + ->get(fn ($model, Context $context) => Group::whereVisibleTo($context->getActor())->get()->all()), + Schema\Relationship\ToOne::make('actor') + ->type('users') + ->includable() + ->get(fn ($model, Context $context) => $context->getActor()->isGuest() ? null : $context->getActor()), + ]; + } + + protected function getLogoUrl(): ?string + { + $logoPath = $this->settings->get('logo_path'); + + return $logoPath ? $this->getAssetUrl($logoPath) : null; + } + + protected function getFaviconUrl(): ?string + { + $faviconPath = $this->settings->get('favicon_path'); + + return $faviconPath ? $this->getAssetUrl($faviconPath) : null; + } + + public function getAssetUrl(string $assetPath): string + { + return $this->assetsFilesystem->url($assetPath); + } +} diff --git a/framework/core/src/Api/Resource/GroupResource.php b/framework/core/src/Api/Resource/GroupResource.php new file mode 100644 index 000000000..d73706353 --- /dev/null +++ b/framework/core/src/Api/Resource/GroupResource.php @@ -0,0 +1,136 @@ + + */ +class GroupResource extends AbstractDatabaseResource +{ + public function __construct( + protected TranslatorInterface $translator + ) { + } + + public function type(): string + { + return 'groups'; + } + + public function model(): string + { + return Group::class; + } + + public function scope(Builder $query, Context $context): void + { + $query->whereVisibleTo($context->getActor()); + } + + public function endpoints(): array + { + return [ + Endpoint\Create::make() + ->authenticated() + ->can('createGroup'), + Endpoint\Update::make() + ->authenticated() + ->can('edit'), + Endpoint\Delete::make() + ->authenticated() + ->can('delete'), + Endpoint\Show::make(), + Endpoint\Index::make(), + ]; + } + + public function fields(): array + { + return [ + Schema\Str::make('nameSingular') + ->requiredOnCreate() + ->get(function (Group $group) { + return $this->translateGroupName($group->name_singular); + }) + ->set(function (Group $group, $value) { + $group->rename($value, null); + }) + ->writable() + ->required(), + Schema\Str::make('namePlural') + ->requiredOnCreate() + ->get(function (Group $group) { + return $this->translateGroupName($group->name_plural); + }) + ->set(function (Group $group, $value) { + $group->rename(null, $value); + }) + ->writable() + ->required(), + Schema\Str::make('color') + ->nullable() + ->writable(), + Schema\Str::make('icon') + ->nullable() + ->writable(), + Schema\Boolean::make('isHidden') + ->writable(), + ]; + } + + public function sorts(): array + { + return [ + SortColumn::make('nameSingular'), + SortColumn::make('namePlural'), + SortColumn::make('isHidden'), + ]; + } + + private function translateGroupName(string $name): string + { + $translation = $this->translator->trans($key = 'core.group.'.strtolower($name)); + + if ($translation !== $key) { + return $translation; + } + + return $name; + } + + public function saving(object $model, Context $context): ?object + { + $this->events->dispatch( + new Saving($model, $context->getActor(), Arr::get($context->body(), 'data', [])) + ); + + return $model; + } + + public function deleting(object $model, Context $context): void + { + $this->events->dispatch( + new Deleting($model, $context->getActor(), []) + ); + + parent::deleting($model, $context); + } +} diff --git a/framework/core/src/Api/Resource/MailSettingResource.php b/framework/core/src/Api/Resource/MailSettingResource.php new file mode 100644 index 000000000..3887bafbc --- /dev/null +++ b/framework/core/src/Api/Resource/MailSettingResource.php @@ -0,0 +1,88 @@ + + */ +class MailSettingResource extends AbstractResource implements Findable +{ + public function __construct( + protected SettingsRepositoryInterface $settings, + protected Factory $validator, + protected Container $container + ) { + } + + public function type(): string + { + return 'mail-settings'; + } + + public function getId(object $model, Context $context): string + { + return '1'; + } + + public function id(Context $context): ?string + { + return '1'; + } + + public function find(string $id, Context $context): ?object + { + return new stdClass(); + } + + public function endpoints(): array + { + return [ + Endpoint\Show::make() + ->route('GET', '/') + ->admin(), + ]; + } + + public function fields(): array + { + return [ + Schema\Arr::make('fields') + ->get(function () { + return array_map(fn (DriverInterface $driver) => $driver->availableSettings(), array_map(function ($driver) { + return $this->container->make($driver); + }, $this->container->make('mail.supported_drivers'))); + }), + Schema\Boolean::make('sending') + ->get(function () { + /** @var DriverInterface $actual */ + $actual = $this->container->make('mail.driver'); + + return $actual->canSend(); + }), + Schema\Arr::make('errors') + ->get(function () { + /** @var DriverInterface $configured */ + $configured = $this->container->make('flarum.mail.configured_driver'); + + return $configured->validate($this->settings, $this->validator); + }), + ]; + } +} diff --git a/framework/core/src/Api/Resource/NotificationResource.php b/framework/core/src/Api/Resource/NotificationResource.php new file mode 100644 index 000000000..17a69a0b9 --- /dev/null +++ b/framework/core/src/Api/Resource/NotificationResource.php @@ -0,0 +1,114 @@ + + */ +class NotificationResource extends AbstractDatabaseResource +{ + protected bool $initialized = false; + + public function __construct( + protected Dispatcher $bus, + protected NotificationRepository $notifications, + ) { + $this->initialized = true; + } + + public function type(): string + { + return 'notifications'; + } + + public function model(): string + { + return Notification::class; + } + + public function query(\Tobyz\JsonApiServer\Context $context): object + { + if ($context->listing(self::class)) { + /** @var Endpoint\Index $endpoint */ + $endpoint = $context->endpoint; + /** @var OffsetPagination $pagination */ + $pagination = ($endpoint->paginationResolver)($context); + + return $this->notifications->query($context->getActor(), $pagination->limit, $pagination->offset); + } + + return parent::query($context); + } + + public function endpoints(): array + { + return [ + Endpoint\Update::make() + ->authenticated(), + Endpoint\Index::make() + ->authenticated() + ->before(function (Context $context) { + $context->getActor()->markNotificationsAsRead()->save(); + }) + ->defaultInclude(array_filter([ + 'fromUser', + 'subject', + $this->initialized && count($this->subjectTypes()) > 1 + ? 'subject.discussion' + : null, + ])) + ->paginate(), + ]; + } + + public function fields(): array + { + return [ + Schema\Str::make('contentType') + ->property('type'), + Schema\Arr::make('content') + ->property('data'), + Schema\DateTime::make('createdAt'), + Schema\Boolean::make('isRead') + ->writable() + ->get(fn (Notification $notification) => (bool) $notification->read_at) + ->set(function (Notification $notification, bool $value, Context $context) { + $this->bus->dispatch( + new ReadNotification($notification->id, $context->getActor()) + ); + }), + + Schema\Relationship\ToOne::make('user') + ->includable(), + Schema\Relationship\ToOne::make('fromUser') + ->type('users') + ->includable(), + Schema\Relationship\ToOne::make('subject') + ->collection($this->subjectTypes()) + ->includable(), + ]; + } + + protected function subjectTypes(): array + { + return $this->api->typesForModels( + (new Notification())->getSubjectModels() + ); + } +} diff --git a/framework/core/src/Api/Resource/PostResource.php b/framework/core/src/Api/Resource/PostResource.php new file mode 100644 index 000000000..6fc513450 --- /dev/null +++ b/framework/core/src/Api/Resource/PostResource.php @@ -0,0 +1,309 @@ + + */ +class PostResource extends AbstractDatabaseResource +{ + public static int $defaultLimit = 20; + + public function __construct( + protected PostRepository $posts, + protected TranslatorInterface $translator, + protected LogReporter $log, + protected Dispatcher $bus + ) { + } + + public function type(): string + { + return 'posts'; + } + + public function model(): string + { + return Post::class; + } + + public function scope(Builder $query, \Tobyz\JsonApiServer\Context $context): void + { + $query->whereVisibleTo($context->getActor()); + } + + public function newModel(\Tobyz\JsonApiServer\Context $context): object + { + if ($context->creating(self::class)) { + $post = new CommentPost(); + + $post->user_id = $context->getActor()->id; + $post->ip_address = $context->request->getAttribute('ipAddress'); + + return $post; + } + + return parent::newModel($context); + } + + public function endpoints(): array + { + return [ + Endpoint\Create::make() + ->authenticated() + ->visible(function (Context $context): bool { + $discussionId = (int) Arr::get($context->body(), 'data.relationships.discussion.data.id'); + + // Make sure the user has permission to reply to this discussion. First, + // make sure the discussion exists and that the user has permission to + // view it; if not, fail with a ModelNotFound exception so we don't give + // away the existence of the discussion. If the user is allowed to view + // it, check if they have permission to reply. + $discussion = Discussion::query() + ->whereVisibleTo($context->getActor()) + ->findOrFail($discussionId); + + // If this is the first post in the discussion, it's technically not a + // "reply", so we won't check for that permission. + if (! $context->internal('isFirstPost')) { + return $context->getActor()->can('reply', $discussion); + } + + return true; + }) + ->defaultInclude([ + 'user', + 'discussion', + 'discussion.posts', + 'discussion.lastPostedUser' + ]), + Endpoint\Update::make() + ->authenticated() + ->defaultInclude([ + 'editedUser', + 'discussion' + ]), + Endpoint\Delete::make() + ->authenticated() + ->can('delete'), + Endpoint\Show::make() + ->defaultInclude([ + 'user', + 'user.groups', + 'editedUser', + 'hiddenUser', + 'discussion' + ]), + Endpoint\Index::make() + ->extractOffset(function (Context $context, array $defaultExtracts): int { + $queryParams = $context->request->getQueryParams(); + + if (($near = Arr::get($queryParams, 'page.near')) > 1) { + $sort = $defaultExtracts['sort']; + $filter = $defaultExtracts['filter']; + + if (count($filter) > 1 || ! isset($filter['discussion']) || $sort) { + throw new BadRequestException( + 'You can only use page[near] with filter[discussion] and the default sort order' + ); + } + + $limit = $defaultExtracts['limit']; + $offset = $this->posts->getIndexForNumber((int) $filter['discussion'], $near, $context->getActor()); + + return max(0, $offset - $limit / 2); + } + + return $defaultExtracts['offset']; + }) + ->defaultInclude([ + 'user', + 'user.groups', + 'editedUser', + 'hiddenUser', + 'discussion' + ]) + ->paginate(static::$defaultLimit), + ]; + } + + public function fields(): array + { + return [ + Schema\Integer::make('number'), + Schema\DateTime::make('createdAt') + ->writable(function (Post $post, Context $context) { + return $context->creating() + && $context->getActor()->isAdmin(); + }) + ->default(fn () => Carbon::now()), + Schema\Str::make('contentType') + ->property('type'), + + Schema\Str::make('content') + ->requiredOnCreate() + ->writable(function (Post $post, Context $context) { + return $context->creating() || ( + $post instanceof CommentPost + && $context->updating() + && $context->getActor()->can('edit', $post) + ); + }) + ->maxLength(63000) // 65535 is without the text formatter XML generated after parsing. So we use 63000 to try being safer. + ->visible(function (Post $post, Context $context) { + return ! ($post instanceof CommentPost) + || $context->getActor()->can('edit', $post); + }) + ->set(function (Post $post, string $value, Context $context) { + if ($post instanceof CommentPost) { + if ($context->creating()) { + $post->setContentAttribute($value, $context->getActor()); + } elseif ($context->updating()) { + $post->revise($value, $context->getActor()); + } + } + }) + ->serialize(function (null|string|array $value, Context $context) { + /** + * Prevent the string type from trying to convert array content (for event posts) to a string. + * @var Schema\Str $field + */ + $field = $context->field; + $field->type = null; + + return $value; + }), + Schema\Str::make('contentHtml') + ->visible(function (Post $post) { + return $post instanceof CommentPost; + }) + ->get(function (CommentPost $post, Context $context) { + try { + $rendered = $post->formatContent($context->request); + $post->setAttribute('renderFailed', false); + } catch (\Exception $e) { + $rendered = $this->translator->trans('core.lib.error.render_failed_message'); + $this->log->report($e); + $post->setAttribute('renderFailed', true); + } + + return $rendered; + }), + Schema\Boolean::make('renderFailed') + ->visible(function (Post $post) { + return $post instanceof CommentPost; + }), + + Schema\Str::make('ipAddress') + ->visible(function (Post $post, Context $context) { + return $post instanceof CommentPost + && $context->getActor()->can('viewIps', $post); + }), + Schema\DateTime::make('editedAt'), + Schema\Boolean::make('isHidden') + ->visible(fn (Post $post) => $post->hidden_at !== null) + ->writable(function (Post $post, Context $context) { + return $context->updating() + && $context->getActor()->can('hide', $post); + }) + ->set(function (Post $post, bool $value, Context $context) { + if ($post instanceof CommentPost) { + if ($value) { + $post->hide($context->getActor()); + } else { + $post->restore(); + } + } + }), + Schema\DateTime::make('hiddenAt') + ->visible(fn (Post $post) => $post->hidden_at !== null), + + Schema\Boolean::make('canEdit') + ->get(fn (Post $post, Context $context) => $context->getActor()->can('edit', $post)), + Schema\Boolean::make('canDelete') + ->get(fn (Post $post, Context $context) => $context->getActor()->can('delete', $post)), + Schema\Boolean::make('canHide') + ->get(fn (Post $post, Context $context) => $context->getActor()->can('hide', $post)), + + Schema\Relationship\ToOne::make('user') + ->includable(), + Schema\Relationship\ToOne::make('discussion') + ->includable() + ->writableOnCreate(), + Schema\Relationship\ToOne::make('editedUser') + ->type('users') + ->includable(), + Schema\Relationship\ToOne::make('hiddenUser') + ->type('users') + ->includable(), + ]; + } + + public function sorts(): array + { + return [ + SortColumn::make('number'), + SortColumn::make('createdAt'), + ]; + } + + /** @param Post $model */ + public function created(object $model, \Tobyz\JsonApiServer\Context $context): ?object + { + $actor = $context->getActor(); + + // After replying, we assume that the user has seen all of the posts + // in the discussion; thus, we will mark the discussion as read if + // they are logged in. + if ($actor->exists) { + $this->bus->dispatch( + new ReadDiscussion($model->discussion_id, $actor, $model->number) + ); + } + + return $model; + } + + /** @param Post $model */ + public function deleting(object $model, \Tobyz\JsonApiServer\Context $context): void + { + $this->events->dispatch( + new Deleting($model, $context->getActor(), []) + ); + } + + public function saving(object $model, \Tobyz\JsonApiServer\Context $context): ?object + { + $this->events->dispatch( + new Saving($model, $context->getActor(), Arr::get($context->body(), 'data', [])) + ); + + return $model; + } +} diff --git a/framework/core/src/Api/Resource/UserResource.php b/framework/core/src/Api/Resource/UserResource.php new file mode 100644 index 000000000..71746fdd2 --- /dev/null +++ b/framework/core/src/Api/Resource/UserResource.php @@ -0,0 +1,456 @@ + + */ +class UserResource extends AbstractDatabaseResource +{ + public function __construct( + protected TranslatorInterface $translator, + protected SlugManager $slugManager, + protected SettingsRepositoryInterface $settings, + protected ImageManager $imageManager, + protected AvatarUploader $avatarUploader, + protected Dispatcher $bus, + ) { + } + + public function type(): string + { + return 'users'; + } + + public function model(): string + { + return User::class; + } + + public function scope(Builder $query, \Tobyz\JsonApiServer\Context $context): void + { + $query->whereVisibleTo($context->getActor()); + } + + public function find(string $id, \Tobyz\JsonApiServer\Context $context): ?object + { + $actor = $context->getActor(); + + if (Arr::get($context->request->getQueryParams(), 'bySlug', false)) { + $user = $this->slugManager->forResource(User::class)->fromSlug($id, $actor); + } else { + $user = $this->query($context)->findOrFail($id); + } + + return $user; + } + + public function endpoints(): array + { + return [ + Endpoint\Create::make() + ->visible(function (Context $context) { + if (! $this->settings->get('allow_sign_up')) { + return $context->getActor()->isAdmin(); + } + + return true; + }), + Endpoint\Update::make() + ->visible(function (User $user, Context $context) { + $actor = $context->getActor(); + $body = $context->body(); + + // Require the user's current password if they are attempting to change + // their own email address. + + if (isset($body['data']['attributes']['email']) && $actor->id === $user->id) { + $password = (string) Arr::get($body, 'meta.password'); + + if (! $actor->checkPassword($password)) { + throw new NotAuthenticatedException; + } + } + + $actor->assertRegistered(); + + return true; + }) + ->defaultInclude(['groups']), + Endpoint\Delete::make() + ->authenticated() + ->can('delete'), + Endpoint\Show::make() + ->defaultInclude(['groups']), + Endpoint\Index::make() + ->can('searchUsers') + ->defaultInclude(['groups']) + ->paginate(), + Endpoint\Endpoint::make('avatar.upload') + ->route('POST', '/{id}/avatar') + ->action(function (Context $context) { + $file = Arr::get($context->request->getUploadedFiles(), 'avatar'); + + return $this->bus->dispatch( + new UploadAvatar((int) $context->modelId, $file, $context->getActor()) + ); + }), + Endpoint\Endpoint::make('avatar.delete') + ->route('DELETE', '/{id}/avatar') + ->action(function (Context $context) { + return $this->bus->dispatch( + new DeleteAvatar(Arr::get($context->request->getQueryParams(), 'id'), $context->getActor()) + ); + }), + ]; + } + + public function fields(): array + { + $translator = $this->translator; + + return [ + Schema\Str::make('username') + ->requiredOnCreateWithout(['token']) + ->unique('users', 'username', true) + ->regex('/^[a-z0-9_-]+$/i') + ->validationMessages([ + 'username.regex' => $translator->trans('core.api.invalid_username_message'), + 'username.required_without' => $translator->trans('validation.required', ['attribute' => $translator->trans('validation.attributes.username')]) + ]) + ->minLength(3) + ->maxLength(30) + ->writable(function (User $user, Context $context) { + return $context->creating() + || $context->getActor()->can('editCredentials', $user); + }) + ->set(function (User $user, string $value) { + if ($user->exists) { + $user->rename($value); + } else { + $user->username = $value; + } + }), + Schema\Str::make('email') + ->requiredOnCreateWithout(['token']) + ->validationMessages([ + 'email.required_without' => $translator->trans('validation.required', ['attribute' => $translator->trans('validation.attributes.email')]) + ]) + ->email(['filter']) + ->unique('users', 'email', true) + ->visible(function (User $user, Context $context) { + return $context->getActor()->can('editCredentials', $user) + || $context->getActor()->id === $user->id; + }) + ->writable(function (User $user, Context $context) { + return $context->creating() + || $context->getActor()->can('editCredentials', $user) + || $context->getActor()->id === $user->id; + }) + ->set(function (User $user, string $value, Context $context) { + if ($user->exists) { + $isSelf = $context->getActor()->id === $user->id; + + if ($isSelf) { + $user->requestEmailChange($value); + } else { + $context->getActor()->assertCan('editCredentials', $user); + $user->changeEmail($value); + } + } else { + $user->email = $value; + } + }), + Schema\Boolean::make('isEmailConfirmed') + ->visible(function (User $user, Context $context) { + return $context->getActor()->can('editCredentials', $user) + || $context->getActor()->id === $user->id; + }) + ->writable(fn (User $user, Context $context) => $context->getActor()->isAdmin()) + ->set(function (User $user, $value, Context $context) { + if (! empty($value) && ($context->updating() || $context->getActor()->isAdmin())) { + $user->activate(); + } + }), + Schema\Str::make('password') + ->requiredOnCreateWithout(['token']) + ->validationMessages([ + 'password.required_without' => $translator->trans('validation.required', ['attribute' => $translator->trans('validation.attributes.password')]) + ]) + ->minLength(8) + ->visible(false) + ->writable(function (User $user, Context $context) { + return $context->creating() + || $context->getActor()->can('editCredentials', $user); + }) + ->set(function (User $user, ?string $value) { + $user->exists && $user->changePassword($value); + }), + // Registration token. + Schema\Str::make('token') + ->visible(false) + ->writable(function (User $user, Context $context) { + return $context->creating(); + }) + ->set(function (User $user, ?string $value, Context $context) { + if ($value) { + /** @var RegistrationToken $token */ + $token = RegistrationToken::validOrFail($value); + + $context->setParam('token', $token); + $user->password ??= Str::random(20); + + $this->applyToken($user, $token); + } + }) + ->save(fn () => null), + Schema\Str::make('displayName'), + Schema\Str::make('avatarUrl'), + Schema\Str::make('slug') + ->get(function (User $user) { + return $this->slugManager->forResource(User::class)->toSlug($user); + }), + Schema\DateTime::make('joinTime') + ->property('joined_at'), + Schema\Integer::make('discussionCount'), + Schema\Integer::make('commentCount'), + Schema\DateTime::make('lastSeenAt') + ->visible(function (User $user, Context $context) { + return $user->getPreference('discloseOnline') || $context->getActor()->can('viewLastSeenAt', $user); + }), + + Schema\DateTime::make('markedAllAsReadAt') + ->visible(fn (User $user, Context $context) => ($context->collection instanceof self || $context->collection instanceof ForumResource) && $context->getActor()->id === $user->id) + ->writable(fn (User $user, Context $context) => $context->getActor()->id === $user->id) + ->set(function (User $user, $value) { + if (! empty($value)) { + $user->markAllAsRead(); + } + }), + + Schema\Integer::make('unreadNotificationCount') + ->visible(fn (User $user, Context $context) => ($context->collection instanceof self || $context->collection instanceof ForumResource) && $context->getActor()->id === $user->id) + ->get(function (User $user): int { + return $user->getUnreadNotificationCount(); + }), + Schema\Integer::make('newNotificationCount') + ->visible(fn (User $user, Context $context) => ($context->collection instanceof self || $context->collection instanceof ForumResource) && $context->getActor()->id === $user->id) + ->get(function (User $user): int { + return $user->getNewNotificationCount(); + }), + Schema\Arr::make('preferences') + ->visible(fn (User $user, Context $context) => ($context->collection instanceof self || $context->collection instanceof ForumResource) && $context->getActor()->id === $user->id) + ->writable(fn (User $user, Context $context) => $context->getActor()->id === $user->id) + ->set(function (User $user, array $value) { + foreach ($value as $k => $v) { + $user->setPreference($k, $v); + } + }), + + Schema\Boolean::make('isAdmin') + ->visible(fn (User $user, Context $context) => ($context->collection instanceof self || $context->collection instanceof ForumResource) && $context->getActor()->id === $user->id) + ->get(fn (User $user, Context $context) => $context->getActor()->isAdmin()), + + Schema\Boolean::make('canEdit') + ->get(function (User $user, Context $context) { + return $context->getActor()->can('edit', $user); + }), + Schema\Boolean::make('canEditCredentials') + ->get(function (User $user, Context $context) { + return $context->getActor()->can('editCredentials', $user); + }), + Schema\Boolean::make('canEditGroups') + ->get(function (User $user, Context $context) { + return $context->getActor()->can('editGroups', $user); + }), + Schema\Boolean::make('canDelete') + ->get(function (User $user, Context $context) { + return $context->getActor()->can('delete', $user); + }), + + Schema\Relationship\ToMany::make('groups') + ->writable(fn (User $user, Context $context) => $context->updating() && $context->getActor()->can('editGroups', $user)) + ->includable() + ->set(function (User $user, $value, Context $context) { + $actor = $context->getActor(); + + $oldGroups = $user->groups()->get()->all(); + $oldGroupIds = Arr::pluck($oldGroups, 'id'); + + $newGroupIds = []; + foreach ($value as $group) { + if ($id = Arr::get($group, 'id')) { + $newGroupIds[] = $id; + } + } + + // Ensure non-admins aren't adding/removing admins + $adminChanged = in_array('1', array_diff($oldGroupIds, $newGroupIds)) || in_array('1', array_diff($newGroupIds, $oldGroupIds)); + $actor->assertPermission(! $adminChanged || $actor->isAdmin()); + + $user->raise( + new GroupsChanged($user, $oldGroups) + ); + + $user->afterSave(function (User $user) use ($newGroupIds) { + $user->groups()->sync($newGroupIds); + $user->unsetRelation('groups'); + }); + }), + ]; + } + + public function sorts(): array + { + return [ + SortColumn::make('username'), + SortColumn::make('commentCount'), + SortColumn::make('discussionCount'), + SortColumn::make('lastSeenAt') + ->visible(function (Context $context) { + return $context->getActor()->hasPermission('user.viewLastSeenAt'); + }), + SortColumn::make('joinedAt'), + ]; + } + + /** @param User $model */ + public function saved(object $model, \Tobyz\JsonApiServer\Context $context): ?object + { + if (($token = $context->getParam('token')) instanceof RegistrationToken) { + $this->fulfillToken($model, $token); + } + + return parent::saved($model, $context); + } + + public function deleting(object $model, \Tobyz\JsonApiServer\Context $context): void + { + $this->events->dispatch( + new Deleting($model, $context->getActor(), []) + ); + } + + public function saving(object $model, \Tobyz\JsonApiServer\Context $context): ?object + { + $this->events->dispatch( + new Saving($model, $context->getActor(), Arr::get($context->body(), 'data', [])) + ); + + return $model; + } + + private function applyToken(User $user, RegistrationToken $token): void + { + foreach ($token->user_attributes as $k => $v) { + if ($k === 'avatar_url') { + $this->uploadAvatarFromUrl($user, $v); + continue; + } + + $user->$k = $v; + + if ($k === 'email') { + $user->activate(); + } + } + + $this->events->dispatch( + new RegisteringFromProvider($user, $token->provider, $token->payload) + ); + } + + /** + * @throws InvalidArgumentException + */ + private function uploadAvatarFromUrl(User $user, string $url): void + { + $urlValidator = $this->validation->make(compact('url'), [ + 'url' => 'required|active_url', + ]); + + if ($urlValidator->fails()) { + throw new ValidationException([ + 'avatar_url' => 'Provided avatar URL must be a valid URI.', + ]); + } + + $scheme = parse_url($url, PHP_URL_SCHEME); + + if (! in_array($scheme, ['http', 'https'])) { + throw new ValidationException([ + 'avatar_url' => "Provided avatar URL must have scheme http or https. Scheme provided was $scheme.", + ]); + } + + $urlContents = $this->retrieveAvatarFromUrl($url); + + if ($urlContents !== null) { + $image = $this->imageManager->read($urlContents); + + $this->avatarUploader->upload($user, $image); + } + } + + private function retrieveAvatarFromUrl(string $url): ?string + { + $client = new Client(); + + try { + $response = $client->get($url); + } catch (\Exception $e) { + return null; + } + + if ($response->getStatusCode() !== 200) { + return null; + } + + return $response->getBody()->getContents(); + } + + private function fulfillToken(User $user, RegistrationToken $token): void + { + $token->delete(); + + if ($token->provider && $token->identifier) { + $user->loginProviders()->create([ + 'provider' => $token->provider, + 'identifier' => $token->identifier + ]); + } + } +} diff --git a/framework/core/src/Api/Schema/Arr.php b/framework/core/src/Api/Schema/Arr.php new file mode 100644 index 000000000..a2bec0b1d --- /dev/null +++ b/framework/core/src/Api/Schema/Arr.php @@ -0,0 +1,23 @@ +type(Type\Arr::make()) + ->rule('array'); + } +} diff --git a/extensions/tags/src/Command/CreateTag.php b/framework/core/src/Api/Schema/Attribute.php similarity index 51% rename from extensions/tags/src/Command/CreateTag.php rename to framework/core/src/Api/Schema/Attribute.php index 2d3d4c99d..4e63dcae9 100644 --- a/extensions/tags/src/Command/CreateTag.php +++ b/framework/core/src/Api/Schema/Attribute.php @@ -7,15 +7,11 @@ * LICENSE file that was distributed with this source code. */ -namespace Flarum\Tags\Command; +namespace Flarum\Api\Schema; -use Flarum\User\User; +use Tobyz\JsonApiServer\Schema\Field\Attribute as BaseAttribute; -class CreateTag +class Attribute extends BaseAttribute { - public function __construct( - public User $actor, - public array $data - ) { - } + // } diff --git a/framework/core/src/Api/Schema/Boolean.php b/framework/core/src/Api/Schema/Boolean.php new file mode 100644 index 000000000..9239bd981 --- /dev/null +++ b/framework/core/src/Api/Schema/Boolean.php @@ -0,0 +1,20 @@ +type(\Tobyz\JsonApiServer\Schema\Type\Boolean::make()) + ->rule('boolean'); + } +} diff --git a/framework/core/src/Api/Schema/Date.php b/framework/core/src/Api/Schema/Date.php new file mode 100644 index 000000000..053bddaa0 --- /dev/null +++ b/framework/core/src/Api/Schema/Date.php @@ -0,0 +1,20 @@ +type(\Tobyz\JsonApiServer\Schema\Type\Date::make()) + ->rule('date'); + } +} diff --git a/framework/core/src/Api/Schema/DateTime.php b/framework/core/src/Api/Schema/DateTime.php new file mode 100644 index 000000000..473ebe2d3 --- /dev/null +++ b/framework/core/src/Api/Schema/DateTime.php @@ -0,0 +1,45 @@ +type(\Tobyz\JsonApiServer\Schema\Type\DateTime::make()) + ->rule('date'); + } + + public function before(string $date, bool|callable $condition = true): static + { + return $this->rule('before:'.$date, $condition); + } + + public function after(string $date, bool|callable $condition = true): static + { + return $this->rule('after:'.$date, $condition); + } + + public function beforeOrEqual(string $date, bool|callable $condition = true): static + { + return $this->rule('before_or_equal:'.$date, $condition); + } + + public function afterOrEqual(string $date, bool|callable $condition = true): static + { + return $this->rule('after_or_equal:'.$date, $condition); + } + + public function format(string $format, bool|callable $condition = true): static + { + return $this->rule('date_format:'.$format, $condition); + } +} diff --git a/framework/core/src/Api/Schema/Integer.php b/framework/core/src/Api/Schema/Integer.php new file mode 100644 index 000000000..0a9f6a1ae --- /dev/null +++ b/framework/core/src/Api/Schema/Integer.php @@ -0,0 +1,20 @@ +type(\Tobyz\JsonApiServer\Schema\Type\Integer::make()) + ->rule('integer'); + } +} diff --git a/framework/core/src/Api/Schema/Number.php b/framework/core/src/Api/Schema/Number.php new file mode 100644 index 000000000..4c0badee2 --- /dev/null +++ b/framework/core/src/Api/Schema/Number.php @@ -0,0 +1,35 @@ +type(\Tobyz\JsonApiServer\Schema\Type\Number::make()) + ->rule('numeric'); + } + + public function min(int $min, bool|callable $condition = true): static + { + return $this->rule("min:$min", $condition); + } + + public function max(int $max, bool|callable $condition = true): static + { + return $this->rule("max:$max", $condition); + } +} diff --git a/extensions/flags/src/Command/CreateFlag.php b/framework/core/src/Api/Schema/Relationship/ToMany.php similarity index 51% rename from extensions/flags/src/Command/CreateFlag.php rename to framework/core/src/Api/Schema/Relationship/ToMany.php index 2c7f23480..075ef5ea4 100644 --- a/extensions/flags/src/Command/CreateFlag.php +++ b/framework/core/src/Api/Schema/Relationship/ToMany.php @@ -7,15 +7,11 @@ * LICENSE file that was distributed with this source code. */ -namespace Flarum\Flags\Command; +namespace Flarum\Api\Schema\Relationship; -use Flarum\User\User; +use Tobyz\JsonApiServer\Schema\Field\ToMany as BaseToMany; -class CreateFlag +class ToMany extends BaseToMany { - public function __construct( - public User $actor, - public array $data - ) { - } + // } diff --git a/framework/core/src/Group/Command/CreateGroup.php b/framework/core/src/Api/Schema/Relationship/ToOne.php similarity index 51% rename from framework/core/src/Group/Command/CreateGroup.php rename to framework/core/src/Api/Schema/Relationship/ToOne.php index 247d23578..29efa4754 100644 --- a/framework/core/src/Group/Command/CreateGroup.php +++ b/framework/core/src/Api/Schema/Relationship/ToOne.php @@ -7,15 +7,11 @@ * LICENSE file that was distributed with this source code. */ -namespace Flarum\Group\Command; +namespace Flarum\Api\Schema\Relationship; -use Flarum\User\User; +use Tobyz\JsonApiServer\Schema\Field\ToOne as BaseToOne; -class CreateGroup +class ToOne extends BaseToOne { - public function __construct( - public User $actor, - public array $data - ) { - } + // } diff --git a/framework/core/src/Api/Schema/Str.php b/framework/core/src/Api/Schema/Str.php new file mode 100644 index 000000000..55a406993 --- /dev/null +++ b/framework/core/src/Api/Schema/Str.php @@ -0,0 +1,46 @@ +type(\Tobyz\JsonApiServer\Schema\Type\Str::make()) + ->rule('string'); + } + + public function minLength(int $length, bool|callable $condition = true): static + { + return $this->rule('min:'.$length, $condition); + } + + public function maxLength(int $length, bool|callable $condition = true): static + { + return $this->rule('max:'.$length, $condition); + } + + public function email(array $validators = [], bool|callable $condition = true): static + { + $validators = implode(',', $validators); + + if (! empty($validators)) { + $validators = ':'.$validators; + } + + return $this->rule("email$validators", $condition); + } + + public function regex(string $pattern, bool|callable $condition = true): static + { + return $this->rule("regex:$pattern", $condition); + } +} diff --git a/framework/core/src/Api/Schema/Type/Arr.php b/framework/core/src/Api/Schema/Type/Arr.php new file mode 100644 index 000000000..fb80a6422 --- /dev/null +++ b/framework/core/src/Api/Schema/Type/Arr.php @@ -0,0 +1,44 @@ + 'array', + ]; + } +} diff --git a/framework/core/src/Api/Serializer/AbstractSerializer.php b/framework/core/src/Api/Serializer/AbstractSerializer.php deleted file mode 100644 index a14209453..000000000 --- a/framework/core/src/Api/Serializer/AbstractSerializer.php +++ /dev/null @@ -1,234 +0,0 @@ - - */ - protected static array $attributeMutators = []; - - /** - * @var array> - */ - protected static array $customRelations = []; - - public function getRequest(): Request - { - return $this->request; - } - - public function setRequest(Request $request): void - { - $this->request = $request; - $this->actor = RequestUtil::getActor($request); - } - - public function getActor(): User - { - return $this->actor; - } - - public function getAttributes(mixed $model, array $fields = null): array - { - if (! is_object($model) && ! is_array($model)) { - return []; - } - - $attributes = $this->getDefaultAttributes($model); - - foreach (array_reverse(array_merge([static::class], class_parents($this))) as $class) { - if (isset(static::$attributeMutators[$class])) { - foreach (static::$attributeMutators[$class] as $callback) { - $attributes = array_merge( - $attributes, - $callback($this, $model, $attributes) - ); - } - } - } - - return $attributes; - } - - /** - * Get the default set of serialized attributes for a model. - */ - abstract protected function getDefaultAttributes(object|array $model): array; - - public function formatDate(DateTime $date = null): ?string - { - return $date?->format(DateTime::RFC3339); - } - - public function getRelationship($model, $name) - { - if ($relationship = $this->getCustomRelationship($model, $name)) { - return $relationship; - } - - return parent::getRelationship($model, $name); - } - - /** - * Get a custom relationship. - */ - protected function getCustomRelationship(object|array $model, string $name): ?Relationship - { - foreach (array_merge([static::class], class_parents($this)) as $class) { - $callback = Arr::get(static::$customRelations, "$class.$name"); - - if (is_callable($callback)) { - $relationship = $callback($this, $model); - - if (isset($relationship) && ! ($relationship instanceof Relationship)) { - throw new LogicException( - 'GetApiRelationship handler must return an instance of '.Relationship::class - ); - } - - return $relationship; - } - } - - return null; - } - - /** - * Get a relationship builder for a has-one relationship. - */ - public function hasOne(object|array $model, SerializerInterface|Closure|string $serializer, string $relation = null): ?Relationship - { - return $this->buildRelationship($model, $serializer, $relation); - } - - /** - * Get a relationship builder for a has-many relationship. - */ - public function hasMany(object|array $model, SerializerInterface|Closure|string $serializer, string $relation = null): ?Relationship - { - return $this->buildRelationship($model, $serializer, $relation, true); - } - - protected function buildRelationship(object|array $model, SerializerInterface|Closure|string $serializer, string $relation = null, bool $many = false): ?Relationship - { - if (is_null($relation)) { - list(, , $caller) = debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT, 3); - - $relation = $caller['function']; - } - - $data = $this->getRelationshipData($model, $relation); - - if ($data) { - $serializer = $this->resolveSerializer($serializer, $model, $data); - - $type = $many ? Collection::class : Resource::class; - - $element = new $type($data, $serializer); - - return new Relationship($element); - } - - return null; - } - - protected function getRelationshipData(object|array $model, string $relation): mixed - { - if (is_object($model)) { - return $model->$relation; - } - - return $model[$relation]; - } - - /** - * @throws InvalidArgumentException - */ - protected function resolveSerializer(SerializerInterface|Closure|string $serializer, object|array $model, mixed $data): SerializerInterface - { - if ($serializer instanceof Closure) { - $serializer = call_user_func($serializer, $model, $data); - } - - if (is_string($serializer)) { - $serializer = $this->resolveSerializerClass($serializer); - } - - if (! ($serializer instanceof SerializerInterface)) { - throw new InvalidArgumentException('Serializer must be an instance of ' - .SerializerInterface::class); - } - - return $serializer; - } - - protected function resolveSerializerClass(string $class): object - { - $serializer = static::$container->make($class); - - $serializer->setRequest($this->request); - - return $serializer; - } - - public static function getContainer(): Container - { - return static::$container; - } - - /** - * @internal - */ - public static function setContainer(Container $container): void - { - static::$container = $container; - } - - /** - * @internal - */ - public static function addAttributeMutator(string $serializerClass, callable $callback): void - { - if (! isset(static::$attributeMutators[$serializerClass])) { - static::$attributeMutators[$serializerClass] = []; - } - - static::$attributeMutators[$serializerClass][] = $callback; - } - - /** - * @internal - */ - public static function setRelationship(string $serializerClass, string $relation, callable $callback): void - { - static::$customRelations[$serializerClass][$relation] = $callback; - } -} diff --git a/framework/core/src/Api/Serializer/AccessTokenSerializer.php b/framework/core/src/Api/Serializer/AccessTokenSerializer.php deleted file mode 100644 index 6b8c8e744..000000000 --- a/framework/core/src/Api/Serializer/AccessTokenSerializer.php +++ /dev/null @@ -1,66 +0,0 @@ -request->getAttribute('session'); - - $agent = new Agent(); - $agent->setUserAgent($model->last_user_agent); - - $attributes = [ - 'token' => $model->token, - 'userId' => $model->user_id, - 'createdAt' => $this->formatDate($model->created_at), - 'lastActivityAt' => $this->formatDate($model->last_activity_at), - 'isCurrent' => $session && $session->get('access_token') === $model->token, - 'isSessionToken' => in_array($model->type, ['session', 'session_remember'], true), - 'title' => $model->title, - 'lastIpAddress' => $model->last_ip_address, - 'device' => $this->translator->trans('core.forum.security.browser_on_operating_system', [ - 'browser' => $agent->browser(), - 'os' => $agent->platform(), - ]), - ]; - - // Unset hidden attributes (like the token value on session tokens) - foreach ($model->getHidden() as $name) { - unset($attributes[$name]); - } - - // Hide the token value to non-actors no matter who they are. - if (isset($attributes['token']) && $this->getActor()->id !== $model->user_id) { - unset($attributes['token']); - } - - return $attributes; - } -} diff --git a/framework/core/src/Api/Serializer/BasicDiscussionSerializer.php b/framework/core/src/Api/Serializer/BasicDiscussionSerializer.php deleted file mode 100644 index 7260f09df..000000000 --- a/framework/core/src/Api/Serializer/BasicDiscussionSerializer.php +++ /dev/null @@ -1,77 +0,0 @@ - $model->title, - 'slug' => $this->slugManager->forResource(Discussion::class)->toSlug($model), - ]; - } - - protected function user(Discussion $discussion): ?Relationship - { - return $this->hasOne($discussion, BasicUserSerializer::class); - } - - protected function firstPost(Discussion $discussion): ?Relationship - { - return $this->hasOne($discussion, BasicPostSerializer::class); - } - - protected function lastPostedUser(Discussion $discussion): ?Relationship - { - return $this->hasOne($discussion, BasicUserSerializer::class); - } - - protected function lastPost(Discussion $discussion): ?Relationship - { - return $this->hasOne($discussion, BasicPostSerializer::class); - } - - protected function posts(Discussion $discussion): ?Relationship - { - return $this->hasMany($discussion, PostSerializer::class); - } - - protected function mostRelevantPost(Discussion $discussion): ?Relationship - { - return $this->hasOne($discussion, PostSerializer::class); - } - - protected function hiddenUser(Discussion $discussion): ?Relationship - { - return $this->hasOne($discussion, BasicUserSerializer::class); - } -} diff --git a/framework/core/src/Api/Serializer/BasicPostSerializer.php b/framework/core/src/Api/Serializer/BasicPostSerializer.php deleted file mode 100644 index 8193e2e24..000000000 --- a/framework/core/src/Api/Serializer/BasicPostSerializer.php +++ /dev/null @@ -1,72 +0,0 @@ - (int) $model->number, - 'createdAt' => $this->formatDate($model->created_at), - 'contentType' => $model->type - ]; - - if ($model instanceof CommentPost) { - try { - $attributes['contentHtml'] = $model->formatContent($this->request); - $attributes['renderFailed'] = false; - } catch (Exception $e) { - $attributes['contentHtml'] = $this->translator->trans('core.lib.error.render_failed_message'); - $this->log->report($e); - $attributes['renderFailed'] = true; - } - } else { - $attributes['content'] = $model->content; - } - - return $attributes; - } - - protected function user(Post $post): ?Relationship - { - return $this->hasOne($post, BasicUserSerializer::class); - } - - protected function discussion(Post $post): ?Relationship - { - return $this->hasOne($post, BasicDiscussionSerializer::class); - } -} diff --git a/framework/core/src/Api/Serializer/BasicUserSerializer.php b/framework/core/src/Api/Serializer/BasicUserSerializer.php deleted file mode 100644 index 0c23b9f47..000000000 --- a/framework/core/src/Api/Serializer/BasicUserSerializer.php +++ /dev/null @@ -1,53 +0,0 @@ - $model->username, - 'displayName' => $model->display_name, - 'avatarUrl' => $model->avatar_url, - 'slug' => $this->slugManager->forResource(User::class)->toSlug($model) - ]; - } - - protected function groups(User $user): Relationship - { - if ($this->getActor()->can('viewHiddenGroups')) { - return $this->hasMany($user, GroupSerializer::class); - } - - return $this->hasMany($user, GroupSerializer::class, 'visibleGroups'); - } -} diff --git a/framework/core/src/Api/Serializer/CurrentUserSerializer.php b/framework/core/src/Api/Serializer/CurrentUserSerializer.php deleted file mode 100644 index b98bf7875..000000000 --- a/framework/core/src/Api/Serializer/CurrentUserSerializer.php +++ /dev/null @@ -1,39 +0,0 @@ - (bool) $model->is_email_confirmed, - 'email' => $model->email, - 'markedAllAsReadAt' => $this->formatDate($model->marked_all_as_read_at), - 'unreadNotificationCount' => (int) $model->getUnreadNotificationCount(), - 'newNotificationCount' => (int) $model->getNewNotificationCount(), - 'preferences' => (array) $model->preferences, - 'isAdmin' => $model->isAdmin(), - ]; - - return $attributes; - } -} diff --git a/framework/core/src/Api/Serializer/DiscussionSerializer.php b/framework/core/src/Api/Serializer/DiscussionSerializer.php deleted file mode 100644 index 705324015..000000000 --- a/framework/core/src/Api/Serializer/DiscussionSerializer.php +++ /dev/null @@ -1,49 +0,0 @@ - (int) $model->comment_count, - 'participantCount' => (int) $model->participant_count, - 'createdAt' => $this->formatDate($model->created_at), - 'lastPostedAt' => $this->formatDate($model->last_posted_at), - 'lastPostNumber' => (int) $model->last_post_number, - 'canReply' => $this->actor->can('reply', $model), - 'canRename' => $this->actor->can('rename', $model), - 'canDelete' => $this->actor->can('delete', $model), - 'canHide' => $this->actor->can('hide', $model) - ]; - - if ($model->hidden_at) { - $attributes['isHidden'] = true; - $attributes['hiddenAt'] = $this->formatDate($model->hidden_at); - } - - Discussion::setStateUser($this->actor); - - if ($state = $model->state) { - $attributes += [ - 'lastReadAt' => $this->formatDate($state->last_read_at), - 'lastReadPostNumber' => (int) $state->last_read_post_number - ]; - } - - return $attributes; - } -} diff --git a/framework/core/src/Api/Serializer/ExtensionReadmeSerializer.php b/framework/core/src/Api/Serializer/ExtensionReadmeSerializer.php deleted file mode 100644 index 4396cd045..000000000 --- a/framework/core/src/Api/Serializer/ExtensionReadmeSerializer.php +++ /dev/null @@ -1,35 +0,0 @@ - $model->getReadme() - ]; - } - - public function getId($extension) - { - return $extension->getId(); - } - - public function getType($extension) - { - return 'extension-readmes'; - } -} diff --git a/framework/core/src/Api/Serializer/ForumSerializer.php b/framework/core/src/Api/Serializer/ForumSerializer.php deleted file mode 100644 index 29f467e3a..000000000 --- a/framework/core/src/Api/Serializer/ForumSerializer.php +++ /dev/null @@ -1,133 +0,0 @@ -config = $config; - $this->assetsFilesystem = $filesystemFactory->disk('flarum-assets'); - $this->settings = $settings; - $this->url = $url; - } - - public function getId($model) - { - return '1'; - } - - /** - * @param array $model - */ - protected function getDefaultAttributes(object|array $model): array - { - $attributes = [ - 'title' => $this->settings->get('forum_title'), - 'description' => $this->settings->get('forum_description'), - 'showLanguageSelector' => (bool) $this->settings->get('show_language_selector', true), - 'baseUrl' => $url = $this->url->to('forum')->base(), - 'basePath' => $path = parse_url($url, PHP_URL_PATH) ?: '', - 'baseOrigin' => substr($url, 0, strlen($url) - strlen($path)), - 'debug' => $this->config->inDebugMode(), - 'apiUrl' => $this->url->to('api')->base(), - 'welcomeTitle' => $this->settings->get('welcome_title'), - 'welcomeMessage' => $this->settings->get('welcome_message'), - 'themePrimaryColor' => $this->settings->get('theme_primary_color'), - 'themeSecondaryColor' => $this->settings->get('theme_secondary_color'), - 'logoUrl' => $this->getLogoUrl(), - 'faviconUrl' => $this->getFaviconUrl(), - 'headerHtml' => $this->settings->get('custom_header'), - 'footerHtml' => $this->settings->get('custom_footer'), - 'allowSignUp' => (bool) $this->settings->get('allow_sign_up'), - 'defaultRoute' => $this->settings->get('default_route'), - 'canViewForum' => $this->actor->can('viewForum'), - 'canStartDiscussion' => $this->actor->can('startDiscussion'), - 'canSearchUsers' => $this->actor->can('searchUsers'), - 'canCreateAccessToken' => $this->actor->can('createAccessToken'), - 'canModerateAccessTokens' => $this->actor->can('moderateAccessTokens'), - 'canEditUserCredentials' => $this->actor->hasPermission('user.editCredentials'), - 'assetsBaseUrl' => rtrim($this->assetsFilesystem->url(''), '/'), - 'jsChunksBaseUrl' => $this->assetsFilesystem->url('js'), - ]; - - if ($this->actor->can('administrate')) { - $attributes['adminUrl'] = $this->url->to('admin')->base(); - $attributes['version'] = Application::VERSION; - } - - return $attributes; - } - - protected function groups(array $model): ?Relationship - { - return $this->hasMany($model, GroupSerializer::class); - } - - protected function getLogoUrl(): ?string - { - $logoPath = $this->settings->get('logo_path'); - - return $logoPath ? $this->getAssetUrl($logoPath) : null; - } - - protected function getFaviconUrl(): ?string - { - $faviconPath = $this->settings->get('favicon_path'); - - return $faviconPath ? $this->getAssetUrl($faviconPath) : null; - } - - public function getAssetUrl(string $assetPath): string - { - return $this->assetsFilesystem->url($assetPath); - } - - protected function actor(array $model): ?Relationship - { - return $this->hasOne($model, CurrentUserSerializer::class); - } -} diff --git a/framework/core/src/Api/Serializer/GroupSerializer.php b/framework/core/src/Api/Serializer/GroupSerializer.php deleted file mode 100644 index 529304d4b..000000000 --- a/framework/core/src/Api/Serializer/GroupSerializer.php +++ /dev/null @@ -1,55 +0,0 @@ - $this->translateGroupName($model->name_singular), - 'namePlural' => $this->translateGroupName($model->name_plural), - 'color' => $model->color, - 'icon' => $model->icon, - 'isHidden' => $model->is_hidden - ]; - } - - private function translateGroupName(string $name): string - { - $translation = $this->translator->trans($key = 'core.group.'.strtolower($name)); - - if ($translation !== $key) { - return $translation; - } - - return $name; - } -} diff --git a/framework/core/src/Api/Serializer/MailSettingsSerializer.php b/framework/core/src/Api/Serializer/MailSettingsSerializer.php deleted file mode 100644 index bff907e53..000000000 --- a/framework/core/src/Api/Serializer/MailSettingsSerializer.php +++ /dev/null @@ -1,40 +0,0 @@ - array_map([$this, 'serializeDriver'], $model['drivers']), - 'sending' => $model['sending'], - 'errors' => $model['errors'], - ]; - } - - private function serializeDriver(DriverInterface $driver): array - { - return $driver->availableSettings(); - } - - public function getId($model) - { - return 'global'; - } -} diff --git a/framework/core/src/Api/Serializer/NotificationSerializer.php b/framework/core/src/Api/Serializer/NotificationSerializer.php deleted file mode 100644 index 70bf2288e..000000000 --- a/framework/core/src/Api/Serializer/NotificationSerializer.php +++ /dev/null @@ -1,66 +0,0 @@ - $model->type, - 'content' => $model->data, - 'createdAt' => $this->formatDate($model->created_at), - 'isRead' => (bool) $model->read_at - ]; - } - - protected function user(Notification $notification): ?Relationship - { - return $this->hasOne($notification, BasicUserSerializer::class); - } - - protected function fromUser(Notification $notification): ?Relationship - { - return $this->hasOne($notification, BasicUserSerializer::class); - } - - protected function subject(Notification $notification): ?Relationship - { - return $this->hasOne($notification, function (Notification $notification) { - return static::$subjectSerializers[$notification->type]; - }); - } - - public static function setSubjectSerializer(string $type, string $serializer): void - { - static::$subjectSerializers[$type] = $serializer; - } -} diff --git a/framework/core/src/Api/Serializer/PostSerializer.php b/framework/core/src/Api/Serializer/PostSerializer.php deleted file mode 100644 index 7bf965c51..000000000 --- a/framework/core/src/Api/Serializer/PostSerializer.php +++ /dev/null @@ -1,77 +0,0 @@ -actor->can('edit', $model); - - if ($model instanceof CommentPost) { - if ($canEdit) { - $attributes['content'] = $model->content; - } - if ($this->actor->can('viewIps', $model)) { - $attributes['ipAddress'] = $model->ip_address; - } - } else { - $attributes['content'] = $model->content; - } - - if ($model->edited_at) { - $attributes['editedAt'] = $this->formatDate($model->edited_at); - } - - if ($model->hidden_at) { - $attributes['isHidden'] = true; - $attributes['hiddenAt'] = $this->formatDate($model->hidden_at); - } - - $attributes += [ - 'canEdit' => $canEdit, - 'canDelete' => $this->actor->can('delete', $model), - 'canHide' => $this->actor->can('hide', $model) - ]; - - return $attributes; - } - - protected function user(Post $post): ?Relationship - { - return $this->hasOne($post, UserSerializer::class); - } - - protected function discussion(Post $post): ?Relationship - { - return $this->hasOne($post, BasicDiscussionSerializer::class); - } - - protected function editedUser(Post $post): ?Relationship - { - return $this->hasOne($post, BasicUserSerializer::class); - } - - protected function hiddenUser(Post $post): ?Relationship - { - return $this->hasOne($post, BasicUserSerializer::class); - } -} diff --git a/framework/core/src/Api/Serializer/UserSerializer.php b/framework/core/src/Api/Serializer/UserSerializer.php deleted file mode 100644 index 28647f27e..000000000 --- a/framework/core/src/Api/Serializer/UserSerializer.php +++ /dev/null @@ -1,48 +0,0 @@ - $this->formatDate($model->joined_at), - 'discussionCount' => (int) $model->discussion_count, - 'commentCount' => (int) $model->comment_count, - 'canEdit' => $this->actor->can('edit', $model), - 'canEditCredentials' => $this->actor->can('editCredentials', $model), - 'canEditGroups' => $this->actor->can('editGroups', $model), - 'canDelete' => $this->actor->can('delete', $model), - ]; - - if ($model->getPreference('discloseOnline') || $this->actor->can('viewLastSeenAt', $model)) { - $attributes += [ - 'lastSeenAt' => $this->formatDate($model->last_seen_at) - ]; - } - - if ($attributes['canEditCredentials'] || $this->actor->id === $model->id) { - $attributes += [ - 'isEmailConfirmed' => (bool) $model->is_email_confirmed, - 'email' => $model->email - ]; - } - - return $attributes; - } -} diff --git a/framework/core/src/Api/Sort/SortColumn.php b/framework/core/src/Api/Sort/SortColumn.php new file mode 100644 index 000000000..6732e2fa0 --- /dev/null +++ b/framework/core/src/Api/Sort/SortColumn.php @@ -0,0 +1,48 @@ + null, + 'desc' => null, + ]; + + public function ascendingAlias(?string $alias): static + { + $this->alias['asc'] = $alias; + + return $this; + } + + public function descendingAlias(?string $alias): static + { + $this->alias['desc'] = $alias; + + return $this; + } + + public function sortMap(): array + { + $map = []; + + foreach ($this->alias as $direction => $alias) { + if ($alias) { + $sort = ($direction === 'asc' ? '' : '-').$this->name; + $map[$alias] = $sort; + } + } + + return $map; + } +} diff --git a/framework/core/src/Api/routes.php b/framework/core/src/Api/routes.php index 7a7dc412d..9ae4cbb09 100644 --- a/framework/core/src/Api/routes.php +++ b/framework/core/src/Api/routes.php @@ -19,27 +19,6 @@ return function (RouteCollection $map, RouteHandlerFactory $route) { $route->toController(Controller\ShowForumController::class) ); - // List access tokens - $map->get( - '/access-tokens', - 'access-tokens.index', - $route->toController(Controller\ListAccessTokensController::class) - ); - - // Create access token - $map->post( - '/access-tokens', - 'access-tokens.create', - $route->toController(Controller\CreateAccessTokenController::class) - ); - - // Delete access token - $map->delete( - '/access-tokens/{id}', - 'access-tokens.delete', - $route->toController(Controller\DeleteAccessTokenController::class) - ); - // Create authentication token $map->post( '/token', @@ -67,55 +46,6 @@ return function (RouteCollection $map, RouteHandlerFactory $route) { |-------------------------------------------------------------------------- */ - // List users - $map->get( - '/users', - 'users.index', - $route->toController(Controller\ListUsersController::class) - ); - - // Register a user - $map->post( - '/users', - 'users.create', - $route->toController(Controller\CreateUserController::class) - ); - - // Get a single user - $map->get( - '/users/{id}', - 'users.show', - $route->toController(Controller\ShowUserController::class) - ); - - // Edit a user - $map->patch( - '/users/{id}', - 'users.update', - $route->toController(Controller\UpdateUserController::class) - ); - - // Delete a user - $map->delete( - '/users/{id}', - 'users.delete', - $route->toController(Controller\DeleteUserController::class) - ); - - // Upload avatar - $map->post( - '/users/{id}/avatar', - 'users.avatar.upload', - $route->toController(Controller\UploadAvatarController::class) - ); - - // Remove avatar - $map->delete( - '/users/{id}/avatar', - 'users.avatar.delete', - $route->toController(Controller\DeleteAvatarController::class) - ); - // send confirmation email $map->post( '/users/{id}/send-confirmation', @@ -129,13 +59,6 @@ return function (RouteCollection $map, RouteHandlerFactory $route) { |-------------------------------------------------------------------------- */ - // List notifications for the current user - $map->get( - '/notifications', - 'notifications.index', - $route->toController(Controller\ListNotificationsController::class) - ); - // Mark all notifications as read $map->post( '/notifications/read', @@ -143,13 +66,6 @@ return function (RouteCollection $map, RouteHandlerFactory $route) { $route->toController(Controller\ReadAllNotificationsController::class) ); - // Mark a single notification as read - $map->patch( - '/notifications/{id}', - 'notifications.update', - $route->toController(Controller\UpdateNotificationController::class) - ); - // Delete all notifications for the current user. $map->delete( '/notifications', @@ -157,129 +73,6 @@ return function (RouteCollection $map, RouteHandlerFactory $route) { $route->toController(Controller\DeleteAllNotificationsController::class) ); - /* - |-------------------------------------------------------------------------- - | Discussions - |-------------------------------------------------------------------------- - */ - - // List discussions - $map->get( - '/discussions', - 'discussions.index', - $route->toController(Controller\ListDiscussionsController::class) - ); - - // Create a discussion - $map->post( - '/discussions', - 'discussions.create', - $route->toController(Controller\CreateDiscussionController::class) - ); - - // Show a single discussion - $map->get( - '/discussions/{id}', - 'discussions.show', - $route->toController(Controller\ShowDiscussionController::class) - ); - - // Edit a discussion - $map->patch( - '/discussions/{id}', - 'discussions.update', - $route->toController(Controller\UpdateDiscussionController::class) - ); - - // Delete a discussion - $map->delete( - '/discussions/{id}', - 'discussions.delete', - $route->toController(Controller\DeleteDiscussionController::class) - ); - - /* - |-------------------------------------------------------------------------- - | Posts - |-------------------------------------------------------------------------- - */ - - // List posts, usually for a discussion - $map->get( - '/posts', - 'posts.index', - $route->toController(Controller\ListPostsController::class) - ); - - // Create a post - $map->post( - '/posts', - 'posts.create', - $route->toController(Controller\CreatePostController::class) - ); - - // Show a single or multiple posts by ID - $map->get( - '/posts/{id}', - 'posts.show', - $route->toController(Controller\ShowPostController::class) - ); - - // Edit a post - $map->patch( - '/posts/{id}', - 'posts.update', - $route->toController(Controller\UpdatePostController::class) - ); - - // Delete a post - $map->delete( - '/posts/{id}', - 'posts.delete', - $route->toController(Controller\DeletePostController::class) - ); - - /* - |-------------------------------------------------------------------------- - | Groups - |-------------------------------------------------------------------------- - */ - - // List groups - $map->get( - '/groups', - 'groups.index', - $route->toController(Controller\ListGroupsController::class) - ); - - // Create a group - $map->post( - '/groups', - 'groups.create', - $route->toController(Controller\CreateGroupController::class) - ); - - // Show a single group - $map->get( - '/groups/{id}', - 'groups.show', - $route->toController(Controller\ShowGroupController::class) - ); - - // Edit a group - $map->patch( - '/groups/{id}', - 'groups.update', - $route->toController(Controller\UpdateGroupController::class) - ); - - // Delete a group - $map->delete( - '/groups/{id}', - 'groups.delete', - $route->toController(Controller\DeleteGroupController::class) - ); - /* |-------------------------------------------------------------------------- | Administration @@ -300,13 +93,6 @@ return function (RouteCollection $map, RouteHandlerFactory $route) { $route->toController(Controller\UninstallExtensionController::class) ); - // Get readme for an extension - $map->get( - '/extension-readmes/{name}', - 'extension-readmes.show', - $route->toController(Controller\ShowExtensionReadmeController::class) - ); - // Extension bisect $map->post( '/extension-bisect', diff --git a/framework/core/src/Database/AbstractModel.php b/framework/core/src/Database/AbstractModel.php index 65c15a7dd..ce1e04a39 100644 --- a/framework/core/src/Database/AbstractModel.php +++ b/framework/core/src/Database/AbstractModel.php @@ -11,10 +11,8 @@ namespace Flarum\Database; use Flarum\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model as Eloquent; -use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\Arr; use Illuminate\Support\Str; -use LogicException; /** * Base model class, building on Eloquent. @@ -46,11 +44,6 @@ abstract class AbstractModel extends Eloquent */ protected array $afterDeleteCallbacks = []; - /** - * @internal - */ - public static array $customRelations = []; - /** * @internal */ @@ -118,47 +111,6 @@ abstract class AbstractModel extends Eloquent return $casts; } - /** - * Get an attribute from the model. If nothing is found, attempt to load - * a custom relation method with this key. - */ - public function getAttribute($key) - { - if (! is_null($value = parent::getAttribute($key))) { - return $value; - } - - // If a custom relation with this key has been set up, then we will load - // and return results from the query and hydrate the relationship's - // value on the "relationships" array. - if (! $this->relationLoaded($key) && ($relation = $this->getCustomRelation($key))) { - if (! $relation instanceof Relation) { - throw new LogicException( - 'Relationship method must return an object of type '.Relation::class - ); - } - - return $this->relations[$key] = $relation->getResults(); - } - - return null; - } - - /** - * Get a custom relation object. - */ - protected function getCustomRelation(string $name): mixed - { - foreach (array_merge([static::class], class_parents($this)) as $class) { - $relation = Arr::get(static::$customRelations, $class.".$name"); - if (! is_null($relation)) { - return $relation($this); - } - } - - return null; - } - /** * Register a callback to be run once after the model is saved. */ @@ -199,15 +151,6 @@ abstract class AbstractModel extends Eloquent return $callbacks; } - public function __call($method, $parameters) - { - if ($relation = $this->getCustomRelation($method)) { - return $relation; - } - - return parent::__call($method, $parameters); - } - public function newModelQuery() { $query = parent::newModelQuery(); diff --git a/framework/core/src/Discussion/Command/DeleteDiscussion.php b/framework/core/src/Discussion/Command/DeleteDiscussion.php deleted file mode 100644 index 805a63fec..000000000 --- a/framework/core/src/Discussion/Command/DeleteDiscussion.php +++ /dev/null @@ -1,22 +0,0 @@ -actor; - - $discussion = $this->discussions->findOrFail($command->discussionId, $actor); - - $actor->assertCan('delete', $discussion); - - $this->events->dispatch( - new Deleting($discussion, $actor, $command->data) - ); - - $discussion->delete(); - - $this->dispatchEventsFor($discussion, $actor); - - return $discussion; - } -} diff --git a/framework/core/src/Discussion/Command/EditDiscussion.php b/framework/core/src/Discussion/Command/EditDiscussion.php deleted file mode 100644 index e49a1bd51..000000000 --- a/framework/core/src/Discussion/Command/EditDiscussion.php +++ /dev/null @@ -1,22 +0,0 @@ -actor; - $data = $command->data; - $attributes = Arr::get($data, 'attributes', []); - - $discussion = $this->discussions->findOrFail($command->discussionId, $actor); - - if (isset($attributes['title'])) { - $actor->assertCan('rename', $discussion); - - $discussion->rename($attributes['title']); - } - - if (isset($attributes['isHidden'])) { - $actor->assertCan('hide', $discussion); - - if ($attributes['isHidden']) { - $discussion->hide($actor); - } else { - $discussion->restore(); - } - } - - $this->events->dispatch( - new Saving($discussion, $actor, $data) - ); - - $this->validator->assertValid($discussion->getDirty()); - - $discussion->save(); - - $this->dispatchEventsFor($discussion, $actor); - - return $discussion; - } -} diff --git a/framework/core/src/Discussion/Command/StartDiscussion.php b/framework/core/src/Discussion/Command/StartDiscussion.php deleted file mode 100644 index e3d8756bf..000000000 --- a/framework/core/src/Discussion/Command/StartDiscussion.php +++ /dev/null @@ -1,22 +0,0 @@ -actor; - $data = $command->data; - $ipAddress = $command->ipAddress; - - $actor->assertCan('startDiscussion'); - - // Create a new Discussion entity, persist it, and dispatch domain - // events. Before persistence, though, fire an event to give plugins - // an opportunity to alter the discussion entity based on data in the - // command they may have passed through in the controller. - $discussion = Discussion::start( - Arr::get($data, 'attributes.title'), - $actor - ); - - $this->events->dispatch( - new Saving($discussion, $actor, $data) - ); - - $this->validator->assertValid($discussion->getAttributes()); - - $discussion->save(); - - // Now that the discussion has been created, we can add the first post. - // We will do this by running the PostReply command. - try { - $post = $this->bus->dispatch( - new PostReply($discussion->id, $actor, $data, $ipAddress, true) - ); - } catch (Exception $e) { - $discussion->delete(); - - throw $e; - } - - // Before we dispatch events, refresh our discussion instance's - // attributes as posting the reply will have changed some of them (e.g. - // last_time.) - $discussion->setRawAttributes($post->discussion->getAttributes(), true); - $discussion->setFirstPost($post); - $discussion->setLastPost($post); - - $this->dispatchEventsFor($discussion, $actor); - - $discussion->save(); - - return $discussion; - } -} diff --git a/framework/core/src/Discussion/Discussion.php b/framework/core/src/Discussion/Discussion.php index 924f9d634..619c3dabb 100644 --- a/framework/core/src/Discussion/Discussion.php +++ b/framework/core/src/Discussion/Discussion.php @@ -16,7 +16,6 @@ use Flarum\Discussion\Event\Deleted; use Flarum\Discussion\Event\Hidden; use Flarum\Discussion\Event\Renamed; use Flarum\Discussion\Event\Restored; -use Flarum\Discussion\Event\Started; use Flarum\Foundation\EventGeneratorTrait; use Flarum\Notification\Notification; use Flarum\Post\MergeableInterface; @@ -115,21 +114,6 @@ class Discussion extends AbstractModel }); } - public static function start(string $title, User $user): static - { - $discussion = new static; - - $discussion->title = $title; - $discussion->created_at = Carbon::now(); - $discussion->user_id = $user->id; - - $discussion->setRelation('user', $user); - - $discussion->raise(new Started($discussion)); - - return $discussion; - } - public function rename(string $title): static { if ($this->title !== $title) { diff --git a/framework/core/src/Discussion/DiscussionValidator.php b/framework/core/src/Discussion/DiscussionValidator.php deleted file mode 100644 index 48013e62d..000000000 --- a/framework/core/src/Discussion/DiscussionValidator.php +++ /dev/null @@ -1,23 +0,0 @@ - [ - 'required', - 'min:3', - 'max:80' - ] - ]; -} diff --git a/framework/core/src/Extend/ApiController.php b/framework/core/src/Extend/ApiController.php deleted file mode 100644 index 8ee6f58c9..000000000 --- a/framework/core/src/Extend/ApiController.php +++ /dev/null @@ -1,429 +0,0 @@ - $controllerClass: The ::class attribute of the controller you are modifying. - * This controller should extend from \Flarum\Api\Controller\AbstractSerializeController. - */ - public function __construct( - private readonly string $controllerClass - ) { - } - - /** - * @template S of AbstractSerializeController - * @param (callable(S $controller): void)|class-string $callback - * - * The callback can be a closure or an invokable class, and should accept: - * - $controller: An instance of this controller. - * - * @return self - */ - public function prepareDataQuery(callable|string $callback): self - { - $this->beforeDataCallbacks[] = $callback; - - return $this; - } - - /** - * @template S of AbstractSerializeController - * @param (callable(S $controller, mixed $data, ServerRequestInterface $request, Document $document): array)|class-string $callback - * - * The callback can be a closure or an invokable class, and should accept: - * - $controller: An instance of this controller. - * - $data: Mixed, can be an array of data or an object (like an instance of Collection or AbstractModel). - * - $request: An instance of \Psr\Http\Message\ServerRequestInterface. - * - $document: An instance of \Tobscure\JsonApi\Document. - * - * The callable should return: - * - An array of additional data to merge with the existing array. - * Or a modified $data array. - * - * @return self - */ - public function prepareDataForSerialization(callable|string $callback): self - { - $this->beforeSerializationCallbacks[] = $callback; - - return $this; - } - - /** - * Set the serializer that will serialize data for the endpoint. - * - * @template S of AbstractSerializeController - * @param class-string $serializerClass: The ::class attribute of the serializer. - * @param (callable(S $controller): bool)|string|null $callback - * - * The optional callback can be a closure or an invokable class, and should accept: - * - $controller: An instance of this controller. - * - * The callable should return: - * - A boolean value to determine if this applies. - * - * @return self - */ - public function setSerializer(string $serializerClass, callable|string $callback = null): self - { - $this->serializer = [$serializerClass, $callback]; - - return $this; - } - - /** - * Include the given relationship by default. - * - * @template S of AbstractSerializeController - * @param array|string $name: The name of the relation. - * @param (callable(S $controller): bool)|class-string|null $callback - * - * The optional callback can be a closure or an invokable class, and should accept: - * - $controller: An instance of this controller. - * - * The callable should return: - * - A boolean value to determine if this applies. - * - * @return self - */ - public function addInclude(array|string $name, callable|string $callback = null): self - { - $this->addIncludes[] = [$name, $callback]; - - return $this; - } - - /** - * Don't include the given relationship by default. - * - * @param array|string $name: The name of the relation. - * @template S of AbstractSerializeController - * @param (callable(S $controller): bool)|class-string|null $callback - * - * The optional callback can be a closure or an invokable class, and should accept: - * - $controller: An instance of this controller. - * - * The callable should return: - * - A boolean value to determine if this applies. - * - * @return self - */ - public function removeInclude(array|string $name, callable|string $callback = null): self - { - $this->removeIncludes[] = [$name, $callback]; - - return $this; - } - - /** - * Make the given relationship available for inclusion. - * - * @param array|string $name: The name of the relation. - * @template S of AbstractSerializeController - * @param (callable(S $controller): bool)|class-string|null $callback - * - * The optional callback can be a closure or an invokable class, and should accept: - * - $controller: An instance of this controller. - * - * The callable should return: - * - A boolean value to determine if this applies. - * - * @return self - */ - public function addOptionalInclude(array|string $name, callable|string $callback = null): self - { - $this->addOptionalIncludes[] = [$name, $callback]; - - return $this; - } - - /** - * Don't allow the given relationship to be included. - * - * @param array|string $name: The name of the relation. - * @template S of AbstractSerializeController - * @param (callable(S $controller): bool)|class-string|null $callback - * - * The optional callback can be a closure or an invokable class, and should accept: - * - $controller: An instance of this controller. - * - * The callable should return: - * - A boolean value to determine if this applies. - * - * @return self - */ - public function removeOptionalInclude(array|string $name, callable|string $callback = null): self - { - $this->removeOptionalIncludes[] = [$name, $callback]; - - return $this; - } - - /** - * Set the default number of results. - * - * @param int $limit - * @template S of AbstractSerializeController - * @param (callable(S $controller): bool)|class-string|null $callback - * - * The optional callback can be a closure or an invokable class, and should accept: - * - $controller: An instance of this controller. - * - * The callable should return: - * - A boolean value to determine if this applies. - * - * @return self - */ - public function setLimit(int $limit, callable|string $callback = null): self - { - $this->limit = [$limit, $callback]; - - return $this; - } - - /** - * Set the maximum number of results. - * - * @param int $max - * @template S of AbstractSerializeController - * @param (callable(S $controller): bool)|class-string|null $callback - * - * The optional callback can be a closure or an invokable class, and should accept: - * - $controller: An instance of this controller. - * - * The callable should return: - * - A boolean value to determine if this applies. - * - * @return self - */ - public function setMaxLimit(int $max, callable|string $callback = null): self - { - $this->maxLimit = [$max, $callback]; - - return $this; - } - - /** - * Allow sorting results by the given field. - * - * @param array|string $field - * @template S of AbstractSerializeController - * @param (callable(S $controller): bool)|class-string|null $callback - * - * The optional callback can be a closure or an invokable class, and should accept: - * - $controller: An instance of this controller. - * - * The callable should return: - * - A boolean value to determine if this applies. - * - * @return self - */ - public function addSortField(array|string $field, callable|string $callback = null): self - { - $this->addSortFields[] = [$field, $callback]; - - return $this; - } - - /** - * Disallow sorting results by the given field. - * - * @param array|string $field - * @template S of AbstractSerializeController - * @param (callable(S $controller): bool)|class-string|null $callback - * - * The optional callback can be a closure or an invokable class, and should accept: - * - $controller: An instance of this controller. - * - * The callable should return: - * - A boolean value to determine if this applies. - * - * @return self - */ - public function removeSortField(array|string $field, callable|string $callback = null): self - { - $this->removeSortFields[] = [$field, $callback]; - - return $this; - } - - /** - * Set the default sort order for the results. - * - * @param array $sort - * @template S of AbstractSerializeController - * @param (callable(S $controller): bool)|class-string|null $callback - * - * The optional callback can be a closure or an invokable class, and should accept: - * - $controller: An instance of this controller. - * - * The callable should return: - * - A boolean value to determine if this applies. - * - * @return self - */ - public function setSort(array $sort, callable|string $callback = null): self - { - $this->sort = [$sort, $callback]; - - return $this; - } - - /** - * Eager loads relationships needed for serializer logic. - * - * First level relationships will be loaded regardless of whether they are included in the response. - * Sub-level relationships will only be loaded if the upper level was included or manually loaded. - * - * @example If a relationship such as: 'relation.subRelation' is specified, - * it will only be loaded if 'relation' is or has been loaded. - * To force load the relationship, both levels have to be specified, - * example: ['relation', 'relation.subRelation']. - * - * @param string|string[] $relations - * @return self - */ - public function load(array|string $relations): self - { - $this->load = array_merge($this->load, array_map('strval', (array) $relations)); - - return $this; - } - - /** - * Allows loading a relationship with additional query modification. - * - * @param string $relation: Relationship name, see load method description. - * @template R of Relation - * @param (callable(Builder|R, \Psr\Http\Message\ServerRequestInterface|null, array): void) $callback - * - * The callback to modify the query, should accept: - * - \Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Eloquent\Relations\Relation $query: A query object. - * - \Psr\Http\Message\ServerRequestInterface|null $request: An instance of the request. - * - array $relations: An array of relations that are to be loaded. - * - * @return self - */ - public function loadWhere(string $relation, callable $callback): self - { - $this->loadCallables = array_merge($this->loadCallables, [$relation => $callback]); - - return $this; - } - - public function extend(Container $container, Extension $extension = null): void - { - $this->beforeDataCallbacks[] = function (AbstractSerializeController $controller) use ($container) { - if (isset($this->serializer) && $this->isApplicable($this->serializer[1], $controller, $container)) { - $controller->setSerializer($this->serializer[0]); - } - - foreach ($this->addIncludes as $addingInclude) { - if ($this->isApplicable($addingInclude[1], $controller, $container)) { - $controller->addInclude($addingInclude[0]); - } - } - - foreach ($this->removeIncludes as $removingInclude) { - if ($this->isApplicable($removingInclude[1], $controller, $container)) { - $controller->removeInclude($removingInclude[0]); - } - } - - foreach ($this->addOptionalIncludes as $addingOptionalInclude) { - if ($this->isApplicable($addingOptionalInclude[1], $controller, $container)) { - $controller->addOptionalInclude($addingOptionalInclude[0]); - } - } - - foreach ($this->removeOptionalIncludes as $removingOptionalInclude) { - if ($this->isApplicable($removingOptionalInclude[1], $controller, $container)) { - $controller->removeOptionalInclude($removingOptionalInclude[0]); - } - } - - foreach ($this->addSortFields as $addingSortField) { - if ($this->isApplicable($addingSortField[1], $controller, $container)) { - $controller->addSortField($addingSortField[0]); - } - } - - foreach ($this->removeSortFields as $removingSortField) { - if ($this->isApplicable($removingSortField[1], $controller, $container)) { - $controller->removeSortField($removingSortField[0]); - } - } - - if (isset($this->limit) && $this->isApplicable($this->limit[1], $controller, $container)) { - $controller->setLimit($this->limit[0]); - } - - if (isset($this->maxLimit) && $this->isApplicable($this->maxLimit[1], $controller, $container)) { - $controller->setMaxLimit($this->maxLimit[0]); - } - - if (isset($this->sort) && $this->isApplicable($this->sort[1], $controller, $container)) { - $controller->setSort($this->sort[0]); - } - }; - - foreach ($this->beforeDataCallbacks as $beforeDataCallback) { - $beforeDataCallback = ContainerUtil::wrapCallback($beforeDataCallback, $container); - AbstractSerializeController::addDataPreparationCallback($this->controllerClass, $beforeDataCallback); - } - - foreach ($this->beforeSerializationCallbacks as $beforeSerializationCallback) { - $beforeSerializationCallback = ContainerUtil::wrapCallback($beforeSerializationCallback, $container); - AbstractSerializeController::addSerializationPreparationCallback($this->controllerClass, $beforeSerializationCallback); - } - - AbstractSerializeController::setLoadRelations($this->controllerClass, $this->load); - AbstractSerializeController::setLoadRelationCallables($this->controllerClass, $this->loadCallables); - } - - private function isApplicable(callable|string|null $callback, AbstractSerializeController $controller, Container $container): bool - { - if (! isset($callback)) { - return true; - } - - $callback = ContainerUtil::wrapCallback($callback, $container); - - return (bool) $callback($controller); - } -} diff --git a/framework/core/src/Extend/ApiResource.php b/framework/core/src/Extend/ApiResource.php new file mode 100644 index 000000000..ec079e0ae --- /dev/null +++ b/framework/core/src/Extend/ApiResource.php @@ -0,0 +1,294 @@ + + */ + private readonly string $resourceClass + ) { + } + + /** + * Add endpoints to the resource. + * + * @param callable|class-string $endpoints must be a callable that returns an array of objects that implement \Flarum\Api\Endpoint\Endpoint. + */ + public function endpoints(callable|string $endpoints): self + { + $this->endpoints[] = $endpoints; + + return $this; + } + + /** + * Remove endpoints from the resource. + * + * @param array $endpoints must be an array of names of the endpoints. + * @param callable|class-string|null $condition a callable that returns a boolean or a string that represents whether this should be applied. + */ + public function removeEndpoints(array $endpoints, callable|string $condition = null): self + { + $this->removeEndpoints[] = [$endpoints, $condition]; + + return $this; + } + + /** + * Modify an endpoint. + * + * @param string|string[] $endpointNameOrClass the name or class name of the endpoint or an array of so. + * @param callable|class-string $mutator a callable that accepts an endpoint and returns the modified endpoint. + */ + public function endpoint(string|array $endpointNameOrClass, callable|string $mutator): self + { + foreach ((array) $endpointNameOrClass as $item) { + $this->endpoint[$item][] = $mutator; + } + + return $this; + } + + /** + * Add fields to the resource. + * + * @param callable|class-string $fields must be a callable that returns an array of objects that implement \Tobyz\JsonApiServer\Schema\Field. + */ + public function fields(callable|string $fields): self + { + $this->fields[] = $fields; + + return $this; + } + + /** + * Remove fields from the resource. + * + * @param array $fields must be an array of field names. + * @param callable|class-string|null $condition a callable that returns a boolean or a string that represents whether this should be applied. + */ + public function removeFields(array $fields, callable|string $condition = null): self + { + $this->removeFields[] = [$fields, $condition]; + + return $this; + } + + /** + * Modify a field. + * + * @param string|string[] $field the name of the field or an array of field names. + * @param callable|class-string $mutator a callable that accepts a field and returns the modified field. + */ + public function field(string|array $field, callable|string $mutator): self + { + foreach ((array) $field as $fieldItem) { + $this->field[$fieldItem][] = $mutator; + } + + return $this; + } + + /** + * Add sorts to the resource. + * + * @param callable|class-string $sorts must be a callable that returns an array of objects that implement \Tobyz\JsonApiServer\Schema\Sort. + */ + public function sorts(callable|string $sorts): self + { + $this->sorts[] = $sorts; + + return $this; + } + + /** + * Remove sorts from the resource. + * + * @param array $sorts must be an array of sort names. + * @param callable|class-string|null $condition a callable that returns a boolean or a string that represents whether this should be applied. + */ + public function removeSorts(array $sorts, callable|string $condition = null): self + { + $this->removeSorts[] = [$sorts, $condition]; + + return $this; + } + + /** + * Modify a sort. + * + * @param string|string[] $sort the name of the sort or an array of sort names. + * @param callable|class-string $mutator a callable that accepts a sort and returns the modified sort. + */ + public function sort(string|array $sort, callable|string $mutator): self + { + foreach ((array) $sort as $sortItem) { + $this->sort[$sortItem][] = $mutator; + } + + return $this; + } + + public function extend(Container $container, Extension $extension = null): void + { + if (! (new ReflectionClass($this->resourceClass))->isAbstract()) { + $container->extend('flarum.api.resources', function (array $resources) { + if (! in_array($this->resourceClass, $resources, true)) { + $resources[] = $this->resourceClass; + } + + return $resources; + }); + } + + /** @var class-string<\Flarum\Api\Resource\AbstractResource|\Flarum\Api\Resource\AbstractDatabaseResource> $resourceClass */ + $resourceClass = $this->resourceClass; + + $resourceClass::mutateEndpoints( + /** + * @var EndpointInterface[] $endpoints + */ + function (array $endpoints, Resource $resource) use ($container): array { + foreach ($this->endpoints as $newEndpointsCallback) { + $newEndpointsCallback = ContainerUtil::wrapCallback($newEndpointsCallback, $container); + $endpoints = array_merge($endpoints, $newEndpointsCallback()); + } + + foreach ($this->removeEndpoints as $removeEndpointClass) { + [$endpointsToRemove, $condition] = $removeEndpointClass; + + if ($this->isApplicable($condition, $resource, $container)) { + $endpoints = array_filter($endpoints, fn (Endpoint $endpoint) => ! in_array($endpoint->name, $endpointsToRemove)); + } + } + + foreach ($endpoints as $key => $endpoint) { + $endpointClass = $endpoint::class; + + if (! empty($this->endpoint[$endpoint->name]) || ! empty($this->endpoint[$endpointClass])) { + foreach (array_merge($this->endpoint[$endpoint->name] ?? [], $this->endpoint[$endpointClass] ?? []) as $mutator) { + $mutateEndpoint = ContainerUtil::wrapCallback($mutator, $container); + $endpoint = $mutateEndpoint($endpoint, $resource); + + if (! $endpoint instanceof EndpointInterface) { + throw new RuntimeException('The endpoint mutator must return an instance of '.EndpointInterface::class); + } + } + } + + $endpoints[$key] = $endpoint; + } + + return $endpoints; + } + ); + + $resourceClass::mutateFields(function (array $fields, Resource $resource) use ($container): array { + foreach ($this->fields as $newFieldsCallback) { + $newFieldsCallback = ContainerUtil::wrapCallback($newFieldsCallback, $container); + $fields = array_merge($fields, $newFieldsCallback()); + } + + foreach ($this->removeFields as $field) { + [$fieldsToRemove, $condition] = $field; + + if ($this->isApplicable($condition, $resource, $container)) { + $fields = array_filter($fields, fn (Field $f) => ! in_array($f->name, $fieldsToRemove)); + } + } + + foreach ($fields as $key => $field) { + if (! empty($this->field[$field->name])) { + foreach ($this->field[$field->name] as $mutator) { + $mutateField = ContainerUtil::wrapCallback($mutator, $container); + $field = $mutateField($field); + + if (! $field instanceof Field) { + throw new RuntimeException('The field mutator must return an instance of '.Field::class); + } + } + } + + $fields[$key] = $field; + } + + return $fields; + }); + + $resourceClass::mutateSorts(function (array $sorts, Resource $resource) use ($container): array { + foreach ($this->sorts as $newSortsCallback) { + $newSortsCallback = ContainerUtil::wrapCallback($newSortsCallback, $container); + $sorts = array_merge($sorts, $newSortsCallback()); + } + + foreach ($this->removeSorts as $sort) { + [$sortsToRemove, $condition] = $sort; + + if ($this->isApplicable($condition, $resource, $container)) { + $sorts = array_filter($sorts, fn (Sort $s) => ! in_array($s->name, $sortsToRemove)); + } + } + + foreach ($sorts as $key => $sort) { + if (! empty($this->sort[$sort->name])) { + foreach ($this->sort[$sort->name] as $mutator) { + $mutateSort = ContainerUtil::wrapCallback($mutator, $container); + $sort = $mutateSort($sort); + + if (! $sort instanceof Sort) { + throw new RuntimeException('The sort mutator must return an instance of '.Sort::class); + } + } + } + + $sorts[$key] = $sort; + } + + return $sorts; + }); + } + + private function isApplicable(callable|string|null $callback, Resource $resource, Container $container): bool + { + if (! isset($callback)) { + return true; + } + + $callback = ContainerUtil::wrapCallback($callback, $container); + + return (bool) $callback($resource); + } +} diff --git a/framework/core/src/Extend/ApiSerializer.php b/framework/core/src/Extend/ApiSerializer.php deleted file mode 100644 index 8448320a1..000000000 --- a/framework/core/src/Extend/ApiSerializer.php +++ /dev/null @@ -1,169 +0,0 @@ - $serializerClass The ::class attribute of the serializer you are modifying. - * This serializer should extend from \Flarum\Api\Serializer\AbstractSerializer. - */ - public function __construct( - private readonly string $serializerClass - ) { - } - - /** - * Add a single attribute to this serializer. - * - * @template T of AbstractModel - * @template S of AbstractSerializer - * @param string $name: The name of the attribute. - * @param (callable(S $serializer, T $model, array $attributes): mixed)|class-string $callback - * - * The callback can be a closure or an invokable class, and should accept: - * - $serializer: An instance of this serializer. - * - $model: An instance of the model being serialized. - * - $attributes: An array of existing attributes. - * - * The callable should return: - * - The value of the attribute. - * - * @return self - */ - public function attribute(string $name, callable|string $callback): self - { - $this->attribute[$name] = $callback; - - return $this; - } - - /** - * Add to or modify the attributes array of this serializer. - * - * @param (callable(AbstractSerializer $serializer, AbstractModel $model, array $attributes): array)|string $callback - * - * The callback can be a closure or an invokable class, and should accept: - * - $serializer: An instance of this serializer. - * - $model: An instance of the model being serialized. - * - $attributes: An array of existing attributes. - * - * The callable should return: - * - An array of additional attributes to merge with the existing array. - * Or a modified $attributes array. - * - * @return self - */ - public function attributes(callable|string $callback): self - { - $this->attributes[] = $callback; - - return $this; - } - - /** - * Establish a simple hasOne relationship from this serializer to another serializer. - * This represents a one-to-one relationship. - * - * @param string $name: The name of the relation. Has to be unique from other relation names. - * The relation has to exist in the model handled by this serializer. - * @param string $serializerClass: The ::class attribute the serializer that handles this relation. - * This serializer should extend from \Flarum\Api\Serializer\AbstractSerializer. - * @return self - */ - public function hasOne(string $name, string $serializerClass): self - { - return $this->relationship($name, function (AbstractSerializer $serializer, $model) use ($serializerClass, $name) { - return $serializer->hasOne($model, $serializerClass, $name); - }); - } - - /** - * Establish a simple hasMany relationship from this serializer to another serializer. - * This represents a one-to-many relationship. - * - * @param string $name: The name of the relation. Has to be unique from other relation names. - * The relation has to exist in the model handled by this serializer. - * @param string $serializerClass: The ::class attribute the serializer that handles this relation. - * This serializer should extend from \Flarum\Api\Serializer\AbstractSerializer. - * @return self - */ - public function hasMany(string $name, string $serializerClass): self - { - return $this->relationship($name, function (AbstractSerializer $serializer, $model) use ($serializerClass, $name) { - return $serializer->hasMany($model, $serializerClass, $name); - }); - } - - /** - * Add a relationship from this serializer to another serializer. - * - * @param string $name: The name of the relation. Has to be unique from other relation names. - * The relation has to exist in the model handled by this serializer. - * @template T of AbstractModel - * @template S of AbstractSerializer - * @param (callable(S $serializer, T $model): Relationship)|class-string $callback - * - * The callable can be a closure or an invokable class, and should accept: - * - $serializer: An instance of this serializer. - * - $model: An instance of the model being serialized. - * - * The callable should return: - * - $relationship: An instance of \Tobscure\JsonApi\Relationship. - * - * @return self - */ - public function relationship(string $name, callable|string $callback): self - { - $this->relationships[$this->serializerClass][$name] = $callback; - - return $this; - } - - public function extend(Container $container, Extension $extension = null): void - { - if (! empty($this->attribute)) { - $this->attributes[] = function ($serializer, $model, $attributes) use ($container) { - foreach ($this->attribute as $attributeName => $callback) { - $callback = ContainerUtil::wrapCallback($callback, $container); - - $attributes[$attributeName] = $callback($serializer, $model, $attributes); - } - - return $attributes; - }; - } - - foreach ($this->attributes as $callback) { - $callback = ContainerUtil::wrapCallback($callback, $container); - - AbstractSerializer::addAttributeMutator($this->serializerClass, $callback); - } - - foreach ($this->relationships as $serializerClass => $relationships) { - foreach ($relationships as $relation => $callback) { - $callback = ContainerUtil::wrapCallback($callback, $container); - - AbstractSerializer::setRelationship($serializerClass, $relation, $callback); - } - } - } -} diff --git a/framework/core/src/Extend/Model.php b/framework/core/src/Extend/Model.php index c6763ae84..4519b4d12 100644 --- a/framework/core/src/Extend/Model.php +++ b/framework/core/src/Extend/Model.php @@ -179,7 +179,9 @@ class Model implements ExtenderInterface public function extend(Container $container, Extension $extension = null): void { foreach ($this->customRelations as $name => $callback) { - Arr::set(AbstractModel::$customRelations, "$this->modelClass.$name", ContainerUtil::wrapCallback($callback, $container)); + /** @var class-string $modelClass */ + $modelClass = $this->modelClass; + $modelClass::resolveRelationUsing($name, ContainerUtil::wrapCallback($callback, $container)); } Arr::set( diff --git a/framework/core/src/Extend/Notification.php b/framework/core/src/Extend/Notification.php index a3d8a7f93..6a9f5f86e 100644 --- a/framework/core/src/Extend/Notification.php +++ b/framework/core/src/Extend/Notification.php @@ -9,7 +9,6 @@ namespace Flarum\Extend; -use Flarum\Api\Serializer\AbstractSerializer; use Flarum\Extension\Extension; use Flarum\Foundation\ContainerUtil; use Flarum\Notification\Blueprint\BlueprintInterface; @@ -20,7 +19,6 @@ use Illuminate\Contracts\Container\Container; class Notification implements ExtenderInterface { private array $blueprints = []; - private array $serializers = []; private array $drivers = []; private array $typesEnabledByDefault = []; private array $beforeSendingCallbacks = []; @@ -28,16 +26,13 @@ class Notification implements ExtenderInterface /** * @param class-string $blueprint: The ::class attribute of the blueprint class. * This blueprint should implement \Flarum\Notification\Blueprint\BlueprintInterface. - * @param class-string $serializer: The ::class attribute of the serializer class. - * This serializer should extend from \Flarum\Api\Serializer\AbstractSerializer. * @param string[] $driversEnabledByDefault: The names of the drivers enabled by default for this notification type. * (example: alert, email). * @return self */ - public function type(string $blueprint, string $serializer, array $driversEnabledByDefault = []): self + public function type(string $blueprint, array $driversEnabledByDefault = []): self { $this->blueprints[$blueprint] = $driversEnabledByDefault; - $this->serializers[$blueprint::getType()] = $serializer; return $this; } @@ -92,10 +87,6 @@ class Notification implements ExtenderInterface return $existingBlueprints; }); - $container->extend('flarum.api.notification_serializers', function ($existingSerializers) { - return array_merge($existingSerializers, $this->serializers); - }); - $container->extend('flarum.notification.drivers', function ($existingDrivers) { return array_merge($existingDrivers, $this->drivers); }); diff --git a/framework/core/src/Extend/Routes.php b/framework/core/src/Extend/Routes.php index fd1b5950c..5e6e2d3fe 100644 --- a/framework/core/src/Extend/Routes.php +++ b/framework/core/src/Extend/Routes.php @@ -40,7 +40,6 @@ class Routes implements ExtenderInterface * * The handler should accept: * - \Psr\Http\Message\ServerRequestInterface $request - * - \Tobscure\JsonApi\Document $document: If it extends one of the Flarum Api controllers. * * The handler should return: * - \Psr\Http\Message\ResponseInterface $response @@ -64,7 +63,6 @@ class Routes implements ExtenderInterface * * The handler should accept: * - \Psr\Http\Message\ServerRequestInterface $request - * - \Tobscure\JsonApi\Document $document: If it extends one of the Flarum Api controllers. * * The handler should return: * - \Psr\Http\Message\ResponseInterface $response @@ -88,7 +86,6 @@ class Routes implements ExtenderInterface * * The handler should accept: * - \Psr\Http\Message\ServerRequestInterface $request - * - \Tobscure\JsonApi\Document $document: If it extends one of the Flarum Api controllers. * * The handler should return: * - \Psr\Http\Message\ResponseInterface $response @@ -112,7 +109,6 @@ class Routes implements ExtenderInterface * * The handler should accept: * - \Psr\Http\Message\ServerRequestInterface $request - * - \Tobscure\JsonApi\Document $document: If it extends one of the Flarum Api controllers. * * The handler should return: * - \Psr\Http\Message\ResponseInterface $response @@ -136,7 +132,6 @@ class Routes implements ExtenderInterface * * The handler should accept: * - \Psr\Http\Message\ServerRequestInterface $request - * - \Tobscure\JsonApi\Document $document: If it extends one of the Flarum Api controllers. * * The handler should return: * - \Psr\Http\Message\ResponseInterface $response diff --git a/framework/core/src/Extend/Settings.php b/framework/core/src/Extend/Settings.php index 35261b45b..5cb7387b2 100644 --- a/framework/core/src/Extend/Settings.php +++ b/framework/core/src/Extend/Settings.php @@ -10,8 +10,8 @@ namespace Flarum\Extend; use Flarum\Admin\WhenSavingSettings; -use Flarum\Api\Serializer\AbstractSerializer; -use Flarum\Api\Serializer\ForumSerializer; +use Flarum\Api\Resource\ForumResource; +use Flarum\Api\Schema\Attribute; use Flarum\Extension\Extension; use Flarum\Foundation\ContainerUtil; use Flarum\Settings\SettingsRepositoryInterface; @@ -114,11 +114,10 @@ class Settings implements ExtenderInterface } if (! empty($this->settings)) { - AbstractSerializer::addAttributeMutator( - ForumSerializer::class, - function () use ($container) { + (new ApiResource(ForumResource::class)) + ->fields(function () use ($container) { $settings = $container->make(SettingsRepositoryInterface::class); - $attributes = []; + $fields = []; foreach ($this->settings as $key => $setting) { $value = $settings->get($key); @@ -128,12 +127,12 @@ class Settings implements ExtenderInterface $value = $callback($value); } - $attributes[$setting['attributeName']] = $value; + $fields[] = Attribute::make($setting['attributeName'])->get(fn () => $value); } - return $attributes; - } - ); + return $fields; + }) + ->extend($container, $extension); } if (! empty($this->lessConfigs)) { diff --git a/framework/core/src/Forum/Content/Discussion.php b/framework/core/src/Forum/Content/Discussion.php index 69019e4e0..b35b95125 100644 --- a/framework/core/src/Forum/Content/Discussion.php +++ b/framework/core/src/Forum/Content/Discussion.php @@ -34,7 +34,6 @@ class Discussion $page = max(1, intval(Arr::get($queryParams, 'page')), 1 + intdiv($near, 20)); $params = [ - 'id' => $id, 'page' => [ 'near' => $near, 'offset' => ($page - 1) * 20, diff --git a/framework/core/src/Forum/Content/Index.php b/framework/core/src/Forum/Content/Index.php index fae888f02..6120494ef 100644 --- a/framework/core/src/Forum/Content/Index.php +++ b/framework/core/src/Forum/Content/Index.php @@ -10,7 +10,7 @@ namespace Flarum\Forum\Content; use Flarum\Api\Client; -use Flarum\Api\Controller\ListDiscussionsController; +use Flarum\Api\Resource\DiscussionResource; use Flarum\Frontend\Document; use Flarum\Http\UrlGenerator; use Flarum\Locale\TranslatorInterface; @@ -27,7 +27,7 @@ class Index protected SettingsRepositoryInterface $settings, protected UrlGenerator $url, protected TranslatorInterface $translator, - protected ListDiscussionsController $controller + protected DiscussionResource $resource, ) { } @@ -38,17 +38,14 @@ class Index $sort = Arr::pull($queryParams, 'sort'); $q = Arr::pull($queryParams, 'q'); $page = max(1, intval(Arr::pull($queryParams, 'page'))); - $filters = Arr::pull($queryParams, 'filter', []); - $sortMap = resolve('flarum.forum.discussions.sortmap'); - $limit = $this->controller->limit; + $sortMap = $this->resource->sortMap(); $params = [ - 'sort' => $sort && isset($sortMap[$sort]) ? $sortMap[$sort] : '', - 'filter' => $filters, + ...$queryParams, + 'sort' => $sort && isset($sortMap[$sort]) ? $sortMap[$sort] : null, 'page' => [ - 'offset' => ($page - 1) * $limit, - 'limit' => $limit + 'number' => $page ], ]; diff --git a/framework/core/src/Forum/Controller/RegisterController.php b/framework/core/src/Forum/Controller/RegisterController.php index 8ea02956b..a261b3341 100644 --- a/framework/core/src/Forum/Controller/RegisterController.php +++ b/framework/core/src/Forum/Controller/RegisterController.php @@ -28,7 +28,7 @@ class RegisterController implements RequestHandlerInterface public function handle(Request $request): ResponseInterface { - $params = ['data' => ['attributes' => $request->getParsedBody()]]; + $params = ['data' => ['type' => 'users', 'attributes' => $request->getParsedBody() ?? []]]; $response = $this->api->withParentRequest($request)->withBody($params)->post('/users'); diff --git a/framework/core/src/Foundation/ErrorHandling/ExceptionHandler/JsonApiExceptionHandler.php b/framework/core/src/Foundation/ErrorHandling/ExceptionHandler/JsonApiExceptionHandler.php new file mode 100644 index 000000000..f9a724c12 --- /dev/null +++ b/framework/core/src/Foundation/ErrorHandling/ExceptionHandler/JsonApiExceptionHandler.php @@ -0,0 +1,26 @@ +getJsonApiStatus()) + ))->withDetails($e->getJsonApiErrors()); + } +} diff --git a/framework/core/src/Foundation/ErrorHandling/JsonApiFormatter.php b/framework/core/src/Foundation/ErrorHandling/JsonApiFormatter.php index e6276c44a..456cd8e58 100644 --- a/framework/core/src/Foundation/ErrorHandling/JsonApiFormatter.php +++ b/framework/core/src/Foundation/ErrorHandling/JsonApiFormatter.php @@ -12,7 +12,6 @@ namespace Flarum\Foundation\ErrorHandling; use Flarum\Api\JsonApiResponse; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; -use Tobscure\JsonApi\Document; /** * A formatter to render exceptions as valid {JSON:API} error object. @@ -28,15 +27,13 @@ class JsonApiFormatter implements HttpFormatter public function format(HandledError $error, Request $request): Response { - $document = new Document; - if ($error->hasDetails()) { - $document->setErrors($this->withDetails($error)); + $errors = $this->withDetails($error); } else { - $document->setErrors($this->default($error)); + $errors = $this->default($error); } - return new JsonApiResponse($document, $error->getStatusCode()); + return new JsonApiResponse(compact('errors'), $error->getStatusCode()); } private function default(HandledError $error): array diff --git a/framework/core/src/Foundation/ErrorHandling/Registry.php b/framework/core/src/Foundation/ErrorHandling/Registry.php index 6c9913173..0c8a55120 100644 --- a/framework/core/src/Foundation/ErrorHandling/Registry.php +++ b/framework/core/src/Foundation/ErrorHandling/Registry.php @@ -73,12 +73,12 @@ class Registry private function handleCustomTypes(Throwable $error): ?HandledError { - $errorClass = $error::class; + foreach ($this->handlerMap as $class => $handler) { + if ($error instanceof $class) { + $handler = new $handler; - if (isset($this->handlerMap[$errorClass])) { - $handler = new $this->handlerMap[$errorClass]; - - return $handler->handle($error); + return $handler->handle($error); + } } return null; diff --git a/framework/core/src/Foundation/ErrorServiceProvider.php b/framework/core/src/Foundation/ErrorServiceProvider.php index f272e8b80..93e25378a 100644 --- a/framework/core/src/Foundation/ErrorServiceProvider.php +++ b/framework/core/src/Foundation/ErrorServiceProvider.php @@ -11,9 +11,10 @@ namespace Flarum\Foundation; use Flarum\Extension\Exception as ExtensionException; use Flarum\Foundation\ErrorHandling as Handling; +use Flarum\Http\Exception\InvalidParameterException; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Validation\ValidationException as IlluminateValidationException; -use Tobscure\JsonApi\Exception\InvalidParameterException; +use Tobyz\JsonApiServer\Exception as TobyzJsonApiServerException; class ErrorServiceProvider extends AbstractServiceProvider { @@ -64,6 +65,7 @@ class ErrorServiceProvider extends AbstractServiceProvider ExtensionException\CircularDependenciesException::class => ExtensionException\CircularDependenciesExceptionHandler::class, ExtensionException\DependentExtensionsException::class => ExtensionException\DependentExtensionsExceptionHandler::class, ExtensionException\MissingDependenciesException::class => ExtensionException\MissingDependenciesExceptionHandler::class, + TobyzJsonApiServerException\ErrorProvider::class => Handling\ExceptionHandler\JsonApiExceptionHandler::class, ]; }); diff --git a/framework/core/src/Foundation/HighMaintenanceModeHandler.php b/framework/core/src/Foundation/HighMaintenanceModeHandler.php index 43c6d9645..b45048e14 100644 --- a/framework/core/src/Foundation/HighMaintenanceModeHandler.php +++ b/framework/core/src/Foundation/HighMaintenanceModeHandler.php @@ -15,7 +15,6 @@ use Laminas\Diactoros\Response\JsonResponse; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; -use Tobscure\JsonApi\Document; class HighMaintenanceModeHandler implements RequestHandlerInterface { @@ -38,10 +37,12 @@ class HighMaintenanceModeHandler implements RequestHandlerInterface private function apiResponse(): ResponseInterface { return new JsonResponse( - (new Document)->setErrors([ - 'status' => '503', - 'title' => self::MESSAGE - ]), + [ + 'errors' => [ + 'status' => '503', + 'title' => self::MESSAGE + ], + ], 503, ['Content-Type' => 'application/vnd.api+json'] ); diff --git a/framework/core/src/Frontend/Compiler/Concerns/HasSources.php b/framework/core/src/Frontend/Compiler/Concerns/HasSources.php index 3a0bd2f8d..4f1c9f504 100644 --- a/framework/core/src/Frontend/Compiler/Concerns/HasSources.php +++ b/framework/core/src/Frontend/Compiler/Concerns/HasSources.php @@ -12,6 +12,9 @@ namespace Flarum\Frontend\Compiler\Concerns; use Flarum\Frontend\Compiler\Source\SourceCollector; use Flarum\Frontend\Compiler\Source\SourceInterface; +/** + * @template T of SourceInterface + */ trait HasSources { /** @@ -25,7 +28,7 @@ trait HasSources } /** - * @return SourceInterface[] + * @return T[] */ protected function getSources(): array { diff --git a/framework/core/src/Frontend/Compiler/JsDirectoryCompiler.php b/framework/core/src/Frontend/Compiler/JsDirectoryCompiler.php index d69be61dc..1059cc5f9 100644 --- a/framework/core/src/Frontend/Compiler/JsDirectoryCompiler.php +++ b/framework/core/src/Frontend/Compiler/JsDirectoryCompiler.php @@ -18,6 +18,8 @@ use Illuminate\Filesystem\FilesystemAdapter; /** * Used to copy JS files from a package directory to the assets' directory. * Without concatenating them. Primarily used for lazy loading JS modules. + * + * @uses HasSources */ class JsDirectoryCompiler implements CompilerInterface { @@ -44,6 +46,7 @@ class JsDirectoryCompiler implements CompilerInterface public function commit(bool $force = false): void { + /** @var DirectorySource $source */ foreach ($this->getSources() as $source) { $this->compileSource($source, $force); } @@ -51,6 +54,7 @@ class JsDirectoryCompiler implements CompilerInterface public function getUrl(): ?string { + /** @var DirectorySource $source */ foreach ($this->getSources() as $source) { $this->eachFile($source, fn (JsCompiler $compiler) => $compiler->getUrl()); } @@ -60,6 +64,7 @@ class JsDirectoryCompiler implements CompilerInterface public function flush(): void { + /** @var DirectorySource $source */ foreach ($this->getSources() as $source) { $this->flushSource($source); } diff --git a/framework/core/src/Group/Command/CreateGroupHandler.php b/framework/core/src/Group/Command/CreateGroupHandler.php deleted file mode 100644 index d85ad8be7..000000000 --- a/framework/core/src/Group/Command/CreateGroupHandler.php +++ /dev/null @@ -1,57 +0,0 @@ -actor; - $data = $command->data; - - $actor->assertRegistered(); - $actor->assertCan('createGroup'); - - $group = Group::build( - Arr::get($data, 'attributes.nameSingular'), - Arr::get($data, 'attributes.namePlural'), - Arr::get($data, 'attributes.color'), - Arr::get($data, 'attributes.icon'), - Arr::get($data, 'attributes.isHidden', false) - ); - - $this->events->dispatch( - new Saving($group, $actor, $data) - ); - - $this->validator->assertValid($group->getAttributes()); - - $group->save(); - - $this->dispatchEventsFor($group, $actor); - - return $group; - } -} diff --git a/framework/core/src/Group/Command/DeleteGroup.php b/framework/core/src/Group/Command/DeleteGroup.php deleted file mode 100644 index 7c1aadfad..000000000 --- a/framework/core/src/Group/Command/DeleteGroup.php +++ /dev/null @@ -1,22 +0,0 @@ -actor; - - $group = $this->groups->findOrFail($command->groupId, $actor); - - $actor->assertCan('delete', $group); - - $this->events->dispatch( - new Deleting($group, $actor, $command->data) - ); - - $group->delete(); - - $this->dispatchEventsFor($group, $actor); - - return $group; - } -} diff --git a/framework/core/src/Group/Command/EditGroup.php b/framework/core/src/Group/Command/EditGroup.php deleted file mode 100644 index 929107ee1..000000000 --- a/framework/core/src/Group/Command/EditGroup.php +++ /dev/null @@ -1,22 +0,0 @@ -actor; - $data = $command->data; - - $group = $this->groups->findOrFail($command->groupId, $actor); - - $actor->assertCan('edit', $group); - - $attributes = Arr::get($data, 'attributes', []); - - if (isset($attributes['nameSingular']) && isset($attributes['namePlural'])) { - $group->rename($attributes['nameSingular'], $attributes['namePlural']); - } - - if (isset($attributes['color'])) { - $group->color = $attributes['color']; - } - - if (isset($attributes['icon'])) { - $group->icon = $attributes['icon']; - } - - if (isset($attributes['isHidden'])) { - $group->is_hidden = $attributes['isHidden']; - } - - $this->events->dispatch( - new Saving($group, $actor, $data) - ); - - $this->validator->assertValid($group->getDirty()); - - $group->save(); - - $this->dispatchEventsFor($group, $actor); - - return $group; - } -} diff --git a/framework/core/src/Group/Group.php b/framework/core/src/Group/Group.php index f4dc5574e..c2fd48ae1 100644 --- a/framework/core/src/Group/Group.php +++ b/framework/core/src/Group/Group.php @@ -55,29 +55,25 @@ class Group extends AbstractModel static::deleted(function (self $group) { $group->raise(new Deleted($group)); }); + + static::creating(function (self $group) { + $group->raise(new Created($group)); + }); } - public static function build(?string $nameSingular, ?string $namePlural, ?string $color = null, ?string $icon = null, bool $isHidden = false): static + public function rename(?string $nameSingular, ?string $namePlural): static { - $group = new static; + if ($nameSingular !== null) { + $this->name_singular = $nameSingular; + } - $group->name_singular = $nameSingular; - $group->name_plural = $namePlural; - $group->color = $color; - $group->icon = $icon; - $group->is_hidden = $isHidden; + if ($namePlural !== null) { + $this->name_plural = $namePlural; + } - $group->raise(new Created($group)); - - return $group; - } - - public function rename(string $nameSingular, string $namePlural): static - { - $this->name_singular = $nameSingular; - $this->name_plural = $namePlural; - - $this->raise(new Renamed($this)); + if ($this->isDirty(['name_singular', 'name_plural'])) { + $this->raise(new Renamed($this)); + } return $this; } diff --git a/framework/core/src/Group/GroupValidator.php b/framework/core/src/Group/GroupValidator.php deleted file mode 100644 index 26a571d1e..000000000 --- a/framework/core/src/Group/GroupValidator.php +++ /dev/null @@ -1,20 +0,0 @@ - ['required'], - 'name_plural' => ['required'] - ]; -} diff --git a/framework/core/src/Http/AccessToken.php b/framework/core/src/Http/AccessToken.php index a9b6db3c3..69aa2e304 100644 --- a/framework/core/src/Http/AccessToken.php +++ b/framework/core/src/Http/AccessToken.php @@ -79,6 +79,14 @@ class AccessToken extends AbstractModel * Generate an access token for the specified user. */ public static function generate(int $userId): static + { + $token = static::make($userId); + $token->save(); + + return $token; + } + + public static function make(int $userId): static { if (static::class === self::class) { throw new \Exception('Use of AccessToken::generate() is not allowed: use the `generate` method on one of the subclasses.'); @@ -91,7 +99,6 @@ class AccessToken extends AbstractModel $token->user_id = $userId; $token->created_at = Carbon::now(); $token->last_activity_at = Carbon::now(); - $token->save(); return $token; } diff --git a/framework/core/src/User/Command/RegisterUser.php b/framework/core/src/Http/Exception/InvalidParameterException.php similarity index 51% rename from framework/core/src/User/Command/RegisterUser.php rename to framework/core/src/Http/Exception/InvalidParameterException.php index a2c306782..7c915c929 100644 --- a/framework/core/src/User/Command/RegisterUser.php +++ b/framework/core/src/Http/Exception/InvalidParameterException.php @@ -7,15 +7,11 @@ * LICENSE file that was distributed with this source code. */ -namespace Flarum\User\Command; +namespace Flarum\Http\Exception; -use Flarum\User\User; +use Exception; -class RegisterUser +class InvalidParameterException extends Exception { - public function __construct( - public User $actor, - public array $data - ) { - } + // } diff --git a/framework/core/src/Http/Middleware/PopulateWithActor.php b/framework/core/src/Http/Middleware/PopulateWithActor.php new file mode 100644 index 000000000..f9e364a0c --- /dev/null +++ b/framework/core/src/Http/Middleware/PopulateWithActor.php @@ -0,0 +1,29 @@ +handle($request); + } +} diff --git a/framework/core/src/Http/RequestUtil.php b/framework/core/src/Http/RequestUtil.php index 6ba3358df..be027ff3e 100644 --- a/framework/core/src/Http/RequestUtil.php +++ b/framework/core/src/Http/RequestUtil.php @@ -12,6 +12,7 @@ namespace Flarum\Http; use Flarum\User\User; use Illuminate\Support\Str; use Psr\Http\Message\ServerRequestInterface as Request; +use Tobyz\JsonApiServer\Exception\BadRequestException; class RequestUtil { @@ -49,4 +50,142 @@ class RequestUtil return $request; } + + public static function extractSort(Request $request, ?string $default, array $available = []): ?array + { + $input = $request->getQueryParams()['sort'] ?? null; + + if (is_null($input) || ! filled($input)) { + $input = $default; + } + + if (! $input) { + return null; + } + + if (! is_string($input)) { + throw new BadRequestException('sort must be a string'); + } + + $sort = []; + + foreach (explode(',', $input) as $field) { + if (str_starts_with($field, '-')) { + $field = substr($field, 1); + $order = 'desc'; + } else { + $order = 'asc'; + } + + $sort[$field] = $order; + } + + $invalid = array_diff(array_keys($sort), $available); + + if (count($invalid)) { + throw new BadRequestException( + 'Invalid sort fields ['.implode(',', $invalid).']', + ); + } + + return $sort; + } + + public static function extractLimit(Request $request, ?int $defaultLimit = null, ?int $max = null): ?int + { + $limit = $request->getQueryParams()['page']['limit'] ?? ''; + + if (! filled($limit)) { + $limit = $defaultLimit; + } + + if (! $limit) { + return null; + } + + if ($max !== null) { + $limit = min($limit, $max); + } + + if ($limit < 1) { + throw new BadRequestException('page[limit] must be at least 1'); + } + + return $limit; + } + + public static function extractOffsetFromNumber(Request $request, int $limit): int + { + $page = (int) ($request->getQueryParams()['page']['number'] ?? 1); + + if ($page < 1) { + throw new BadRequestException('page[number] must be at least 1'); + } + + return ($page - 1) * $limit; + } + + public static function extractOffset(Request $request, ?int $limit = 0): int + { + if ($request->getQueryParams()['page']['number'] ?? false) { + return self::extractOffsetFromNumber($request, $limit); + } + + $offset = (int) ($request->getQueryParams()['page']['offset'] ?? 0); + + if ($offset < 0) { + throw new BadRequestException('page[offset] must be at least 0'); + } + + return $offset; + } + + public static function extractInclude(Request $request, ?array $available): array + { + $include = $request->getQueryParams()['include'] ?? ''; + + if (! is_string($include)) { + throw new BadRequestException('include must be a string'); + } + + $includes = array_filter(explode(',', $include)); + + $invalid = array_diff($includes, $available); + + if (count($invalid)) { + throw new BadRequestException('Invalid includes ['.implode(',', $invalid).']'); + } + + return $includes; + } + + public static function extractFilter(Request $request): array + { + $filter = $request->getQueryParams()['filter'] ?? []; + + if (! is_array($filter)) { + throw new BadRequestException('filter must be an array'); + } + + return $filter; + } + + public static function extractFields(Request $request, ?array $available = null): array + { + $fields = $request->getQueryParams()['fields'] ?? []; + + if (! is_array($fields)) { + throw new BadRequestException('fields must be an array'); + } + + if ($available !== null) { + $invalid = array_diff(array_keys($fields), $available); + + if (count($invalid)) { + throw new BadRequestException('Invalid fields ['.implode(',', $invalid).']'); + } + } + + return $fields; + } } diff --git a/framework/core/src/Http/RouteHandlerFactory.php b/framework/core/src/Http/RouteHandlerFactory.php index bbfea9230..79d431e74 100644 --- a/framework/core/src/Http/RouteHandlerFactory.php +++ b/framework/core/src/Http/RouteHandlerFactory.php @@ -10,6 +10,7 @@ namespace Flarum\Http; use Closure; +use Flarum\Api\JsonApi; use Flarum\Frontend\Controller as FrontendController; use Illuminate\Contracts\Container\Container; use InvalidArgumentException; @@ -37,6 +38,25 @@ class RouteHandlerFactory }; } + /** + * @param class-string<\Tobyz\JsonApiServer\Resource\AbstractResource> $resourceClass + */ + public function toApiResource(string $resourceClass, string $endpointName): Closure + { + return function (Request $request, array $routeParams) use ($resourceClass, $endpointName) { + /** @var JsonApi $api */ + $api = $this->container->make(JsonApi::class); + + $api->validateQueryParameters($request); + + $request = $request->withQueryParams(array_merge($request->getQueryParams(), $routeParams)); + + return $api->forResource($resourceClass) + ->forEndpoint($endpointName) + ->handle($request); + }; + } + public function toFrontend(string $frontend, callable|string|null $content = null): Closure { return $this->toController(function (Container $container) use ($frontend, $content) { diff --git a/framework/core/src/Notification/NotificationRepository.php b/framework/core/src/Notification/NotificationRepository.php index 496c88e05..56ecb7845 100644 --- a/framework/core/src/Notification/NotificationRepository.php +++ b/framework/core/src/Notification/NotificationRepository.php @@ -11,6 +11,7 @@ namespace Flarum\Notification; use Carbon\Carbon; use Flarum\User\User; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; class NotificationRepository @@ -19,6 +20,14 @@ class NotificationRepository * @return Collection */ public function findByUser(User $user, ?int $limit = null, int $offset = 0): Collection + { + return $this->query($user, $limit, $offset)->get(); + } + + /** + * @return Builder + */ + public function query(User $user, ?int $limit = null, int $offset = 0): Builder { $primaries = Notification::query() ->selectRaw('MAX(id) AS id') @@ -35,8 +44,7 @@ class NotificationRepository return Notification::query() ->select('notifications.*', 'p.unread_count') ->joinSub($primaries, 'p', 'notifications.id', '=', 'p.id') - ->latest() - ->get(); + ->latest(); } public function markAllAsRead(User $user): void diff --git a/framework/core/src/Notification/NotificationSyncer.php b/framework/core/src/Notification/NotificationSyncer.php index 25b4847bd..c1d56ef0e 100644 --- a/framework/core/src/Notification/NotificationSyncer.php +++ b/framework/core/src/Notification/NotificationSyncer.php @@ -127,6 +127,7 @@ class NotificationSyncer /** * Limit notifications to one per user for the entire duration of the given * callback. + * @todo: useless when using a queue. replace with a better solution. */ public function onePerUser(callable $callback): void { diff --git a/framework/core/src/Post/Command/DeletePost.php b/framework/core/src/Post/Command/DeletePost.php deleted file mode 100644 index 9841de522..000000000 --- a/framework/core/src/Post/Command/DeletePost.php +++ /dev/null @@ -1,22 +0,0 @@ -actor; - - $post = $this->posts->findOrFail($command->postId, $actor); - - $actor->assertCan('delete', $post); - - $this->events->dispatch( - new Deleting($post, $actor, $command->data) - ); - - $post->delete(); - - $this->dispatchEventsFor($post, $actor); - - return $post; - } -} diff --git a/framework/core/src/Post/Command/EditPost.php b/framework/core/src/Post/Command/EditPost.php deleted file mode 100644 index 591ea5b11..000000000 --- a/framework/core/src/Post/Command/EditPost.php +++ /dev/null @@ -1,22 +0,0 @@ -actor; - $data = $command->data; - - $post = $this->posts->findOrFail($command->postId, $actor); - - if ($post instanceof CommentPost) { - $attributes = Arr::get($data, 'attributes', []); - - if (isset($attributes['content'])) { - $actor->assertCan('edit', $post); - - $post->revise($attributes['content'], $actor); - } - - if (isset($attributes['isHidden'])) { - $actor->assertCan('hide', $post); - - if ($attributes['isHidden']) { - $post->hide($actor); - } else { - $post->restore(); - } - } - } - - $this->events->dispatch( - new Saving($post, $actor, $data) - ); - - $this->validator->assertValid($post->getDirty()); - - $post->save(); - - $this->dispatchEventsFor($post, $actor); - - return $post; - } -} diff --git a/framework/core/src/Post/Command/PostReply.php b/framework/core/src/Post/Command/PostReply.php deleted file mode 100644 index d1e09b43d..000000000 --- a/framework/core/src/Post/Command/PostReply.php +++ /dev/null @@ -1,24 +0,0 @@ -actor; - - // Make sure the user has permission to reply to this discussion. First, - // make sure the discussion exists and that the user has permission to - // view it; if not, fail with a ModelNotFound exception so we don't give - // away the existence of the discussion. If the user is allowed to view - // it, check if they have permission to reply. - $discussion = $this->discussions->findOrFail($command->discussionId, $actor); - - // If this is the first post in the discussion, it's technically not a - // "reply", so we won't check for that permission. - if (! $command->isFirstPost) { - $actor->assertCan('reply', $discussion); - } - - // Create a new Post entity, persist it, and dispatch domain events. - // Before persistence, though, fire an event to give plugins an - // opportunity to alter the post entity based on data in the command. - $post = CommentPost::reply( - $discussion->id, - Arr::get($command->data, 'attributes.content'), - $actor->id, - $command->ipAddress, - $command->actor, - ); - - if ($actor->isAdmin() && ($time = Arr::get($command->data, 'attributes.createdAt'))) { - $post->created_at = new Carbon($time); - } - - $this->events->dispatch( - new Saving($post, $actor, $command->data) - ); - - $this->validator->assertValid($post->getAttributes()); - - $post->save(); - - $this->notifications->onePerUser(function () use ($post, $actor) { - $this->dispatchEventsFor($post, $actor); - }); - - return $post; - } -} diff --git a/framework/core/src/Post/CommentPost.php b/framework/core/src/Post/CommentPost.php index c93216f65..91c618a5f 100644 --- a/framework/core/src/Post/CommentPost.php +++ b/framework/core/src/Post/CommentPost.php @@ -30,22 +30,13 @@ class CommentPost extends Post protected $observables = ['hidden']; - public static function reply(int $discussionId, string $content, int $userId, ?string $ipAddress, ?User $actor = null): static + public static function boot() { - $post = new static; + parent::boot(); - $post->created_at = Carbon::now(); - $post->discussion_id = $discussionId; - $post->user_id = $userId; - $post->type = static::$type; - $post->ip_address = $ipAddress; - - // Set content last, as the parsing may rely on other post attributes. - $post->setContentAttribute($content, $actor); - - $post->raise(new Posted($post)); - - return $post; + static::creating(function (self $post) { + $post->raise(new Posted($post)); + }); } public function revise(string $content, User $actor): static diff --git a/framework/core/src/Post/PostValidator.php b/framework/core/src/Post/PostValidator.php deleted file mode 100644 index 2018d9e11..000000000 --- a/framework/core/src/Post/PostValidator.php +++ /dev/null @@ -1,22 +0,0 @@ - [ - 'required', - 'max:65535' - ] - ]; -} diff --git a/framework/core/src/Search/Database/AbstractSearcher.php b/framework/core/src/Search/Database/AbstractSearcher.php index 86f026f0e..59de0166e 100644 --- a/framework/core/src/Search/Database/AbstractSearcher.php +++ b/framework/core/src/Search/Database/AbstractSearcher.php @@ -39,7 +39,7 @@ abstract class AbstractSearcher implements SearcherInterface $this->applySort($search, $criteria->sort, $criteria->sortIsDefault); $this->applyOffset($search, $criteria->offset); - $this->applyLimit($search, $criteria->limit + 1); + $this->applyLimit($search, $criteria->limit ? $criteria->limit + 1 : null); foreach ($this->mutators as $mutator) { $mutator($search, $criteria); @@ -54,7 +54,7 @@ abstract class AbstractSearcher implements SearcherInterface $results->pop(); } - return new SearchResults($results, $areMoreResults, $this->getTotalResults($query)); + return new SearchResults($results, $areMoreResults, $this->getTotalResults($query->clone())); } protected function getTotalResults(Builder $query): Closure @@ -63,21 +63,12 @@ abstract class AbstractSearcher implements SearcherInterface $query = $query->toBase(); if ($query->unions) { - $query->unions = null; // @phpstan-ignore-line - $query->unionLimit = null; // @phpstan-ignore-line - $query->unionOffset = null; // @phpstan-ignore-line - $query->unionOrders = null; // @phpstan-ignore-line - $query->setBindings([], 'union'); + $query = $query + ->cloneWithout(['unions', 'unionLimit', 'unionOffset', 'unionOrders']) + ->cloneWithoutBindings(['union']); } - $query->offset = null; // @phpstan-ignore-line - $query->limit = null; // @phpstan-ignore-line - $query->orders = null; // @phpstan-ignore-line - $query->setBindings([], 'order'); - - return $query->getConnection() - ->table($query, 'results') - ->count(); + return $query->getCountForPagination(); }; } @@ -111,7 +102,7 @@ abstract class AbstractSearcher implements SearcherInterface protected function applyLimit(DatabaseSearchState $state, ?int $limit): void { - if ($limit > 0) { + if ($limit && $limit > 0) { $state->getQuery()->take($limit); } } diff --git a/framework/core/src/Search/SearchManager.php b/framework/core/src/Search/SearchManager.php index dfaafaa31..02f54f752 100644 --- a/framework/core/src/Search/SearchManager.php +++ b/framework/core/src/Search/SearchManager.php @@ -68,4 +68,12 @@ class SearchManager return $defaultDriver->searcher($resourceClass)->search($criteria); } + + public function searchable(string $resourceClass): bool + { + $driver = $this->driverFor($resourceClass); + $defaultDriver = $this->driver(DatabaseSearchDriver::name()); + + return $driver->supports($resourceClass) || $defaultDriver->supports($resourceClass); + } } diff --git a/framework/core/src/User/Command/DeleteUser.php b/framework/core/src/User/Command/DeleteUser.php deleted file mode 100644 index 0fad3270f..000000000 --- a/framework/core/src/User/Command/DeleteUser.php +++ /dev/null @@ -1,22 +0,0 @@ -actor; - $user = $this->users->findOrFail($command->userId, $actor); - - $actor->assertCan('delete', $user); - - $this->events->dispatch( - new Deleting($user, $actor, $command->data) - ); - - $user->delete(); - - $this->dispatchEventsFor($user, $actor); - - return $user; - } -} diff --git a/framework/core/src/User/Command/EditUser.php b/framework/core/src/User/Command/EditUser.php deleted file mode 100644 index 44cb8c176..000000000 --- a/framework/core/src/User/Command/EditUser.php +++ /dev/null @@ -1,22 +0,0 @@ -actor; - $data = $command->data; - - $user = $this->users->findOrFail($command->userId, $actor); - - $isSelf = $actor->id === $user->id; - - $attributes = Arr::get($data, 'attributes', []); - $relationships = Arr::get($data, 'relationships', []); - $validate = []; - - if (isset($attributes['username'])) { - $actor->assertCan('editCredentials', $user); - $user->rename($attributes['username']); - } - - if (isset($attributes['email'])) { - if ($isSelf) { - $user->requestEmailChange($attributes['email']); - - if ($attributes['email'] !== $user->email) { - $validate['email'] = $attributes['email']; - } - } else { - $actor->assertCan('editCredentials', $user); - $user->changeEmail($attributes['email']); - } - } - - if (! empty($attributes['isEmailConfirmed'])) { - $actor->assertAdmin(); - $user->activate(); - } - - if (isset($attributes['password'])) { - $actor->assertCan('editCredentials', $user); - $user->changePassword($attributes['password']); - - $validate['password'] = $attributes['password']; - } - - if (! empty($attributes['markedAllAsReadAt'])) { - $actor->assertPermission($isSelf); - $user->markAllAsRead(); - } - - if (! empty($attributes['preferences'])) { - $actor->assertPermission($isSelf); - - foreach ($attributes['preferences'] as $k => $v) { - $user->setPreference($k, $v); - } - } - - if (isset($relationships['groups']['data']) && is_array($relationships['groups']['data'])) { - $actor->assertCan('editGroups', $user); - - $oldGroups = $user->groups()->get()->all(); - $oldGroupIds = Arr::pluck($oldGroups, 'id'); - - $newGroupIds = []; - foreach ($relationships['groups']['data'] as $group) { - if ($id = Arr::get($group, 'id')) { - $newGroupIds[] = $id; - } - } - - // Ensure non-admins aren't adding/removing admins - $adminChanged = in_array('1', array_diff($oldGroupIds, $newGroupIds)) || in_array('1', array_diff($newGroupIds, $oldGroupIds)); - $actor->assertPermission(! $adminChanged || $actor->isAdmin()); - - $user->raise( - new GroupsChanged($user, $oldGroups) - ); - - $user->afterSave(function (User $user) use ($newGroupIds) { - $user->groups()->sync($newGroupIds); - $user->unsetRelation('groups'); - }); - } - - $this->events->dispatch( - new Saving($user, $actor, $data) - ); - - $this->validator->setUser($user); - $this->validator->assertValid(array_merge($user->getDirty(), $validate)); - - $user->save(); - - $this->dispatchEventsFor($user, $actor); - - return $user; - } -} diff --git a/framework/core/src/User/Command/RegisterUserHandler.php b/framework/core/src/User/Command/RegisterUserHandler.php deleted file mode 100644 index 87f5c0fcd..000000000 --- a/framework/core/src/User/Command/RegisterUserHandler.php +++ /dev/null @@ -1,176 +0,0 @@ -actor; - $data = $command->data; - - if (! $this->settings->get('allow_sign_up')) { - $actor->assertAdmin(); - } - - $password = Arr::get($data, 'attributes.password'); - - // If a valid authentication token was provided as an attribute, - // then we won't require the user to choose a password. - if (isset($data['attributes']['token'])) { - /** @var RegistrationToken $token */ - $token = RegistrationToken::validOrFail($data['attributes']['token']); - - $password = $password ?: Str::random(20); - } - - $user = User::register( - Arr::get($data, 'attributes.username'), - Arr::get($data, 'attributes.email'), - $password - ); - - if (isset($token)) { - $this->applyToken($user, $token); - } - - if ($actor->isAdmin() && Arr::get($data, 'attributes.isEmailConfirmed')) { - $user->activate(); - } - - $this->events->dispatch( - new Saving($user, $actor, $data) - ); - - $this->userValidator->assertValid(array_merge($user->getAttributes(), compact('password'))); - - $user->save(); - - if (isset($token)) { - $this->fulfillToken($user, $token); - } - - $this->dispatchEventsFor($user, $actor); - - return $user; - } - - private function applyToken(User $user, RegistrationToken $token): void - { - foreach ($token->user_attributes as $k => $v) { - if ($k === 'avatar_url') { - $this->uploadAvatarFromUrl($user, $v); - continue; - } - - $user->$k = $v; - - if ($k === 'email') { - $user->activate(); - } - } - - $this->events->dispatch( - new RegisteringFromProvider($user, $token->provider, $token->payload) - ); - } - - /** - * @throws InvalidArgumentException - */ - private function uploadAvatarFromUrl(User $user, string $url): void - { - $urlValidator = $this->validator->make(compact('url'), [ - 'url' => 'required|active_url', - ]); - - if ($urlValidator->fails()) { - throw new InvalidArgumentException('Provided avatar URL must be a valid URI.', 503); - } - - $scheme = parse_url($url, PHP_URL_SCHEME); - - if (! in_array($scheme, ['http', 'https'])) { - throw new InvalidArgumentException("Provided avatar URL must have scheme http or https. Scheme provided was $scheme.", 503); - } - - $urlContents = $this->retrieveAvatarFromUrl($url); - - if ($urlContents !== null) { - $image = $this->imageManager->read($urlContents); - - $this->avatarUploader->upload($user, $image); - } - } - - private function retrieveAvatarFromUrl(string $url): ?string - { - $client = new Client(); - - try { - $response = $client->get($url); - } catch (\Exception $e) { - return null; - } - - if ($response->getStatusCode() !== 200) { - return null; - } - - return $response->getBody()->getContents(); - } - - private function fulfillToken(User $user, RegistrationToken $token): void - { - $token->delete(); - - if ($token->provider && $token->identifier) { - $user->loginProviders()->create([ - 'provider' => $token->provider, - 'identifier' => $token->identifier - ]); - } - } -} diff --git a/framework/core/src/User/User.php b/framework/core/src/User/User.php index 6ad65e2c0..07c6f391d 100644 --- a/framework/core/src/User/User.php +++ b/framework/core/src/User/User.php @@ -154,20 +154,12 @@ class User extends AbstractModel Notification::whereSubject($user)->delete(); }); - } - public static function register(?string $username, ?string $email, ?string $password): static - { - $user = new static; + static::creating(function (self $user) { + $user->joined_at = Carbon::now(); - $user->username = $username; - $user->email = $email; - $user->password = $password; - $user->joined_at = Carbon::now(); - - $user->raise(new Registered($user)); - - return $user; + $user->raise(new Registered($user)); + }); } public static function setGate(Access\Gate $gate): void diff --git a/framework/core/tests/integration/api/AbstractSerializeControllerTest.php b/framework/core/tests/integration/api/AbstractSerializeControllerTest.php deleted file mode 100644 index cd5be3d89..000000000 --- a/framework/core/tests/integration/api/AbstractSerializeControllerTest.php +++ /dev/null @@ -1,53 +0,0 @@ -extend( - (new Extend\Routes('api')) - ->get('/dummy-serialize', 'dummy-serialize', DummySerializeController::class) - ); - - $response = $this->send( - $this->request('GET', '/api/dummy-serialize') - ); - - $json = json_decode($contents = (string) $response->getBody(), true); - - $this->assertEquals(500, $response->getStatusCode(), $contents); - $this->assertStringStartsWith('InvalidArgumentException: Serializer required for controller: '.DummySerializeController::class, $json['errors'][0]['detail']); - } -} - -class DummySerializeController extends AbstractSerializeController -{ - public ?string $serializer = null; - - protected function data(ServerRequestInterface $request, Document $document): mixed - { - return []; - } - - protected function createElement(mixed $data, SerializerInterface $serializer): ElementInterface - { - return $data; - } -} diff --git a/framework/core/tests/integration/api/access_tokens/CreateTest.php b/framework/core/tests/integration/api/access_tokens/CreateTest.php index 07613e576..e7e69a186 100644 --- a/framework/core/tests/integration/api/access_tokens/CreateTest.php +++ b/framework/core/tests/integration/api/access_tokens/CreateTest.php @@ -53,6 +53,7 @@ class CreateTest extends TestCase 'authenticatedAs' => $authenticatedAs, 'json' => [ 'data' => [ + 'type' => 'access-tokens', 'attributes' => [ 'title' => 'Dev' ] @@ -61,7 +62,7 @@ class CreateTest extends TestCase ]) ); - $this->assertEquals(201, $response->getStatusCode()); + $this->assertEquals(201, $response->getStatusCode(), (string) $response->getBody()); } /** @@ -75,6 +76,7 @@ class CreateTest extends TestCase 'authenticatedAs' => $authenticatedAs, 'json' => [ 'data' => [ + 'type' => 'access-tokens', 'attributes' => [ 'title' => 'Dev' ] @@ -83,7 +85,7 @@ class CreateTest extends TestCase ]) ); - $this->assertEquals(403, $response->getStatusCode()); + $this->assertEquals(403, $response->getStatusCode(), (string) $response->getBody()); } /** @@ -94,10 +96,16 @@ class CreateTest extends TestCase $response = $this->send( $this->request('POST', '/api/access-tokens', [ 'authenticatedAs' => 1, + 'json' => [ + 'data' => [ + 'type' => 'access-tokens', + 'attributes' => [] + ] + ] ]) ); - $this->assertEquals(422, $response->getStatusCode()); + $this->assertEquals(422, $response->getStatusCode(), (string) $response->getBody()); } public function canCreateTokens(): array diff --git a/framework/core/tests/integration/api/discussions/CreateTest.php b/framework/core/tests/integration/api/discussions/CreateTest.php index 999f67086..e9d8c5f14 100644 --- a/framework/core/tests/integration/api/discussions/CreateTest.php +++ b/framework/core/tests/integration/api/discussions/CreateTest.php @@ -43,6 +43,7 @@ class CreateTest extends TestCase 'authenticatedAs' => 1, 'json' => [ 'data' => [ + 'type' => 'discussions', 'attributes' => [ 'title' => 'Test post', 'content' => '', @@ -52,10 +53,11 @@ class CreateTest extends TestCase ]) ); - $this->assertEquals(422, $response->getStatusCode()); + $body = (string) $response->getBody(); + + $this->assertEquals(422, $response->getStatusCode(), $body); // The response body should contain details about the failed validation - $body = (string) $response->getBody(); $this->assertJson($body); $this->assertEquals([ 'errors' => [ @@ -79,6 +81,7 @@ class CreateTest extends TestCase 'authenticatedAs' => 1, 'json' => [ 'data' => [ + 'type' => 'discussions', 'attributes' => [ 'title' => '', 'content' => 'Test post', @@ -115,6 +118,7 @@ class CreateTest extends TestCase 'authenticatedAs' => 1, 'json' => [ 'data' => [ + 'type' => 'discussions', 'attributes' => [ 'title' => 'test - too-obscure', 'content' => 'predetermined content for automated testing - too-obscure', @@ -124,11 +128,13 @@ class CreateTest extends TestCase ]) ); - $this->assertEquals(201, $response->getStatusCode()); + $body = $response->getBody()->getContents(); + + $this->assertEquals(201, $response->getStatusCode(), $body); /** @var Discussion $discussion */ $discussion = Discussion::firstOrFail(); - $data = json_decode($response->getBody()->getContents(), true); + $data = json_decode($body, true); $this->assertEquals('test - too-obscure', $discussion->title); $this->assertEquals('test - too-obscure', Arr::get($data, 'data.attributes.title')); @@ -147,6 +153,7 @@ class CreateTest extends TestCase 'authenticatedAs' => 1, 'json' => [ 'data' => [ + 'type' => 'discussions', 'attributes' => [ 'title' => '我是一个土豆', 'content' => 'predetermined content for automated testing', @@ -179,6 +186,7 @@ class CreateTest extends TestCase 'authenticatedAs' => 1, 'json' => [ 'data' => [ + 'type' => 'discussions', 'attributes' => [ 'title' => '我是一个土豆', 'content' => 'predetermined content for automated testing', @@ -206,6 +214,7 @@ class CreateTest extends TestCase 'authenticatedAs' => 2, 'json' => [ 'data' => [ + 'type' => 'discussions', 'attributes' => [ 'title' => 'test - too-obscure', 'content' => 'predetermined content for automated testing - too-obscure', @@ -220,6 +229,7 @@ class CreateTest extends TestCase 'authenticatedAs' => 2, 'json' => [ 'data' => [ + 'type' => 'discussions', 'attributes' => [ 'title' => 'test - too-obscure', 'content' => 'Second predetermined content for automated testing - too-obscure', @@ -242,6 +252,7 @@ class CreateTest extends TestCase 'authenticatedAs' => 1, 'json' => [ 'data' => [ + 'type' => 'discussions', 'attributes' => [ 'title' => 'test - too-obscure', 'content' => 'predetermined content for automated testing - too-obscure', @@ -256,6 +267,7 @@ class CreateTest extends TestCase 'authenticatedAs' => 1, 'json' => [ 'data' => [ + 'type' => 'discussions', 'attributes' => [ 'title' => 'test - too-obscure', 'content' => 'Second predetermined content for automated testing - too-obscure', diff --git a/framework/core/tests/integration/api/discussions/ShowTest.php b/framework/core/tests/integration/api/discussions/ShowTest.php index 6ee1550c7..b32714fb1 100644 --- a/framework/core/tests/integration/api/discussions/ShowTest.php +++ b/framework/core/tests/integration/api/discussions/ShowTest.php @@ -56,7 +56,7 @@ class ShowTest extends TestCase ]) ); - $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals(200, $response->getStatusCode(), $response->getBody()->getContents()); } /** @@ -74,7 +74,7 @@ class ShowTest extends TestCase ]) ); - $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals(200, $response->getStatusCode(), $response->getBody()->getContents()); } /** @@ -116,7 +116,7 @@ class ShowTest extends TestCase $json = json_decode($response->getBody()->getContents(), true); - $this->assertEquals(2, Arr::get($json, 'data.relationships.posts.data.0.id')); + $this->assertEquals(2, Arr::get($json, 'data.relationships.posts.data.0.id'), $response->getBody()->getContents()); } /** @@ -128,7 +128,7 @@ class ShowTest extends TestCase $this->request('GET', '/api/discussions/2') ); - $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals(200, $response->getStatusCode(), $response->getBody()->getContents()); } /** diff --git a/framework/core/tests/integration/api/forum/ShowTest.php b/framework/core/tests/integration/api/forum/ShowTest.php index 4ad3979aa..3b221546b 100644 --- a/framework/core/tests/integration/api/forum/ShowTest.php +++ b/framework/core/tests/integration/api/forum/ShowTest.php @@ -45,7 +45,8 @@ class ShowTest extends TestCase $json = json_decode($response->getBody()->getContents(), true); - $this->assertArrayNotHasKey('actor', Arr::get($json, 'data.relationships')); + $this->assertArrayHasKey('actor', Arr::get($json, 'data.relationships')); + $this->assertNull(Arr::get($json, 'data.relationships.actor.data')); } /** diff --git a/framework/core/tests/integration/api/groups/CreateTest.php b/framework/core/tests/integration/api/groups/CreateTest.php index c5aa13953..04d70dacf 100644 --- a/framework/core/tests/integration/api/groups/CreateTest.php +++ b/framework/core/tests/integration/api/groups/CreateTest.php @@ -45,7 +45,7 @@ class CreateTest extends TestCase ]) ); - $this->assertEquals(422, $response->getStatusCode()); + $this->assertEquals(400, $response->getStatusCode(), (string) $response->getBody()); } /** @@ -58,6 +58,7 @@ class CreateTest extends TestCase 'authenticatedAs' => 1, 'json' => [ 'data' => [ + 'type' => 'groups', 'attributes' => [ 'nameSingular' => 'flarumite', 'namePlural' => 'flarumites', @@ -69,10 +70,12 @@ class CreateTest extends TestCase ]) ); - $this->assertEquals(201, $response->getStatusCode()); + $body = $response->getBody()->getContents(); + + $this->assertEquals(201, $response->getStatusCode(), $body); // Verify API response body - $data = json_decode($response->getBody()->getContents(), true); + $data = json_decode($body, true); $this->assertEquals('flarumite', Arr::get($data, 'data.attributes.nameSingular')); $this->assertEquals('flarumites', Arr::get($data, 'data.attributes.namePlural')); $this->assertEquals('test', Arr::get($data, 'data.attributes.icon')); @@ -96,6 +99,7 @@ class CreateTest extends TestCase 'authenticatedAs' => 2, 'json' => [ 'data' => [ + 'type' => 'groups', 'attributes' => [ 'nameSingular' => 'flarumite', 'namePlural' => 'flarumites', diff --git a/framework/core/tests/integration/api/groups/ShowTest.php b/framework/core/tests/integration/api/groups/ShowTest.php index bac6d16d4..09455e038 100644 --- a/framework/core/tests/integration/api/groups/ShowTest.php +++ b/framework/core/tests/integration/api/groups/ShowTest.php @@ -76,7 +76,7 @@ class ShowTest extends TestCase ); // Hidden group should not be returned for guest - $this->assertEquals(404, $response->getStatusCode()); + $this->assertEquals(404, $response->getStatusCode(), (string) $response->getBody()); } /** @@ -110,7 +110,7 @@ class ShowTest extends TestCase // If group does not exist in database, controller // should reject the request with 404 Not found - $this->assertEquals(404, $response->getStatusCode()); + $this->assertEquals(404, $response->getStatusCode(), (string) $response->getBody()); } protected function hiddenGroup(): array diff --git a/framework/core/tests/integration/api/notifications/ListTest.php b/framework/core/tests/integration/api/notifications/ListTest.php index 8b94feeeb..4ed6868ec 100644 --- a/framework/core/tests/integration/api/notifications/ListTest.php +++ b/framework/core/tests/integration/api/notifications/ListTest.php @@ -54,6 +54,6 @@ class ListTest extends TestCase ]) ); - $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals(200, $response->getStatusCode(), (string) $response->getBody()); } } diff --git a/framework/core/tests/integration/api/notifications/UpdateTest.php b/framework/core/tests/integration/api/notifications/UpdateTest.php new file mode 100644 index 000000000..4a34b4739 --- /dev/null +++ b/framework/core/tests/integration/api/notifications/UpdateTest.php @@ -0,0 +1,67 @@ +prepareDatabase([ + User::class => [ + $this->normalUser(), + ], + Discussion::class => [ + ['id' => 1, 'title' => 'Foo', 'comment_count' => 1, 'user_id' => 2], + ], + Post::class => [ + ['id' => 1, 'discussion_id' => 1, 'user_id' => 2, 'type' => 'comment', 'content' => 'Foo'], + ], + Notification::class => [ + ['id' => 1, 'user_id' => 2, 'from_user_id' => 1, 'type' => 'discussionRenamed', 'subject_id' => 1, 'read_at' => null], + ] + ]); + } + + /** + * @test + */ + public function can_mark_all_as_read() + { + $response = $this->send( + $this->request('PATCH', '/api/notifications/1', [ + 'authenticatedAs' => 2, + 'json' => [ + 'data' => [ + 'type' => 'notifications', + 'attributes' => [ + 'isRead' => true + ], + ], + ], + ]) + ); + + $this->assertEquals(200, $response->getStatusCode(), (string) $response->getBody()); + } +} diff --git a/framework/core/tests/integration/api/posts/CreateTest.php b/framework/core/tests/integration/api/posts/CreateTest.php index 828c2eed7..d5db5b995 100644 --- a/framework/core/tests/integration/api/posts/CreateTest.php +++ b/framework/core/tests/integration/api/posts/CreateTest.php @@ -71,18 +71,21 @@ class CreateTest extends TestCase 'authenticatedAs' => $actorId, 'json' => [ 'data' => [ + 'type' => 'posts', 'attributes' => [ 'content' => 'reply with predetermined content for automated testing - too-obscure', ], 'relationships' => [ - 'discussion' => ['data' => ['id' => $discussionId]], + 'discussion' => [ + 'data' => ['type' => 'discussions', 'id' => $discussionId] + ], ], ], ], ]) ); - $this->assertEquals($responseStatus, $response->getStatusCode()); + $this->assertEquals($responseStatus, $response->getStatusCode(), (string) $response->getBody()); } public function discussionRepliesPrvider(): array @@ -106,6 +109,7 @@ class CreateTest extends TestCase 'authenticatedAs' => 2, 'json' => [ 'data' => [ + 'type' => 'posts', 'attributes' => [ 'content' => 'reply with predetermined content for automated testing - too-obscure', ], @@ -122,6 +126,7 @@ class CreateTest extends TestCase 'authenticatedAs' => 2, 'json' => [ 'data' => [ + 'type' => 'posts', 'attributes' => [ 'content' => 'Second reply with predetermined content for automated testing - too-obscure', ], @@ -133,6 +138,6 @@ class CreateTest extends TestCase ]) ); - $this->assertEquals(429, $response->getStatusCode()); + $this->assertEquals(429, $response->getStatusCode(), (string) $response->getBody()); } } diff --git a/framework/core/tests/integration/api/posts/ListTest.php b/framework/core/tests/integration/api/posts/ListTest.php index 7daf880e6..e6f8caf4e 100644 --- a/framework/core/tests/integration/api/posts/ListTest.php +++ b/framework/core/tests/integration/api/posts/ListTest.php @@ -74,8 +74,10 @@ class ListTest extends TestCase $this->request('GET', '/api/posts', ['authenticatedAs' => 1]) ); - $this->assertEquals(200, $response->getStatusCode()); - $data = json_decode($response->getBody()->getContents(), true); + $body = $response->getBody()->getContents(); + + $this->assertEquals(200, $response->getStatusCode(), $body); + $data = json_decode($body, true); $this->assertEquals(5, count($data['data'])); } diff --git a/framework/core/tests/integration/api/users/CreateTest.php b/framework/core/tests/integration/api/users/CreateTest.php index c3894fc0e..7c400cff8 100644 --- a/framework/core/tests/integration/api/users/CreateTest.php +++ b/framework/core/tests/integration/api/users/CreateTest.php @@ -39,15 +39,19 @@ class CreateTest extends TestCase 'POST', '/api/users', [ - 'json' => ['data' => ['attributes' => []]], + 'json' => ['data' => [ + 'type' => 'users', + 'attributes' => [], + ]], ] )->withAttribute('bypassCsrfToken', true) ); - $this->assertEquals(422, $response->getStatusCode()); + $body = (string) $response->getBody(); + + $this->assertEquals(422, $response->getStatusCode(), $body); // The response body should contain details about the failed validation - $body = (string) $response->getBody(); $this->assertJson($body); $this->assertEquals([ 'errors' => [ @@ -96,7 +100,7 @@ class CreateTest extends TestCase )->withAttribute('bypassCsrfToken', true) ); - $this->assertEquals(201, $response->getStatusCode()); + $this->assertEquals(201, $response->getStatusCode(), (string) $response->getBody()); /** @var User $user */ $user = User::where('username', 'test')->firstOrFail(); @@ -227,12 +231,12 @@ class CreateTest extends TestCase $this->assertJson($body); $decodedBody = json_decode($body, true); - $this->assertEquals(500, $response->getStatusCode()); + $this->assertEquals(422, $response->getStatusCode(), $body); $firstError = $decodedBody['errors'][0]; // Check that the error is an invalid URI - $this->assertStringStartsWith('InvalidArgumentException: Provided avatar URL must have scheme http or https. Scheme provided was '.$regToken['scheme'].'.', $firstError['detail']); + $this->assertStringContainsString('Provided avatar URL must have scheme http or https. Scheme provided was '.$regToken['scheme'].'.', $firstError['detail']); } } @@ -301,12 +305,12 @@ class CreateTest extends TestCase $this->assertJson($body); $decodedBody = json_decode($body, true); - $this->assertEquals(500, $response->getStatusCode()); + $this->assertEquals(422, $response->getStatusCode(), $body); $firstError = $decodedBody['errors'][0]; // Check that the error is an invalid URI - $this->assertStringStartsWith('InvalidArgumentException: Provided avatar URL must be a valid URI.', $firstError['detail']); + $this->assertStringContainsString('Provided avatar URL must be a valid URI.', $firstError['detail']); } } @@ -374,7 +378,7 @@ class CreateTest extends TestCase )->withAttribute('bypassCsrfToken', true) ); - $this->assertEquals(201, $response->getStatusCode()); + $this->assertEquals(201, $response->getStatusCode(), (string) $response->getBody()); $user = User::where('username', $regToken->user_attributes['username'])->firstOrFail(); diff --git a/framework/core/tests/integration/api/users/GroupSearchTest.php b/framework/core/tests/integration/api/users/GroupSearchTest.php index d38c5b707..f07e17c4a 100644 --- a/framework/core/tests/integration/api/users/GroupSearchTest.php +++ b/framework/core/tests/integration/api/users/GroupSearchTest.php @@ -86,7 +86,7 @@ class GroupSearchTest extends TestCase $responseBodyContents = json_decode($response->getBody()->getContents(), true); $this->assertCount(0, $responseBodyContents['data'], json_encode($responseBodyContents)); - $this->assertArrayNotHasKey('included', $responseBodyContents, json_encode($responseBodyContents)); + $this->assertCount(0, $responseBodyContents['included'], json_encode($responseBodyContents)); $response = $this->createRequest(['admins'], 2); $responseBodyContents = json_decode($response->getBody()->getContents(), true); @@ -99,7 +99,7 @@ class GroupSearchTest extends TestCase $responseBodyContents = json_decode($response->getBody()->getContents(), true); $this->assertCount(0, $responseBodyContents['data'], json_encode($responseBodyContents)); - $this->assertArrayNotHasKey('included', $responseBodyContents, json_encode($responseBodyContents)); + $this->assertCount(0, $responseBodyContents['included'], json_encode($responseBodyContents)); $response = $this->createRequest(['1'], 2); $responseBodyContents = json_decode($response->getBody()->getContents(), true); @@ -112,7 +112,7 @@ class GroupSearchTest extends TestCase $responseBodyContents = json_decode($response->getBody()->getContents(), true); $this->assertCount(0, $responseBodyContents['data'], json_encode($responseBodyContents)); - $this->assertArrayNotHasKey('included', $responseBodyContents, json_encode($responseBodyContents)); + $this->assertCount(0, $responseBodyContents['included'], json_encode($responseBodyContents)); } /** @@ -131,7 +131,7 @@ class GroupSearchTest extends TestCase $responseBodyContents = json_decode($response->getBody()->getContents(), true); $this->assertCount(0, $responseBodyContents['data'], json_encode($responseBodyContents)); - $this->assertArrayNotHasKey('included', $responseBodyContents, json_encode($responseBodyContents)); + $this->assertCount(0, $responseBodyContents['included'], json_encode($responseBodyContents)); } /** @@ -149,10 +149,8 @@ class GroupSearchTest extends TestCase $responseBodyContents = json_decode($response->getBody()->getContents(), true); $this->assertCount(4, $responseBodyContents['data'], json_encode($responseBodyContents)); $this->assertCount(4, $responseBodyContents['included'], json_encode($responseBodyContents)); - $this->assertEquals(1, $responseBodyContents['included'][0]['id']); - $this->assertEquals(4, $responseBodyContents['included'][1]['id']); - $this->assertEquals(5, $responseBodyContents['included'][2]['id']); - $this->assertEquals(6, $responseBodyContents['included'][3]['id']); + + $this->assertEqualsCanonicalizing([1, 4, 5, 6], array_column($responseBodyContents['included'], 'id')); } /** @@ -171,7 +169,7 @@ class GroupSearchTest extends TestCase $responseBodyContents = json_decode($response->getBody()->getContents(), true); $this->assertCount(0, $responseBodyContents['data'], json_encode($responseBodyContents)); - $this->assertArrayNotHasKey('included', $responseBodyContents, json_encode($responseBodyContents)); + $this->assertCount(0, $responseBodyContents['included'], json_encode($responseBodyContents)); $response = $this->createRequest(['admins'], 1); $responseBodyContents = json_decode($response->getBody()->getContents(), true); @@ -184,7 +182,7 @@ class GroupSearchTest extends TestCase $responseBodyContents = json_decode($response->getBody()->getContents(), true); $this->assertCount(0, $responseBodyContents['data'], json_encode($responseBodyContents)); - $this->assertArrayNotHasKey('included', $responseBodyContents, json_encode($responseBodyContents)); + $this->assertCount(0, $responseBodyContents['included'], json_encode($responseBodyContents)); $response = $this->createRequest(['1'], 1); $responseBodyContents = json_decode($response->getBody()->getContents(), true); @@ -197,7 +195,7 @@ class GroupSearchTest extends TestCase $responseBodyContents = json_decode($response->getBody()->getContents(), true); $this->assertCount(0, $responseBodyContents['data'], json_encode($responseBodyContents)); - $this->assertArrayNotHasKey('included', $responseBodyContents, json_encode($responseBodyContents)); + $this->assertCount(0, $responseBodyContents['included'], json_encode($responseBodyContents)); } /** @@ -225,11 +223,8 @@ class GroupSearchTest extends TestCase $responseBodyContents = json_decode($response->getBody()->getContents(), true); $this->assertCount(5, $responseBodyContents['data'], json_encode($responseBodyContents)); $this->assertCount(5, $responseBodyContents['included'], json_encode($responseBodyContents)); - $this->assertEquals(1, $responseBodyContents['included'][0]['id']); - $this->assertEquals(99, $responseBodyContents['included'][1]['id']); - $this->assertEquals(4, $responseBodyContents['included'][2]['id']); - $this->assertEquals(5, $responseBodyContents['included'][3]['id']); - $this->assertEquals(6, $responseBodyContents['included'][4]['id']); + + $this->assertEqualsCanonicalizing([1, 99, 4, 5, 6], array_column($responseBodyContents['included'], 'id')); } private function createRequest(array $group, int $userId = null) diff --git a/framework/core/tests/integration/api/users/PasswordEmailTokensTest.php b/framework/core/tests/integration/api/users/PasswordEmailTokensTest.php index b07ecebb6..5d373f85f 100644 --- a/framework/core/tests/integration/api/users/PasswordEmailTokensTest.php +++ b/framework/core/tests/integration/api/users/PasswordEmailTokensTest.php @@ -102,6 +102,7 @@ class PasswordEmailTokensTest extends TestCase 'authenticatedAs' => 2, 'json' => [ 'data' => [ + 'type' => 'users', 'attributes' => [ 'email' => 'new-normal@machine.local' ] @@ -113,7 +114,7 @@ class PasswordEmailTokensTest extends TestCase ]) ); - $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals(200, $response->getStatusCode(), $response->getBody()); $this->assertEquals(1, EmailToken::query()->where('user_id', 2)->count()); } diff --git a/framework/core/tests/integration/api/users/SendActivationEmailTest.php b/framework/core/tests/integration/api/users/SendActivationEmailTest.php index fe688fa1c..3d0149eba 100644 --- a/framework/core/tests/integration/api/users/SendActivationEmailTest.php +++ b/framework/core/tests/integration/api/users/SendActivationEmailTest.php @@ -45,7 +45,7 @@ class SendActivationEmailTest extends TestCase ); // We don't want to delay tests too long. - EmailActivationThrottler::$timeout = 5; + EmailActivationThrottler::$timeout = 1; sleep(EmailActivationThrottler::$timeout + 1); } diff --git a/framework/core/tests/integration/api/users/SendPasswordResetEmailTest.php b/framework/core/tests/integration/api/users/SendPasswordResetEmailTest.php index b86812585..4c46536a1 100644 --- a/framework/core/tests/integration/api/users/SendPasswordResetEmailTest.php +++ b/framework/core/tests/integration/api/users/SendPasswordResetEmailTest.php @@ -48,7 +48,7 @@ class SendPasswordResetEmailTest extends TestCase ); // We don't want to delay tests too long. - PasswordResetThrottler::$timeout = 5; + PasswordResetThrottler::$timeout = 1; sleep(PasswordResetThrottler::$timeout + 1); } diff --git a/framework/core/tests/integration/api/users/UpdateTest.php b/framework/core/tests/integration/api/users/UpdateTest.php index 284fd4feb..353c96046 100644 --- a/framework/core/tests/integration/api/users/UpdateTest.php +++ b/framework/core/tests/integration/api/users/UpdateTest.php @@ -68,13 +68,15 @@ class UpdateTest extends TestCase $response = $this->send( $this->request('PATCH', '/api/users/2', [ 'authenticatedAs' => 2, - 'json' => [], + 'json' => [ + 'data' => [] + ], ]) ); // Test for successful response and that the email is included in the response - $this->assertEquals(200, $response->getStatusCode()); - $this->assertStringContainsString('normal@machine.local', (string) $response->getBody()); + $this->assertEquals(200, $response->getStatusCode(), $body = (string) $response->getBody()); + $this->assertStringContainsString('normal@machine.local', $body); } /** @@ -85,13 +87,15 @@ class UpdateTest extends TestCase $response = $this->send( $this->request('PATCH', '/api/users/1', [ 'authenticatedAs' => 2, - 'json' => [], + 'json' => [ + 'data' => [] + ], ]) ); // Make sure sensitive information is not made public - $this->assertEquals(200, $response->getStatusCode()); - $this->assertStringNotContainsString('admin@machine.local', (string) $response->getBody()); + $this->assertEquals(200, $response->getStatusCode(), $body = (string) $response->getBody()); + $this->assertStringNotContainsString('admin@machine.local', $body); } /** @@ -107,7 +111,7 @@ class UpdateTest extends TestCase ]) ); - $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals(200, $response->getStatusCode(), (string) $response->getBody()); } /** @@ -120,6 +124,7 @@ class UpdateTest extends TestCase 'authenticatedAs' => 2, 'json' => [ 'data' => [ + 'type' => 'users', 'attributes' => [ 'email' => 'someOtherEmail@example.com', ] @@ -131,7 +136,7 @@ class UpdateTest extends TestCase ]) ); - $this->assertEquals(401, $response->getStatusCode()); + $this->assertEquals(401, $response->getStatusCode(), (string) $response->getBody()); } /** @@ -144,6 +149,7 @@ class UpdateTest extends TestCase 'authenticatedAs' => 2, 'json' => [ 'data' => [ + 'type' => 'users', 'attributes' => [ 'email' => 'someOtherEmail@example.com', ] @@ -180,7 +186,7 @@ class UpdateTest extends TestCase ); // We don't want to delay tests too long. - EmailChangeThrottler::$timeout = 5; + EmailChangeThrottler::$timeout = 1; sleep(EmailChangeThrottler::$timeout + 1); } @@ -223,6 +229,7 @@ class UpdateTest extends TestCase 'authenticatedAs' => 2, 'json' => [ 'data' => [ + 'type' => 'users', 'attributes' => [ 'username' => 'iCantChangeThis', ], @@ -243,6 +250,7 @@ class UpdateTest extends TestCase 'authenticatedAs' => 2, 'json' => [ 'data' => [ + 'type' => 'users', 'attributes' => [ 'preferences' => [ 'something' => 'else' @@ -268,7 +276,7 @@ class UpdateTest extends TestCase 'relationships' => [ 'groups' => [ 'data' => [ - ['id' => 1, 'type' => 'group'] + ['id' => 1, 'type' => 'groups'] ] ] ], @@ -289,6 +297,7 @@ class UpdateTest extends TestCase 'authenticatedAs' => 2, 'json' => [ 'data' => [ + 'type' => 'users', 'attributes' => [ 'markedAllAsReadAt' => Carbon::now() ], @@ -309,6 +318,7 @@ class UpdateTest extends TestCase 'authenticatedAs' => 2, 'json' => [ 'data' => [ + 'type' => 'users', 'attributes' => [ 'isEmailConfirmed' => true ], @@ -345,6 +355,7 @@ class UpdateTest extends TestCase 'authenticatedAs' => 3, 'json' => [ 'data' => [ + 'type' => 'users', 'attributes' => [ 'email' => 'someOtherEmail@example.com', ] @@ -368,6 +379,7 @@ class UpdateTest extends TestCase 'authenticatedAs' => 3, 'json' => [ 'data' => [ + 'type' => 'users', 'attributes' => [ 'username' => 'iCantChangeThis', ], @@ -391,7 +403,7 @@ class UpdateTest extends TestCase 'relationships' => [ 'groups' => [ 'data' => [ - ['id' => 1, 'type' => 'group'] + ['id' => 1, 'type' => 'groups'] ] ] ], @@ -412,6 +424,7 @@ class UpdateTest extends TestCase 'authenticatedAs' => 2, 'json' => [ 'data' => [ + 'type' => 'users', 'attributes' => [ 'isEmailConfirmed' => true ], @@ -450,6 +463,7 @@ class UpdateTest extends TestCase 'authenticatedAs' => 3, 'json' => [ 'data' => [ + 'type' => 'users', 'attributes' => [ 'email' => 'someOtherEmail@example.com', ] @@ -471,6 +485,7 @@ class UpdateTest extends TestCase 'authenticatedAs' => 3, 'json' => [ 'data' => [ + 'type' => 'users', 'attributes' => [ 'username' => 'iCanChangeThis', ], @@ -492,6 +507,7 @@ class UpdateTest extends TestCase 'authenticatedAs' => 3, 'json' => [ 'data' => [ + 'type' => 'users', 'attributes' => [ 'email' => 'someOtherEmail@example.com', ] @@ -513,6 +529,7 @@ class UpdateTest extends TestCase 'authenticatedAs' => 3, 'json' => [ 'data' => [ + 'type' => 'users', 'attributes' => [ 'username' => 'iCanChangeThis', ], @@ -537,7 +554,7 @@ class UpdateTest extends TestCase 'relationships' => [ 'groups' => [ 'data' => [ - ['id' => 4, 'type' => 'group'] + ['id' => 4, 'type' => 'groups'] ] ] ], @@ -545,7 +562,7 @@ class UpdateTest extends TestCase ], ]) ); - $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals(200, $response->getStatusCode(), (string) $response->getBody()); } /** @@ -585,7 +602,7 @@ class UpdateTest extends TestCase 'relationships' => [ 'groups' => [ 'data' => [ - ['id' => 1, 'type' => 'group'] + ['id' => 1, 'type' => 'groups'] ] ] ], @@ -610,7 +627,7 @@ class UpdateTest extends TestCase 'relationships' => [ 'groups' => [ 'data' => [ - ['id' => 1, 'type' => 'group'] + ['id' => 1, 'type' => 'groups'] ] ] ], @@ -632,6 +649,7 @@ class UpdateTest extends TestCase 'authenticatedAs' => 2, 'json' => [ 'data' => [ + 'type' => 'users', 'attributes' => [ 'isEmailConfirmed' => true ], @@ -652,6 +670,7 @@ class UpdateTest extends TestCase 'authenticatedAs' => 1, 'json' => [ 'data' => [ + 'type' => 'users', 'attributes' => [ 'preferences' => [ 'something' => 'else' @@ -674,6 +693,7 @@ class UpdateTest extends TestCase 'authenticatedAs' => 1, 'json' => [ 'data' => [ + 'type' => 'users', 'attributes' => [ 'markedAllAsReadAt' => Carbon::now() ], @@ -694,6 +714,7 @@ class UpdateTest extends TestCase 'authenticatedAs' => 1, 'json' => [ 'data' => [ + 'type' => 'users', 'attributes' => [ 'isEmailConfirmed' => true ], @@ -724,7 +745,7 @@ class UpdateTest extends TestCase ], ]) ); - $this->assertEquals(403, $response->getStatusCode()); + $this->assertEquals(403, $response->getStatusCode(), (string) $response->getBody()); } /** diff --git a/framework/core/tests/integration/extenders/ApiControllerTest.php b/framework/core/tests/integration/extenders/ApiControllerTest.php index 6c79825b8..5fe3fa832 100644 --- a/framework/core/tests/integration/extenders/ApiControllerTest.php +++ b/framework/core/tests/integration/extenders/ApiControllerTest.php @@ -10,24 +10,23 @@ namespace Flarum\Tests\integration\extenders; use Carbon\Carbon; -use Flarum\Api\Controller\AbstractShowController; -use Flarum\Api\Controller\ListDiscussionsController; -use Flarum\Api\Controller\ListUsersController; -use Flarum\Api\Controller\ShowDiscussionController; -use Flarum\Api\Controller\ShowForumController; -use Flarum\Api\Controller\ShowPostController; -use Flarum\Api\Controller\ShowUserController; -use Flarum\Api\Serializer\DiscussionSerializer; -use Flarum\Api\Serializer\ForumSerializer; -use Flarum\Api\Serializer\PostSerializer; -use Flarum\Api\Serializer\UserSerializer; +use Flarum\Api\Context; +use Flarum\Api\Endpoint\Index; +use Flarum\Api\Endpoint\Show; +use Flarum\Api\Resource\AbstractDatabaseResource; +use Flarum\Api\Resource\DiscussionResource; +use Flarum\Api\Resource\UserResource; +use Flarum\Api\Schema\Relationship\ToMany; +use Flarum\Api\Sort\SortColumn; use Flarum\Discussion\Discussion; use Flarum\Extend; +use Flarum\Foundation\ValidationException; use Flarum\Post\Post; use Flarum\Testing\integration\RetrievesAuthorizedUsers; use Flarum\Testing\integration\TestCase; use Flarum\User\User; use Illuminate\Support\Arr; +use Tobyz\JsonApiServer\Schema\Field\Field; class ApiControllerTest extends TestCase { @@ -52,6 +51,7 @@ class ApiControllerTest extends TestCase Post::class => [ ['id' => 1, 'discussion_id' => 3, 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'type' => 'discussionRenamed', 'content' => '

can i haz relationz?

'], ['id' => 2, 'discussion_id' => 2, 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'type' => 'discussionRenamed', 'content' => '

can i haz relationz?

'], + ['id' => 3, 'discussion_id' => 1, 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'type' => 'discussionRenamed', 'content' => '

can i haz relationz?

'], ['id' => 3, 'discussion_id' => 1, 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 1, 'type' => 'discussionRenamed', 'content' => '

can i haz relationz?

'], ], ]); @@ -60,12 +60,16 @@ class ApiControllerTest extends TestCase /** * @test */ - public function prepare_data_serialization_callback_works_if_added() + public function after_endpoint_callback_works_if_added() { $this->extend( - (new Extend\ApiController(ShowDiscussionController::class)) - ->prepareDataForSerialization(function ($controller, Discussion $discussion) { - $discussion->title = 'dataSerializationPrepCustomTitle'; + (new Extend\ApiResource(DiscussionResource::class)) + ->endpoint(Show::class, function (Show $endpoint): Show { + return $endpoint->after(function ($context, Discussion $discussion) { + $discussion->title = 'dataSerializationPrepCustomTitle'; + + return $discussion; + }); }) ); @@ -75,19 +79,21 @@ class ApiControllerTest extends TestCase ]) ); - $payload = json_decode($response->getBody()->getContents(), true); + $payload = json_decode($body = $response->getBody()->getContents(), true); - $this->assertEquals('dataSerializationPrepCustomTitle', $payload['data']['attributes']['title']); + $this->assertEquals('dataSerializationPrepCustomTitle', $payload['data']['attributes']['title'], $body); } /** * @test */ - public function prepare_data_serialization_callback_works_with_invokable_classes() + public function after_endpoint_callback_works_with_invokable_classes() { $this->extend( - (new Extend\ApiController(ShowDiscussionController::class)) - ->prepareDataForSerialization(CustomPrepareDataSerializationInvokableClass::class) + (new Extend\ApiResource(DiscussionResource::class)) + ->endpoint(Show::class, function (Show $endpoint): Show { + return $endpoint->after(CustomAfterEndpointInvokableClass::class); + }) ); $response = $this->send( @@ -96,72 +102,26 @@ class ApiControllerTest extends TestCase ]) ); - $payload = json_decode($response->getBody()->getContents(), true); + $payload = json_decode($body = $response->getBody()->getContents(), true); - $this->assertEquals(CustomPrepareDataSerializationInvokableClass::class, $payload['data']['attributes']['title']); + $this->assertEquals(CustomAfterEndpointInvokableClass::class, $payload['data']['attributes']['title'], $body); } /** * @test */ - public function prepare_data_serialization_allows_passing_args_by_reference_with_closures() + public function after_endpoint_callback_works_if_added_to_parent_class() { $this->extend( - (new Extend\ApiSerializer(ForumSerializer::class)) - ->hasMany('referenceTest', UserSerializer::class), - (new Extend\ApiController(ShowForumController::class)) - ->addInclude('referenceTest') - ->prepareDataForSerialization(function ($controller, &$data) { - $data['referenceTest'] = User::limit(2)->get(); - }) - ); + (new Extend\ApiResource(AbstractDatabaseResource::class)) + ->endpoint(Show::class, function (Show $endpoint): Show { + return $endpoint->after(function (Context $context, object $model) { + if ($context->collection instanceof DiscussionResource) { + $model->title = 'dataSerializationPrepCustomTitle2'; + } - $response = $this->send( - $this->request('GET', '/api', [ - 'authenticatedAs' => 1, - ]) - ); - - $payload = json_decode($response->getBody()->getContents(), true); - - $this->assertArrayHasKey('referenceTest', $payload['data']['relationships']); - } - - /** - * @test - */ - public function prepare_data_serialization_allows_passing_args_by_reference_with_invokable_classes() - { - $this->extend( - (new Extend\ApiSerializer(ForumSerializer::class)) - ->hasMany('referenceTest2', UserSerializer::class), - (new Extend\ApiController(ShowForumController::class)) - ->addInclude('referenceTest2') - ->prepareDataForSerialization(CustomInvokableClassArgsReference::class) - ); - - $response = $this->send( - $this->request('GET', '/api', [ - 'authenticatedAs' => 1, - ]) - ); - - $payload = json_decode($response->getBody()->getContents(), true); - - $this->assertArrayHasKey('referenceTest2', $payload['data']['relationships']); - } - - /** - * @test - */ - public function prepare_data_serialization_callback_works_if_added_to_parent_class() - { - $this->extend( - (new Extend\ApiController(AbstractShowController::class)) - ->prepareDataForSerialization(function ($controller, Discussion $discussion) { - if ($controller instanceof ShowDiscussionController) { - $discussion->title = 'dataSerializationPrepCustomTitle2'; - } + return $model; + }); }) ); @@ -179,18 +139,26 @@ class ApiControllerTest extends TestCase /** * @test */ - public function prepare_data_serialization_callback_prioritizes_child_classes() + public function after_endpoint_callback_prioritizes_child_classes() { $this->extend( - (new Extend\ApiController(AbstractShowController::class)) - ->prepareDataForSerialization(function ($controller, Discussion $discussion) { - if ($controller instanceof ShowDiscussionController) { - $discussion->title = 'dataSerializationPrepCustomTitle3'; - } + (new Extend\ApiResource(DiscussionResource::class)) + ->endpoint(Show::class, function (Show $endpoint): Show { + return $endpoint->after(function (Context $context, object $model) { + $model->title = 'dataSerializationPrepCustomTitle4'; + + return $model; + }); }), - (new Extend\ApiController(ShowDiscussionController::class)) - ->prepareDataForSerialization(function ($controller, Discussion $discussion) { - $discussion->title = 'dataSerializationPrepCustomTitle4'; + (new Extend\ApiResource(AbstractDatabaseResource::class)) + ->endpoint(Show::class, function (Show $endpoint): Show { + return $endpoint->after(function (Context $context, object $model) { + if ($context->collection instanceof DiscussionResource) { + $model->title = 'dataSerializationPrepCustomTitle3'; + } + + return $model; + }); }) ); @@ -200,22 +168,22 @@ class ApiControllerTest extends TestCase ]) ); - $payload = json_decode($response->getBody()->getContents(), true); + $payload = json_decode($body = $response->getBody()->getContents(), true); - $this->assertEquals('dataSerializationPrepCustomTitle4', $payload['data']['attributes']['title']); + $this->assertEquals('dataSerializationPrepCustomTitle4', $payload['data']['attributes']['title'], $body); } /** * @test */ - public function prepare_data_query_callback_works_if_added_to_parent_class() + public function before_endpoint_callback_works_if_added_to_parent_class() { $this->extend( - (new Extend\ApiController(AbstractShowController::class)) - ->prepareDataQuery(function ($controller) { - if ($controller instanceof ShowDiscussionController) { - $controller->setSerializer(CustomDiscussionSerializer2::class); - } + (new Extend\ApiResource(AbstractDatabaseResource::class)) + ->endpoint(Show::class, function (Show $endpoint): Show { + return $endpoint->before(function () { + throw new ValidationException(['field' => 'error on purpose']); + }); }) ); @@ -225,26 +193,29 @@ class ApiControllerTest extends TestCase ]) ); - $payload = json_decode($response->getBody()->getContents(), true); + $body = $response->getBody()->getContents(); - $this->assertArrayHasKey('customSerializer2', $payload['data']['attributes']); + $this->assertEquals(422, $response->getStatusCode(), $body); + $this->assertStringContainsString('error on purpose', $body, $body); } /** * @test */ - public function prepare_data_query_callback_prioritizes_child_classes() + public function before_endpoint_callback_prioritizes_child_classes() { $this->extend( - (new Extend\ApiController(AbstractShowController::class)) - ->prepareDataForSerialization(function ($controller) { - if ($controller instanceof ShowDiscussionController) { - $controller->setSerializer(CustomDiscussionSerializer2::class); - } + (new Extend\ApiResource(DiscussionResource::class)) + ->endpoint(Show::class, function (Show $endpoint): Show { + return $endpoint->before(function () { + throw new ValidationException(['field' => 'error on purpose from exact resource']); + }); }), - (new Extend\ApiController(ShowDiscussionController::class)) - ->prepareDataForSerialization(function ($controller) { - $controller->setSerializer(CustomDiscussionSerializer::class); + (new Extend\ApiResource(AbstractDatabaseResource::class)) + ->endpoint(Show::class, function (Show $endpoint): Show { + return $endpoint->before(function () { + throw new ValidationException(['field' => 'error on purpose from abstract resource']); + }); }) ); @@ -254,95 +225,10 @@ class ApiControllerTest extends TestCase ]) ); - $payload = json_decode($response->getBody()->getContents(), true); + $body = $response->getBody()->getContents(); - $this->assertArrayHasKey('customSerializer', $payload['data']['attributes']); - } - - /** - * @test - */ - public function custom_serializer_doesnt_work_by_default() - { - $response = $this->send( - $this->request('GET', '/api/discussions/1', [ - 'authenticatedAs' => 1, - ]) - ); - - $payload = json_decode($response->getBody()->getContents(), true); - - $this->assertArrayNotHasKey('customSerializer', $payload['data']['attributes']); - } - - /** - * @test - */ - public function custom_serializer_works_if_set() - { - $this->extend( - (new Extend\ApiController(ShowDiscussionController::class)) - ->setSerializer(CustomDiscussionSerializer::class) - ); - - $response = $this->send( - $this->request('GET', '/api/discussions/1', [ - 'authenticatedAs' => 1, - ]) - ); - - $payload = json_decode($response->getBody()->getContents(), true); - - $this->assertArrayHasKey('customSerializer', $payload['data']['attributes']); - } - - /** - * @test - */ - public function custom_serializer_works_if_set_with_invokable_class() - { - $this->extend( - (new Extend\ApiController(ShowPostController::class)) - ->setSerializer(CustomPostSerializer::class, CustomApiControllerInvokableClass::class) - ); - $this->prepareDatabase([ - Post::class => [ - ['id' => 1, 'discussion_id' => 1, 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'type' => 'comment', 'content' => '

foo bar

'], - ], - ]); - - $response = $this->send( - $this->request('GET', '/api/posts/1', [ - 'authenticatedAs' => 1, - ]) - ); - - $payload = json_decode($response->getBody()->getContents(), true); - - $this->assertArrayHasKey('customSerializer', $payload['data']['attributes']); - } - - /** - * @test - */ - public function custom_serializer_doesnt_work_with_false_callback_return() - { - $this->extend( - (new Extend\ApiController(ShowUserController::class)) - ->setSerializer(CustomUserSerializer::class, function () { - return false; - }) - ); - - $response = $this->send( - $this->request('GET', '/api/users/2', [ - 'authenticatedAs' => 1, - ]) - ); - - $payload = json_decode($response->getBody()->getContents(), true); - - $this->assertArrayNotHasKey('customSerializer', $payload['data']['attributes']); + $this->assertEquals(422, $response->getStatusCode(), $body); + $this->assertStringContainsString('error on purpose from abstract resource', $body, $body); } /** @@ -370,10 +256,15 @@ class ApiControllerTest extends TestCase $this->extend( (new Extend\Model(User::class)) ->hasMany('customApiControllerRelation', Discussion::class, 'user_id'), - (new Extend\ApiSerializer(UserSerializer::class)) - ->hasMany('customApiControllerRelation', DiscussionSerializer::class), - (new Extend\ApiController(ShowUserController::class)) - ->addInclude('customApiControllerRelation') + (new Extend\ApiResource(UserResource::class)) + ->fields(fn () => [ + ToMany::make('customApiControllerRelation') + ->type('discussions') + ->includable(), + ]) + ->endpoint(Show::class, function (Show $endpoint): Show { + return $endpoint->addDefaultInclude(['customApiControllerRelation']); + }) ); $response = $this->send( @@ -382,9 +273,9 @@ class ApiControllerTest extends TestCase ]) ); - $payload = json_decode($response->getBody()->getContents(), true); + $payload = json_decode($body = $response->getBody()->getContents(), true); - $this->assertArrayHasKey('customApiControllerRelation', $payload['data']['relationships']); + $this->assertArrayHasKey('customApiControllerRelation', $payload['data']['relationships'] ?? [], $body); } /** @@ -395,10 +286,12 @@ class ApiControllerTest extends TestCase $this->extend( (new Extend\Model(User::class)) ->hasMany('customApiControllerRelation2', Discussion::class, 'user_id'), - (new Extend\ApiSerializer(UserSerializer::class)) - ->hasMany('customApiControllerRelation2', DiscussionSerializer::class), - (new Extend\ApiController(ShowUserController::class)) - ->addOptionalInclude('customApiControllerRelation2') + (new Extend\ApiResource(UserResource::class)) + ->fields(fn () => [ + ToMany::make('customApiControllerRelation2') + ->type('discussions') + ->includable(), + ]) ); $response = $this->send( @@ -411,7 +304,7 @@ class ApiControllerTest extends TestCase $payload = json_decode($response->getBody()->getContents(), true); - $this->assertArrayHasKey('customApiControllerRelation2', $payload['data']['relationships']); + $this->assertArrayHasKey('customApiControllerRelation2', $payload['data']['relationships'] ?? []); } /** @@ -436,8 +329,10 @@ class ApiControllerTest extends TestCase public function custom_relationship_not_included_if_removed() { $this->extend( - (new Extend\ApiController(ShowUserController::class)) - ->removeInclude('groups') + (new Extend\ApiResource(UserResource::class)) + ->endpoint(Show::class, function (Show $endpoint): Show { + return $endpoint->removeDefaultInclude(['groups']); + }) ); $response = $this->send( @@ -459,11 +354,13 @@ class ApiControllerTest extends TestCase $this->extend( (new Extend\Model(User::class)) ->hasMany('customApiControllerRelation2', Discussion::class, 'user_id'), - (new Extend\ApiSerializer(UserSerializer::class)) - ->hasMany('customApiControllerRelation2', DiscussionSerializer::class), - (new Extend\ApiController(ShowUserController::class)) - ->addOptionalInclude('customApiControllerRelation2') - ->removeOptionalInclude('customApiControllerRelation2') + (new Extend\ApiResource(UserResource::class)) + ->fields(fn () => [ + ToMany::make('customApiControllerRelation2') + ->type('discussions') + ->includable(), + ]) + ->field('customApiControllerRelation2', fn (Field $field) => $field->includable(false)) ); $response = $this->send( @@ -499,8 +396,10 @@ class ApiControllerTest extends TestCase public function custom_limit_works_if_set() { $this->extend( - (new Extend\ApiController(ListDiscussionsController::class)) - ->setLimit(1) + (new Extend\ApiResource(DiscussionResource::class)) + ->endpoint(Index::class, function (Index $endpoint): Index { + return $endpoint->limit(1); + }) ); $response = $this->send( @@ -520,8 +419,10 @@ class ApiControllerTest extends TestCase public function custom_max_limit_works_if_set() { $this->extend( - (new Extend\ApiController(ListDiscussionsController::class)) - ->setMaxLimit(1) + (new Extend\ApiResource(DiscussionResource::class)) + ->endpoint(Index::class, function (Index $endpoint): Index { + return $endpoint->maxLimit(1); + }) ); $response = $this->send( @@ -553,37 +454,16 @@ class ApiControllerTest extends TestCase $this->assertEquals(400, $response->getStatusCode()); } - /** - * @test - */ - public function custom_sort_field_doesnt_work_with_false_callback_return() - { - $this->extend( - (new Extend\ApiController(ListDiscussionsController::class)) - ->addSortField('userId', function () { - return false; - }) - ); - - $response = $this->send( - $this->request('GET', '/api/discussions', [ - 'authenticatedAs' => 1, - ])->withQueryParams([ - 'sort' => 'userId', - ]) - ); - - $this->assertEquals(400, $response->getStatusCode()); - } - /** * @test */ public function custom_sort_field_exists_if_added() { $this->extend( - (new Extend\ApiController(ListDiscussionsController::class)) - ->addSortField('userId') + (new Extend\ApiResource(DiscussionResource::class)) + ->sorts(fn () => [ + SortColumn::make('userId') + ]), ); $response = $this->send( @@ -594,9 +474,9 @@ class ApiControllerTest extends TestCase ]) ); - $payload = json_decode($response->getBody()->getContents(), true); + $payload = json_decode($body = $response->getBody()->getContents(), true); - $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals(200, $response->getStatusCode(), $body); $this->assertEquals([3, 1, 2], Arr::pluck($payload['data'], 'id')); } @@ -622,8 +502,8 @@ class ApiControllerTest extends TestCase public function custom_sort_field_doesnt_exist_if_removed() { $this->extend( - (new Extend\ApiController(ListDiscussionsController::class)) - ->removeSortField('createdAt') + (new Extend\ApiResource(DiscussionResource::class)) + ->removeSorts(['createdAt']) ); $response = $this->send( @@ -634,7 +514,7 @@ class ApiControllerTest extends TestCase ]) ); - $this->assertEquals(400, $response->getStatusCode()); + $this->assertEquals(400, $response->getStatusCode(), $response->getBody()->getContents()); } /** @@ -643,9 +523,13 @@ class ApiControllerTest extends TestCase public function custom_sort_field_works_if_set() { $this->extend( - (new Extend\ApiController(ListDiscussionsController::class)) - ->addSortField('userId') - ->setSort(['userId' => 'desc']) + (new Extend\ApiResource(DiscussionResource::class)) + ->sorts(fn () => [ + SortColumn::make('userId') + ]) + ->endpoint(Index::class, function (Index $endpoint): Index { + return $endpoint->defaultSort('-userId'); + }) ); $response = $this->send( @@ -670,11 +554,13 @@ class ApiControllerTest extends TestCase $this->extend( (new Extend\Model(User::class)) ->hasOne('firstLevelRelation', Post::class, 'user_id'), - (new Extend\ApiController(ListUsersController::class)) - ->prepareDataForSerialization(function ($controller, $data) use (&$users) { - $users = $data; + (new Extend\ApiResource(UserResource::class)) + ->endpoint(Index::class, function (Index $endpoint) use (&$users) { + return $endpoint->after(function ($context, $data) use (&$users) { + $users = $data; - return []; + return $data; + }); }) ); @@ -697,22 +583,25 @@ class ApiControllerTest extends TestCase $this->extend( (new Extend\Model(User::class)) ->hasOne('firstLevelRelation', Post::class, 'user_id'), - (new Extend\ApiController(ListUsersController::class)) - ->load('firstLevelRelation') - ->prepareDataForSerialization(function ($controller, $data) use (&$users) { - $users = $data; + (new Extend\ApiResource(UserResource::class)) + ->endpoint(Index::class, function (Index $endpoint) use (&$users) { + return $endpoint + ->eagerLoad('firstLevelRelation') + ->after(function ($context, $data) use (&$users) { + $users = $data; - return []; + return $data; + }); }) ); - $this->send( + $response = $this->send( $this->request('GET', '/api/users', [ 'authenticatedAs' => 1, ]) ); - $this->assertFalse($users->filter->relationLoaded('firstLevelRelation')->isEmpty()); + $this->assertFalse($users->filter->relationLoaded('firstLevelRelation')->isEmpty(), $response->getBody()->getContents()); } /** @@ -725,13 +614,14 @@ class ApiControllerTest extends TestCase $this->extend( (new Extend\Model(User::class)) ->hasOne('firstLevelRelation', Post::class, 'user_id'), - (new Extend\Model(Post::class)) - ->belongsTo('secondLevelRelation', Discussion::class), - (new Extend\ApiController(ListUsersController::class)) - ->prepareDataForSerialization(function ($controller, $data) use (&$users) { - $users = $data; + (new Extend\ApiResource(UserResource::class)) + ->endpoint(Index::class, function (Index $endpoint) use (&$users) { + return $endpoint + ->after(function ($context, $data) use (&$users) { + $users = $data; - return []; + return $data; + }); }) ); @@ -756,12 +646,15 @@ class ApiControllerTest extends TestCase ->hasOne('firstLevelRelation', Post::class, 'user_id'), (new Extend\Model(Post::class)) ->belongsTo('secondLevelRelation', Discussion::class), - (new Extend\ApiController(ListUsersController::class)) - ->load(['firstLevelRelation', 'firstLevelRelation.secondLevelRelation']) - ->prepareDataForSerialization(function ($controller, $data) use (&$users) { - $users = $data; + (new Extend\ApiResource(UserResource::class)) + ->endpoint(Index::class, function (Index $endpoint) use (&$users) { + return $endpoint + ->eagerLoad(['firstLevelRelation.secondLevelRelation']) + ->after(function ($context, $data) use (&$users) { + $users = $data; - return []; + return $data; + }); }) ); @@ -784,14 +677,15 @@ class ApiControllerTest extends TestCase $this->extend( (new Extend\Model(User::class)) ->hasOne('firstLevelRelation', Post::class, 'user_id'), - (new Extend\Model(Post::class)) - ->belongsTo('secondLevelRelation', Discussion::class), - (new Extend\ApiController(ListUsersController::class)) - ->load(['firstLevelRelation.secondLevelRelation']) - ->prepareDataForSerialization(function ($controller, $data) use (&$users) { - $users = $data; + (new Extend\ApiResource(UserResource::class)) + ->endpoint(Index::class, function (Index $endpoint) use (&$users) { + return $endpoint + ->eagerLoadWhenIncluded(['firstLevelRelation' => ['secondLevelRelation']]) + ->after(function ($context, $data) use (&$users) { + $users = $data; - return []; + return $data; + }); }) ); @@ -814,12 +708,15 @@ class ApiControllerTest extends TestCase $this->extend( (new Extend\Model(User::class)) ->hasOne('firstLevelRelation', Post::class, 'user_id'), - (new Extend\ApiController(ListUsersController::class)) - ->loadWhere('firstLevelRelation', function ($query, $request) {}) - ->prepareDataForSerialization(function ($controller, $data) use (&$users) { - $users = $data; + (new Extend\ApiResource(UserResource::class)) + ->endpoint(Index::class, function (Index $endpoint) use (&$users) { + return $endpoint + ->eagerLoadWhere('firstLevelRelation', function ($query, $request) {}) + ->after(function ($context, $data) use (&$users) { + $users = $data; - return []; + return $data; + }); }) ); @@ -844,13 +741,16 @@ class ApiControllerTest extends TestCase ->hasOne('firstLevelRelation', Post::class, 'user_id'), (new Extend\Model(Post::class)) ->belongsTo('secondLevelRelation', Discussion::class), - (new Extend\ApiController(ListUsersController::class)) - ->load('firstLevelRelation') - ->loadWhere('firstLevelRelation.secondLevelRelation', function ($query, $request) {}) - ->prepareDataForSerialization(function ($controller, $data) use (&$users) { - $users = $data; + (new Extend\ApiResource(UserResource::class)) + ->endpoint(Index::class, function (Index $endpoint) use (&$users) { + return $endpoint + ->eagerLoad('firstLevelRelation') + ->eagerLoadWhere('firstLevelRelation.secondLevelRelation', function ($query, $request) {}) + ->after(function ($context, $data) use (&$users) { + $users = $data; - return []; + return $data; + }); }) ); @@ -862,129 +762,14 @@ class ApiControllerTest extends TestCase $this->assertFalse($users->pluck('firstLevelRelation')->filter->relationLoaded('secondLevelRelation')->isEmpty()); } - - /** - * @test - */ - public function custom_callable_second_level_relation_is_not_loaded_when_first_level_is_not() - { - $users = null; - - $this->extend( - (new Extend\Model(User::class)) - ->hasOne('firstLevelRelation', Post::class, 'user_id'), - (new Extend\Model(Post::class)) - ->belongsTo('secondLevelRelation', Discussion::class), - (new Extend\ApiController(ListUsersController::class)) - ->loadWhere('firstLevelRelation.secondLevelRelation', function ($query, $request) {}) - ->prepareDataForSerialization(function ($controller, $data) use (&$users) { - $users = $data; - - return []; - }) - ); - - $this->send( - $this->request('GET', '/api/users', [ - 'authenticatedAs' => 1, - ]) - ); - - $this->assertTrue($users->pluck('firstLevelRelation')->filter->relationLoaded('secondLevelRelation')->isEmpty()); - } - - /** - * @test - */ - public function custom_callable_second_level_relation_is_loaded_when_first_level_is() - { - $users = null; - - $this->extend( - (new Extend\Model(User::class)) - ->hasOne('firstLevelRelation', Post::class, 'user_id'), - (new Extend\Model(Post::class)) - ->belongsTo('secondLevelRelation', Discussion::class), - (new Extend\ApiController(ListUsersController::class)) - ->loadWhere('firstLevelRelation', function ($query, $request) {}) - ->loadWhere('firstLevelRelation.secondLevelRelation', function ($query, $request) {}) - ->prepareDataForSerialization(function ($controller, $data) use (&$users) { - $users = $data; - - return []; - }) - ); - - $this->send( - $this->request('GET', '/api/users', [ - 'authenticatedAs' => 1, - ]) - ); - - $this->assertTrue($users->pluck('firstLevelRelation')->filter->relationLoaded('secondLevelRelation')->isNotEmpty()); - } } -class CustomDiscussionSerializer extends DiscussionSerializer +class CustomAfterEndpointInvokableClass { - protected function getDefaultAttributes(object|array $model): array - { - return parent::getDefaultAttributes($model) + [ - 'customSerializer' => true - ]; - } -} - -class CustomDiscussionSerializer2 extends DiscussionSerializer -{ - protected function getDefaultAttributes(object|array $model): array - { - return parent::getDefaultAttributes($model) + [ - 'customSerializer2' => true - ]; - } -} - -class CustomUserSerializer extends UserSerializer -{ - protected function getDefaultAttributes(object|array $model): array - { - return parent::getDefaultAttributes($model) + [ - 'customSerializer' => true - ]; - } -} - -class CustomPostSerializer extends PostSerializer -{ - protected function getDefaultAttributes(object|array $model): array - { - return parent::getDefaultAttributes($model) + [ - 'customSerializer' => true - ]; - } -} - -class CustomApiControllerInvokableClass -{ - public function __invoke() - { - return true; - } -} - -class CustomPrepareDataSerializationInvokableClass -{ - public function __invoke(ShowDiscussionController $controller, Discussion $discussion) + public function __invoke(Context $context, Discussion $discussion): Discussion { $discussion->title = __CLASS__; - } -} -class CustomInvokableClassArgsReference -{ - public function __invoke($controller, &$data) - { - $data['referenceTest2'] = User::limit(2)->get(); + return $discussion; } } diff --git a/framework/core/tests/integration/extenders/ApiSerializerTest.php b/framework/core/tests/integration/extenders/ApiSerializerTest.php index fa48203ad..c0281ea3b 100644 --- a/framework/core/tests/integration/extenders/ApiSerializerTest.php +++ b/framework/core/tests/integration/extenders/ApiSerializerTest.php @@ -10,13 +10,11 @@ namespace Flarum\Tests\integration\extenders; use Carbon\Carbon; -use Flarum\Api\Controller\ShowUserController; -use Flarum\Api\Serializer\AbstractSerializer; -use Flarum\Api\Serializer\BasicUserSerializer; -use Flarum\Api\Serializer\DiscussionSerializer; -use Flarum\Api\Serializer\ForumSerializer; -use Flarum\Api\Serializer\PostSerializer; -use Flarum\Api\Serializer\UserSerializer; +use Flarum\Api\Endpoint\Show; +use Flarum\Api\Resource\AbstractDatabaseResource; +use Flarum\Api\Resource\ForumResource; +use Flarum\Api\Resource\UserResource; +use Flarum\Api\Schema; use Flarum\Discussion\Discussion; use Flarum\Extend; use Flarum\Post\Post; @@ -74,12 +72,11 @@ class ApiSerializerTest extends TestCase public function custom_attributes_exist_if_added() { $this->extend( - (new Extend\ApiSerializer(ForumSerializer::class)) - ->attributes(function () { - return [ - 'customAttribute' => true - ]; - }) + (new Extend\ApiResource(ForumResource::class)) + ->fields(fn () => [ + Schema\Boolean::make('customAttribute') + ->get(fn () => true), + ]) ); $this->app(); @@ -101,8 +98,8 @@ class ApiSerializerTest extends TestCase public function custom_attributes_with_invokable_exist_if_added() { $this->extend( - (new Extend\ApiSerializer(ForumSerializer::class)) - ->attributes(CustomAttributesInvokableClass::class) + (new Extend\ApiResource(ForumResource::class)) + ->fields(CustomAttributesInvokableClass::class) ); $this->app(); @@ -124,12 +121,11 @@ class ApiSerializerTest extends TestCase public function custom_attributes_exist_if_added_to_parent_class() { $this->extend( - (new Extend\ApiSerializer(BasicUserSerializer::class)) - ->attributes(function () { - return [ - 'customAttribute' => true - ]; - }) + (new Extend\ApiResource(AbstractDatabaseResource::class)) + ->fields(fn () => [ + Schema\Boolean::make('customAttribute') + ->get(fn () => true), + ]) ); $this->app(); @@ -151,18 +147,16 @@ class ApiSerializerTest extends TestCase public function custom_attributes_prioritize_child_classes() { $this->extend( - (new Extend\ApiSerializer(BasicUserSerializer::class)) - ->attributes(function () { - return [ - 'customAttribute' => 'initialValue' - ]; - }), - (new Extend\ApiSerializer(UserSerializer::class)) - ->attributes(function () { - return [ - 'customAttribute' => 'newValue' - ]; - }) + (new Extend\ApiResource(AbstractDatabaseResource::class)) + ->fields(fn () => [ + Schema\Str::make('customAttribute') + ->get(fn () => 'initialValue') + ]), + (new Extend\ApiResource(UserResource::class)) + ->fields(fn () => [ + Schema\Str::make('customAttribute') + ->get(fn () => 'newValue') + ]), ); $this->app(); @@ -179,130 +173,27 @@ class ApiSerializerTest extends TestCase $this->assertEquals('newValue', $payload['data']['attributes']['customAttribute']); } - /** - * @test - */ - public function custom_single_attribute_exists_if_added() - { - $this->extend( - (new Extend\ApiSerializer(ForumSerializer::class)) - ->attribute('customSingleAttribute', function () { - return true; - })->attribute('customSingleAttribute_0', function () { - return 0; - }) - ); - - $this->app(); - - $response = $this->send( - $this->request('GET', '/api', [ - 'authenticatedAs' => 1, - ]) - ); - - $payload = json_decode($response->getBody()->getContents(), true); - - $this->assertArrayHasKey('customSingleAttribute', $payload['data']['attributes']); - $this->assertArrayHasKey('customSingleAttribute_0', $payload['data']['attributes']); - $this->assertEquals(0, $payload['data']['attributes']['customSingleAttribute_0']); - } - - /** - * @test - */ - public function custom_single_attribute_with_invokable_exists_if_added() - { - $this->extend( - (new Extend\ApiSerializer(ForumSerializer::class)) - ->attribute('customSingleAttribute_1', CustomSingleAttributeInvokableClass::class) - ); - - $this->app(); - - $response = $this->send( - $this->request('GET', '/api', [ - 'authenticatedAs' => 1, - ]) - ); - - $payload = json_decode($response->getBody()->getContents(), true); - - $this->assertArrayHasKey('customSingleAttribute_1', $payload['data']['attributes']); - } - - /** - * @test - */ - public function custom_single_attribute_exists_if_added_to_parent_class() - { - $this->extend( - (new Extend\ApiSerializer(BasicUserSerializer::class)) - ->attribute('customSingleAttribute_2', function () { - return true; - }) - ); - - $this->app(); - - $response = $this->send( - $this->request('GET', '/api/users/2', [ - 'authenticatedAs' => 1, - ]) - ); - - $payload = json_decode($response->getBody()->getContents(), true); - - $this->assertArrayHasKey('customSingleAttribute_2', $payload['data']['attributes']); - } - - /** - * @test - */ - public function custom_single_attribute_prioritizes_child_classes() - { - $this->extend( - (new Extend\ApiSerializer(BasicUserSerializer::class)) - ->attribute('customSingleAttribute_3', function () { - return 'initialValue'; - }), - (new Extend\ApiSerializer(UserSerializer::class)) - ->attribute('customSingleAttribute_3', function () { - return 'newValue'; - }) - ); - - $this->app(); - - $response = $this->send( - $this->request('GET', '/api/users/2', [ - 'authenticatedAs' => 1, - ]) - ); - - $payload = json_decode($response->getBody()->getContents(), true); - - $this->assertArrayHasKey('customSingleAttribute_3', $payload['data']['attributes']); - $this->assertEquals('newValue', $payload['data']['attributes']['customSingleAttribute_3']); - } - /** * @test */ public function custom_attributes_can_be_overriden() { $this->extend( - (new Extend\ApiSerializer(BasicUserSerializer::class)) - ->attribute('someCustomAttribute', function () { - return 'newValue'; - })->attributes(function () { - return [ - 'someCustomAttribute' => 'initialValue', - 'someOtherCustomAttribute' => 'initialValue', - ]; - })->attribute('someOtherCustomAttribute', function () { - return 'newValue'; - }) + (new Extend\ApiResource(UserResource::class)) + ->fields(fn () => [ + Schema\Str::make('someCustomAttribute') + ->get(fn () => 'newValue'), + ]) + ->fields(fn () => [ + Schema\Str::make('someCustomAttribute') + ->get(fn () => 'secondValue'), + Schema\Str::make('someOtherCustomAttribute') + ->get(fn () => 'secondValue'), + ]) + ->fields(fn () => [ + Schema\Str::make('someOtherCustomAttribute') + ->get(fn () => 'newValue'), + ]) ); $this->app(); @@ -316,7 +207,7 @@ class ApiSerializerTest extends TestCase $payload = json_decode($response->getBody()->getContents(), true); $this->assertArrayHasKey('someCustomAttribute', $payload['data']['attributes']); - $this->assertEquals('newValue', $payload['data']['attributes']['someCustomAttribute']); + $this->assertEquals('secondValue', $payload['data']['attributes']['someCustomAttribute']); $this->assertArrayHasKey('someOtherCustomAttribute', $payload['data']['attributes']); $this->assertEquals('newValue', $payload['data']['attributes']['someOtherCustomAttribute']); } @@ -327,8 +218,10 @@ class ApiSerializerTest extends TestCase public function custom_relations_dont_exist_by_default() { $this->extend( - (new Extend\ApiController(ShowUserController::class)) - ->addInclude(['customSerializerRelation', 'postCustomRelation', 'anotherCustomRelation']) + (new Extend\ApiResource(UserResource::class)) + ->endpoint(Show::class, function (Show $endpoint): Show { + return $endpoint->addDefaultInclude(['customSerializerRelation', 'postCustomRelation', 'anotherCustomRelation']); + }) ); $response = $this->send( @@ -337,11 +230,7 @@ class ApiSerializerTest extends TestCase ]) ); - $responseJson = json_decode($response->getBody(), true); - - $this->assertArrayNotHasKey('customSerializerRelation', $responseJson['data']['relationships']); - $this->assertArrayNotHasKey('postCustomRelation', $responseJson['data']['relationships']); - $this->assertArrayNotHasKey('anotherCustomRelation', $responseJson['data']['relationships']); + $this->assertEquals(400, $response->getStatusCode()); } /** @@ -352,10 +241,15 @@ class ApiSerializerTest extends TestCase $this->extend( (new Extend\Model(User::class)) ->hasMany('customSerializerRelation', Discussion::class, 'user_id'), - (new Extend\ApiSerializer(UserSerializer::class)) - ->hasMany('customSerializerRelation', DiscussionSerializer::class), - (new Extend\ApiController(ShowUserController::class)) - ->addInclude('customSerializerRelation') + (new Extend\ApiResource(UserResource::class)) + ->fields(fn () => [ + Schema\Relationship\ToMany::make('customSerializerRelation') + ->type('discussions') + ->includable() + ]) + ->endpoint(Show::class, function (Show $endpoint) { + return $endpoint->addDefaultInclude(['customSerializerRelation']); + }) ); $response = $this->send( @@ -378,64 +272,15 @@ class ApiSerializerTest extends TestCase $this->extend( (new Extend\Model(User::class)) ->hasOne('customSerializerRelation', Discussion::class, 'user_id'), - (new Extend\ApiSerializer(UserSerializer::class)) - ->hasOne('customSerializerRelation', DiscussionSerializer::class), - (new Extend\ApiController(ShowUserController::class)) - ->addInclude('customSerializerRelation') - ); - - $response = $this->send( - $this->request('GET', '/api/users/2', [ - 'authenticatedAs' => 1, - ]) - ); - - $responseJson = json_decode($response->getBody(), true); - - $this->assertArrayHasKey('customSerializerRelation', $responseJson['data']['relationships']); - $this->assertEquals('discussions', $responseJson['data']['relationships']['customSerializerRelation']['data']['type']); - } - - /** - * @test - */ - public function custom_relationship_exists_if_added() - { - $this->extend( - (new Extend\Model(User::class)) - ->hasOne('customSerializerRelation', Discussion::class, 'user_id'), - (new Extend\ApiSerializer(UserSerializer::class)) - ->relationship('customSerializerRelation', function (AbstractSerializer $serializer, $model) { - return $serializer->hasOne($model, DiscussionSerializer::class, 'customSerializerRelation'); - }), - (new Extend\ApiController(ShowUserController::class)) - ->addInclude('customSerializerRelation') - ); - - $response = $this->send( - $this->request('GET', '/api/users/2', [ - 'authenticatedAs' => 1, - ]) - ); - - $responseJson = json_decode($response->getBody(), true); - - $this->assertArrayHasKey('customSerializerRelation', $responseJson['data']['relationships']); - $this->assertEquals('discussions', $responseJson['data']['relationships']['customSerializerRelation']['data']['type']); - } - - /** - * @test - */ - public function custom_relationship_with_invokable_exists_if_added() - { - $this->extend( - (new Extend\Model(User::class)) - ->hasOne('customSerializerRelation', Discussion::class, 'user_id'), - (new Extend\ApiSerializer(UserSerializer::class)) - ->relationship('customSerializerRelation', CustomRelationshipInvokableClass::class), - (new Extend\ApiController(ShowUserController::class)) - ->addInclude('customSerializerRelation') + (new Extend\ApiResource(UserResource::class)) + ->fields(fn () => [ + Schema\Relationship\ToOne::make('customSerializerRelation') + ->type('discussions') + ->includable() + ]) + ->endpoint(Show::class, function (Show $endpoint) { + return $endpoint->addDefaultInclude(['customSerializerRelation']); + }) ); $response = $this->send( @@ -458,10 +303,15 @@ class ApiSerializerTest extends TestCase $this->extend( (new Extend\Model(User::class)) ->hasMany('anotherCustomRelation', Discussion::class, 'user_id'), - (new Extend\ApiSerializer(BasicUserSerializer::class)) - ->hasMany('anotherCustomRelation', DiscussionSerializer::class), - (new Extend\ApiController(ShowUserController::class)) - ->addInclude('anotherCustomRelation') + (new Extend\ApiResource(AbstractDatabaseResource::class)) + ->fields(fn () => [ + Schema\Relationship\ToMany::make('anotherCustomRelation') + ->type('discussions') + ->includable() + ]) + ->endpoint(Show::class, function (Show $endpoint) { + return $endpoint->addDefaultInclude(['anotherCustomRelation']); + }) ); $response = $this->send( @@ -475,62 +325,15 @@ class ApiSerializerTest extends TestCase $this->assertArrayHasKey('anotherCustomRelation', $responseJson['data']['relationships']); $this->assertCount(3, $responseJson['data']['relationships']['anotherCustomRelation']['data']); } - - /** - * @test - */ - public function custom_relationship_prioritizes_child_classes() - { - $this->extend( - (new Extend\Model(User::class)) - ->hasOne('postCustomRelation', Post::class, 'user_id'), - (new Extend\Model(User::class)) - ->hasOne('discussionCustomRelation', Discussion::class, 'user_id'), - (new Extend\ApiSerializer(BasicUserSerializer::class)) - ->hasOne('postCustomRelation', PostSerializer::class), - (new Extend\ApiSerializer(UserSerializer::class)) - ->relationship('postCustomRelation', function (AbstractSerializer $serializer, $model) { - return $serializer->hasOne($model, DiscussionSerializer::class, 'discussionCustomRelation'); - }), - (new Extend\ApiController(ShowUserController::class)) - ->addInclude('postCustomRelation') - ); - - $response = $this->send( - $this->request('GET', '/api/users/2', [ - 'authenticatedAs' => 1, - ]) - ); - - $responseJson = json_decode($response->getBody(), true); - - $this->assertArrayHasKey('postCustomRelation', $responseJson['data']['relationships']); - $this->assertEquals('discussions', $responseJson['data']['relationships']['postCustomRelation']['data']['type']); - } } class CustomAttributesInvokableClass { - public function __invoke() + public function __invoke(): array { return [ - 'customAttributeFromInvokable' => true + Schema\Boolean::make('customAttributeFromInvokable') + ->get(fn () => true), ]; } } - -class CustomSingleAttributeInvokableClass -{ - public function __invoke() - { - return true; - } -} - -class CustomRelationshipInvokableClass -{ - public function __invoke(AbstractSerializer $serializer, $model) - { - return $serializer->hasOne($model, DiscussionSerializer::class, 'customSerializerRelation'); - } -} diff --git a/framework/core/tests/integration/extenders/ConditionalTest.php b/framework/core/tests/integration/extenders/ConditionalTest.php index 3dc8c021b..efeadb182 100644 --- a/framework/core/tests/integration/extenders/ConditionalTest.php +++ b/framework/core/tests/integration/extenders/ConditionalTest.php @@ -10,7 +10,8 @@ namespace Flarum\Tests\integration\extenders; use Exception; -use Flarum\Api\Serializer\ForumSerializer; +use Flarum\Api\Resource\ForumResource; +use Flarum\Api\Schema\Boolean; use Flarum\Extend; use Flarum\Extension\ExtensionManager; use Flarum\Testing\integration\RetrievesAuthorizedUsers; @@ -25,14 +26,13 @@ class ConditionalTest extends TestCase { $this->extend( (new Extend\Conditional()) - ->when(true, function () { - return [ - (new Extend\ApiSerializer(ForumSerializer::class)) - ->attributes(function () { - return ['customConditionalAttribute' => true]; - }) - ]; - }) + ->when(true, fn () => [ + (new Extend\ApiResource(ForumResource::class)) + ->fields(fn () => [ + Boolean::make('customConditionalAttribute') + ->get(fn () => true) + ]) + ]) ); $this->app(); @@ -53,14 +53,13 @@ class ConditionalTest extends TestCase { $this->extend( (new Extend\Conditional()) - ->when(false, function () { - return [ - (new Extend\ApiSerializer(ForumSerializer::class)) - ->attributes(function () { - return ['customConditionalAttribute' => true]; - }) - ]; - }) + ->when(false, fn () => [ + (new Extend\ApiResource(ForumResource::class)) + ->fields(fn () => [ + Boolean::make('customConditionalAttribute') + ->get(fn () => true) + ]) + ]) ); $this->app(); @@ -81,16 +80,13 @@ class ConditionalTest extends TestCase { $this->extend( (new Extend\Conditional()) - ->when(function () { - return true; - }, function () { - return [ - (new Extend\ApiSerializer(ForumSerializer::class)) - ->attributes(function () { - return ['customConditionalAttribute' => true]; - }) - ]; - }) + ->when(fn () => true, fn () => [ + (new Extend\ApiResource(ForumResource::class)) + ->fields(fn () => [ + Boolean::make('customConditionalAttribute') + ->get(fn () => true) + ]) + ]) ); $this->app(); @@ -111,16 +107,13 @@ class ConditionalTest extends TestCase { $this->extend( (new Extend\Conditional()) - ->when(function () { - return false; - }, function () { - return [ - (new Extend\ApiSerializer(ForumSerializer::class)) - ->attributes(function () { - return ['customConditionalAttribute' => true]; - }) - ]; - }) + ->when(fn () => false, fn () => [ + (new Extend\ApiResource(ForumResource::class)) + ->fields(fn () => [ + Boolean::make('customConditionalAttribute') + ->get(fn () => true) + ]) + ]) ); $this->app(); @@ -147,14 +140,13 @@ class ConditionalTest extends TestCase if (! $extensions) { throw new Exception('ExtensionManager not injected'); } - }, function () { - return [ - (new Extend\ApiSerializer(ForumSerializer::class)) - ->attributes(function () { - return ['customConditionalAttribute' => true]; - }) - ]; - }) + }, fn () => [ + (new Extend\ApiResource(ForumResource::class)) + ->fields(fn () => [ + Boolean::make('customConditionalAttribute') + ->get(fn () => true) + ]) + ]) ); $this->app(); @@ -294,14 +286,13 @@ class ConditionalTest extends TestCase { $this->extend( (new Extend\Conditional()) - ->when(false, function () { - return [ - (new Extend\ApiSerializer(ForumSerializer::class)) - ->attributes(function () { - return ['customConditionalAttribute' => true]; - }) - ]; - }) + ->when(false, fn () => [ + (new Extend\ApiResource(ForumResource::class)) + ->fields(fn () => [ + Boolean::make('customConditionalAttribute') + ->get(fn () => true) + ]) + ]) ); $this->app(); @@ -322,14 +313,13 @@ class ConditionalTest extends TestCase { $this->extend( (new Extend\Conditional()) - ->when(true, function () { - return [ - (new Extend\ApiSerializer(ForumSerializer::class)) - ->attributes(function () { - return ['customConditionalAttribute' => true]; - }) - ]; - }) + ->when(true, fn () => [ + (new Extend\ApiResource(ForumResource::class)) + ->fields(fn () => [ + Boolean::make('customConditionalAttribute') + ->get(fn () => true) + ]) + ]) ); $this->app(); @@ -370,12 +360,11 @@ class TestExtender public function __invoke(): array { return [ - (new Extend\ApiSerializer(ForumSerializer::class)) - ->attributes(function () { - return [ - 'customConditionalAttribute' => true - ]; - }) + (new Extend\ApiResource(ForumResource::class)) + ->fields(fn () => [ + Boolean::make('customConditionalAttribute') + ->get(fn () => true) + ]) ]; } } diff --git a/framework/core/tests/integration/extenders/EventTest.php b/framework/core/tests/integration/extenders/EventTest.php index e79b51af4..83534f9d7 100644 --- a/framework/core/tests/integration/extenders/EventTest.php +++ b/framework/core/tests/integration/extenders/EventTest.php @@ -9,33 +9,42 @@ namespace Flarum\Tests\integration\extenders; +use Flarum\Api\JsonApi; +use Flarum\Api\Resource\GroupResource; use Flarum\Extend; use Flarum\Foundation\Application; -use Flarum\Group\Command\CreateGroup; use Flarum\Group\Event\Created; +use Flarum\Group\Group; use Flarum\Locale\TranslatorInterface; use Flarum\Testing\integration\RetrievesAuthorizedUsers; use Flarum\Testing\integration\TestCase; use Flarum\User\User; -use Illuminate\Contracts\Bus\Dispatcher as BusDispatcher; use Illuminate\Contracts\Events\Dispatcher; class EventTest extends TestCase { use RetrievesAuthorizedUsers; - protected function buildGroup() + protected function buildGroup(): Group { - $bus = $this->app()->getContainer()->make(BusDispatcher::class); + /** @var JsonApi $api */ + $api = $this->app()->getContainer()->make(JsonApi::class); - return $bus->dispatch( - new CreateGroup(User::find(1), ['attributes' => [ - 'nameSingular' => 'test group', - 'namePlural' => 'test groups', - 'color' => '#000000', - 'icon' => 'fas fa-crown', - ]]) - ); + return $api->forResource(GroupResource::class) + ->forEndpoint('create') + ->process( + body: [ + 'data' => [ + 'attributes' => [ + 'nameSingular' => 'test group', + 'namePlural' => 'test groups', + 'color' => '#000000', + 'icon' => 'fas fa-crown', + ] + ], + ], + options: ['actor' => User::find(1)] + ); } /** diff --git a/framework/core/tests/integration/extenders/MailTest.php b/framework/core/tests/integration/extenders/MailTest.php index 9e1d7d2a7..d22dad21d 100644 --- a/framework/core/tests/integration/extenders/MailTest.php +++ b/framework/core/tests/integration/extenders/MailTest.php @@ -34,7 +34,11 @@ class MailTest extends TestCase ]) ); - $fields = json_decode($response->getBody()->getContents(), true)['data']['attributes']['fields']; + $body = $response->getBody()->getContents(); + + $this->assertEquals(200, $response->getStatusCode(), $body); + + $fields = json_decode($body, true)['data']['attributes']['fields']; // The custom driver does not exist $this->assertArrayNotHasKey('custom', $fields); diff --git a/framework/core/tests/integration/extenders/ModelPrivateTest.php b/framework/core/tests/integration/extenders/ModelPrivateTest.php index cde9e0096..97e116296 100644 --- a/framework/core/tests/integration/extenders/ModelPrivateTest.php +++ b/framework/core/tests/integration/extenders/ModelPrivateTest.php @@ -19,6 +19,13 @@ class ModelPrivateTest extends TestCase { use RetrievesAuthorizedUsers; + protected function setUp(): void + { + parent::setUp(); + + Discussion::unguard(); + } + /** * @test */ @@ -28,8 +35,10 @@ class ModelPrivateTest extends TestCase $user = User::find(1); - $discussion = Discussion::start('Some Discussion', $user); - $discussion->save(); + $discussion = Discussion::create([ + 'title' => 'Some Discussion', + 'user_id' => $user->id, + ]); $this->assertNull($discussion->is_private); } @@ -50,10 +59,14 @@ class ModelPrivateTest extends TestCase $user = User::find(1); - $privateDiscussion = Discussion::start('Private Discussion', $user); - $publicDiscussion = Discussion::start('Public Discussion', $user); - $privateDiscussion->save(); - $publicDiscussion->save(); + $privateDiscussion = Discussion::create([ + 'title' => 'Private Discussion', + 'user_id' => $user->id, + ]); + $publicDiscussion = Discussion::create([ + 'title' => 'Public Discussion', + 'user_id' => $user->id, + ]); $this->assertTrue($privateDiscussion->is_private); $this->assertFalse($publicDiscussion->is_private); @@ -73,10 +86,14 @@ class ModelPrivateTest extends TestCase $user = User::find(1); - $privateDiscussion = Discussion::start('Private Discussion', $user); - $publicDiscussion = Discussion::start('Public Discussion', $user); - $privateDiscussion->save(); - $publicDiscussion->save(); + $privateDiscussion = Discussion::create([ + 'title' => 'Private Discussion', + 'user_id' => $user->id, + ]); + $publicDiscussion = Discussion::create([ + 'title' => 'Public Discussion', + 'user_id' => $user->id, + ]); $this->assertTrue($privateDiscussion->is_private); $this->assertFalse($publicDiscussion->is_private); @@ -102,10 +119,14 @@ class ModelPrivateTest extends TestCase $user = User::find(1); - $privateDiscussion = Discussion::start('Private Discussion', $user); - $publicDiscussion = Discussion::start('Public Discussion', $user); - $privateDiscussion->save(); - $publicDiscussion->save(); + $privateDiscussion = Discussion::create([ + 'title' => 'Private Discussion', + 'user_id' => $user->id, + ]); + $publicDiscussion = Discussion::create([ + 'title' => 'Public Discussion', + 'user_id' => $user->id, + ]); $this->assertTrue($privateDiscussion->is_private); $this->assertFalse($publicDiscussion->is_private); diff --git a/framework/core/tests/integration/extenders/ModelTest.php b/framework/core/tests/integration/extenders/ModelTest.php index a99aade13..1fb65ce15 100644 --- a/framework/core/tests/integration/extenders/ModelTest.php +++ b/framework/core/tests/integration/extenders/ModelTest.php @@ -306,11 +306,13 @@ class ModelTest extends TestCase $this->app(); - $group1 = new Group; - $group2 = new Group; + Group::boot(); - $this->assertEquals(1, $group1->counter); - $this->assertEquals(3, $group2->counter); + $group1 = new Group(); + $group2 = new Group(); + + $this->assertEquals(3, $group1->counter); + $this->assertEquals(4, $group2->counter); } /** diff --git a/framework/core/tests/integration/extenders/NotificationTest.php b/framework/core/tests/integration/extenders/NotificationTest.php index c77f1d769..ee5e44f8c 100644 --- a/framework/core/tests/integration/extenders/NotificationTest.php +++ b/framework/core/tests/integration/extenders/NotificationTest.php @@ -31,19 +31,6 @@ class NotificationTest extends TestCase $this->assertArrayNotHasKey('customNotificationType', Notification::getSubjectModels()); } - /** - * @test - */ - public function notification_serializer_doesnt_exist_by_default() - { - $this->app(); - - $this->assertNotContains( - 'customNotificationTypeSerializer', - $this->app->getContainer()->make('flarum.api.notification_serializers') - ); - } - /** * @test */ @@ -57,34 +44,13 @@ class NotificationTest extends TestCase */ public function notification_type_exists_if_added() { - $this->extend((new Extend\Notification)->type( - CustomNotificationType::class, - 'customNotificationTypeSerializer' - )); + $this->extend((new Extend\Notification)->type(CustomNotificationType::class)); $this->app(); $this->assertArrayHasKey('customNotificationType', Notification::getSubjectModels()); } - /** - * @test - */ - public function notification_serializer_exists_if_added() - { - $this->extend((new Extend\Notification)->type( - CustomNotificationType::class, - 'customNotificationTypeSerializer' - )); - - $this->app(); - - $this->assertContains( - 'customNotificationTypeSerializer', - $this->app->getContainer()->make('flarum.api.notification_serializers') - ); - } - /** * @test */ @@ -107,9 +73,9 @@ class NotificationTest extends TestCase { $this->extend( (new Extend\Notification()) - ->type(CustomNotificationType::class, 'customSerializer') - ->type(SecondCustomNotificationType::class, 'secondCustomSerializer', ['customDriver']) - ->type(ThirdCustomNotificationType::class, 'thirdCustomSerializer') + ->type(CustomNotificationType::class) + ->type(SecondCustomNotificationType::class, ['customDriver']) + ->type(ThirdCustomNotificationType::class) ->driver('customDriver', CustomNotificationDriver::class, [CustomNotificationType::class]) ->driver('secondCustomDriver', SecondCustomNotificationDriver::class, [SecondCustomNotificationType::class]) ); @@ -132,7 +98,7 @@ class NotificationTest extends TestCase { $this->extend( (new Extend\Notification) - ->type(CustomNotificationType::class, 'customNotificationTypeSerializer') + ->type(CustomNotificationType::class) ->driver('customNotificationDriver', CustomNotificationDriver::class) ->beforeSending(function ($blueprint, $users) { if ($blueprint instanceof CustomNotificationType) { diff --git a/framework/core/tests/integration/extenders/SearchIndexTest.php b/framework/core/tests/integration/extenders/SearchIndexTest.php index dba9874e0..6a6c8ac84 100644 --- a/framework/core/tests/integration/extenders/SearchIndexTest.php +++ b/framework/core/tests/integration/extenders/SearchIndexTest.php @@ -42,13 +42,18 @@ class SearchIndexTest extends TestCase public static function modelProvider(): array { return [ - ['discussions', Discussion::class, 'title'], - ['posts', CommentPost::class, 'content'], + ['discussions', Discussion::class, [ + 'title' => 'test', + 'content' => 'test!', + ]], + ['posts', CommentPost::class, [ + 'content' => 'test!!', + ]], ]; } /** @dataProvider modelProvider */ - public function test_indexer_triggered_on_create(string $type, string $modelClass, string $attribute) + public function test_indexer_triggered_on_create(string $type, string $modelClass, array $attributes) { $this->extend( (new Extend\SearchIndex()) @@ -61,9 +66,8 @@ class SearchIndexTest extends TestCase 'authenticatedAs' => 1, 'json' => [ 'data' => [ - 'attributes' => [ - $attribute => 'test', - ], + 'type' => $type, + 'attributes' => $attributes, 'relationships' => ($type === 'posts' ? [ 'discussion' => [ 'data' => [ @@ -71,7 +75,7 @@ class SearchIndexTest extends TestCase 'id' => 1, ], ], - ] : null), + ] : []), ] ], ]), @@ -81,7 +85,7 @@ class SearchIndexTest extends TestCase } /** @dataProvider modelProvider */ - public function test_indexer_triggered_on_save(string $type, string $modelClass, string $attribute) + public function test_indexer_triggered_on_save(string $type, string $modelClass, array $attributes) { $this->extend( (new Extend\SearchIndex()) @@ -94,9 +98,8 @@ class SearchIndexTest extends TestCase 'authenticatedAs' => 1, 'json' => [ 'data' => [ - 'attributes' => [ - $attribute => 'changed' - ] + 'type' => $type, + 'attributes' => $type === 'discussions' ? array_diff_key($attributes, ['content' => null]) : $attributes, ] ], ]), @@ -106,7 +109,7 @@ class SearchIndexTest extends TestCase } /** @dataProvider modelProvider */ - public function test_indexer_triggered_on_delete(string $type, string $modelClass, string $attribute) + public function test_indexer_triggered_on_delete(string $type, string $modelClass, array $attributes) { $this->extend( (new Extend\SearchIndex()) @@ -125,7 +128,7 @@ class SearchIndexTest extends TestCase } /** @dataProvider modelProvider */ - public function test_indexer_triggered_on_hide(string $type, string $modelClass, string $attribute) + public function test_indexer_triggered_on_hide(string $type, string $modelClass, array $attributes) { $this->extend( (new Extend\SearchIndex()) @@ -138,6 +141,7 @@ class SearchIndexTest extends TestCase 'authenticatedAs' => 1, 'json' => [ 'data' => [ + 'type' => $type, 'attributes' => [ 'isHidden' => true ] @@ -150,7 +154,7 @@ class SearchIndexTest extends TestCase } /** @dataProvider modelProvider */ - public function test_indexer_triggered_on_restore(string $type, string $modelClass, string $attribute) + public function test_indexer_triggered_on_restore(string $type, string $modelClass, array $attributes) { $this->extend( (new Extend\SearchIndex()) @@ -163,6 +167,7 @@ class SearchIndexTest extends TestCase 'authenticatedAs' => 1, 'json' => [ 'data' => [ + 'type' => $type, 'attributes' => [ 'isHidden' => false ] diff --git a/framework/core/tests/integration/extenders/SettingsTest.php b/framework/core/tests/integration/extenders/SettingsTest.php index ce6f2944e..336f74745 100644 --- a/framework/core/tests/integration/extenders/SettingsTest.php +++ b/framework/core/tests/integration/extenders/SettingsTest.php @@ -97,6 +97,30 @@ class SettingsTest extends TestCase $this->assertEquals('customValueModified', $payload['data']['attributes']['customPrefix.customSetting']); } + /** + * @test + */ + public function custom_setting_callback_can_cast_to_type() + { + $this->extend( + (new Extend\Settings()) + ->serializeToForum('customPrefix.customSetting', 'custom-prefix.custom_setting', function ($value) { + return (bool) $value; + }) + ); + + $response = $this->send( + $this->request('GET', '/api', [ + 'authenticatedAs' => 1, + ]) + ); + + $payload = json_decode($response->getBody()->getContents(), true); + + $this->assertArrayHasKey('customPrefix.customSetting', $payload['data']['attributes']); + $this->assertEquals(true, $payload['data']['attributes']['customPrefix.customSetting']); + } + /** * @test */ diff --git a/framework/core/tests/integration/extenders/ThemeTest.php b/framework/core/tests/integration/extenders/ThemeTest.php index 57faea58f..85cb578ae 100644 --- a/framework/core/tests/integration/extenders/ThemeTest.php +++ b/framework/core/tests/integration/extenders/ThemeTest.php @@ -149,7 +149,7 @@ class ThemeTest extends TestCase $response = $this->send($this->request('GET', '/')); - $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals(200, $response->getStatusCode(), $response->getBody()->getContents()); $cssFilePath = $this->app()->getContainer()->make('filesystem')->disk('flarum-assets')->path('forum.css'); $contents = file_get_contents($cssFilePath); diff --git a/framework/core/tests/integration/extenders/ValidatorTest.php b/framework/core/tests/integration/extenders/ValidatorTest.php index ddb3b577c..cf83bd0d6 100644 --- a/framework/core/tests/integration/extenders/ValidatorTest.php +++ b/framework/core/tests/integration/extenders/ValidatorTest.php @@ -10,16 +10,16 @@ namespace Flarum\Tests\integration\extenders; use Flarum\Extend; -use Flarum\Group\GroupValidator; +use Flarum\Foundation\AbstractValidator; use Flarum\Testing\integration\TestCase; -use Flarum\User\UserValidator; +use Flarum\User\User; use Illuminate\Validation\ValidationException; class ValidatorTest extends TestCase { private function extendToRequireLongPassword() { - $this->extend((new Extend\Validator(UserValidator::class))->configure(function ($flarumValidator, $validator) { + $this->extend((new Extend\Validator(CustomUserValidator::class))->configure(function ($flarumValidator, $validator) { $validator->setRules([ 'password' => [ 'required', @@ -31,7 +31,7 @@ class ValidatorTest extends TestCase private function extendToRequireLongPasswordViaInvokableClass() { - $this->extend((new Extend\Validator(UserValidator::class))->configure(CustomValidatorClass::class)); + $this->extend((new Extend\Validator(CustomUserValidator::class))->configure(CustomValidatorClass::class)); } /** @@ -39,7 +39,7 @@ class ValidatorTest extends TestCase */ public function custom_validation_rule_does_not_exist_by_default() { - $this->app()->getContainer()->make(UserValidator::class)->assertValid(['password' => 'simplePassword']); + $this->app()->getContainer()->make(CustomUserValidator::class)->assertValid(['password' => 'simplePassword']); // If we have gotten this far, no validation exception has been thrown, so the test is succesful. $this->assertTrue(true); @@ -54,7 +54,7 @@ class ValidatorTest extends TestCase $this->expectException(ValidationException::class); - $this->app()->getContainer()->make(UserValidator::class)->assertValid(['password' => 'simplePassword']); + $this->app()->getContainer()->make(CustomUserValidator::class)->assertValid(['password' => 'simplePassword']); } /** @@ -66,7 +66,7 @@ class ValidatorTest extends TestCase $this->expectException(ValidationException::class); - $this->app()->getContainer()->make(UserValidator::class)->assertValid(['password' => 'simplePassword']); + $this->app()->getContainer()->make(CustomUserValidator::class)->assertValid(['password' => 'simplePassword']); } /** @@ -76,7 +76,7 @@ class ValidatorTest extends TestCase { $this->extendToRequireLongPassword(); - $this->app()->getContainer()->make(GroupValidator::class)->assertValid(['password' => 'simplePassword']); + $this->app()->getContainer()->make(CustomValidator::class)->assertValid(['password' => 'simplePassword']); // If we have gotten this far, no validation exception has been thrown, so the test is succesful. $this->assertTrue(true); @@ -95,3 +95,57 @@ class CustomValidatorClass ] + $validator->getRules()); } } + +class CustomUserValidator extends AbstractValidator +{ + protected ?User $user = null; + + public function getUser(): ?User + { + return $this->user; + } + + public function setUser(User $user): void + { + $this->user = $user; + } + + protected function getRules(): array + { + $idSuffix = $this->user ? ','.$this->user->id : ''; + + return [ + 'username' => [ + 'required', + 'regex:/^[a-z0-9_-]+$/i', + 'unique:users,username'.$idSuffix, + 'min:3', + 'max:30' + ], + 'email' => [ + 'required', + 'email:filter', + 'unique:users,email'.$idSuffix + ], + 'password' => [ + 'required', + 'min:8' + ] + ]; + } + + protected function getMessages(): array + { + return [ + 'username.regex' => $this->translator->trans('core.api.invalid_username_message') + ]; + } +} + +class CustomValidator extends AbstractValidator +{ + protected array $rules = [ + 'name_singular' => ['required'], + 'name_plural' => ['required'] + ]; +} diff --git a/framework/core/tests/integration/forum/RegisterTest.php b/framework/core/tests/integration/forum/RegisterTest.php index 9668e31b9..3cb5631a0 100644 --- a/framework/core/tests/integration/forum/RegisterTest.php +++ b/framework/core/tests/integration/forum/RegisterTest.php @@ -34,10 +34,11 @@ class RegisterTest extends TestCase $this->request('POST', '/register') ); - $this->assertEquals(422, $response->getStatusCode()); + $body = (string) $response->getBody(); + + $this->assertEquals(422, $response->getStatusCode(), $body); // The response body should contain details about the failed validation - $body = (string) $response->getBody(); $this->assertJson($body); $this->assertEquals([ 'errors' => [ diff --git a/framework/core/tests/integration/policy/DiscussionPolicyTest.php b/framework/core/tests/integration/policy/DiscussionPolicyTest.php index a1950975f..45cb91946 100644 --- a/framework/core/tests/integration/policy/DiscussionPolicyTest.php +++ b/framework/core/tests/integration/policy/DiscussionPolicyTest.php @@ -10,10 +10,10 @@ namespace Flarum\Tests\integration\policy; use Carbon\Carbon; -use Flarum\Bus\Dispatcher; +use Flarum\Api\JsonApi; +use Flarum\Api\Resource\PostResource; use Flarum\Discussion\Discussion; use Flarum\Foundation\DispatchEventsTrait; -use Flarum\Post\Command\PostReply; use Flarum\Post\Post; use Flarum\Testing\integration\RetrievesAuthorizedUsers; use Flarum\Testing\integration\TestCase; @@ -94,9 +94,30 @@ class DiscussionPolicyTest extends TestCase $this->assertTrue($user->can('rename', $discussion)); $this->assertFalse($user->can('rename', $discussionWithReply)); - $this->app()->getContainer()->make(Dispatcher::class)->dispatch( - new PostReply(1, User::findOrFail(1), ['attributes' => ['content' => 'test']], null) - ); + /** @var JsonApi $api */ + $api = $this->app()->getContainer()->make(JsonApi::class); + + $api + ->forResource(PostResource::class) + ->forEndpoint('create') + ->process( + body: [ + 'data' => [ + 'attributes' => [ + 'content' => 'test' + ], + 'relationships' => [ + 'discussion' => [ + 'data' => [ + 'type' => 'discussions', + 'id' => '1' + ], + ], + ], + ], + ], + options: ['actor' => User::findOrFail(1)] + ); // Date further into the future Carbon::setTestNow('2025-01-01 13:00:00'); diff --git a/js-packages/jest-config/setup-env.js b/js-packages/jest-config/setup-env.js index 5b20e8985..bf95a0ded 100644 --- a/js-packages/jest-config/setup-env.js +++ b/js-packages/jest-config/setup-env.js @@ -19,11 +19,25 @@ function bootApp() { { type: 'forums', id: '1', - attributes: {}, + attributes: { + canEditUserCredentials: true, + }, + }, + { + type: 'users', + id: '1', + attributes: { + id: 1, + username: 'admin', + displayName: 'Admin', + email: 'admin@machine.local', + joinTime: '2021-01-01T00:00:00Z', + isEmailConfirmed: true, + }, }, ], session: { - userId: 0, + userId: 1, csrfToken: 'test', }, }); diff --git a/php-packages/phpstan/extension.neon b/php-packages/phpstan/extension.neon index 7de009331..92b95be28 100644 --- a/php-packages/phpstan/extension.neon +++ b/php-packages/phpstan/extension.neon @@ -16,6 +16,9 @@ parameters: - stubs/Illuminate/Contracts/Filesystem/Cloud.stub - stubs/Illuminate/Contracts/Filesystem/Filesystem.stub + # We know for a fact the JsonApi object used internally is always the Flarum one. + - stubs/Tobyz/JsonApiServer/JsonApi.stub + services: - class: Flarum\PHPStan\Relations\ModelRelationsExtension diff --git a/php-packages/phpstan/larastan-extension.neon b/php-packages/phpstan/larastan-extension.neon index daeaa1130..869fcd0d0 100644 --- a/php-packages/phpstan/larastan-extension.neon +++ b/php-packages/phpstan/larastan-extension.neon @@ -12,6 +12,7 @@ parameters: bootstrapFiles: - bootstrap.php checkOctaneCompatibility: false + noEnvCallsOutsideOfConfig: false noModelMake: true noUnnecessaryCollectionCall: true noUnnecessaryCollectionCallOnly: [] @@ -24,9 +25,11 @@ parameters: checkModelProperties: false checkPhpDocMissingReturn: false checkUnusedViews: false + checkModelAppends: false parametersSchema: checkOctaneCompatibility: bool() + noEnvCallsOutsideOfConfig: bool() noModelMake: bool() noUnnecessaryCollectionCall: bool() noUnnecessaryCollectionCallOnly: listOf(string()) @@ -38,8 +41,11 @@ parametersSchema: disableSchemaScan: bool() checkModelProperties: bool() checkUnusedViews: bool() + checkModelAppends: bool() conditionalTags: + Larastan\Larastan\Rules\NoEnvCallsOutsideOfConfigRule: + phpstan.rules.rule: %noEnvCallsOutsideOfConfig% Larastan\Larastan\Rules\NoModelMakeRule: phpstan.rules.rule: %noModelMake% Larastan\Larastan\Rules\NoUnnecessaryCollectionCallRule: @@ -52,6 +58,8 @@ conditionalTags: phpstan.rules.rule: %checkModelProperties% Larastan\Larastan\Rules\UnusedViewsRule: phpstan.rules.rule: %checkUnusedViews% + Larastan\Larastan\Rules\ModelAppendsRule: + phpstan.rules.rule: %checkModelAppends% services: - @@ -163,6 +171,10 @@ services: class: Larastan\Larastan\Properties\ModelRelationsExtension tags: - phpstan.broker.propertiesClassReflectionExtension + - + class: Larastan\Larastan\ReturnTypes\ModelOnlyDynamicMethodReturnTypeExtension + tags: + - phpstan.broker.dynamicMethodReturnTypeExtension - class: Larastan\Larastan\ReturnTypes\ModelFactoryDynamicStaticMethodReturnTypeExtension @@ -287,7 +299,7 @@ services: - phpstan.broker.dynamicMethodReturnTypeExtension - - class: Larastan\Larastan\ReturnTypes\CollectionGenericStaticMethodDynamicMethodReturnTypeExtension + class: Larastan\Larastan\ReturnTypes\EnumerableGenericStaticMethodDynamicMethodReturnTypeExtension tags: - phpstan.broker.dynamicMethodReturnTypeExtension @@ -302,7 +314,7 @@ services: - phpstan.broker.dynamicMethodReturnTypeExtension - - class: Larastan\Larastan\ReturnTypes\CollectionGenericStaticMethodDynamicStaticMethodReturnTypeExtension + class: Larastan\Larastan\ReturnTypes\EnumerableGenericStaticMethodDynamicStaticMethodReturnTypeExtension tags: - phpstan.broker.dynamicStaticMethodReturnTypeExtension @@ -348,6 +360,11 @@ services: tags: - phpstan.broker.dynamicFunctionReturnTypeExtension + - + class: Larastan\Larastan\ReturnTypes\Helpers\StrExtension + tags: + - phpstan.broker.dynamicFunctionReturnTypeExtension + - class: Larastan\Larastan\ReturnTypes\Helpers\TapExtension tags: @@ -371,6 +388,9 @@ services: - class: Larastan\Larastan\Rules\OctaneCompatibilityRule + - + class: Larastan\Larastan\Rules\NoEnvCallsOutsideOfConfigRule + - class: Larastan\Larastan\Rules\NoModelMakeRule @@ -383,6 +403,9 @@ services: - class: Larastan\Larastan\Rules\ModelProperties\ModelPropertyRule + - + class: Larastan\Larastan\Rules\ModelAppendsRule + - class: Larastan\Larastan\Rules\ModelProperties\ModelPropertyStaticCallRule @@ -413,6 +436,7 @@ services: databaseMigrationPath: %databaseMigrationsPath% disableMigrationScan: %disableMigrationScan% parser: @currentPhpVersionSimpleDirectParser + reflectionProvider: @reflectionProvider - class: Larastan\Larastan\Properties\SquashedMigrationHelper @@ -423,6 +447,9 @@ services: - class: Larastan\Larastan\Properties\ModelCastHelper + - + class: Larastan\Larastan\Properties\ModelPropertyHelper + - class: Larastan\Larastan\Rules\ModelProperties\ModelPropertiesRuleHelper @@ -451,11 +478,14 @@ services: dispatchableClass: Illuminate\Foundation\Events\Dispatchable tags: - phpstan.rules.rule - - Larastan\Larastan\Properties\Schema\PhpMyAdminDataTypeToPhpTypeConverter + + - + class: Larastan\Larastan\Properties\Schema\PhpMyAdminDataTypeToPhpTypeConverter - class: Larastan\Larastan\LarastanStubFilesExtension - tags: [phpstan.stubFilesExtension] + tags: + - phpstan.stubFilesExtension - class: Larastan\Larastan\Rules\UnusedViewsRule @@ -507,35 +537,48 @@ services: class: Larastan\Larastan\ReturnTypes\ConsoleCommand\ArgumentDynamicReturnTypeExtension tags: - phpstan.broker.dynamicMethodReturnTypeExtension + - class: Larastan\Larastan\ReturnTypes\ConsoleCommand\HasArgumentDynamicReturnTypeExtension tags: - phpstan.broker.dynamicMethodReturnTypeExtension + - class: Larastan\Larastan\ReturnTypes\ConsoleCommand\OptionDynamicReturnTypeExtension tags: - phpstan.broker.dynamicMethodReturnTypeExtension + - class: Larastan\Larastan\ReturnTypes\ConsoleCommand\HasOptionDynamicReturnTypeExtension tags: - phpstan.broker.dynamicMethodReturnTypeExtension + - class: Larastan\Larastan\ReturnTypes\TranslatorGetReturnTypeExtension tags: - phpstan.broker.dynamicMethodReturnTypeExtension + - class: Larastan\Larastan\ReturnTypes\TransHelperReturnTypeExtension tags: - phpstan.broker.dynamicFunctionReturnTypeExtension + - class: Larastan\Larastan\ReturnTypes\DoubleUnderscoreHelperReturnTypeExtension tags: - phpstan.broker.dynamicFunctionReturnTypeExtension - - Larastan\Larastan\ReturnTypes\AppMakeHelper - - Larastan\Larastan\Internal\ConsoleApplicationResolver - - Larastan\Larastan\Internal\ConsoleApplicationHelper - - Larastan\Larastan\Support\HigherOrderCollectionProxyHelper + - + class: Larastan\Larastan\ReturnTypes\AppMakeHelper + + - + class: Larastan\Larastan\Internal\ConsoleApplicationResolver + + - + class: Larastan\Larastan\Internal\ConsoleApplicationHelper + + - + class: Larastan\Larastan\Support\HigherOrderCollectionProxyHelper rules: - Larastan\Larastan\Rules\UselessConstructs\NoUselessWithFunctionCallsRule diff --git a/php-packages/phpstan/phpstan-baseline.neon b/php-packages/phpstan/phpstan-baseline.neon index be8117205..19c3148b3 100644 --- a/php-packages/phpstan/phpstan-baseline.neon +++ b/php-packages/phpstan/phpstan-baseline.neon @@ -30,3 +30,13 @@ parameters: # ignore this error, so we have to ignore it globally. - message: '#^Parameter \#[0-9]+ \$[A-z0-9_]+ of method Flarum\Extend\[A-z0-9_:\\()]+ expects \(?callable\([A-z0-9_,|\\: ()-]+\)\)?, (callable|Closure)\([A-z0-9_,|\\: ()-]+\) given\.$#' reportUnmatched: false + + # PHPStan suddenly doesn't recognize callables can be function names? + - message: '#^Parameter \#[0-9]+ \$[A-z0-9_]+ of function [A-z0-9_:\\()]+ expects \(?callable\([A-z0-9_,|\\: ()-]+, ''[A-z0-9_:\\()]+'' given\.$#' + reportUnmatched: false + + # Not if we're using our own static make method. + - message: '#^Called ''Model\:\:make\(\)'' which performs unnecessary work, use ''new Model\(\)''\.$#' + + # This assumes that the phpdoc telling it it's not nullable is correct, that's not the case for internal Laravel typings. + - message: '#^Property [A-z0-9-_:$,\\]+ \([A-z]+\) on left side of \?\? is not nullable\.$#' diff --git a/php-packages/phpstan/stubs/Tobyz/JsonApiServer/JsonApi.stub b/php-packages/phpstan/stubs/Tobyz/JsonApiServer/JsonApi.stub new file mode 100644 index 000000000..c2d37c69f --- /dev/null +++ b/php-packages/phpstan/stubs/Tobyz/JsonApiServer/JsonApi.stub @@ -0,0 +1,11 @@ + $instance->uniqueKeys ?? null, ]; } else { + if (class_exists($tableOrModelClass) && is_subclass_of($tableOrModelClass, Model::class)) { + $tableOrModelClass = (new $tableOrModelClass)->getTable(); + } + $databaseContent[$tableOrModelClass] = [ 'rows' => $_rows, 'unique' => null,