diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index c161ab51fc..0000000000 --- a/.gitattributes +++ /dev/null @@ -1,4 +0,0 @@ -*.js linguist-language=Java -*.css linguist-language=Java -*.ftl linguist-language=FreeMarker -*.html linguist-language=Vue diff --git a/.github/actions/docker-buildx-push/action.yaml b/.github/actions/docker-buildx-push/action.yaml index d377b43347..819ea02788 100644 --- a/.github/actions/docker-buildx-push/action.yaml +++ b/.github/actions/docker-buildx-push/action.yaml @@ -14,6 +14,14 @@ inputs: description: Token for the DockerHub account required: false default: "" + f2c-registry-user: + description: "User name of Fit2Cloud Docker Registry." + required: false + default: "" + f2c-registry-token: + description: "Token of Fit2Cloud Docker Registry." + required: false + default: "" push: description: Should push the docker image or not. required: false @@ -37,6 +45,7 @@ runs: images: | ghcr.io/${{ github.repository_owner }}/${{ inputs.image-name }} halohub/${{ inputs.image-name }} + registry.fit2cloud.com/halo/${{ inputs.image-name }} tags: | type=schedule,pattern=nightly-{{date 'YYYYMMDD'}},enabled=${{ github.event_name == 'schedule' }} type=ref,event=branch,enabled=${{ github.event_name == 'push' }} @@ -64,6 +73,13 @@ runs: with: username: ${{ inputs.dockerhub-user }} password: ${{ inputs.dockerhub-token }} + - name: Login to Fit2Cloud Docker Registry + if: inputs.f2c-registry-token != '' && github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: registry.fit2cloud.com + username: ${{ inputs.f2c-registry-user }} + password: ${{ inputs.f2c-registry-token }} - name: Build and push uses: docker/build-push-action@v5 with: diff --git a/.github/workflows/halo.yaml b/.github/workflows/halo.yaml index b83bae4abd..660a9a65f8 100644 --- a/.github/workflows/halo.yaml +++ b/.github/workflows/halo.yaml @@ -98,6 +98,8 @@ jobs: ghcr-token: ${{ secrets.GITHUB_TOKEN }} dockerhub-user: ${{ secrets.DOCKER_USERNAME }} dockerhub-token: ${{ secrets.DOCKER_TOKEN }} + f2c-registry-user: ${{ secrets.F2C_REGISTRY_USER }} + f2c-registry-token: ${{ secrets.F2C_REGISTRY_TOKEN }} push: true platforms: linux/amd64,linux/arm64/v8,linux/ppc64le,linux/s390x diff --git a/OWNERS b/OWNERS index a707d8b421..5691d610b4 100644 --- a/OWNERS +++ b/OWNERS @@ -9,3 +9,4 @@ approvers: - ruibaby - guqing - JohnNiang +- LIlGG diff --git a/README.md b/README.md index 82c1ab2988..90d8919eaf 100755 --- a/README.md +++ b/README.md @@ -27,10 +27,16 @@ ## 快速开始 +如果你的设备有 Docker 环境,可以使用以下命令快速启动一个 Halo 的体验环境: + ```bash -docker run -d --name halo -p 8090:8090 -v ~/.halo2:/root/.halo2 halohub/halo:2.19 +docker run -d --name halo -p 8090:8090 -v ~/.halo2:/root/.halo2 halohub/halo:2.20 ``` +或者点击下方按钮使用 [Gitpod](https://gitpod.io/) 启动一个体验环境: + +[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/halo-sigs/gitpod-demo) + 以上仅作为体验使用,详细部署文档请查阅: ## 在线体验 diff --git a/api-docs/openapi/v3_0/aggregated.json b/api-docs/openapi/v3_0/aggregated.json index 7006547132..3296993b1f 100644 --- a/api-docs/openapi/v3_0/aggregated.json +++ b/api-docs/openapi/v3_0/aggregated.json @@ -2651,30 +2651,6 @@ ] } }, - "/apis/api.console.halo.run/v1alpha1/plugin-presets": { - "get": { - "description": "List all plugin presets in the system.", - "operationId": "ListPluginPresets", - "responses": { - "default": { - "content": { - "*/*": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Plugin" - } - } - } - }, - "description": "default response" - } - }, - "tags": [ - "PluginV1alpha1Console" - ] - } - }, "/apis/api.console.halo.run/v1alpha1/plugins": { "get": { "description": "List plugins using query criteria and sort params", @@ -2871,7 +2847,7 @@ }, "/apis/api.console.halo.run/v1alpha1/plugins/{name}/config": { "get": { - "description": "Fetch configMap of plugin by configured configMapName.", + "description": "Fetch configMap of plugin by configured configMapName. it is deprecated since 2.20.0", "operationId": "fetchPluginConfig", "parameters": [ { @@ -2900,7 +2876,8 @@ ] }, "put": { - "description": "Update the configMap of plugin setting.", + "deprecated": true, + "description": "Update the configMap of plugin setting, it is deprecated since 2.20.0", "operationId": "updatePluginConfig", "parameters": [ { @@ -2939,6 +2916,70 @@ ] } }, + "/apis/api.console.halo.run/v1alpha1/plugins/{name}/json-config": { + "get": { + "description": "Fetch converted json config of plugin by configured configMapName.", + "operationId": "fetchPluginJsonConfig", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "default": { + "content": { + "*/*": { + "schema": { + "type": "object" + } + } + }, + "description": "default response" + } + }, + "tags": [ + "PluginV1alpha1Console" + ] + }, + "put": { + "description": "Update the config of plugin setting.", + "operationId": "updatePluginJsonConfig", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "204": { + "content": {}, + "description": "No Content" + } + }, + "tags": [ + "PluginV1alpha1Console" + ] + } + }, "/apis/api.console.halo.run/v1alpha1/plugins/{name}/plugin-state": { "put": { "description": "Change the running state of a plugin by name.", @@ -4244,38 +4285,6 @@ ] } }, - "/apis/api.console.halo.run/v1alpha1/system/initialize": { - "post": { - "description": "Initialize system", - "operationId": "initialize", - "requestBody": { - "content": { - "*/*": { - "schema": { - "$ref": "#/components/schemas/SystemInitializationRequest" - } - } - } - }, - "responses": { - "201": { - "description": "System initialization successfully.", - "headers": { - "Location": { - "description": "Redirect URL.", - "schema": { - "type": "string" - }, - "style": "simple" - } - } - } - }, - "tags": [ - "SystemV1alpha1Console" - ] - } - }, "/apis/api.console.halo.run/v1alpha1/tags": { "get": { "description": "List Post Tags.", @@ -4545,7 +4554,8 @@ }, "/apis/api.console.halo.run/v1alpha1/themes/{name}/config": { "get": { - "description": "Fetch configMap of theme by configured configMapName.", + "deprecated": true, + "description": "Fetch configMap of theme by configured configMapName. It is deprecated.", "operationId": "fetchThemeConfig", "parameters": [ { @@ -4574,7 +4584,8 @@ ] }, "put": { - "description": "Update the configMap of theme setting.", + "deprecated": true, + "description": "Update the configMap of theme setting. It is deprecated.", "operationId": "updateThemeConfig", "parameters": [ { @@ -4637,6 +4648,70 @@ ] } }, + "/apis/api.console.halo.run/v1alpha1/themes/{name}/json-config": { + "get": { + "description": "Fetch converted json config of theme by configured configMapName.", + "operationId": "fetchThemeJsonConfig", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "default": { + "content": { + "*/*": { + "schema": { + "type": "object" + } + } + }, + "description": "default response" + } + }, + "tags": [ + "ThemeV1alpha1Console" + ] + }, + "put": { + "description": "Update the configMap of theme setting.", + "operationId": "updateThemeJsonConfig", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "204": { + "content": {}, + "description": "No Content" + } + }, + "tags": [ + "ThemeV1alpha1Console" + ] + } + }, "/apis/api.console.halo.run/v1alpha1/themes/{name}/reload": { "put": { "description": "Reload theme setting.", @@ -6381,76 +6456,27 @@ ] } }, - "/apis/api.halo.run/v1alpha1/users/-/send-password-reset-email": { - "post": { - "description": "Send password reset email when forgot password", - "operationId": "SendPasswordResetEmail", - "requestBody": { - "content": { - "*/*": { - "schema": { - "$ref": "#/components/schemas/PasswordResetEmailRequest" - } - } - }, - "required": true - }, - "responses": { - "204 NO_CONTENT": { - "content": {}, - "description": "default response" - } - }, - "tags": [ - "UserV1alpha1Public" - ] - } - }, - "/apis/api.halo.run/v1alpha1/users/-/send-register-verify-email": { - "post": { - "description": "Send registration verification email, which can be called when mustVerifyEmailOnRegistration in user settings is true", - "operationId": "SendRegisterVerifyEmail", - "requestBody": { - "content": { - "*/*": { - "schema": { - "$ref": "#/components/schemas/RegisterVerifyEmailRequest" - } + "/apis/api.notification.halo.run/v1alpha1/notifiers/{name}/receiver-config": { + "get": { + "description": "Fetch receiver config of notifier", + "operationId": "FetchReceiverConfig", + "parameters": [ + { + "description": "Notifier name", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" } - }, - "required": true - }, - "responses": { - "204 NO_CONTENT": { - "content": {}, - "description": "default response" } - }, - "tags": [ - "UserV1alpha1Public" - ] - } - }, - "/apis/api.halo.run/v1alpha1/users/-/signup": { - "post": { - "description": "Sign up a new user", - "operationId": "SignUp", - "requestBody": { - "content": { - "*/*": { - "schema": { - "$ref": "#/components/schemas/SignUpRequest" - } - } - }, - "required": true - }, + ], "responses": { "default": { "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/User" + "type": "object" } } }, @@ -6458,17 +6484,15 @@ } }, "tags": [ - "UserV1alpha1Public" + "NotifierV1alpha1Uc" ] - } - }, - "/apis/api.halo.run/v1alpha1/users/{name}/reset-password": { - "put": { - "description": "Reset password by token", - "operationId": "ResetPasswordByToken", + }, + "post": { + "description": "Save receiver config of notifier", + "operationId": "SaveReceiverConfig", "parameters": [ { - "description": "The name of the user", + "description": "Notifier name", "in": "path", "name": "name", "required": true, @@ -6479,98 +6503,32 @@ ], "requestBody": { "content": { - "*/*": { + "application/json": { "schema": { - "$ref": "#/components/schemas/ResetPasswordRequest" + "type": "object" } } }, "required": true }, "responses": { - "204 NO_CONTENT": { + "default": { "content": {}, "description": "default response" } }, "tags": [ - "UserV1alpha1Public" + "NotifierV1alpha1Uc" ] } }, - "/apis/api.notification.halo.run/v1alpha1/notifiers/{name}/receiver-config": { + "/apis/api.notification.halo.run/v1alpha1/subscriptions/{name}/unsubscribe": { "get": { - "description": "Fetch receiver config of notifier", - "operationId": "FetchReceiverConfig", + "description": "Unsubscribe a subscription", + "operationId": "Unsubscribe", "parameters": [ { - "description": "Notifier name", - "in": "path", - "name": "name", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "default": { - "content": { - "*/*": { - "schema": { - "type": "object" - } - } - }, - "description": "default response" - } - }, - "tags": [ - "NotifierV1alpha1Uc" - ] - }, - "post": { - "description": "Save receiver config of notifier", - "operationId": "SaveReceiverConfig", - "parameters": [ - { - "description": "Notifier name", - "in": "path", - "name": "name", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - }, - "required": true - }, - "responses": { - "default": { - "content": {}, - "description": "default response" - } - }, - "tags": [ - "NotifierV1alpha1Uc" - ] - } - }, - "/apis/api.notification.halo.run/v1alpha1/subscriptions/{name}/unsubscribe": { - "get": { - "description": "Unsubscribe a subscription", - "operationId": "Unsubscribe", - "parameters": [ - { - "description": "Subscription name", + "description": "Subscription name", "in": "path", "name": "name", "required": true, @@ -6601,7 +6559,7 @@ } }, "tags": [ - "api.notification.halo.run/v1alpha1/Subscription" + "NotificationV1alpha1Public" ] } }, @@ -7434,6 +7392,72 @@ ] } }, + "/apis/console.api.halo.run/v1alpha1/systemconfigs/{group}": { + "get": { + "description": "Get system config by group", + "operationId": "getSystemConfigByGroup", + "parameters": [ + { + "description": "Group of the system config", + "in": "path", + "name": "group", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "default": { + "content": { + "*/*": { + "schema": { + "type": "object" + } + }, + "application/json": {} + }, + "description": "default response" + } + }, + "tags": [ + "SystemConfigV1alpha1Console" + ] + }, + "put": { + "description": "Update system config by group", + "operationId": "updateSystemConfigByGroup", + "parameters": [ + { + "description": "Group of the system config", + "in": "path", + "name": "group", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "*/*": { + "schema": { + "type": "object" + } + } + } + }, + "responses": { + "204 NO_CONTENT": { + "content": {}, + "description": "default response" + } + }, + "tags": [ + "SystemConfigV1alpha1Console" + ] + } + }, "/apis/console.api.migration.halo.run/v1alpha1/backup-files": { "get": { "description": "Get backup files from backup root.", @@ -14642,76 +14666,30 @@ ] } }, - "/apis/uc.api.content.halo.run/v1alpha1/attachments": { - "post": { - "description": "Create attachment for the given post.", - "operationId": "CreateAttachmentForPost", - "parameters": [ - { - "description": "Wait for permalink.", - "in": "query", - "name": "waitForPermalink", - "schema": { - "type": "boolean" - } - } - ], - "requestBody": { - "content": { - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/PostAttachmentRequest" - } - } - } - }, - "responses": { - "default": { - "content": { - "*/*": { - "schema": { - "$ref": "#/components/schemas/Attachment" - } - } - }, - "description": "default response" - } - }, - "tags": [ - "AttachmentV1alpha1Uc" - ] - } - }, - "/apis/uc.api.content.halo.run/v1alpha1/attachments/-/upload-from-url": { - "post": { - "description": "Upload attachment from the given URL.", - "operationId": "ExternalTransferAttachment_1", + "/apis/uc.api.auth.halo.run/v1alpha1/user-connections/{registerId}/disconnect": { + "put": { + "description": "Disconnect my connection from a third-party platform.", + "operationId": "DisconnectMyConnection", "parameters": [ { - "description": "Wait for permalink.", - "in": "query", - "name": "waitForPermalink", + "description": "The registration ID of the third-party platform.", + "in": "path", + "name": "registerId", + "required": true, "schema": { - "type": "boolean" + "type": "string" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UploadFromUrlRequest" - } - } - }, - "required": true - }, "responses": { "default": { "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/Attachment" + "type": "array", + "items": { + "$ref": "#/components/schemas/UserConnection" + } } } }, @@ -14719,7 +14697,7 @@ } }, "tags": [ - "AttachmentV1alpha1Uc" + "UserConnectionV1alpha1Uc" ] } }, @@ -15037,6 +15015,38 @@ ] } }, + "/apis/uc.api.content.halo.run/v1alpha1/posts/{name}/recycle": { + "delete": { + "description": "Move my post to recycle bin.", + "operationId": "RecycleMyPost", + "parameters": [ + { + "description": "Post name", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "default": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/Post" + } + } + }, + "description": "default response" + } + }, + "tags": [ + "PostV1alpha1Uc" + ] + } + }, "/apis/uc.api.content.halo.run/v1alpha1/posts/{name}/unpublish": { "put": { "description": "Unpublish my post.", @@ -15348,17 +15358,265 @@ } }, "tags": [ - "PersonalAccessTokenV1alpha1Uc" + "PersonalAccessTokenV1alpha1Uc" + ] + }, + "post": { + "description": "Generate a PAT.", + "operationId": "GeneratePat", + "requestBody": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/PersonalAccessToken" + } + } + }, + "required": true + }, + "responses": { + "default": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/PersonalAccessToken" + } + } + }, + "description": "default response" + } + }, + "tags": [ + "PersonalAccessTokenV1alpha1Uc" + ] + } + }, + "/apis/uc.api.security.halo.run/v1alpha1/personalaccesstokens/{name}": { + "delete": { + "description": "Delete a PAT", + "operationId": "DeletePat", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": {}, + "tags": [ + "PersonalAccessTokenV1alpha1Uc" + ] + }, + "get": { + "description": "Obtain a PAT.", + "operationId": "ObtainPat", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": {}, + "tags": [ + "PersonalAccessTokenV1alpha1Uc" + ] + } + }, + "/apis/uc.api.security.halo.run/v1alpha1/personalaccesstokens/{name}/actions/restoration": { + "put": { + "description": "Restore a PAT.", + "operationId": "RestorePat", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": {}, + "tags": [ + "PersonalAccessTokenV1alpha1Uc" + ] + } + }, + "/apis/uc.api.security.halo.run/v1alpha1/personalaccesstokens/{name}/actions/revocation": { + "put": { + "description": "Revoke a PAT", + "operationId": "RevokePat", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": {}, + "tags": [ + "PersonalAccessTokenV1alpha1Uc" + ] + } + }, + "/apis/uc.api.storage.halo.run/v1alpha1/attachments": { + "get": { + "description": "List attachments of the current user uploaded.", + "operationId": "ListMyAttachments", + "parameters": [ + { + "description": "Page number. Default is 0.", + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "description": "Size number. Default is 0.", + "in": "query", + "name": "size", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "description": "Label selector. e.g.: hidden!\u003dtrue", + "in": "query", + "name": "labelSelector", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", + "in": "query", + "name": "fieldSelector", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", + "in": "query", + "name": "sort", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "description": "Filter attachments without group. This parameter will ignore group parameter.", + "in": "query", + "name": "ungrouped", + "schema": { + "type": "boolean" + } + }, + { + "description": "Keyword for searching.", + "in": "query", + "name": "keyword", + "schema": { + "type": "string" + } + }, + { + "description": "Acceptable media types.", + "in": "query", + "name": "accepts", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + ], + "responses": { + "default": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/AttachmentList" + } + } + }, + "description": "default response" + } + }, + "tags": [ + "AttachmentV1alpha1Uc" + ] + }, + "post": { + "description": "Create attachment for the given post.", + "operationId": "CreateAttachmentForPost", + "parameters": [ + { + "description": "Wait for permalink.", + "in": "query", + "name": "waitForPermalink", + "schema": { + "type": "boolean" + } + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PostAttachmentRequest" + } + } + } + }, + "responses": { + "default": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/Attachment" + } + } + }, + "description": "default response" + } + }, + "tags": [ + "AttachmentV1alpha1Uc" ] - }, + } + }, + "/apis/uc.api.storage.halo.run/v1alpha1/attachments/-/upload": { "post": { - "description": "Generate a PAT.", - "operationId": "GeneratePat", + "description": "Upload attachment to user center storage.", + "operationId": "UploadUcAttachment", "requestBody": { "content": { - "*/*": { + "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/PersonalAccessToken" + "$ref": "#/components/schemas/UcUploadRequest" } } }, @@ -15369,7 +15627,7 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/PersonalAccessToken" + "$ref": "#/components/schemas/Attachment" } } }, @@ -15377,98 +15635,61 @@ } }, "tags": [ - "PersonalAccessTokenV1alpha1Uc" - ] - } - }, - "/apis/uc.api.security.halo.run/v1alpha1/personalaccesstokens/{name}": { - "delete": { - "description": "Delete a PAT", - "operationId": "DeletePat", - "parameters": [ - { - "in": "path", - "name": "name", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": {}, - "tags": [ - "PersonalAccessTokenV1alpha1Uc" - ] - }, - "get": { - "description": "Obtain a PAT.", - "operationId": "ObtainPat", - "parameters": [ - { - "in": "path", - "name": "name", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": {}, - "tags": [ - "PersonalAccessTokenV1alpha1Uc" + "AttachmentV1alpha1Uc" ] } }, - "/apis/uc.api.security.halo.run/v1alpha1/personalaccesstokens/{name}/actions/restoration": { - "put": { - "description": "Restore a PAT.", - "operationId": "RestorePat", + "/apis/uc.api.storage.halo.run/v1alpha1/attachments/-/upload-from-url": { + "post": { + "description": "Upload attachment from the given URL.", + "operationId": "ExternalTransferAttachment_1", "parameters": [ { - "in": "path", - "name": "name", - "required": true, + "description": "Wait for permalink.", + "in": "query", + "name": "waitForPermalink", "schema": { - "type": "string" + "type": "boolean" } } ], - "responses": {}, - "tags": [ - "PersonalAccessTokenV1alpha1Uc" - ] - } - }, - "/apis/uc.api.security.halo.run/v1alpha1/personalaccesstokens/{name}/actions/revocation": { - "put": { - "description": "Revoke a PAT", - "operationId": "RevokePat", - "parameters": [ - { - "in": "path", - "name": "name", - "required": true, - "schema": { - "type": "string" + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UploadFromUrlRequest" + } } + }, + "required": true + }, + "responses": { + "default": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/Attachment" + } + } + }, + "description": "default response" } - ], - "responses": {}, + }, "tags": [ - "PersonalAccessTokenV1alpha1Uc" + "AttachmentV1alpha1Uc" ] } }, - "/login/public-key": { + "/system/setup": { "get": { - "description": "Read public key for encrypting password.", - "operationId": "GetPublicKey", + "description": "Jump to setup page", + "operationId": "JumpToSetupPage", "responses": { "default": { "content": { - "*/*": { + "text/html": { "schema": { - "$ref": "#/components/schemas/PublicKeyResponse" + "type": "string" } } }, @@ -15476,8 +15697,12 @@ } }, "tags": [ - "Login" + "SystemV1alpha1Public" ] + }, + "post": { + "operationId": "SetNoCacheForSetUpPage", + "responses": {} } } }, @@ -15834,11 +16059,19 @@ }, "AuthProviderSpec": { "required": [ + "authType", "authenticationUrl", "displayName" ], "type": "object", "properties": { + "authType": { + "type": "string", + "enum": [ + "FORM", + "OAUTH2" + ] + }, "authenticationUrl": { "type": "string", "description": "Authentication url of the auth provider" @@ -15862,9 +16095,11 @@ "logo": { "type": "string" }, - "priority": { - "type": "integer", - "format": "int32" + "method": { + "type": "string" + }, + "rememberMeSupport": { + "type": "boolean" }, "settingRef": { "$ref": "#/components/schemas/SettingRef" @@ -16282,7 +16517,7 @@ "description": "Old password." }, "password": { - "minLength": 6, + "minLength": 5, "type": "string", "description": "New password." } @@ -16295,7 +16530,7 @@ "type": "object", "properties": { "password": { - "minLength": 6, + "minLength": 5, "type": "string", "description": "New password." } @@ -18084,6 +18319,13 @@ ], "type": "object", "properties": { + "authType": { + "type": "string", + "enum": [ + "FORM", + "OAUTH2" + ] + }, "authenticationUrl": { "type": "string" }, @@ -18111,6 +18353,10 @@ "name": { "type": "string" }, + "priority": { + "type": "integer", + "format": "int32" + }, "privileged": { "type": "boolean" }, @@ -19636,6 +19882,9 @@ } } }, + "Part": { + "type": "object" + }, "PasswordRequest": { "required": [ "password" @@ -19647,21 +19896,6 @@ } } }, - "PasswordResetEmailRequest": { - "required": [ - "email", - "username" - ], - "type": "object", - "properties": { - "email": { - "type": "string" - }, - "username": { - "type": "string" - } - } - }, "PatSpec": { "required": [ "name", @@ -20500,9 +20734,6 @@ } }, "PostStatus": { - "required": [ - "phase" - ], "type": "object", "properties": { "commentsCount": { @@ -20595,14 +20826,6 @@ } } }, - "PublicKeyResponse": { - "type": "object", - "properties": { - "base64Format": { - "type": "string" - } - } - }, "Reason": { "required": [ "apiVersion", @@ -20987,17 +21210,6 @@ }, "description": "Extension reference object. The name is mandatory" }, - "RegisterVerifyEmailRequest": { - "required": [ - "email" - ], - "type": "object", - "properties": { - "email": { - "type": "string" - } - } - }, "RememberMeToken": { "required": [ "apiVersion", @@ -21426,22 +21638,6 @@ } } }, - "ResetPasswordRequest": { - "required": [ - "newPassword", - "token" - ], - "type": "object", - "properties": { - "newPassword": { - "minLength": 6, - "type": "string" - }, - "token": { - "type": "string" - } - } - }, "RestoreRequest": { "type": "object", "properties": { @@ -22194,27 +22390,6 @@ } } }, - "SignUpRequest": { - "required": [ - "password", - "user" - ], - "type": "object", - "properties": { - "password": { - "minLength": 6, - "type": "string" - }, - "user": { - "$ref": "#/components/schemas/User" - }, - "verifyCode": { - "maxLength": 6, - "minLength": 6, - "type": "string" - } - } - }, "SinglePage": { "required": [ "apiVersion", @@ -22404,9 +22579,6 @@ } }, "SinglePageStatus": { - "required": [ - "phase" - ], "type": "object", "properties": { "commentsCount": { @@ -22806,29 +22978,6 @@ }, "description": "The subscriber to be notified" }, - "SystemInitializationRequest": { - "required": [ - "password", - "username" - ], - "type": "object", - "properties": { - "email": { - "type": "string" - }, - "password": { - "minLength": 3, - "type": "string" - }, - "siteTitle": { - "type": "string" - }, - "username": { - "minLength": 1, - "type": "string" - } - } - }, "Tag": { "required": [ "apiVersion", @@ -23443,6 +23592,39 @@ } } }, + "UcUploadRequest": { + "required": [ + "file" + ], + "type": "object", + "properties": { + "file": { + "type": "string", + "format": "binary" + }, + "formData": { + "type": "object", + "properties": { + "all": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/Part" + }, + "writeOnly": true + }, + "empty": { + "type": "boolean" + } + }, + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Part" + } + } + } + } + }, "UpgradeFromUriRequest": { "required": [ "uri" @@ -23592,36 +23774,15 @@ }, "UserConnectionSpec": { "required": [ - "accessToken", - "displayName", "providerUserId", "registrationId", "username" ], "type": "object", "properties": { - "accessToken": { - "type": "string" - }, - "avatarUrl": { - "type": "string" - }, - "displayName": { - "type": "string" - }, - "expiresAt": { - "type": "string", - "format": "date-time" - }, - "profileUrl": { - "type": "string" - }, "providerUserId": { "type": "string" }, - "refreshToken": { - "type": "string" - }, "registrationId": { "type": "string" }, diff --git a/api-docs/openapi/v3_0/apis_console.api_v1alpha1.json b/api-docs/openapi/v3_0/apis_console.api_v1alpha1.json index 9ff6d788c3..e492fe3dc4 100644 --- a/api-docs/openapi/v3_0/apis_console.api_v1alpha1.json +++ b/api-docs/openapi/v3_0/apis_console.api_v1alpha1.json @@ -518,30 +518,6 @@ ] } }, - "/apis/api.console.halo.run/v1alpha1/plugin-presets": { - "get": { - "description": "List all plugin presets in the system.", - "operationId": "ListPluginPresets", - "responses": { - "default": { - "content": { - "*/*": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Plugin" - } - } - } - }, - "description": "default response" - } - }, - "tags": [ - "PluginV1alpha1Console" - ] - } - }, "/apis/api.console.halo.run/v1alpha1/plugins": { "get": { "description": "List plugins using query criteria and sort params", @@ -738,7 +714,7 @@ }, "/apis/api.console.halo.run/v1alpha1/plugins/{name}/config": { "get": { - "description": "Fetch configMap of plugin by configured configMapName.", + "description": "Fetch configMap of plugin by configured configMapName. it is deprecated since 2.20.0", "operationId": "fetchPluginConfig", "parameters": [ { @@ -767,7 +743,8 @@ ] }, "put": { - "description": "Update the configMap of plugin setting.", + "deprecated": true, + "description": "Update the configMap of plugin setting, it is deprecated since 2.20.0", "operationId": "updatePluginConfig", "parameters": [ { @@ -806,6 +783,70 @@ ] } }, + "/apis/api.console.halo.run/v1alpha1/plugins/{name}/json-config": { + "get": { + "description": "Fetch converted json config of plugin by configured configMapName.", + "operationId": "fetchPluginJsonConfig", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "default": { + "content": { + "*/*": { + "schema": { + "type": "object" + } + } + }, + "description": "default response" + } + }, + "tags": [ + "PluginV1alpha1Console" + ] + }, + "put": { + "description": "Update the config of plugin setting.", + "operationId": "updatePluginJsonConfig", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "204": { + "content": {}, + "description": "No Content" + } + }, + "tags": [ + "PluginV1alpha1Console" + ] + } + }, "/apis/api.console.halo.run/v1alpha1/plugins/{name}/plugin-state": { "put": { "description": "Change the running state of a plugin by name.", @@ -2111,38 +2152,6 @@ ] } }, - "/apis/api.console.halo.run/v1alpha1/system/initialize": { - "post": { - "description": "Initialize system", - "operationId": "initialize", - "requestBody": { - "content": { - "*/*": { - "schema": { - "$ref": "#/components/schemas/SystemInitializationRequest" - } - } - } - }, - "responses": { - "201": { - "description": "System initialization successfully.", - "headers": { - "Location": { - "description": "Redirect URL.", - "schema": { - "type": "string" - }, - "style": "simple" - } - } - } - }, - "tags": [ - "SystemV1alpha1Console" - ] - } - }, "/apis/api.console.halo.run/v1alpha1/tags": { "get": { "description": "List Post Tags.", @@ -2412,7 +2421,8 @@ }, "/apis/api.console.halo.run/v1alpha1/themes/{name}/config": { "get": { - "description": "Fetch configMap of theme by configured configMapName.", + "deprecated": true, + "description": "Fetch configMap of theme by configured configMapName. It is deprecated.", "operationId": "fetchThemeConfig", "parameters": [ { @@ -2441,7 +2451,8 @@ ] }, "put": { - "description": "Update the configMap of theme setting.", + "deprecated": true, + "description": "Update the configMap of theme setting. It is deprecated.", "operationId": "updateThemeConfig", "parameters": [ { @@ -2504,6 +2515,70 @@ ] } }, + "/apis/api.console.halo.run/v1alpha1/themes/{name}/json-config": { + "get": { + "description": "Fetch converted json config of theme by configured configMapName.", + "operationId": "fetchThemeJsonConfig", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "default": { + "content": { + "*/*": { + "schema": { + "type": "object" + } + } + }, + "description": "default response" + } + }, + "tags": [ + "ThemeV1alpha1Console" + ] + }, + "put": { + "description": "Update the configMap of theme setting.", + "operationId": "updateThemeJsonConfig", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "204": { + "content": {}, + "description": "No Content" + } + }, + "tags": [ + "ThemeV1alpha1Console" + ] + } + }, "/apis/api.console.halo.run/v1alpha1/themes/{name}/reload": { "put": { "description": "Reload theme setting.", @@ -3136,6 +3211,72 @@ ] } }, + "/apis/console.api.halo.run/v1alpha1/systemconfigs/{group}": { + "get": { + "description": "Get system config by group", + "operationId": "getSystemConfigByGroup", + "parameters": [ + { + "description": "Group of the system config", + "in": "path", + "name": "group", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "default": { + "content": { + "*/*": { + "schema": { + "type": "object" + } + }, + "application/json": {} + }, + "description": "default response" + } + }, + "tags": [ + "SystemConfigV1alpha1Console" + ] + }, + "put": { + "description": "Update system config by group", + "operationId": "updateSystemConfigByGroup", + "parameters": [ + { + "description": "Group of the system config", + "in": "path", + "name": "group", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "*/*": { + "schema": { + "type": "object" + } + } + } + }, + "responses": { + "204 NO_CONTENT": { + "content": {}, + "description": "default response" + } + }, + "tags": [ + "SystemConfigV1alpha1Console" + ] + } + }, "/apis/console.api.migration.halo.run/v1alpha1/backup-files": { "get": { "description": "Get backup files from backup root.", @@ -3427,11 +3568,19 @@ }, "AuthProviderSpec": { "required": [ + "authType", "authenticationUrl", "displayName" ], "type": "object", "properties": { + "authType": { + "type": "string", + "enum": [ + "FORM", + "OAUTH2" + ] + }, "authenticationUrl": { "type": "string", "description": "Authentication url of the auth provider" @@ -3455,9 +3604,11 @@ "logo": { "type": "string" }, - "priority": { - "type": "integer", - "format": "int32" + "method": { + "type": "string" + }, + "rememberMeSupport": { + "type": "boolean" }, "settingRef": { "$ref": "#/components/schemas/SettingRef" @@ -3604,7 +3755,7 @@ "description": "Old password." }, "password": { - "minLength": 6, + "minLength": 5, "type": "string", "description": "New password." } @@ -3617,7 +3768,7 @@ "type": "object", "properties": { "password": { - "minLength": 6, + "minLength": 5, "type": "string", "description": "New password." } @@ -4281,6 +4432,13 @@ ], "type": "object", "properties": { + "authType": { + "type": "string", + "enum": [ + "FORM", + "OAUTH2" + ] + }, "authenticationUrl": { "type": "string" }, @@ -4308,6 +4466,10 @@ "name": { "type": "string" }, + "priority": { + "type": "integer", + "format": "int32" + }, "privileged": { "type": "boolean" }, @@ -5276,9 +5438,6 @@ } }, "PostStatus": { - "required": [ - "phase" - ], "type": "object", "properties": { "commentsCount": { @@ -5808,9 +5967,6 @@ } }, "SinglePageStatus": { - "required": [ - "phase" - ], "type": "object", "properties": { "commentsCount": { @@ -5880,29 +6036,6 @@ } } }, - "SystemInitializationRequest": { - "required": [ - "password", - "username" - ], - "type": "object", - "properties": { - "email": { - "type": "string" - }, - "password": { - "minLength": 3, - "type": "string" - }, - "siteTitle": { - "type": "string" - }, - "username": { - "minLength": 1, - "type": "string" - } - } - }, "Tag": { "required": [ "apiVersion", diff --git a/api-docs/openapi/v3_0/apis_extension.api_v1alpha1.json b/api-docs/openapi/v3_0/apis_extension.api_v1alpha1.json index 342a5684a0..0652eb77db 100644 --- a/api-docs/openapi/v3_0/apis_extension.api_v1alpha1.json +++ b/api-docs/openapi/v3_0/apis_extension.api_v1alpha1.json @@ -4757,10 +4757,10 @@ ] } }, - "/apis/plugin.halo.run/v1alpha1/extensiondefinitions": { + "/apis/notification.halo.run/v1alpha1/notifications": { "get": { - "description": "List ExtensionDefinition", - "operationId": "listExtensionDefinition", + "description": "List Notification", + "operationId": "listNotification", "parameters": [ { "description": "Page number. Default is 0.", @@ -4819,54 +4819,54 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/ExtensionDefinitionList" + "$ref": "#/components/schemas/NotificationList" } } }, - "description": "Response extensiondefinitions" + "description": "Response notifications" } }, "tags": [ - "ExtensionDefinitionV1alpha1" + "NotificationV1alpha1" ] }, "post": { - "description": "Create ExtensionDefinition", - "operationId": "createExtensionDefinition", + "description": "Create Notification", + "operationId": "createNotification", "requestBody": { "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/ExtensionDefinition" + "$ref": "#/components/schemas/Notification" } } }, - "description": "Fresh extensiondefinition" + "description": "Fresh notification" }, "responses": { "200": { "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/ExtensionDefinition" + "$ref": "#/components/schemas/Notification" } } }, - "description": "Response extensiondefinitions created just now" + "description": "Response notifications created just now" } }, "tags": [ - "ExtensionDefinitionV1alpha1" + "NotificationV1alpha1" ] } }, - "/apis/plugin.halo.run/v1alpha1/extensiondefinitions/{name}": { + "/apis/notification.halo.run/v1alpha1/notifications/{name}": { "delete": { - "description": "Delete ExtensionDefinition", - "operationId": "deleteExtensionDefinition", + "description": "Delete Notification", + "operationId": "deleteNotification", "parameters": [ { - "description": "Name of extensiondefinition", + "description": "Name of notification", "in": "path", "name": "name", "required": true, @@ -4877,19 +4877,19 @@ ], "responses": { "200": { - "description": "Response extensiondefinition deleted just now" + "description": "Response notification deleted just now" } }, "tags": [ - "ExtensionDefinitionV1alpha1" + "NotificationV1alpha1" ] }, "get": { - "description": "Get ExtensionDefinition", - "operationId": "getExtensionDefinition", + "description": "Get Notification", + "operationId": "getNotification", "parameters": [ { - "description": "Name of extensiondefinition", + "description": "Name of notification", "in": "path", "name": "name", "required": true, @@ -4903,23 +4903,23 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/ExtensionDefinition" + "$ref": "#/components/schemas/Notification" } } }, - "description": "Response single extensiondefinition" + "description": "Response single notification" } }, "tags": [ - "ExtensionDefinitionV1alpha1" + "NotificationV1alpha1" ] }, "patch": { - "description": "Patch ExtensionDefinition", - "operationId": "patchExtensionDefinition", + "description": "Patch Notification", + "operationId": "patchNotification", "parameters": [ { - "description": "Name of extensiondefinition", + "description": "Name of notification", "in": "path", "name": "name", "required": true, @@ -4942,23 +4942,23 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/ExtensionDefinition" + "$ref": "#/components/schemas/Notification" } } }, - "description": "Response extensiondefinition patched just now" + "description": "Response notification patched just now" } }, "tags": [ - "ExtensionDefinitionV1alpha1" + "NotificationV1alpha1" ] }, "put": { - "description": "Update ExtensionDefinition", - "operationId": "updateExtensionDefinition", + "description": "Update Notification", + "operationId": "updateNotification", "parameters": [ { - "description": "Name of extensiondefinition", + "description": "Name of notification", "in": "path", "name": "name", "required": true, @@ -4971,33 +4971,33 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/ExtensionDefinition" + "$ref": "#/components/schemas/Notification" } } }, - "description": "Updated extensiondefinition" + "description": "Updated notification" }, "responses": { "200": { "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/ExtensionDefinition" + "$ref": "#/components/schemas/Notification" } } }, - "description": "Response extensiondefinitions updated just now" + "description": "Response notifications updated just now" } }, "tags": [ - "ExtensionDefinitionV1alpha1" + "NotificationV1alpha1" ] } }, - "/apis/plugin.halo.run/v1alpha1/extensionpointdefinitions": { + "/apis/notification.halo.run/v1alpha1/notificationtemplates": { "get": { - "description": "List ExtensionPointDefinition", - "operationId": "listExtensionPointDefinition", + "description": "List NotificationTemplate", + "operationId": "listNotificationTemplate", "parameters": [ { "description": "Page number. Default is 0.", @@ -5056,54 +5056,54 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/ExtensionPointDefinitionList" + "$ref": "#/components/schemas/NotificationTemplateList" } } }, - "description": "Response extensionpointdefinitions" + "description": "Response notificationtemplates" } }, "tags": [ - "ExtensionPointDefinitionV1alpha1" + "NotificationTemplateV1alpha1" ] }, "post": { - "description": "Create ExtensionPointDefinition", - "operationId": "createExtensionPointDefinition", + "description": "Create NotificationTemplate", + "operationId": "createNotificationTemplate", "requestBody": { "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/ExtensionPointDefinition" + "$ref": "#/components/schemas/NotificationTemplate" } } }, - "description": "Fresh extensionpointdefinition" + "description": "Fresh notificationtemplate" }, "responses": { "200": { "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/ExtensionPointDefinition" + "$ref": "#/components/schemas/NotificationTemplate" } } }, - "description": "Response extensionpointdefinitions created just now" + "description": "Response notificationtemplates created just now" } }, "tags": [ - "ExtensionPointDefinitionV1alpha1" + "NotificationTemplateV1alpha1" ] } }, - "/apis/plugin.halo.run/v1alpha1/extensionpointdefinitions/{name}": { + "/apis/notification.halo.run/v1alpha1/notificationtemplates/{name}": { "delete": { - "description": "Delete ExtensionPointDefinition", - "operationId": "deleteExtensionPointDefinition", + "description": "Delete NotificationTemplate", + "operationId": "deleteNotificationTemplate", "parameters": [ { - "description": "Name of extensionpointdefinition", + "description": "Name of notificationtemplate", "in": "path", "name": "name", "required": true, @@ -5114,19 +5114,19 @@ ], "responses": { "200": { - "description": "Response extensionpointdefinition deleted just now" + "description": "Response notificationtemplate deleted just now" } }, "tags": [ - "ExtensionPointDefinitionV1alpha1" + "NotificationTemplateV1alpha1" ] }, "get": { - "description": "Get ExtensionPointDefinition", - "operationId": "getExtensionPointDefinition", + "description": "Get NotificationTemplate", + "operationId": "getNotificationTemplate", "parameters": [ { - "description": "Name of extensionpointdefinition", + "description": "Name of notificationtemplate", "in": "path", "name": "name", "required": true, @@ -5140,23 +5140,23 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/ExtensionPointDefinition" + "$ref": "#/components/schemas/NotificationTemplate" } } }, - "description": "Response single extensionpointdefinition" + "description": "Response single notificationtemplate" } }, "tags": [ - "ExtensionPointDefinitionV1alpha1" + "NotificationTemplateV1alpha1" ] }, "patch": { - "description": "Patch ExtensionPointDefinition", - "operationId": "patchExtensionPointDefinition", + "description": "Patch NotificationTemplate", + "operationId": "patchNotificationTemplate", "parameters": [ { - "description": "Name of extensionpointdefinition", + "description": "Name of notificationtemplate", "in": "path", "name": "name", "required": true, @@ -5179,23 +5179,23 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/ExtensionPointDefinition" + "$ref": "#/components/schemas/NotificationTemplate" } } }, - "description": "Response extensionpointdefinition patched just now" + "description": "Response notificationtemplate patched just now" } }, "tags": [ - "ExtensionPointDefinitionV1alpha1" + "NotificationTemplateV1alpha1" ] }, "put": { - "description": "Update ExtensionPointDefinition", - "operationId": "updateExtensionPointDefinition", + "description": "Update NotificationTemplate", + "operationId": "updateNotificationTemplate", "parameters": [ { - "description": "Name of extensionpointdefinition", + "description": "Name of notificationtemplate", "in": "path", "name": "name", "required": true, @@ -5208,33 +5208,33 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/ExtensionPointDefinition" + "$ref": "#/components/schemas/NotificationTemplate" } } }, - "description": "Updated extensionpointdefinition" + "description": "Updated notificationtemplate" }, "responses": { "200": { "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/ExtensionPointDefinition" + "$ref": "#/components/schemas/NotificationTemplate" } } }, - "description": "Response extensionpointdefinitions updated just now" + "description": "Response notificationtemplates updated just now" } }, "tags": [ - "ExtensionPointDefinitionV1alpha1" + "NotificationTemplateV1alpha1" ] } }, - "/apis/plugin.halo.run/v1alpha1/plugins": { + "/apis/notification.halo.run/v1alpha1/notifierDescriptors": { "get": { - "description": "List Plugin", - "operationId": "listPlugin", + "description": "List NotifierDescriptor", + "operationId": "listNotifierDescriptor", "parameters": [ { "description": "Page number. Default is 0.", @@ -5293,54 +5293,54 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/PluginList" + "$ref": "#/components/schemas/NotifierDescriptorList" } } }, - "description": "Response plugins" + "description": "Response notifierDescriptors" } }, "tags": [ - "PluginV1alpha1" + "NotifierDescriptorV1alpha1" ] }, "post": { - "description": "Create Plugin", - "operationId": "createPlugin", + "description": "Create NotifierDescriptor", + "operationId": "createNotifierDescriptor", "requestBody": { "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/Plugin" + "$ref": "#/components/schemas/NotifierDescriptor" } } }, - "description": "Fresh plugin" + "description": "Fresh notifierDescriptor" }, "responses": { "200": { "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/Plugin" + "$ref": "#/components/schemas/NotifierDescriptor" } } }, - "description": "Response plugins created just now" + "description": "Response notifierDescriptors created just now" } }, "tags": [ - "PluginV1alpha1" + "NotifierDescriptorV1alpha1" ] } }, - "/apis/plugin.halo.run/v1alpha1/plugins/{name}": { + "/apis/notification.halo.run/v1alpha1/notifierDescriptors/{name}": { "delete": { - "description": "Delete Plugin", - "operationId": "deletePlugin", + "description": "Delete NotifierDescriptor", + "operationId": "deleteNotifierDescriptor", "parameters": [ { - "description": "Name of plugin", + "description": "Name of notifierDescriptor", "in": "path", "name": "name", "required": true, @@ -5351,19 +5351,19 @@ ], "responses": { "200": { - "description": "Response plugin deleted just now" + "description": "Response notifierDescriptor deleted just now" } }, "tags": [ - "PluginV1alpha1" + "NotifierDescriptorV1alpha1" ] }, "get": { - "description": "Get Plugin", - "operationId": "getPlugin", + "description": "Get NotifierDescriptor", + "operationId": "getNotifierDescriptor", "parameters": [ { - "description": "Name of plugin", + "description": "Name of notifierDescriptor", "in": "path", "name": "name", "required": true, @@ -5377,23 +5377,23 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/Plugin" + "$ref": "#/components/schemas/NotifierDescriptor" } } }, - "description": "Response single plugin" + "description": "Response single notifierDescriptor" } }, "tags": [ - "PluginV1alpha1" + "NotifierDescriptorV1alpha1" ] }, "patch": { - "description": "Patch Plugin", - "operationId": "patchPlugin", + "description": "Patch NotifierDescriptor", + "operationId": "patchNotifierDescriptor", "parameters": [ { - "description": "Name of plugin", + "description": "Name of notifierDescriptor", "in": "path", "name": "name", "required": true, @@ -5416,23 +5416,23 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/Plugin" + "$ref": "#/components/schemas/NotifierDescriptor" } } }, - "description": "Response plugin patched just now" + "description": "Response notifierDescriptor patched just now" } }, "tags": [ - "PluginV1alpha1" + "NotifierDescriptorV1alpha1" ] }, "put": { - "description": "Update Plugin", - "operationId": "updatePlugin", + "description": "Update NotifierDescriptor", + "operationId": "updateNotifierDescriptor", "parameters": [ { - "description": "Name of plugin", + "description": "Name of notifierDescriptor", "in": "path", "name": "name", "required": true, @@ -5445,33 +5445,33 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/Plugin" + "$ref": "#/components/schemas/NotifierDescriptor" } } }, - "description": "Updated plugin" + "description": "Updated notifierDescriptor" }, "responses": { "200": { "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/Plugin" + "$ref": "#/components/schemas/NotifierDescriptor" } } }, - "description": "Response plugins updated just now" + "description": "Response notifierDescriptors updated just now" } }, "tags": [ - "PluginV1alpha1" + "NotifierDescriptorV1alpha1" ] } }, - "/apis/plugin.halo.run/v1alpha1/reverseproxies": { + "/apis/notification.halo.run/v1alpha1/reasons": { "get": { - "description": "List ReverseProxy", - "operationId": "listReverseProxy", + "description": "List Reason", + "operationId": "listReason", "parameters": [ { "description": "Page number. Default is 0.", @@ -5530,54 +5530,54 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/ReverseProxyList" + "$ref": "#/components/schemas/ReasonList" } } }, - "description": "Response reverseproxies" + "description": "Response reasons" } }, "tags": [ - "ReverseProxyV1alpha1" + "ReasonV1alpha1" ] }, "post": { - "description": "Create ReverseProxy", - "operationId": "createReverseProxy", + "description": "Create Reason", + "operationId": "createReason", "requestBody": { "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/ReverseProxy" + "$ref": "#/components/schemas/Reason" } } }, - "description": "Fresh reverseproxy" + "description": "Fresh reason" }, "responses": { "200": { "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/ReverseProxy" + "$ref": "#/components/schemas/Reason" } } }, - "description": "Response reverseproxies created just now" + "description": "Response reasons created just now" } }, "tags": [ - "ReverseProxyV1alpha1" + "ReasonV1alpha1" ] } }, - "/apis/plugin.halo.run/v1alpha1/reverseproxies/{name}": { + "/apis/notification.halo.run/v1alpha1/reasons/{name}": { "delete": { - "description": "Delete ReverseProxy", - "operationId": "deleteReverseProxy", + "description": "Delete Reason", + "operationId": "deleteReason", "parameters": [ { - "description": "Name of reverseproxy", + "description": "Name of reason", "in": "path", "name": "name", "required": true, @@ -5588,19 +5588,19 @@ ], "responses": { "200": { - "description": "Response reverseproxy deleted just now" + "description": "Response reason deleted just now" } }, "tags": [ - "ReverseProxyV1alpha1" + "ReasonV1alpha1" ] }, "get": { - "description": "Get ReverseProxy", - "operationId": "getReverseProxy", + "description": "Get Reason", + "operationId": "getReason", "parameters": [ { - "description": "Name of reverseproxy", + "description": "Name of reason", "in": "path", "name": "name", "required": true, @@ -5614,23 +5614,23 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/ReverseProxy" + "$ref": "#/components/schemas/Reason" } } }, - "description": "Response single reverseproxy" + "description": "Response single reason" } }, "tags": [ - "ReverseProxyV1alpha1" + "ReasonV1alpha1" ] }, "patch": { - "description": "Patch ReverseProxy", - "operationId": "patchReverseProxy", + "description": "Patch Reason", + "operationId": "patchReason", "parameters": [ { - "description": "Name of reverseproxy", + "description": "Name of reason", "in": "path", "name": "name", "required": true, @@ -5653,23 +5653,23 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/ReverseProxy" + "$ref": "#/components/schemas/Reason" } } }, - "description": "Response reverseproxy patched just now" + "description": "Response reason patched just now" } }, "tags": [ - "ReverseProxyV1alpha1" + "ReasonV1alpha1" ] }, "put": { - "description": "Update ReverseProxy", - "operationId": "updateReverseProxy", + "description": "Update Reason", + "operationId": "updateReason", "parameters": [ { - "description": "Name of reverseproxy", + "description": "Name of reason", "in": "path", "name": "name", "required": true, @@ -5682,33 +5682,33 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/ReverseProxy" + "$ref": "#/components/schemas/Reason" } } }, - "description": "Updated reverseproxy" + "description": "Updated reason" }, "responses": { "200": { "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/ReverseProxy" + "$ref": "#/components/schemas/Reason" } } }, - "description": "Response reverseproxies updated just now" + "description": "Response reasons updated just now" } }, "tags": [ - "ReverseProxyV1alpha1" + "ReasonV1alpha1" ] } }, - "/apis/plugin.halo.run/v1alpha1/searchengines": { + "/apis/notification.halo.run/v1alpha1/reasontypes": { "get": { - "description": "List SearchEngine", - "operationId": "listSearchEngine", + "description": "List ReasonType", + "operationId": "listReasonType", "parameters": [ { "description": "Page number. Default is 0.", @@ -5767,54 +5767,54 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/SearchEngineList" + "$ref": "#/components/schemas/ReasonTypeList" } } }, - "description": "Response searchengines" + "description": "Response reasontypes" } }, "tags": [ - "SearchEngineV1alpha1" + "ReasonTypeV1alpha1" ] }, "post": { - "description": "Create SearchEngine", - "operationId": "createSearchEngine", + "description": "Create ReasonType", + "operationId": "createReasonType", "requestBody": { "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/SearchEngine" + "$ref": "#/components/schemas/ReasonType" } } }, - "description": "Fresh searchengine" + "description": "Fresh reasontype" }, "responses": { "200": { "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/SearchEngine" + "$ref": "#/components/schemas/ReasonType" } } }, - "description": "Response searchengines created just now" + "description": "Response reasontypes created just now" } }, "tags": [ - "SearchEngineV1alpha1" + "ReasonTypeV1alpha1" ] } }, - "/apis/plugin.halo.run/v1alpha1/searchengines/{name}": { + "/apis/notification.halo.run/v1alpha1/reasontypes/{name}": { "delete": { - "description": "Delete SearchEngine", - "operationId": "deleteSearchEngine", + "description": "Delete ReasonType", + "operationId": "deleteReasonType", "parameters": [ { - "description": "Name of searchengine", + "description": "Name of reasontype", "in": "path", "name": "name", "required": true, @@ -5825,19 +5825,19 @@ ], "responses": { "200": { - "description": "Response searchengine deleted just now" + "description": "Response reasontype deleted just now" } }, "tags": [ - "SearchEngineV1alpha1" + "ReasonTypeV1alpha1" ] }, "get": { - "description": "Get SearchEngine", - "operationId": "getSearchEngine", + "description": "Get ReasonType", + "operationId": "getReasonType", "parameters": [ { - "description": "Name of searchengine", + "description": "Name of reasontype", "in": "path", "name": "name", "required": true, @@ -5851,23 +5851,23 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/SearchEngine" + "$ref": "#/components/schemas/ReasonType" } } }, - "description": "Response single searchengine" + "description": "Response single reasontype" } }, "tags": [ - "SearchEngineV1alpha1" + "ReasonTypeV1alpha1" ] }, "patch": { - "description": "Patch SearchEngine", - "operationId": "patchSearchEngine", + "description": "Patch ReasonType", + "operationId": "patchReasonType", "parameters": [ { - "description": "Name of searchengine", + "description": "Name of reasontype", "in": "path", "name": "name", "required": true, @@ -5890,23 +5890,23 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/SearchEngine" + "$ref": "#/components/schemas/ReasonType" } } }, - "description": "Response searchengine patched just now" + "description": "Response reasontype patched just now" } }, "tags": [ - "SearchEngineV1alpha1" + "ReasonTypeV1alpha1" ] }, "put": { - "description": "Update SearchEngine", - "operationId": "updateSearchEngine", + "description": "Update ReasonType", + "operationId": "updateReasonType", "parameters": [ { - "description": "Name of searchengine", + "description": "Name of reasontype", "in": "path", "name": "name", "required": true, @@ -5919,33 +5919,33 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/SearchEngine" + "$ref": "#/components/schemas/ReasonType" } } }, - "description": "Updated searchengine" + "description": "Updated reasontype" }, "responses": { "200": { "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/SearchEngine" + "$ref": "#/components/schemas/ReasonType" } } }, - "description": "Response searchengines updated just now" + "description": "Response reasontypes updated just now" } }, "tags": [ - "SearchEngineV1alpha1" + "ReasonTypeV1alpha1" ] } }, - "/apis/security.halo.run/v1alpha1/devices": { + "/apis/notification.halo.run/v1alpha1/subscriptions": { "get": { - "description": "List Device", - "operationId": "listDevice", + "description": "List Subscription", + "operationId": "listSubscription", "parameters": [ { "description": "Page number. Default is 0.", @@ -6004,54 +6004,54 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/DeviceList" + "$ref": "#/components/schemas/SubscriptionList" } } }, - "description": "Response devices" + "description": "Response subscriptions" } }, "tags": [ - "DeviceV1alpha1" + "SubscriptionV1alpha1" ] }, "post": { - "description": "Create Device", - "operationId": "createDevice", + "description": "Create Subscription", + "operationId": "createSubscription", "requestBody": { "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/Device" + "$ref": "#/components/schemas/Subscription" } } }, - "description": "Fresh device" + "description": "Fresh subscription" }, "responses": { "200": { "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/Device" + "$ref": "#/components/schemas/Subscription" } } }, - "description": "Response devices created just now" + "description": "Response subscriptions created just now" } }, "tags": [ - "DeviceV1alpha1" + "SubscriptionV1alpha1" ] } }, - "/apis/security.halo.run/v1alpha1/devices/{name}": { + "/apis/notification.halo.run/v1alpha1/subscriptions/{name}": { "delete": { - "description": "Delete Device", - "operationId": "deleteDevice", + "description": "Delete Subscription", + "operationId": "deleteSubscription", "parameters": [ { - "description": "Name of device", + "description": "Name of subscription", "in": "path", "name": "name", "required": true, @@ -6062,19 +6062,19 @@ ], "responses": { "200": { - "description": "Response device deleted just now" + "description": "Response subscription deleted just now" } }, "tags": [ - "DeviceV1alpha1" + "SubscriptionV1alpha1" ] }, "get": { - "description": "Get Device", - "operationId": "getDevice", + "description": "Get Subscription", + "operationId": "getSubscription", "parameters": [ { - "description": "Name of device", + "description": "Name of subscription", "in": "path", "name": "name", "required": true, @@ -6088,23 +6088,23 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/Device" + "$ref": "#/components/schemas/Subscription" } } }, - "description": "Response single device" + "description": "Response single subscription" } }, "tags": [ - "DeviceV1alpha1" + "SubscriptionV1alpha1" ] }, "patch": { - "description": "Patch Device", - "operationId": "patchDevice", + "description": "Patch Subscription", + "operationId": "patchSubscription", "parameters": [ { - "description": "Name of device", + "description": "Name of subscription", "in": "path", "name": "name", "required": true, @@ -6127,23 +6127,23 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/Device" + "$ref": "#/components/schemas/Subscription" } } }, - "description": "Response device patched just now" + "description": "Response subscription patched just now" } }, "tags": [ - "DeviceV1alpha1" + "SubscriptionV1alpha1" ] }, "put": { - "description": "Update Device", - "operationId": "updateDevice", + "description": "Update Subscription", + "operationId": "updateSubscription", "parameters": [ { - "description": "Name of device", + "description": "Name of subscription", "in": "path", "name": "name", "required": true, @@ -6156,33 +6156,33 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/Device" + "$ref": "#/components/schemas/Subscription" } } }, - "description": "Updated device" + "description": "Updated subscription" }, "responses": { "200": { "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/Device" + "$ref": "#/components/schemas/Subscription" } } }, - "description": "Response devices updated just now" + "description": "Response subscriptions updated just now" } }, "tags": [ - "DeviceV1alpha1" + "SubscriptionV1alpha1" ] } }, - "/apis/security.halo.run/v1alpha1/personalaccesstokens": { + "/apis/plugin.halo.run/v1alpha1/extensiondefinitions": { "get": { - "description": "List PersonalAccessToken", - "operationId": "listPersonalAccessToken", + "description": "List ExtensionDefinition", + "operationId": "listExtensionDefinition", "parameters": [ { "description": "Page number. Default is 0.", @@ -6241,54 +6241,54 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/PersonalAccessTokenList" + "$ref": "#/components/schemas/ExtensionDefinitionList" } } }, - "description": "Response personalaccesstokens" + "description": "Response extensiondefinitions" } }, "tags": [ - "PersonalAccessTokenV1alpha1" + "ExtensionDefinitionV1alpha1" ] }, "post": { - "description": "Create PersonalAccessToken", - "operationId": "createPersonalAccessToken", + "description": "Create ExtensionDefinition", + "operationId": "createExtensionDefinition", "requestBody": { "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/PersonalAccessToken" + "$ref": "#/components/schemas/ExtensionDefinition" } } }, - "description": "Fresh personalaccesstoken" + "description": "Fresh extensiondefinition" }, "responses": { "200": { "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/PersonalAccessToken" + "$ref": "#/components/schemas/ExtensionDefinition" } } }, - "description": "Response personalaccesstokens created just now" + "description": "Response extensiondefinitions created just now" } }, "tags": [ - "PersonalAccessTokenV1alpha1" + "ExtensionDefinitionV1alpha1" ] } }, - "/apis/security.halo.run/v1alpha1/personalaccesstokens/{name}": { + "/apis/plugin.halo.run/v1alpha1/extensiondefinitions/{name}": { "delete": { - "description": "Delete PersonalAccessToken", - "operationId": "deletePersonalAccessToken", + "description": "Delete ExtensionDefinition", + "operationId": "deleteExtensionDefinition", "parameters": [ { - "description": "Name of personalaccesstoken", + "description": "Name of extensiondefinition", "in": "path", "name": "name", "required": true, @@ -6299,19 +6299,19 @@ ], "responses": { "200": { - "description": "Response personalaccesstoken deleted just now" + "description": "Response extensiondefinition deleted just now" } }, "tags": [ - "PersonalAccessTokenV1alpha1" + "ExtensionDefinitionV1alpha1" ] }, "get": { - "description": "Get PersonalAccessToken", - "operationId": "getPersonalAccessToken", + "description": "Get ExtensionDefinition", + "operationId": "getExtensionDefinition", "parameters": [ { - "description": "Name of personalaccesstoken", + "description": "Name of extensiondefinition", "in": "path", "name": "name", "required": true, @@ -6325,23 +6325,23 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/PersonalAccessToken" + "$ref": "#/components/schemas/ExtensionDefinition" } } }, - "description": "Response single personalaccesstoken" + "description": "Response single extensiondefinition" } }, "tags": [ - "PersonalAccessTokenV1alpha1" + "ExtensionDefinitionV1alpha1" ] }, "patch": { - "description": "Patch PersonalAccessToken", - "operationId": "patchPersonalAccessToken", + "description": "Patch ExtensionDefinition", + "operationId": "patchExtensionDefinition", "parameters": [ { - "description": "Name of personalaccesstoken", + "description": "Name of extensiondefinition", "in": "path", "name": "name", "required": true, @@ -6364,23 +6364,23 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/PersonalAccessToken" + "$ref": "#/components/schemas/ExtensionDefinition" } } }, - "description": "Response personalaccesstoken patched just now" + "description": "Response extensiondefinition patched just now" } }, "tags": [ - "PersonalAccessTokenV1alpha1" + "ExtensionDefinitionV1alpha1" ] }, "put": { - "description": "Update PersonalAccessToken", - "operationId": "updatePersonalAccessToken", + "description": "Update ExtensionDefinition", + "operationId": "updateExtensionDefinition", "parameters": [ { - "description": "Name of personalaccesstoken", + "description": "Name of extensiondefinition", "in": "path", "name": "name", "required": true, @@ -6393,33 +6393,33 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/PersonalAccessToken" + "$ref": "#/components/schemas/ExtensionDefinition" } } }, - "description": "Updated personalaccesstoken" + "description": "Updated extensiondefinition" }, "responses": { "200": { "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/PersonalAccessToken" + "$ref": "#/components/schemas/ExtensionDefinition" } } }, - "description": "Response personalaccesstokens updated just now" + "description": "Response extensiondefinitions updated just now" } }, "tags": [ - "PersonalAccessTokenV1alpha1" + "ExtensionDefinitionV1alpha1" ] } }, - "/apis/security.halo.run/v1alpha1/remembermetokens": { + "/apis/plugin.halo.run/v1alpha1/extensionpointdefinitions": { "get": { - "description": "List RememberMeToken", - "operationId": "listRememberMeToken", + "description": "List ExtensionPointDefinition", + "operationId": "listExtensionPointDefinition", "parameters": [ { "description": "Page number. Default is 0.", @@ -6478,54 +6478,54 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/RememberMeTokenList" + "$ref": "#/components/schemas/ExtensionPointDefinitionList" } } }, - "description": "Response remembermetokens" + "description": "Response extensionpointdefinitions" } }, "tags": [ - "RememberMeTokenV1alpha1" + "ExtensionPointDefinitionV1alpha1" ] }, "post": { - "description": "Create RememberMeToken", - "operationId": "createRememberMeToken", + "description": "Create ExtensionPointDefinition", + "operationId": "createExtensionPointDefinition", "requestBody": { "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/RememberMeToken" + "$ref": "#/components/schemas/ExtensionPointDefinition" } } }, - "description": "Fresh remembermetoken" + "description": "Fresh extensionpointdefinition" }, "responses": { "200": { "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/RememberMeToken" + "$ref": "#/components/schemas/ExtensionPointDefinition" } } }, - "description": "Response remembermetokens created just now" + "description": "Response extensionpointdefinitions created just now" } }, "tags": [ - "RememberMeTokenV1alpha1" + "ExtensionPointDefinitionV1alpha1" ] } }, - "/apis/security.halo.run/v1alpha1/remembermetokens/{name}": { + "/apis/plugin.halo.run/v1alpha1/extensionpointdefinitions/{name}": { "delete": { - "description": "Delete RememberMeToken", - "operationId": "deleteRememberMeToken", + "description": "Delete ExtensionPointDefinition", + "operationId": "deleteExtensionPointDefinition", "parameters": [ { - "description": "Name of remembermetoken", + "description": "Name of extensionpointdefinition", "in": "path", "name": "name", "required": true, @@ -6536,19 +6536,19 @@ ], "responses": { "200": { - "description": "Response remembermetoken deleted just now" + "description": "Response extensionpointdefinition deleted just now" } }, "tags": [ - "RememberMeTokenV1alpha1" + "ExtensionPointDefinitionV1alpha1" ] }, "get": { - "description": "Get RememberMeToken", - "operationId": "getRememberMeToken", + "description": "Get ExtensionPointDefinition", + "operationId": "getExtensionPointDefinition", "parameters": [ { - "description": "Name of remembermetoken", + "description": "Name of extensionpointdefinition", "in": "path", "name": "name", "required": true, @@ -6562,23 +6562,23 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/RememberMeToken" + "$ref": "#/components/schemas/ExtensionPointDefinition" } } }, - "description": "Response single remembermetoken" + "description": "Response single extensionpointdefinition" } }, "tags": [ - "RememberMeTokenV1alpha1" + "ExtensionPointDefinitionV1alpha1" ] }, "patch": { - "description": "Patch RememberMeToken", - "operationId": "patchRememberMeToken", + "description": "Patch ExtensionPointDefinition", + "operationId": "patchExtensionPointDefinition", "parameters": [ { - "description": "Name of remembermetoken", + "description": "Name of extensionpointdefinition", "in": "path", "name": "name", "required": true, @@ -6601,23 +6601,23 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/RememberMeToken" + "$ref": "#/components/schemas/ExtensionPointDefinition" } } }, - "description": "Response remembermetoken patched just now" + "description": "Response extensionpointdefinition patched just now" } }, "tags": [ - "RememberMeTokenV1alpha1" + "ExtensionPointDefinitionV1alpha1" ] }, "put": { - "description": "Update RememberMeToken", - "operationId": "updateRememberMeToken", + "description": "Update ExtensionPointDefinition", + "operationId": "updateExtensionPointDefinition", "parameters": [ { - "description": "Name of remembermetoken", + "description": "Name of extensionpointdefinition", "in": "path", "name": "name", "required": true, @@ -6630,33 +6630,33 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/RememberMeToken" + "$ref": "#/components/schemas/ExtensionPointDefinition" } } }, - "description": "Updated remembermetoken" + "description": "Updated extensionpointdefinition" }, "responses": { "200": { "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/RememberMeToken" + "$ref": "#/components/schemas/ExtensionPointDefinition" } } }, - "description": "Response remembermetokens updated just now" + "description": "Response extensionpointdefinitions updated just now" } }, "tags": [ - "RememberMeTokenV1alpha1" + "ExtensionPointDefinitionV1alpha1" ] } }, - "/apis/storage.halo.run/v1alpha1/attachments": { + "/apis/plugin.halo.run/v1alpha1/plugins": { "get": { - "description": "List Attachment", - "operationId": "listAttachment", + "description": "List Plugin", + "operationId": "listPlugin", "parameters": [ { "description": "Page number. Default is 0.", @@ -6715,54 +6715,54 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/AttachmentList" + "$ref": "#/components/schemas/PluginList" } } }, - "description": "Response attachments" + "description": "Response plugins" } }, "tags": [ - "AttachmentV1alpha1" + "PluginV1alpha1" ] }, "post": { - "description": "Create Attachment", - "operationId": "createAttachment", + "description": "Create Plugin", + "operationId": "createPlugin", "requestBody": { "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/Attachment" + "$ref": "#/components/schemas/Plugin" } } }, - "description": "Fresh attachment" + "description": "Fresh plugin" }, "responses": { "200": { "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/Attachment" + "$ref": "#/components/schemas/Plugin" } } }, - "description": "Response attachments created just now" + "description": "Response plugins created just now" } }, "tags": [ - "AttachmentV1alpha1" + "PluginV1alpha1" ] } }, - "/apis/storage.halo.run/v1alpha1/attachments/{name}": { + "/apis/plugin.halo.run/v1alpha1/plugins/{name}": { "delete": { - "description": "Delete Attachment", - "operationId": "deleteAttachment", + "description": "Delete Plugin", + "operationId": "deletePlugin", "parameters": [ { - "description": "Name of attachment", + "description": "Name of plugin", "in": "path", "name": "name", "required": true, @@ -6773,19 +6773,19 @@ ], "responses": { "200": { - "description": "Response attachment deleted just now" + "description": "Response plugin deleted just now" } }, "tags": [ - "AttachmentV1alpha1" + "PluginV1alpha1" ] }, "get": { - "description": "Get Attachment", - "operationId": "getAttachment", + "description": "Get Plugin", + "operationId": "getPlugin", "parameters": [ { - "description": "Name of attachment", + "description": "Name of plugin", "in": "path", "name": "name", "required": true, @@ -6799,23 +6799,23 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/Attachment" + "$ref": "#/components/schemas/Plugin" } } }, - "description": "Response single attachment" + "description": "Response single plugin" } }, "tags": [ - "AttachmentV1alpha1" + "PluginV1alpha1" ] }, "patch": { - "description": "Patch Attachment", - "operationId": "patchAttachment", + "description": "Patch Plugin", + "operationId": "patchPlugin", "parameters": [ { - "description": "Name of attachment", + "description": "Name of plugin", "in": "path", "name": "name", "required": true, @@ -6838,23 +6838,23 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/Attachment" + "$ref": "#/components/schemas/Plugin" } } }, - "description": "Response attachment patched just now" + "description": "Response plugin patched just now" } }, "tags": [ - "AttachmentV1alpha1" + "PluginV1alpha1" ] }, "put": { - "description": "Update Attachment", - "operationId": "updateAttachment", + "description": "Update Plugin", + "operationId": "updatePlugin", "parameters": [ { - "description": "Name of attachment", + "description": "Name of plugin", "in": "path", "name": "name", "required": true, @@ -6867,33 +6867,33 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/Attachment" + "$ref": "#/components/schemas/Plugin" } } }, - "description": "Updated attachment" + "description": "Updated plugin" }, "responses": { "200": { "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/Attachment" + "$ref": "#/components/schemas/Plugin" } } }, - "description": "Response attachments updated just now" + "description": "Response plugins updated just now" } }, "tags": [ - "AttachmentV1alpha1" + "PluginV1alpha1" ] } }, - "/apis/storage.halo.run/v1alpha1/groups": { + "/apis/plugin.halo.run/v1alpha1/reverseproxies": { "get": { - "description": "List Group", - "operationId": "listGroup", + "description": "List ReverseProxy", + "operationId": "listReverseProxy", "parameters": [ { "description": "Page number. Default is 0.", @@ -6952,54 +6952,54 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/GroupList" + "$ref": "#/components/schemas/ReverseProxyList" } } }, - "description": "Response groups" + "description": "Response reverseproxies" } }, "tags": [ - "GroupV1alpha1" + "ReverseProxyV1alpha1" ] }, "post": { - "description": "Create Group", - "operationId": "createGroup", + "description": "Create ReverseProxy", + "operationId": "createReverseProxy", "requestBody": { "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/Group" + "$ref": "#/components/schemas/ReverseProxy" } } }, - "description": "Fresh group" + "description": "Fresh reverseproxy" }, "responses": { "200": { "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/Group" + "$ref": "#/components/schemas/ReverseProxy" } } }, - "description": "Response groups created just now" + "description": "Response reverseproxies created just now" } }, "tags": [ - "GroupV1alpha1" + "ReverseProxyV1alpha1" ] } }, - "/apis/storage.halo.run/v1alpha1/groups/{name}": { + "/apis/plugin.halo.run/v1alpha1/reverseproxies/{name}": { "delete": { - "description": "Delete Group", - "operationId": "deleteGroup", + "description": "Delete ReverseProxy", + "operationId": "deleteReverseProxy", "parameters": [ { - "description": "Name of group", + "description": "Name of reverseproxy", "in": "path", "name": "name", "required": true, @@ -7010,19 +7010,19 @@ ], "responses": { "200": { - "description": "Response group deleted just now" + "description": "Response reverseproxy deleted just now" } }, "tags": [ - "GroupV1alpha1" + "ReverseProxyV1alpha1" ] }, "get": { - "description": "Get Group", - "operationId": "getGroup", + "description": "Get ReverseProxy", + "operationId": "getReverseProxy", "parameters": [ { - "description": "Name of group", + "description": "Name of reverseproxy", "in": "path", "name": "name", "required": true, @@ -7036,23 +7036,23 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/Group" + "$ref": "#/components/schemas/ReverseProxy" } } }, - "description": "Response single group" + "description": "Response single reverseproxy" } }, "tags": [ - "GroupV1alpha1" + "ReverseProxyV1alpha1" ] }, "patch": { - "description": "Patch Group", - "operationId": "patchGroup", + "description": "Patch ReverseProxy", + "operationId": "patchReverseProxy", "parameters": [ { - "description": "Name of group", + "description": "Name of reverseproxy", "in": "path", "name": "name", "required": true, @@ -7075,23 +7075,23 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/Group" + "$ref": "#/components/schemas/ReverseProxy" } } }, - "description": "Response group patched just now" + "description": "Response reverseproxy patched just now" } }, "tags": [ - "GroupV1alpha1" + "ReverseProxyV1alpha1" ] }, "put": { - "description": "Update Group", - "operationId": "updateGroup", + "description": "Update ReverseProxy", + "operationId": "updateReverseProxy", "parameters": [ { - "description": "Name of group", + "description": "Name of reverseproxy", "in": "path", "name": "name", "required": true, @@ -7104,33 +7104,33 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/Group" + "$ref": "#/components/schemas/ReverseProxy" } } }, - "description": "Updated group" + "description": "Updated reverseproxy" }, "responses": { "200": { "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/Group" + "$ref": "#/components/schemas/ReverseProxy" } } }, - "description": "Response groups updated just now" + "description": "Response reverseproxies updated just now" } }, "tags": [ - "GroupV1alpha1" + "ReverseProxyV1alpha1" ] } }, - "/apis/storage.halo.run/v1alpha1/localthumbnails": { + "/apis/plugin.halo.run/v1alpha1/searchengines": { "get": { - "description": "List LocalThumbnail", - "operationId": "listLocalThumbnail", + "description": "List SearchEngine", + "operationId": "listSearchEngine", "parameters": [ { "description": "Page number. Default is 0.", @@ -7189,54 +7189,54 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/LocalThumbnailList" + "$ref": "#/components/schemas/SearchEngineList" } } }, - "description": "Response localthumbnails" + "description": "Response searchengines" } }, "tags": [ - "LocalThumbnailV1alpha1" + "SearchEngineV1alpha1" ] }, "post": { - "description": "Create LocalThumbnail", - "operationId": "createLocalThumbnail", + "description": "Create SearchEngine", + "operationId": "createSearchEngine", "requestBody": { "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/LocalThumbnail" + "$ref": "#/components/schemas/SearchEngine" } } }, - "description": "Fresh localthumbnail" + "description": "Fresh searchengine" }, "responses": { "200": { "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/LocalThumbnail" + "$ref": "#/components/schemas/SearchEngine" } } }, - "description": "Response localthumbnails created just now" + "description": "Response searchengines created just now" } }, "tags": [ - "LocalThumbnailV1alpha1" + "SearchEngineV1alpha1" ] } }, - "/apis/storage.halo.run/v1alpha1/localthumbnails/{name}": { + "/apis/plugin.halo.run/v1alpha1/searchengines/{name}": { "delete": { - "description": "Delete LocalThumbnail", - "operationId": "deleteLocalThumbnail", + "description": "Delete SearchEngine", + "operationId": "deleteSearchEngine", "parameters": [ { - "description": "Name of localthumbnail", + "description": "Name of searchengine", "in": "path", "name": "name", "required": true, @@ -7247,19 +7247,19 @@ ], "responses": { "200": { - "description": "Response localthumbnail deleted just now" + "description": "Response searchengine deleted just now" } }, "tags": [ - "LocalThumbnailV1alpha1" + "SearchEngineV1alpha1" ] }, "get": { - "description": "Get LocalThumbnail", - "operationId": "getLocalThumbnail", + "description": "Get SearchEngine", + "operationId": "getSearchEngine", "parameters": [ { - "description": "Name of localthumbnail", + "description": "Name of searchengine", "in": "path", "name": "name", "required": true, @@ -7273,23 +7273,23 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/LocalThumbnail" + "$ref": "#/components/schemas/SearchEngine" } } }, - "description": "Response single localthumbnail" + "description": "Response single searchengine" } }, "tags": [ - "LocalThumbnailV1alpha1" + "SearchEngineV1alpha1" ] }, "patch": { - "description": "Patch LocalThumbnail", - "operationId": "patchLocalThumbnail", + "description": "Patch SearchEngine", + "operationId": "patchSearchEngine", "parameters": [ { - "description": "Name of localthumbnail", + "description": "Name of searchengine", "in": "path", "name": "name", "required": true, @@ -7312,23 +7312,23 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/LocalThumbnail" + "$ref": "#/components/schemas/SearchEngine" } } }, - "description": "Response localthumbnail patched just now" + "description": "Response searchengine patched just now" } }, "tags": [ - "LocalThumbnailV1alpha1" + "SearchEngineV1alpha1" ] }, "put": { - "description": "Update LocalThumbnail", - "operationId": "updateLocalThumbnail", + "description": "Update SearchEngine", + "operationId": "updateSearchEngine", "parameters": [ { - "description": "Name of localthumbnail", + "description": "Name of searchengine", "in": "path", "name": "name", "required": true, @@ -7341,33 +7341,33 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/LocalThumbnail" + "$ref": "#/components/schemas/SearchEngine" } } }, - "description": "Updated localthumbnail" + "description": "Updated searchengine" }, "responses": { "200": { "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/LocalThumbnail" + "$ref": "#/components/schemas/SearchEngine" } } }, - "description": "Response localthumbnails updated just now" + "description": "Response searchengines updated just now" } }, "tags": [ - "LocalThumbnailV1alpha1" + "SearchEngineV1alpha1" ] } }, - "/apis/storage.halo.run/v1alpha1/policies": { + "/apis/security.halo.run/v1alpha1/devices": { "get": { - "description": "List Policy", - "operationId": "listPolicy", + "description": "List Device", + "operationId": "listDevice", "parameters": [ { "description": "Page number. Default is 0.", @@ -7426,54 +7426,54 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/PolicyList" + "$ref": "#/components/schemas/DeviceList" } } }, - "description": "Response policies" + "description": "Response devices" } }, "tags": [ - "PolicyV1alpha1" + "DeviceV1alpha1" ] }, "post": { - "description": "Create Policy", - "operationId": "createPolicy", + "description": "Create Device", + "operationId": "createDevice", "requestBody": { "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/Policy" + "$ref": "#/components/schemas/Device" } } }, - "description": "Fresh policy" + "description": "Fresh device" }, "responses": { "200": { "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/Policy" + "$ref": "#/components/schemas/Device" } } }, - "description": "Response policies created just now" + "description": "Response devices created just now" } }, "tags": [ - "PolicyV1alpha1" + "DeviceV1alpha1" ] } }, - "/apis/storage.halo.run/v1alpha1/policies/{name}": { + "/apis/security.halo.run/v1alpha1/devices/{name}": { "delete": { - "description": "Delete Policy", - "operationId": "deletePolicy", + "description": "Delete Device", + "operationId": "deleteDevice", "parameters": [ { - "description": "Name of policy", + "description": "Name of device", "in": "path", "name": "name", "required": true, @@ -7484,19 +7484,19 @@ ], "responses": { "200": { - "description": "Response policy deleted just now" + "description": "Response device deleted just now" } }, "tags": [ - "PolicyV1alpha1" + "DeviceV1alpha1" ] }, "get": { - "description": "Get Policy", - "operationId": "getPolicy", + "description": "Get Device", + "operationId": "getDevice", "parameters": [ { - "description": "Name of policy", + "description": "Name of device", "in": "path", "name": "name", "required": true, @@ -7510,23 +7510,23 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/Policy" + "$ref": "#/components/schemas/Device" } } }, - "description": "Response single policy" + "description": "Response single device" } }, "tags": [ - "PolicyV1alpha1" + "DeviceV1alpha1" ] }, "patch": { - "description": "Patch Policy", - "operationId": "patchPolicy", + "description": "Patch Device", + "operationId": "patchDevice", "parameters": [ { - "description": "Name of policy", + "description": "Name of device", "in": "path", "name": "name", "required": true, @@ -7549,23 +7549,23 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/Policy" + "$ref": "#/components/schemas/Device" } } }, - "description": "Response policy patched just now" + "description": "Response device patched just now" } }, "tags": [ - "PolicyV1alpha1" + "DeviceV1alpha1" ] }, "put": { - "description": "Update Policy", - "operationId": "updatePolicy", + "description": "Update Device", + "operationId": "updateDevice", "parameters": [ { - "description": "Name of policy", + "description": "Name of device", "in": "path", "name": "name", "required": true, @@ -7578,33 +7578,33 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/Policy" + "$ref": "#/components/schemas/Device" } } }, - "description": "Updated policy" + "description": "Updated device" }, "responses": { "200": { "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/Policy" + "$ref": "#/components/schemas/Device" } } }, - "description": "Response policies updated just now" + "description": "Response devices updated just now" } }, "tags": [ - "PolicyV1alpha1" + "DeviceV1alpha1" ] } }, - "/apis/storage.halo.run/v1alpha1/policytemplates": { + "/apis/security.halo.run/v1alpha1/personalaccesstokens": { "get": { - "description": "List PolicyTemplate", - "operationId": "listPolicyTemplate", + "description": "List PersonalAccessToken", + "operationId": "listPersonalAccessToken", "parameters": [ { "description": "Page number. Default is 0.", @@ -7663,54 +7663,54 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/PolicyTemplateList" + "$ref": "#/components/schemas/PersonalAccessTokenList" } } }, - "description": "Response policytemplates" + "description": "Response personalaccesstokens" } }, "tags": [ - "PolicyTemplateV1alpha1" + "PersonalAccessTokenV1alpha1" ] }, "post": { - "description": "Create PolicyTemplate", - "operationId": "createPolicyTemplate", + "description": "Create PersonalAccessToken", + "operationId": "createPersonalAccessToken", "requestBody": { "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/PolicyTemplate" + "$ref": "#/components/schemas/PersonalAccessToken" } } }, - "description": "Fresh policytemplate" + "description": "Fresh personalaccesstoken" }, "responses": { "200": { "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/PolicyTemplate" + "$ref": "#/components/schemas/PersonalAccessToken" } } }, - "description": "Response policytemplates created just now" + "description": "Response personalaccesstokens created just now" } }, "tags": [ - "PolicyTemplateV1alpha1" + "PersonalAccessTokenV1alpha1" ] } }, - "/apis/storage.halo.run/v1alpha1/policytemplates/{name}": { + "/apis/security.halo.run/v1alpha1/personalaccesstokens/{name}": { "delete": { - "description": "Delete PolicyTemplate", - "operationId": "deletePolicyTemplate", + "description": "Delete PersonalAccessToken", + "operationId": "deletePersonalAccessToken", "parameters": [ { - "description": "Name of policytemplate", + "description": "Name of personalaccesstoken", "in": "path", "name": "name", "required": true, @@ -7721,19 +7721,19 @@ ], "responses": { "200": { - "description": "Response policytemplate deleted just now" + "description": "Response personalaccesstoken deleted just now" } }, "tags": [ - "PolicyTemplateV1alpha1" + "PersonalAccessTokenV1alpha1" ] }, "get": { - "description": "Get PolicyTemplate", - "operationId": "getPolicyTemplate", + "description": "Get PersonalAccessToken", + "operationId": "getPersonalAccessToken", "parameters": [ { - "description": "Name of policytemplate", + "description": "Name of personalaccesstoken", "in": "path", "name": "name", "required": true, @@ -7747,23 +7747,23 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/PolicyTemplate" + "$ref": "#/components/schemas/PersonalAccessToken" } } }, - "description": "Response single policytemplate" + "description": "Response single personalaccesstoken" } }, "tags": [ - "PolicyTemplateV1alpha1" + "PersonalAccessTokenV1alpha1" ] }, "patch": { - "description": "Patch PolicyTemplate", - "operationId": "patchPolicyTemplate", + "description": "Patch PersonalAccessToken", + "operationId": "patchPersonalAccessToken", "parameters": [ { - "description": "Name of policytemplate", + "description": "Name of personalaccesstoken", "in": "path", "name": "name", "required": true, @@ -7786,23 +7786,23 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/PolicyTemplate" + "$ref": "#/components/schemas/PersonalAccessToken" } } }, - "description": "Response policytemplate patched just now" + "description": "Response personalaccesstoken patched just now" } }, "tags": [ - "PolicyTemplateV1alpha1" + "PersonalAccessTokenV1alpha1" ] }, "put": { - "description": "Update PolicyTemplate", - "operationId": "updatePolicyTemplate", + "description": "Update PersonalAccessToken", + "operationId": "updatePersonalAccessToken", "parameters": [ { - "description": "Name of policytemplate", + "description": "Name of personalaccesstoken", "in": "path", "name": "name", "required": true, @@ -7815,33 +7815,33 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/PolicyTemplate" + "$ref": "#/components/schemas/PersonalAccessToken" } } }, - "description": "Updated policytemplate" + "description": "Updated personalaccesstoken" }, "responses": { "200": { "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/PolicyTemplate" + "$ref": "#/components/schemas/PersonalAccessToken" } } }, - "description": "Response policytemplates updated just now" + "description": "Response personalaccesstokens updated just now" } }, "tags": [ - "PolicyTemplateV1alpha1" + "PersonalAccessTokenV1alpha1" ] } }, - "/apis/storage.halo.run/v1alpha1/thumbnails": { + "/apis/security.halo.run/v1alpha1/remembermetokens": { "get": { - "description": "List Thumbnail", - "operationId": "listThumbnail", + "description": "List RememberMeToken", + "operationId": "listRememberMeToken", "parameters": [ { "description": "Page number. Default is 0.", @@ -7900,54 +7900,54 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/ThumbnailList" + "$ref": "#/components/schemas/RememberMeTokenList" } } }, - "description": "Response thumbnails" + "description": "Response remembermetokens" } }, "tags": [ - "ThumbnailV1alpha1" + "RememberMeTokenV1alpha1" ] }, "post": { - "description": "Create Thumbnail", - "operationId": "createThumbnail", + "description": "Create RememberMeToken", + "operationId": "createRememberMeToken", "requestBody": { "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/Thumbnail" + "$ref": "#/components/schemas/RememberMeToken" } } }, - "description": "Fresh thumbnail" + "description": "Fresh remembermetoken" }, "responses": { "200": { "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/Thumbnail" + "$ref": "#/components/schemas/RememberMeToken" } } }, - "description": "Response thumbnails created just now" + "description": "Response remembermetokens created just now" } }, "tags": [ - "ThumbnailV1alpha1" + "RememberMeTokenV1alpha1" ] } }, - "/apis/storage.halo.run/v1alpha1/thumbnails/{name}": { + "/apis/security.halo.run/v1alpha1/remembermetokens/{name}": { "delete": { - "description": "Delete Thumbnail", - "operationId": "deleteThumbnail", + "description": "Delete RememberMeToken", + "operationId": "deleteRememberMeToken", "parameters": [ { - "description": "Name of thumbnail", + "description": "Name of remembermetoken", "in": "path", "name": "name", "required": true, @@ -7958,19 +7958,19 @@ ], "responses": { "200": { - "description": "Response thumbnail deleted just now" + "description": "Response remembermetoken deleted just now" } }, "tags": [ - "ThumbnailV1alpha1" + "RememberMeTokenV1alpha1" ] }, "get": { - "description": "Get Thumbnail", - "operationId": "getThumbnail", + "description": "Get RememberMeToken", + "operationId": "getRememberMeToken", "parameters": [ { - "description": "Name of thumbnail", + "description": "Name of remembermetoken", "in": "path", "name": "name", "required": true, @@ -7984,23 +7984,23 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/Thumbnail" + "$ref": "#/components/schemas/RememberMeToken" } } }, - "description": "Response single thumbnail" + "description": "Response single remembermetoken" } }, "tags": [ - "ThumbnailV1alpha1" + "RememberMeTokenV1alpha1" ] }, "patch": { - "description": "Patch Thumbnail", - "operationId": "patchThumbnail", + "description": "Patch RememberMeToken", + "operationId": "patchRememberMeToken", "parameters": [ { - "description": "Name of thumbnail", + "description": "Name of remembermetoken", "in": "path", "name": "name", "required": true, @@ -8023,23 +8023,23 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/Thumbnail" + "$ref": "#/components/schemas/RememberMeToken" } } }, - "description": "Response thumbnail patched just now" + "description": "Response remembermetoken patched just now" } }, "tags": [ - "ThumbnailV1alpha1" + "RememberMeTokenV1alpha1" ] }, "put": { - "description": "Update Thumbnail", - "operationId": "updateThumbnail", + "description": "Update RememberMeToken", + "operationId": "updateRememberMeToken", "parameters": [ { - "description": "Name of thumbnail", + "description": "Name of remembermetoken", "in": "path", "name": "name", "required": true, @@ -8052,33 +8052,33 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/Thumbnail" + "$ref": "#/components/schemas/RememberMeToken" } } }, - "description": "Updated thumbnail" + "description": "Updated remembermetoken" }, "responses": { "200": { "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/Thumbnail" + "$ref": "#/components/schemas/RememberMeToken" } } }, - "description": "Response thumbnails updated just now" + "description": "Response remembermetokens updated just now" } }, "tags": [ - "ThumbnailV1alpha1" + "RememberMeTokenV1alpha1" ] } }, - "/apis/theme.halo.run/v1alpha1/themes": { + "/apis/storage.halo.run/v1alpha1/attachments": { "get": { - "description": "List Theme", - "operationId": "listTheme", + "description": "List Attachment", + "operationId": "listAttachment", "parameters": [ { "description": "Page number. Default is 0.", @@ -8137,54 +8137,54 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/ThemeList" + "$ref": "#/components/schemas/AttachmentList" } } }, - "description": "Response themes" + "description": "Response attachments" } }, "tags": [ - "ThemeV1alpha1" + "AttachmentV1alpha1" ] }, "post": { - "description": "Create Theme", - "operationId": "createTheme", + "description": "Create Attachment", + "operationId": "createAttachment", "requestBody": { "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/Theme" + "$ref": "#/components/schemas/Attachment" } } }, - "description": "Fresh theme" + "description": "Fresh attachment" }, "responses": { "200": { "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/Theme" + "$ref": "#/components/schemas/Attachment" } } }, - "description": "Response themes created just now" + "description": "Response attachments created just now" } }, "tags": [ - "ThemeV1alpha1" + "AttachmentV1alpha1" ] } }, - "/apis/theme.halo.run/v1alpha1/themes/{name}": { + "/apis/storage.halo.run/v1alpha1/attachments/{name}": { "delete": { - "description": "Delete Theme", - "operationId": "deleteTheme", + "description": "Delete Attachment", + "operationId": "deleteAttachment", "parameters": [ { - "description": "Name of theme", + "description": "Name of attachment", "in": "path", "name": "name", "required": true, @@ -8195,19 +8195,19 @@ ], "responses": { "200": { - "description": "Response theme deleted just now" + "description": "Response attachment deleted just now" } }, "tags": [ - "ThemeV1alpha1" + "AttachmentV1alpha1" ] }, "get": { - "description": "Get Theme", - "operationId": "getTheme", + "description": "Get Attachment", + "operationId": "getAttachment", "parameters": [ { - "description": "Name of theme", + "description": "Name of attachment", "in": "path", "name": "name", "required": true, @@ -8221,23 +8221,23 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/Theme" + "$ref": "#/components/schemas/Attachment" } } }, - "description": "Response single theme" + "description": "Response single attachment" } }, "tags": [ - "ThemeV1alpha1" + "AttachmentV1alpha1" ] }, "patch": { - "description": "Patch Theme", - "operationId": "patchTheme", + "description": "Patch Attachment", + "operationId": "patchAttachment", "parameters": [ { - "description": "Name of theme", + "description": "Name of attachment", "in": "path", "name": "name", "required": true, @@ -8260,23 +8260,23 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/Theme" + "$ref": "#/components/schemas/Attachment" } } }, - "description": "Response theme patched just now" + "description": "Response attachment patched just now" } }, "tags": [ - "ThemeV1alpha1" + "AttachmentV1alpha1" ] }, "put": { - "description": "Update Theme", - "operationId": "updateTheme", + "description": "Update Attachment", + "operationId": "updateAttachment", "parameters": [ { - "description": "Name of theme", + "description": "Name of attachment", "in": "path", "name": "name", "required": true, @@ -8289,81 +8289,1745 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/Theme" + "$ref": "#/components/schemas/Attachment" } } }, - "description": "Updated theme" + "description": "Updated attachment" }, "responses": { "200": { "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/Theme" + "$ref": "#/components/schemas/Attachment" } } }, - "description": "Response themes updated just now" + "description": "Response attachments updated just now" } }, "tags": [ - "ThemeV1alpha1" + "AttachmentV1alpha1" ] } - } - }, - "components": { - "schemas": { - "AddOperation": { - "required": [ - "op", - "path", - "value" - ], - "type": "object", - "properties": { - "op": { - "type": "string", - "enum": [ - "add" - ] - }, - "path": { - "pattern": "^(/[^/~]*(~[01][^/~]*)*)*$", - "type": "string", - "description": "A JSON Pointer path pointing to the location to move/copy from.", - "example": "/a/b/c" + }, + "/apis/storage.halo.run/v1alpha1/groups": { + "get": { + "description": "List Group", + "operationId": "listGroup", + "parameters": [ + { + "description": "Page number. Default is 0.", + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "format": "int32" + } }, - "value": { - "description": "Value can be any JSON value" - } - } - }, - "AnnotationSetting": { - "required": [ - "apiVersion", - "kind", - "metadata", - "spec" - ], - "type": "object", - "properties": { - "apiVersion": { - "type": "string" + { + "description": "Size number. Default is 0.", + "in": "query", + "name": "size", + "schema": { + "type": "integer", + "format": "int32" + } }, - "kind": { - "type": "string" + { + "description": "Label selector. e.g.: hidden!\u003dtrue", + "in": "query", + "name": "labelSelector", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } }, - "metadata": { - "$ref": "#/components/schemas/Metadata" + { + "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", + "in": "query", + "name": "fieldSelector", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } }, - "spec": { - "$ref": "#/components/schemas/AnnotationSettingSpec" - } + { + "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", + "in": "query", + "name": "sort", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + ], + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/GroupList" + } + } + }, + "description": "Response groups" + } + }, + "tags": [ + "GroupV1alpha1" + ] + }, + "post": { + "description": "Create Group", + "operationId": "createGroup", + "requestBody": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/Group" + } + } + }, + "description": "Fresh group" + }, + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/Group" + } + } + }, + "description": "Response groups created just now" + } + }, + "tags": [ + "GroupV1alpha1" + ] + } + }, + "/apis/storage.halo.run/v1alpha1/groups/{name}": { + "delete": { + "description": "Delete Group", + "operationId": "deleteGroup", + "parameters": [ + { + "description": "Name of group", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Response group deleted just now" + } + }, + "tags": [ + "GroupV1alpha1" + ] + }, + "get": { + "description": "Get Group", + "operationId": "getGroup", + "parameters": [ + { + "description": "Name of group", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/Group" + } + } + }, + "description": "Response single group" + } + }, + "tags": [ + "GroupV1alpha1" + ] + }, + "patch": { + "description": "Patch Group", + "operationId": "patchGroup", + "parameters": [ + { + "description": "Name of group", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/JsonPatch" + } + } + } + }, + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/Group" + } + } + }, + "description": "Response group patched just now" + } + }, + "tags": [ + "GroupV1alpha1" + ] + }, + "put": { + "description": "Update Group", + "operationId": "updateGroup", + "parameters": [ + { + "description": "Name of group", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/Group" + } + } + }, + "description": "Updated group" + }, + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/Group" + } + } + }, + "description": "Response groups updated just now" + } + }, + "tags": [ + "GroupV1alpha1" + ] + } + }, + "/apis/storage.halo.run/v1alpha1/localthumbnails": { + "get": { + "description": "List LocalThumbnail", + "operationId": "listLocalThumbnail", + "parameters": [ + { + "description": "Page number. Default is 0.", + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "description": "Size number. Default is 0.", + "in": "query", + "name": "size", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "description": "Label selector. e.g.: hidden!\u003dtrue", + "in": "query", + "name": "labelSelector", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", + "in": "query", + "name": "fieldSelector", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", + "in": "query", + "name": "sort", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + ], + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/LocalThumbnailList" + } + } + }, + "description": "Response localthumbnails" + } + }, + "tags": [ + "LocalThumbnailV1alpha1" + ] + }, + "post": { + "description": "Create LocalThumbnail", + "operationId": "createLocalThumbnail", + "requestBody": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/LocalThumbnail" + } + } + }, + "description": "Fresh localthumbnail" + }, + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/LocalThumbnail" + } + } + }, + "description": "Response localthumbnails created just now" + } + }, + "tags": [ + "LocalThumbnailV1alpha1" + ] + } + }, + "/apis/storage.halo.run/v1alpha1/localthumbnails/{name}": { + "delete": { + "description": "Delete LocalThumbnail", + "operationId": "deleteLocalThumbnail", + "parameters": [ + { + "description": "Name of localthumbnail", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Response localthumbnail deleted just now" + } + }, + "tags": [ + "LocalThumbnailV1alpha1" + ] + }, + "get": { + "description": "Get LocalThumbnail", + "operationId": "getLocalThumbnail", + "parameters": [ + { + "description": "Name of localthumbnail", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/LocalThumbnail" + } + } + }, + "description": "Response single localthumbnail" + } + }, + "tags": [ + "LocalThumbnailV1alpha1" + ] + }, + "patch": { + "description": "Patch LocalThumbnail", + "operationId": "patchLocalThumbnail", + "parameters": [ + { + "description": "Name of localthumbnail", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/JsonPatch" + } + } + } + }, + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/LocalThumbnail" + } + } + }, + "description": "Response localthumbnail patched just now" + } + }, + "tags": [ + "LocalThumbnailV1alpha1" + ] + }, + "put": { + "description": "Update LocalThumbnail", + "operationId": "updateLocalThumbnail", + "parameters": [ + { + "description": "Name of localthumbnail", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/LocalThumbnail" + } + } + }, + "description": "Updated localthumbnail" + }, + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/LocalThumbnail" + } + } + }, + "description": "Response localthumbnails updated just now" + } + }, + "tags": [ + "LocalThumbnailV1alpha1" + ] + } + }, + "/apis/storage.halo.run/v1alpha1/policies": { + "get": { + "description": "List Policy", + "operationId": "listPolicy", + "parameters": [ + { + "description": "Page number. Default is 0.", + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "description": "Size number. Default is 0.", + "in": "query", + "name": "size", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "description": "Label selector. e.g.: hidden!\u003dtrue", + "in": "query", + "name": "labelSelector", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", + "in": "query", + "name": "fieldSelector", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", + "in": "query", + "name": "sort", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + ], + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/PolicyList" + } + } + }, + "description": "Response policies" + } + }, + "tags": [ + "PolicyV1alpha1" + ] + }, + "post": { + "description": "Create Policy", + "operationId": "createPolicy", + "requestBody": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/Policy" + } + } + }, + "description": "Fresh policy" + }, + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/Policy" + } + } + }, + "description": "Response policies created just now" + } + }, + "tags": [ + "PolicyV1alpha1" + ] + } + }, + "/apis/storage.halo.run/v1alpha1/policies/{name}": { + "delete": { + "description": "Delete Policy", + "operationId": "deletePolicy", + "parameters": [ + { + "description": "Name of policy", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Response policy deleted just now" + } + }, + "tags": [ + "PolicyV1alpha1" + ] + }, + "get": { + "description": "Get Policy", + "operationId": "getPolicy", + "parameters": [ + { + "description": "Name of policy", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/Policy" + } + } + }, + "description": "Response single policy" + } + }, + "tags": [ + "PolicyV1alpha1" + ] + }, + "patch": { + "description": "Patch Policy", + "operationId": "patchPolicy", + "parameters": [ + { + "description": "Name of policy", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/JsonPatch" + } + } + } + }, + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/Policy" + } + } + }, + "description": "Response policy patched just now" + } + }, + "tags": [ + "PolicyV1alpha1" + ] + }, + "put": { + "description": "Update Policy", + "operationId": "updatePolicy", + "parameters": [ + { + "description": "Name of policy", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/Policy" + } + } + }, + "description": "Updated policy" + }, + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/Policy" + } + } + }, + "description": "Response policies updated just now" + } + }, + "tags": [ + "PolicyV1alpha1" + ] + } + }, + "/apis/storage.halo.run/v1alpha1/policytemplates": { + "get": { + "description": "List PolicyTemplate", + "operationId": "listPolicyTemplate", + "parameters": [ + { + "description": "Page number. Default is 0.", + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "description": "Size number. Default is 0.", + "in": "query", + "name": "size", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "description": "Label selector. e.g.: hidden!\u003dtrue", + "in": "query", + "name": "labelSelector", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", + "in": "query", + "name": "fieldSelector", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", + "in": "query", + "name": "sort", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + ], + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/PolicyTemplateList" + } + } + }, + "description": "Response policytemplates" + } + }, + "tags": [ + "PolicyTemplateV1alpha1" + ] + }, + "post": { + "description": "Create PolicyTemplate", + "operationId": "createPolicyTemplate", + "requestBody": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/PolicyTemplate" + } + } + }, + "description": "Fresh policytemplate" + }, + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/PolicyTemplate" + } + } + }, + "description": "Response policytemplates created just now" + } + }, + "tags": [ + "PolicyTemplateV1alpha1" + ] + } + }, + "/apis/storage.halo.run/v1alpha1/policytemplates/{name}": { + "delete": { + "description": "Delete PolicyTemplate", + "operationId": "deletePolicyTemplate", + "parameters": [ + { + "description": "Name of policytemplate", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Response policytemplate deleted just now" + } + }, + "tags": [ + "PolicyTemplateV1alpha1" + ] + }, + "get": { + "description": "Get PolicyTemplate", + "operationId": "getPolicyTemplate", + "parameters": [ + { + "description": "Name of policytemplate", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/PolicyTemplate" + } + } + }, + "description": "Response single policytemplate" + } + }, + "tags": [ + "PolicyTemplateV1alpha1" + ] + }, + "patch": { + "description": "Patch PolicyTemplate", + "operationId": "patchPolicyTemplate", + "parameters": [ + { + "description": "Name of policytemplate", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/JsonPatch" + } + } + } + }, + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/PolicyTemplate" + } + } + }, + "description": "Response policytemplate patched just now" + } + }, + "tags": [ + "PolicyTemplateV1alpha1" + ] + }, + "put": { + "description": "Update PolicyTemplate", + "operationId": "updatePolicyTemplate", + "parameters": [ + { + "description": "Name of policytemplate", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/PolicyTemplate" + } + } + }, + "description": "Updated policytemplate" + }, + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/PolicyTemplate" + } + } + }, + "description": "Response policytemplates updated just now" + } + }, + "tags": [ + "PolicyTemplateV1alpha1" + ] + } + }, + "/apis/storage.halo.run/v1alpha1/thumbnails": { + "get": { + "description": "List Thumbnail", + "operationId": "listThumbnail", + "parameters": [ + { + "description": "Page number. Default is 0.", + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "description": "Size number. Default is 0.", + "in": "query", + "name": "size", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "description": "Label selector. e.g.: hidden!\u003dtrue", + "in": "query", + "name": "labelSelector", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", + "in": "query", + "name": "fieldSelector", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", + "in": "query", + "name": "sort", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + ], + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ThumbnailList" + } + } + }, + "description": "Response thumbnails" + } + }, + "tags": [ + "ThumbnailV1alpha1" + ] + }, + "post": { + "description": "Create Thumbnail", + "operationId": "createThumbnail", + "requestBody": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/Thumbnail" + } + } + }, + "description": "Fresh thumbnail" + }, + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/Thumbnail" + } + } + }, + "description": "Response thumbnails created just now" + } + }, + "tags": [ + "ThumbnailV1alpha1" + ] + } + }, + "/apis/storage.halo.run/v1alpha1/thumbnails/{name}": { + "delete": { + "description": "Delete Thumbnail", + "operationId": "deleteThumbnail", + "parameters": [ + { + "description": "Name of thumbnail", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Response thumbnail deleted just now" + } + }, + "tags": [ + "ThumbnailV1alpha1" + ] + }, + "get": { + "description": "Get Thumbnail", + "operationId": "getThumbnail", + "parameters": [ + { + "description": "Name of thumbnail", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/Thumbnail" + } + } + }, + "description": "Response single thumbnail" + } + }, + "tags": [ + "ThumbnailV1alpha1" + ] + }, + "patch": { + "description": "Patch Thumbnail", + "operationId": "patchThumbnail", + "parameters": [ + { + "description": "Name of thumbnail", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/JsonPatch" + } + } + } + }, + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/Thumbnail" + } + } + }, + "description": "Response thumbnail patched just now" + } + }, + "tags": [ + "ThumbnailV1alpha1" + ] + }, + "put": { + "description": "Update Thumbnail", + "operationId": "updateThumbnail", + "parameters": [ + { + "description": "Name of thumbnail", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/Thumbnail" + } + } + }, + "description": "Updated thumbnail" + }, + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/Thumbnail" + } + } + }, + "description": "Response thumbnails updated just now" + } + }, + "tags": [ + "ThumbnailV1alpha1" + ] + } + }, + "/apis/theme.halo.run/v1alpha1/themes": { + "get": { + "description": "List Theme", + "operationId": "listTheme", + "parameters": [ + { + "description": "Page number. Default is 0.", + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "description": "Size number. Default is 0.", + "in": "query", + "name": "size", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "description": "Label selector. e.g.: hidden!\u003dtrue", + "in": "query", + "name": "labelSelector", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", + "in": "query", + "name": "fieldSelector", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", + "in": "query", + "name": "sort", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + ], + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ThemeList" + } + } + }, + "description": "Response themes" + } + }, + "tags": [ + "ThemeV1alpha1" + ] + }, + "post": { + "description": "Create Theme", + "operationId": "createTheme", + "requestBody": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/Theme" + } + } + }, + "description": "Fresh theme" + }, + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/Theme" + } + } + }, + "description": "Response themes created just now" + } + }, + "tags": [ + "ThemeV1alpha1" + ] + } + }, + "/apis/theme.halo.run/v1alpha1/themes/{name}": { + "delete": { + "description": "Delete Theme", + "operationId": "deleteTheme", + "parameters": [ + { + "description": "Name of theme", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Response theme deleted just now" + } + }, + "tags": [ + "ThemeV1alpha1" + ] + }, + "get": { + "description": "Get Theme", + "operationId": "getTheme", + "parameters": [ + { + "description": "Name of theme", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/Theme" + } + } + }, + "description": "Response single theme" + } + }, + "tags": [ + "ThemeV1alpha1" + ] + }, + "patch": { + "description": "Patch Theme", + "operationId": "patchTheme", + "parameters": [ + { + "description": "Name of theme", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/JsonPatch" + } + } + } + }, + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/Theme" + } + } + }, + "description": "Response theme patched just now" + } + }, + "tags": [ + "ThemeV1alpha1" + ] + }, + "put": { + "description": "Update Theme", + "operationId": "updateTheme", + "parameters": [ + { + "description": "Name of theme", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/Theme" + } + } + }, + "description": "Updated theme" + }, + "responses": { + "200": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/Theme" + } + } + }, + "description": "Response themes updated just now" + } + }, + "tags": [ + "ThemeV1alpha1" + ] + } + } + }, + "components": { + "schemas": { + "AddOperation": { + "required": [ + "op", + "path", + "value" + ], + "type": "object", + "properties": { + "op": { + "type": "string", + "enum": [ + "add" + ] + }, + "path": { + "pattern": "^(/[^/~]*(~[01][^/~]*)*)*$", + "type": "string", + "description": "A JSON Pointer path pointing to the location to move/copy from.", + "example": "/a/b/c" + }, + "value": { + "description": "Value can be any JSON value" + } + } + }, + "AnnotationSetting": { + "required": [ + "apiVersion", + "kind", + "metadata", + "spec" + ], + "type": "object", + "properties": { + "apiVersion": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/Metadata" + }, + "spec": { + "$ref": "#/components/schemas/AnnotationSettingSpec" + } + } + }, + "AnnotationSettingList": { + "required": [ + "first", + "hasNext", + "hasPrevious", + "items", + "last", + "page", + "size", + "total", + "totalPages" + ], + "type": "object", + "properties": { + "first": { + "type": "boolean", + "description": "Indicates whether current page is the first page." + }, + "hasNext": { + "type": "boolean", + "description": "Indicates whether current page has previous page." + }, + "hasPrevious": { + "type": "boolean", + "description": "Indicates whether current page has previous page." + }, + "items": { + "type": "array", + "description": "A chunk of items.", + "items": { + "$ref": "#/components/schemas/AnnotationSetting" + } + }, + "last": { + "type": "boolean", + "description": "Indicates whether current page is the last page." + }, + "page": { + "type": "integer", + "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", + "format": "int32" + }, + "size": { + "type": "integer", + "description": "Size of each page. If not set or equal to 0, it means no pagination.", + "format": "int32" + }, + "total": { + "type": "integer", + "description": "Total elements.", + "format": "int64" + }, + "totalPages": { + "type": "integer", + "description": "Indicates total pages.", + "format": "int64" + } + } + }, + "AnnotationSettingSpec": { + "required": [ + "formSchema", + "targetRef" + ], + "type": "object", + "properties": { + "formSchema": { + "minLength": 1, + "type": "array", + "items": { + "minLength": 1, + "type": "object" + } + }, + "targetRef": { + "$ref": "#/components/schemas/GroupKind" + } + } + }, + "Attachment": { + "required": [ + "apiVersion", + "kind", + "metadata", + "spec" + ], + "type": "object", + "properties": { + "apiVersion": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/Metadata" + }, + "spec": { + "$ref": "#/components/schemas/AttachmentSpec" + }, + "status": { + "$ref": "#/components/schemas/AttachmentStatus" + } + } + }, + "AttachmentList": { + "required": [ + "first", + "hasNext", + "hasPrevious", + "items", + "last", + "page", + "size", + "total", + "totalPages" + ], + "type": "object", + "properties": { + "first": { + "type": "boolean", + "description": "Indicates whether current page is the first page." + }, + "hasNext": { + "type": "boolean", + "description": "Indicates whether current page has previous page." + }, + "hasPrevious": { + "type": "boolean", + "description": "Indicates whether current page has previous page." + }, + "items": { + "type": "array", + "description": "A chunk of items.", + "items": { + "$ref": "#/components/schemas/Attachment" + } + }, + "last": { + "type": "boolean", + "description": "Indicates whether current page is the last page." + }, + "page": { + "type": "integer", + "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", + "format": "int32" + }, + "size": { + "type": "integer", + "description": "Size of each page. If not set or equal to 0, it means no pagination.", + "format": "int32" + }, + "total": { + "type": "integer", + "description": "Total elements.", + "format": "int64" + }, + "totalPages": { + "type": "integer", + "description": "Indicates total pages.", + "format": "int64" + } + } + }, + "AttachmentSpec": { + "type": "object", + "properties": { + "displayName": { + "type": "string", + "description": "Display name of attachment" + }, + "groupName": { + "type": "string", + "description": "Group name" + }, + "mediaType": { + "type": "string", + "description": "Media type of attachment" + }, + "ownerName": { + "type": "string", + "description": "Name of User who uploads the attachment" + }, + "policyName": { + "type": "string", + "description": "Policy name" + }, + "size": { + "minimum": 0, + "type": "integer", + "description": "Size of attachment. Unit is Byte", + "format": "int64" + }, + "tags": { + "uniqueItems": true, + "type": "array", + "description": "Tags of attachment", + "items": { + "type": "string", + "description": "Tag name" + } + } } }, - "AnnotationSettingList": { + "AttachmentStatus": { + "type": "object", + "properties": { + "permalink": { + "type": "string", + "description": "Permalink of attachment.\nIf it is in local storage, the public URL will be set.\nIf it is in s3 storage, the Object URL will be set.\n" + }, + "thumbnails": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, + "AuthProvider": { + "required": [ + "apiVersion", + "kind", + "metadata", + "spec" + ], + "type": "object", + "properties": { + "apiVersion": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/Metadata" + }, + "spec": { + "$ref": "#/components/schemas/AuthProviderSpec" + } + } + }, + "AuthProviderList": { "required": [ "first", "hasNext", @@ -8393,7 +10057,7 @@ "type": "array", "description": "A chunk of items.", "items": { - "$ref": "#/components/schemas/AnnotationSetting" + "$ref": "#/components/schemas/AuthProvider" } }, "last": { @@ -8422,27 +10086,209 @@ } } }, - "AnnotationSettingSpec": { + "AuthProviderSpec": { "required": [ - "formSchema", - "targetRef" + "authType", + "authenticationUrl", + "displayName" ], "type": "object", "properties": { - "formSchema": { + "authType": { + "type": "string", + "enum": [ + "FORM", + "OAUTH2" + ] + }, + "authenticationUrl": { + "type": "string", + "description": "Authentication url of the auth provider" + }, + "bindingUrl": { + "type": "string" + }, + "configMapRef": { + "$ref": "#/components/schemas/ConfigMapRef" + }, + "description": { + "type": "string" + }, + "displayName": { + "type": "string", + "description": "Display name of the auth provider" + }, + "helpPage": { + "type": "string" + }, + "logo": { + "type": "string" + }, + "method": { + "type": "string" + }, + "rememberMeSupport": { + "type": "boolean" + }, + "settingRef": { + "$ref": "#/components/schemas/SettingRef" + }, + "unbindUrl": { + "type": "string" + }, + "website": { + "type": "string" + } + } + }, + "Author": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "name": { "minLength": 1, + "type": "string" + }, + "website": { + "type": "string" + } + } + }, + "Backup": { + "required": [ + "apiVersion", + "kind", + "metadata" + ], + "type": "object", + "properties": { + "apiVersion": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/Metadata" + }, + "spec": { + "$ref": "#/components/schemas/BackupSpec" + }, + "status": { + "$ref": "#/components/schemas/BackupStatus" + } + } + }, + "BackupList": { + "required": [ + "first", + "hasNext", + "hasPrevious", + "items", + "last", + "page", + "size", + "total", + "totalPages" + ], + "type": "object", + "properties": { + "first": { + "type": "boolean", + "description": "Indicates whether current page is the first page." + }, + "hasNext": { + "type": "boolean", + "description": "Indicates whether current page has previous page." + }, + "hasPrevious": { + "type": "boolean", + "description": "Indicates whether current page has previous page." + }, + "items": { "type": "array", + "description": "A chunk of items.", "items": { - "minLength": 1, - "type": "object" + "$ref": "#/components/schemas/Backup" } }, - "targetRef": { - "$ref": "#/components/schemas/GroupKind" + "last": { + "type": "boolean", + "description": "Indicates whether current page is the last page." + }, + "page": { + "type": "integer", + "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", + "format": "int32" + }, + "size": { + "type": "integer", + "description": "Size of each page. If not set or equal to 0, it means no pagination.", + "format": "int32" + }, + "total": { + "type": "integer", + "description": "Total elements.", + "format": "int64" + }, + "totalPages": { + "type": "integer", + "description": "Indicates total pages.", + "format": "int64" } } }, - "Attachment": { + "BackupSpec": { + "type": "object", + "properties": { + "expiresAt": { + "type": "string", + "format": "date-time" + }, + "format": { + "type": "string", + "description": "Backup file format. Currently, only zip format is supported." + } + } + }, + "BackupStatus": { + "type": "object", + "properties": { + "completionTimestamp": { + "type": "string", + "format": "date-time" + }, + "failureMessage": { + "type": "string" + }, + "failureReason": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "phase": { + "type": "string", + "enum": [ + "PENDING", + "RUNNING", + "SUCCEEDED", + "FAILED" + ] + }, + "size": { + "type": "integer", + "format": "int64" + }, + "startTimestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "Category": { "required": [ "apiVersion", "kind", @@ -8461,14 +10307,14 @@ "$ref": "#/components/schemas/Metadata" }, "spec": { - "$ref": "#/components/schemas/AttachmentSpec" + "$ref": "#/components/schemas/CategorySpec" }, "status": { - "$ref": "#/components/schemas/AttachmentStatus" + "$ref": "#/components/schemas/CategoryStatus" } } }, - "AttachmentList": { + "CategoryList": { "required": [ "first", "hasNext", @@ -8498,7 +10344,7 @@ "type": "array", "description": "A chunk of items.", "items": { - "$ref": "#/components/schemas/Attachment" + "$ref": "#/components/schemas/Category" } }, "last": { @@ -8527,62 +10373,72 @@ } } }, - "AttachmentSpec": { + "CategorySpec": { + "required": [ + "displayName", + "priority", + "slug" + ], "type": "object", "properties": { - "displayName": { - "type": "string", - "description": "Display name of attachment" + "children": { + "type": "array", + "items": { + "type": "string" + } }, - "groupName": { - "type": "string", - "description": "Group name" + "cover": { + "type": "string" }, - "mediaType": { - "type": "string", - "description": "Media type of attachment" + "description": { + "type": "string" }, - "ownerName": { - "type": "string", - "description": "Name of User who uploads the attachment" + "displayName": { + "minLength": 1, + "type": "string" }, - "policyName": { - "type": "string", - "description": "Policy name" + "hideFromList": { + "type": "boolean" }, - "size": { - "minimum": 0, + "postTemplate": { + "maxLength": 255, + "type": "string" + }, + "preventParentPostCascadeQuery": { + "type": "boolean" + }, + "priority": { "type": "integer", - "description": "Size of attachment. Unit is Byte", - "format": "int64" + "format": "int32", + "default": 0 }, - "tags": { - "uniqueItems": true, - "type": "array", - "description": "Tags of attachment", - "items": { - "type": "string", - "description": "Tag name" - } + "slug": { + "minLength": 1, + "type": "string" + }, + "template": { + "maxLength": 255, + "type": "string" } } }, - "AttachmentStatus": { + "CategoryStatus": { "type": "object", "properties": { "permalink": { - "type": "string", - "description": "Permalink of attachment.\nIf it is in local storage, the public URL will be set.\nIf it is in s3 storage, the Object URL will be set.\n" + "type": "string" }, - "thumbnails": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "postCount": { + "type": "integer", + "format": "int32" + }, + "visiblePostCount": { + "type": "integer", + "format": "int32" } } }, - "AuthProvider": { + "Comment": { "required": [ "apiVersion", "kind", @@ -8601,11 +10457,14 @@ "$ref": "#/components/schemas/Metadata" }, "spec": { - "$ref": "#/components/schemas/AuthProviderSpec" + "$ref": "#/components/schemas/CommentSpec" + }, + "status": { + "$ref": "#/components/schemas/CommentStatus" } } }, - "AuthProviderList": { + "CommentList": { "required": [ "first", "hasNext", @@ -8635,7 +10494,7 @@ "type": "array", "description": "A chunk of items.", "items": { - "$ref": "#/components/schemas/AuthProvider" + "$ref": "#/components/schemas/Comment" } }, "last": { @@ -8664,67 +10523,166 @@ } } }, - "AuthProviderSpec": { + "CommentOwner": { "required": [ - "authenticationUrl", - "displayName" + "kind", + "name" ], "type": "object", "properties": { - "authenticationUrl": { - "type": "string", - "description": "Authentication url of the auth provider" + "annotations": { + "type": "object", + "additionalProperties": { + "type": "string" + } }, - "bindingUrl": { + "displayName": { "type": "string" }, - "configMapRef": { - "$ref": "#/components/schemas/ConfigMapRef" + "kind": { + "minLength": 1, + "type": "string" }, - "description": { + "name": { + "maxLength": 64, "type": "string" + } + } + }, + "CommentSpec": { + "required": [ + "allowNotification", + "approved", + "content", + "hidden", + "owner", + "priority", + "raw", + "subjectRef", + "top" + ], + "type": "object", + "properties": { + "allowNotification": { + "type": "boolean", + "default": true }, - "displayName": { + "approved": { + "type": "boolean", + "default": false + }, + "approvedTime": { "type": "string", - "description": "Display name of the auth provider" + "format": "date-time" }, - "helpPage": { + "content": { + "minLength": 1, "type": "string" }, - "logo": { + "creationTime": { + "type": "string", + "format": "date-time" + }, + "hidden": { + "type": "boolean", + "default": false + }, + "ipAddress": { "type": "string" }, + "lastReadTime": { + "type": "string", + "format": "date-time" + }, + "owner": { + "$ref": "#/components/schemas/CommentOwner" + }, "priority": { "type": "integer", - "format": "int32" - }, - "settingRef": { - "$ref": "#/components/schemas/SettingRef" + "format": "int32", + "default": 0 }, - "unbindUrl": { + "raw": { + "minLength": 1, "type": "string" }, - "website": { + "subjectRef": { + "$ref": "#/components/schemas/Ref" + }, + "top": { + "type": "boolean", + "default": false + }, + "userAgent": { "type": "string" } } }, - "Author": { + "CommentStatus": { + "type": "object", + "properties": { + "hasNewReply": { + "type": "boolean" + }, + "lastReplyTime": { + "type": "string", + "format": "date-time" + }, + "observedVersion": { + "type": "integer", + "format": "int64" + }, + "replyCount": { + "type": "integer", + "format": "int32" + }, + "unreadReplyCount": { + "type": "integer", + "format": "int32" + }, + "visibleReplyCount": { + "type": "integer", + "format": "int32" + } + } + }, + "Condition": { "required": [ - "name" + "lastTransitionTime", + "status", + "type" ], "type": "object", "properties": { - "name": { - "minLength": 1, + "lastTransitionTime": { + "type": "string", + "format": "date-time" + }, + "message": { + "maxLength": 32768, "type": "string" }, - "website": { + "reason": { + "maxLength": 1024, + "pattern": "^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$", + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "TRUE", + "FALSE", + "UNKNOWN" + ] + }, + "type": { + "maxLength": 316, + "pattern": "^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$", "type": "string" } } }, - "Backup": { + "ConfigMap": { "required": [ "apiVersion", "kind", @@ -8735,21 +10693,21 @@ "apiVersion": { "type": "string" }, + "data": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" - }, - "spec": { - "$ref": "#/components/schemas/BackupSpec" - }, - "status": { - "$ref": "#/components/schemas/BackupStatus" } } }, - "BackupList": { + "ConfigMapList": { "required": [ "first", "hasNext", @@ -8779,7 +10737,7 @@ "type": "array", "description": "A chunk of items.", "items": { - "$ref": "#/components/schemas/Backup" + "$ref": "#/components/schemas/ConfigMap" } }, "last": { @@ -8808,81 +10766,86 @@ } } }, - "BackupSpec": { + "ConfigMapRef": { + "required": [ + "name" + ], "type": "object", "properties": { - "expiresAt": { - "type": "string", - "format": "date-time" - }, - "format": { - "type": "string", - "description": "Backup file format. Currently, only zip format is supported." + "name": { + "minLength": 1, + "type": "string" } } }, - "BackupStatus": { + "CopyOperation": { + "required": [ + "op", + "from", + "path" + ], "type": "object", "properties": { - "completionTimestamp": { + "from": { + "pattern": "^(/[^/~]*(~[01][^/~]*)*)*$", "type": "string", - "format": "date-time" - }, - "failureMessage": { - "type": "string" - }, - "failureReason": { - "type": "string" - }, - "filename": { - "type": "string" + "description": "A JSON Pointer path pointing to the location to move/copy from.", + "example": "/a/b/c" }, - "phase": { + "op": { "type": "string", "enum": [ - "PENDING", - "RUNNING", - "SUCCEEDED", - "FAILED" + "copy" ] }, - "size": { - "type": "integer", - "format": "int64" - }, - "startTimestamp": { + "path": { + "pattern": "^(/[^/~]*(~[01][^/~]*)*)*$", "type": "string", - "format": "date-time" + "description": "A JSON Pointer path pointing to the location to move/copy from.", + "example": "/a/b/c" } } }, - "Category": { + "Counter": { "required": [ "apiVersion", "kind", - "metadata", - "spec" + "metadata" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, + "approvedComment": { + "type": "integer", + "format": "int32" + }, + "downvote": { + "type": "integer", + "format": "int32" + }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, - "spec": { - "$ref": "#/components/schemas/CategorySpec" + "totalComment": { + "type": "integer", + "format": "int32" }, - "status": { - "$ref": "#/components/schemas/CategoryStatus" + "upvote": { + "type": "integer", + "format": "int32" + }, + "visit": { + "type": "integer", + "format": "int32" } } }, - "CategoryList": { + "CounterList": { "required": [ "first", "hasNext", @@ -8912,7 +10875,7 @@ "type": "array", "description": "A chunk of items.", "items": { - "$ref": "#/components/schemas/Category" + "$ref": "#/components/schemas/Counter" } }, "last": { @@ -8941,77 +10904,36 @@ } } }, - "CategorySpec": { - "required": [ - "displayName", - "priority", - "slug" - ], + "CustomTemplates": { "type": "object", "properties": { - "children": { + "category": { "type": "array", "items": { - "type": "string" + "$ref": "#/components/schemas/TemplateDescriptor" } }, - "cover": { - "type": "string" - }, - "description": { - "type": "string" - }, - "displayName": { - "minLength": 1, - "type": "string" - }, - "hideFromList": { - "type": "boolean" - }, - "postTemplate": { - "maxLength": 255, - "type": "string" - }, - "preventParentPostCascadeQuery": { - "type": "boolean" - }, - "priority": { - "type": "integer", - "format": "int32", - "default": 0 - }, - "slug": { - "minLength": 1, - "type": "string" - }, - "template": { - "maxLength": 255, - "type": "string" - } - } - }, - "CategoryStatus": { - "type": "object", - "properties": { - "permalink": { - "type": "string" - }, - "postCount": { - "type": "integer", - "format": "int32" + "page": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TemplateDescriptor" + } }, - "visiblePostCount": { - "type": "integer", - "format": "int32" + "post": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TemplateDescriptor" + } } } }, - "Comment": { + "Device": { "required": [ "apiVersion", "kind", "metadata", - "spec" + "spec", + "status" ], "type": "object", "properties": { @@ -9025,14 +10947,14 @@ "$ref": "#/components/schemas/Metadata" }, "spec": { - "$ref": "#/components/schemas/CommentSpec" + "$ref": "#/components/schemas/DeviceSpec" }, "status": { - "$ref": "#/components/schemas/CommentStatus" + "$ref": "#/components/schemas/DeviceStatus" } } }, - "CommentList": { + "DeviceList": { "required": [ "first", "hasNext", @@ -9062,7 +10984,7 @@ "type": "array", "description": "A chunk of items.", "items": { - "$ref": "#/components/schemas/Comment" + "$ref": "#/components/schemas/Device" } }, "last": { @@ -9091,191 +11013,175 @@ } } }, - "CommentOwner": { + "DeviceSpec": { "required": [ - "kind", - "name" + "ipAddress", + "principalName", + "sessionId" ], "type": "object", "properties": { - "annotations": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "displayName": { - "type": "string" - }, - "kind": { - "minLength": 1, - "type": "string" - }, - "name": { - "maxLength": 64, + "ipAddress": { + "maxLength": 129, "type": "string" - } - } - }, - "CommentSpec": { - "required": [ - "allowNotification", - "approved", - "content", - "hidden", - "owner", - "priority", - "raw", - "subjectRef", - "top" - ], - "type": "object", - "properties": { - "allowNotification": { - "type": "boolean", - "default": true - }, - "approved": { - "type": "boolean", - "default": false }, - "approvedTime": { + "lastAccessedTime": { "type": "string", "format": "date-time" }, - "content": { - "minLength": 1, - "type": "string" - }, - "creationTime": { + "lastAuthenticatedTime": { "type": "string", "format": "date-time" }, - "hidden": { - "type": "boolean", - "default": false - }, - "ipAddress": { + "principalName": { + "minLength": 1, "type": "string" }, - "lastReadTime": { - "type": "string", - "format": "date-time" - }, - "owner": { - "$ref": "#/components/schemas/CommentOwner" - }, - "priority": { - "type": "integer", - "format": "int32", - "default": 0 + "rememberMeSeriesId": { + "type": "string" }, - "raw": { + "sessionId": { "minLength": 1, "type": "string" }, - "subjectRef": { - "$ref": "#/components/schemas/Ref" - }, - "top": { + "userAgent": { + "maxLength": 500, + "type": "string" + } + } + }, + "DeviceStatus": { + "type": "object", + "properties": { + "browser": { + "type": "string" + }, + "os": { + "type": "string" + } + } + }, + "Excerpt": { + "required": [ + "autoGenerate" + ], + "type": "object", + "properties": { + "autoGenerate": { "type": "boolean", - "default": false + "default": true }, - "userAgent": { + "raw": { "type": "string" } } }, - "CommentStatus": { + "ExtensionDefinition": { + "required": [ + "apiVersion", + "kind", + "metadata", + "spec" + ], "type": "object", "properties": { - "hasNewReply": { - "type": "boolean" - }, - "lastReplyTime": { - "type": "string", - "format": "date-time" - }, - "observedVersion": { - "type": "integer", - "format": "int64" + "apiVersion": { + "type": "string" }, - "replyCount": { - "type": "integer", - "format": "int32" + "kind": { + "type": "string" }, - "unreadReplyCount": { - "type": "integer", - "format": "int32" + "metadata": { + "$ref": "#/components/schemas/Metadata" }, - "visibleReplyCount": { - "type": "integer", - "format": "int32" + "spec": { + "$ref": "#/components/schemas/ExtensionSpec" } } }, - "Condition": { + "ExtensionDefinitionList": { "required": [ - "lastTransitionTime", - "status", - "type" + "first", + "hasNext", + "hasPrevious", + "items", + "last", + "page", + "size", + "total", + "totalPages" ], "type": "object", "properties": { - "lastTransitionTime": { - "type": "string", - "format": "date-time" + "first": { + "type": "boolean", + "description": "Indicates whether current page is the first page." }, - "message": { - "maxLength": 32768, - "type": "string" + "hasNext": { + "type": "boolean", + "description": "Indicates whether current page has previous page." }, - "reason": { - "maxLength": 1024, - "pattern": "^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$", - "type": "string" + "hasPrevious": { + "type": "boolean", + "description": "Indicates whether current page has previous page." }, - "status": { - "type": "string", - "enum": [ - "TRUE", - "FALSE", - "UNKNOWN" - ] + "items": { + "type": "array", + "description": "A chunk of items.", + "items": { + "$ref": "#/components/schemas/ExtensionDefinition" + } }, - "type": { - "maxLength": 316, - "pattern": "^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$", - "type": "string" + "last": { + "type": "boolean", + "description": "Indicates whether current page is the last page." + }, + "page": { + "type": "integer", + "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", + "format": "int32" + }, + "size": { + "type": "integer", + "description": "Size of each page. If not set or equal to 0, it means no pagination.", + "format": "int32" + }, + "total": { + "type": "integer", + "description": "Total elements.", + "format": "int64" + }, + "totalPages": { + "type": "integer", + "description": "Indicates total pages.", + "format": "int64" } } }, - "ConfigMap": { + "ExtensionPointDefinition": { "required": [ "apiVersion", "kind", - "metadata" + "metadata", + "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, - "data": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" + }, + "spec": { + "$ref": "#/components/schemas/ExtensionPointSpec" } } }, - "ConfigMapList": { + "ExtensionPointDefinitionList": { "required": [ "first", "hasNext", @@ -9305,7 +11211,7 @@ "type": "array", "description": "A chunk of items.", "items": { - "$ref": "#/components/schemas/ConfigMap" + "$ref": "#/components/schemas/ExtensionPointDefinition" } }, "last": { @@ -9334,86 +11240,109 @@ } } }, - "ConfigMapRef": { + "ExtensionPointSpec": { "required": [ - "name" + "className", + "displayName", + "type" ], "type": "object", "properties": { - "name": { - "minLength": 1, + "className": { + "type": "string" + }, + "description": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "icon": { "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "SINGLETON", + "MULTI_INSTANCE" + ] } } }, - "CopyOperation": { + "ExtensionSpec": { "required": [ - "op", - "from", - "path" + "className", + "displayName", + "extensionPointName" ], "type": "object", "properties": { - "from": { - "pattern": "^(/[^/~]*(~[01][^/~]*)*)*$", - "type": "string", - "description": "A JSON Pointer path pointing to the location to move/copy from.", - "example": "/a/b/c" + "className": { + "type": "string" }, - "op": { - "type": "string", - "enum": [ - "copy" - ] + "description": { + "type": "string" }, - "path": { - "pattern": "^(/[^/~]*(~[01][^/~]*)*)*$", - "type": "string", - "description": "A JSON Pointer path pointing to the location to move/copy from.", - "example": "/a/b/c" + "displayName": { + "type": "string" + }, + "extensionPointName": { + "type": "string" + }, + "icon": { + "type": "string" } } }, - "Counter": { + "FileReverseProxyProvider": { + "type": "object", + "properties": { + "directory": { + "type": "string" + }, + "filename": { + "type": "string" + } + } + }, + "Group": { "required": [ "apiVersion", "kind", - "metadata" + "metadata", + "spec" ], "type": "object", "properties": { "apiVersion": { "type": "string" }, - "approvedComment": { - "type": "integer", - "format": "int32" - }, - "downvote": { - "type": "integer", - "format": "int32" - }, "kind": { "type": "string" }, "metadata": { "$ref": "#/components/schemas/Metadata" }, - "totalComment": { - "type": "integer", - "format": "int32" + "spec": { + "$ref": "#/components/schemas/GroupSpec" }, - "upvote": { - "type": "integer", - "format": "int32" + "status": { + "$ref": "#/components/schemas/GroupStatus" + } + } + }, + "GroupKind": { + "type": "object", + "properties": { + "group": { + "type": "string" }, - "visit": { - "type": "integer", - "format": "int32" + "kind": { + "type": "string" } } }, - "CounterList": { + "GroupList": { "required": [ "first", "hasNext", @@ -9443,7 +11372,7 @@ "type": "array", "description": "A chunk of items.", "items": { - "$ref": "#/components/schemas/Counter" + "$ref": "#/components/schemas/Group" } }, "last": { @@ -9472,36 +11401,122 @@ } } }, - "CustomTemplates": { + "GroupSpec": { + "required": [ + "displayName" + ], "type": "object", "properties": { - "category": { - "type": "array", - "items": { - "$ref": "#/components/schemas/TemplateDescriptor" - } + "displayName": { + "type": "string", + "description": "Display name of group" + } + } + }, + "GroupStatus": { + "type": "object", + "properties": { + "totalAttachments": { + "minimum": 0, + "type": "integer", + "description": "Total of attachments under the current group", + "format": "int64" }, - "page": { - "type": "array", - "items": { - "$ref": "#/components/schemas/TemplateDescriptor" - } + "updateTimestamp": { + "type": "string", + "description": "Update timestamp of the group", + "format": "date-time" + } + } + }, + "InterestReason": { + "required": [ + "reasonType", + "subject" + ], + "type": "object", + "properties": { + "expression": { + "type": "string", + "description": "The expression to be interested in" }, - "post": { - "type": "array", - "items": { - "$ref": "#/components/schemas/TemplateDescriptor" + "reasonType": { + "type": "string", + "description": "The name of the reason definition to be interested in" + }, + "subject": { + "$ref": "#/components/schemas/InterestReasonSubject" + } + }, + "description": "The reason to be interested in" + }, + "InterestReasonSubject": { + "required": [ + "apiVersion", + "kind" + ], + "type": "object", + "properties": { + "apiVersion": { + "minLength": 1, + "type": "string" + }, + "kind": { + "minLength": 1, + "type": "string" + }, + "name": { + "type": "string", + "description": "if name is not specified, it presents all subjects of the specified reason type and custom resources" + } + }, + "description": "The subject name of reason type to be interested in" + }, + "JsonPatch": { + "minItems": 1, + "uniqueItems": true, + "type": "array", + "description": "JSON schema for JSONPatch operations", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/AddOperation" + }, + { + "$ref": "#/components/schemas/ReplaceOperation" + }, + { + "$ref": "#/components/schemas/TestOperation" + }, + { + "$ref": "#/components/schemas/RemoveOperation" + }, + { + "$ref": "#/components/schemas/MoveOperation" + }, + { + "$ref": "#/components/schemas/CopyOperation" } + ] + } + }, + "License": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "url": { + "type": "string" } } }, - "Device": { + "LocalThumbnail": { "required": [ "apiVersion", "kind", "metadata", - "spec", - "status" + "spec" ], "type": "object", "properties": { @@ -9515,14 +11530,14 @@ "$ref": "#/components/schemas/Metadata" }, "spec": { - "$ref": "#/components/schemas/DeviceSpec" + "$ref": "#/components/schemas/LocalThumbnailSpec" }, "status": { - "$ref": "#/components/schemas/DeviceStatus" + "$ref": "#/components/schemas/LocalThumbnailStatus" } } }, - "DeviceList": { + "LocalThumbnailList": { "required": [ "first", "hasNext", @@ -9552,7 +11567,7 @@ "type": "array", "description": "A chunk of items.", "items": { - "$ref": "#/components/schemas/Device" + "$ref": "#/components/schemas/LocalThumbnail" } }, "last": { @@ -9581,70 +11596,88 @@ } } }, - "DeviceSpec": { + "LocalThumbnailSpec": { "required": [ - "ipAddress", - "principalName", - "sessionId" + "filePath", + "imageSignature", + "imageUri", + "size", + "thumbSignature", + "thumbnailUri" ], "type": "object", "properties": { - "ipAddress": { - "maxLength": 129, + "filePath": { "type": "string" }, - "lastAccessedTime": { - "type": "string", - "format": "date-time" - }, - "lastAuthenticatedTime": { - "type": "string", - "format": "date-time" - }, - "principalName": { + "imageSignature": { "minLength": 1, "type": "string" }, - "rememberMeSeriesId": { + "imageUri": { + "minLength": 1, "type": "string" }, - "sessionId": { + "size": { + "type": "string", + "enum": [ + "S", + "M", + "L", + "XL" + ] + }, + "thumbSignature": { "minLength": 1, "type": "string" }, - "userAgent": { - "maxLength": 500, + "thumbnailUri": { + "minLength": 1, "type": "string" } } }, - "DeviceStatus": { + "LocalThumbnailStatus": { "type": "object", "properties": { - "browser": { - "type": "string" - }, - "os": { - "type": "string" + "phase": { + "type": "string", + "enum": [ + "PENDING", + "SUCCEEDED", + "FAILED" + ] } } }, - "Excerpt": { + "LoginHistory": { "required": [ - "autoGenerate" + "loginAt", + "sourceIp", + "successful", + "userAgent" ], "type": "object", "properties": { - "autoGenerate": { - "type": "boolean", - "default": true + "loginAt": { + "type": "string", + "format": "date-time" }, - "raw": { + "reason": { + "type": "string" + }, + "sourceIp": { + "type": "string" + }, + "successful": { + "type": "boolean" + }, + "userAgent": { "type": "string" } } }, - "ExtensionDefinition": { + "Menu": { "required": [ "apiVersion", "kind", @@ -9657,76 +11690,17 @@ "type": "string" }, "kind": { - "type": "string" - }, - "metadata": { - "$ref": "#/components/schemas/Metadata" - }, - "spec": { - "$ref": "#/components/schemas/ExtensionSpec" - } - } - }, - "ExtensionDefinitionList": { - "required": [ - "first", - "hasNext", - "hasPrevious", - "items", - "last", - "page", - "size", - "total", - "totalPages" - ], - "type": "object", - "properties": { - "first": { - "type": "boolean", - "description": "Indicates whether current page is the first page." - }, - "hasNext": { - "type": "boolean", - "description": "Indicates whether current page has previous page." - }, - "hasPrevious": { - "type": "boolean", - "description": "Indicates whether current page has previous page." - }, - "items": { - "type": "array", - "description": "A chunk of items.", - "items": { - "$ref": "#/components/schemas/ExtensionDefinition" - } - }, - "last": { - "type": "boolean", - "description": "Indicates whether current page is the last page." - }, - "page": { - "type": "integer", - "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", - "format": "int32" - }, - "size": { - "type": "integer", - "description": "Size of each page. If not set or equal to 0, it means no pagination.", - "format": "int32" + "type": "string" }, - "total": { - "type": "integer", - "description": "Total elements.", - "format": "int64" + "metadata": { + "$ref": "#/components/schemas/Metadata" }, - "totalPages": { - "type": "integer", - "description": "Indicates total pages.", - "format": "int64" + "spec": { + "$ref": "#/components/schemas/MenuSpec" } } }, - "ExtensionPointDefinition": { + "MenuItem": { "required": [ "apiVersion", "kind", @@ -9745,11 +11719,14 @@ "$ref": "#/components/schemas/Metadata" }, "spec": { - "$ref": "#/components/schemas/ExtensionPointSpec" + "$ref": "#/components/schemas/MenuItemSpec" + }, + "status": { + "$ref": "#/components/schemas/MenuItemStatus" } } }, - "ExtensionPointDefinitionList": { + "MenuItemList": { "required": [ "first", "hasNext", @@ -9779,7 +11756,7 @@ "type": "array", "description": "A chunk of items.", "items": { - "$ref": "#/components/schemas/ExtensionPointDefinition" + "$ref": "#/components/schemas/MenuItem" } }, "last": { @@ -9808,109 +11785,62 @@ } } }, - "ExtensionPointSpec": { - "required": [ - "className", - "displayName", - "type" - ], + "MenuItemSpec": { "type": "object", "properties": { - "className": { - "type": "string" - }, - "description": { - "type": "string" + "children": { + "uniqueItems": true, + "type": "array", + "description": "Children of this menu item", + "items": { + "type": "string", + "description": "The name of menu item child" + } }, "displayName": { - "type": "string" + "type": "string", + "description": "The display name of menu item." }, - "icon": { - "type": "string" + "href": { + "type": "string", + "description": "The href of this menu item." }, - "type": { + "priority": { + "type": "integer", + "description": "The priority is for ordering.", + "format": "int32" + }, + "target": { "type": "string", + "description": "The \u003ca\u003e target attribute of this menu item.", "enum": [ - "SINGLETON", - "MULTI_INSTANCE" + "_blank", + "_self", + "_parent", + "_top" ] - } - } - }, - "ExtensionSpec": { - "required": [ - "className", - "displayName", - "extensionPointName" - ], - "type": "object", - "properties": { - "className": { - "type": "string" - }, - "description": { - "type": "string" - }, - "displayName": { - "type": "string" - }, - "extensionPointName": { - "type": "string" - }, - "icon": { - "type": "string" - } - } - }, - "FileReverseProxyProvider": { - "type": "object", - "properties": { - "directory": { - "type": "string" - }, - "filename": { - "type": "string" - } - } - }, - "Group": { - "required": [ - "apiVersion", - "kind", - "metadata", - "spec" - ], - "type": "object", - "properties": { - "apiVersion": { - "type": "string" - }, - "kind": { - "type": "string" - }, - "metadata": { - "$ref": "#/components/schemas/Metadata" - }, - "spec": { - "$ref": "#/components/schemas/GroupSpec" }, - "status": { - "$ref": "#/components/schemas/GroupStatus" + "targetRef": { + "$ref": "#/components/schemas/Ref" } - } + }, + "description": "The spec of menu item." }, - "GroupKind": { + "MenuItemStatus": { "type": "object", "properties": { - "group": { - "type": "string" + "displayName": { + "type": "string", + "description": "Calculated Display name of menu item." }, - "kind": { - "type": "string" + "href": { + "type": "string", + "description": "Calculated href of manu item." } - } + }, + "description": "The status of menu item." }, - "GroupList": { + "MenuList": { "required": [ "first", "hasNext", @@ -9940,7 +11870,7 @@ "type": "array", "description": "A chunk of items.", "items": { - "$ref": "#/components/schemas/Group" + "$ref": "#/components/schemas/Menu" } }, "last": { @@ -9969,7 +11899,7 @@ } } }, - "GroupSpec": { + "MenuSpec": { "required": [ "displayName" ], @@ -9977,71 +11907,105 @@ "properties": { "displayName": { "type": "string", - "description": "Display name of group" + "description": "The display name of the menu." + }, + "menuItems": { + "uniqueItems": true, + "type": "array", + "description": "Names of menu children below this menu.", + "items": { + "type": "string", + "description": "Names of menu children below this menu." + } } - } + }, + "description": "The spec of menu." }, - "GroupStatus": { + "Metadata": { + "required": [ + "name" + ], "type": "object", "properties": { - "totalAttachments": { - "minimum": 0, - "type": "integer", - "description": "Total of attachments under the current group", - "format": "int64" + "annotations": { + "type": "object", + "additionalProperties": { + "type": "string" + } }, - "updateTimestamp": { + "creationTimestamp": { "type": "string", - "description": "Update timestamp of the group", - "format": "date-time" - } - } - }, - "JsonPatch": { - "minItems": 1, - "uniqueItems": true, - "type": "array", - "description": "JSON schema for JSONPatch operations", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/AddOperation" - }, - { - "$ref": "#/components/schemas/ReplaceOperation" - }, - { - "$ref": "#/components/schemas/TestOperation" - }, - { - "$ref": "#/components/schemas/RemoveOperation" - }, - { - "$ref": "#/components/schemas/MoveOperation" - }, - { - "$ref": "#/components/schemas/CopyOperation" + "format": "date-time", + "nullable": true + }, + "deletionTimestamp": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "finalizers": { + "uniqueItems": true, + "type": "array", + "nullable": true, + "items": { + "type": "string", + "nullable": true } - ] + }, + "generateName": { + "type": "string", + "description": "The name field will be generated automatically according to the given generateName field" + }, + "labels": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "name": { + "type": "string", + "description": "Metadata name" + }, + "version": { + "type": "integer", + "format": "int64", + "nullable": true + } } }, - "License": { + "MoveOperation": { + "required": [ + "op", + "from", + "path" + ], "type": "object", "properties": { - "name": { - "type": "string" + "from": { + "pattern": "^(/[^/~]*(~[01][^/~]*)*)*$", + "type": "string", + "description": "A JSON Pointer path pointing to the location to move/copy from.", + "example": "/a/b/c" }, - "url": { - "type": "string" + "op": { + "type": "string", + "enum": [ + "move" + ] + }, + "path": { + "pattern": "^(/[^/~]*(~[01][^/~]*)*)*$", + "type": "string", + "description": "A JSON Pointer path pointing to the location to move/copy from.", + "example": "/a/b/c" } } }, - "LocalThumbnail": { + "Notification": { "required": [ "apiVersion", "kind", - "metadata", - "spec" + "metadata" ], "type": "object", "properties": { @@ -10055,14 +12019,11 @@ "$ref": "#/components/schemas/Metadata" }, "spec": { - "$ref": "#/components/schemas/LocalThumbnailSpec" - }, - "status": { - "$ref": "#/components/schemas/LocalThumbnailStatus" + "$ref": "#/components/schemas/NotificationSpec" } } }, - "LocalThumbnailList": { + "NotificationList": { "required": [ "first", "hasNext", @@ -10092,7 +12053,7 @@ "type": "array", "description": "A chunk of items.", "items": { - "$ref": "#/components/schemas/LocalThumbnail" + "$ref": "#/components/schemas/Notification" } }, "last": { @@ -10121,116 +12082,50 @@ } } }, - "LocalThumbnailSpec": { + "NotificationSpec": { "required": [ - "filePath", - "imageSignature", - "imageUri", - "size", - "thumbSignature", - "thumbnailUri" + "htmlContent", + "rawContent", + "reason", + "recipient", + "title" ], "type": "object", "properties": { - "filePath": { - "type": "string" - }, - "imageSignature": { - "minLength": 1, - "type": "string" - }, - "imageUri": { - "minLength": 1, + "htmlContent": { "type": "string" }, - "size": { + "lastReadAt": { "type": "string", - "enum": [ - "S", - "M", - "L", - "XL" - ] + "format": "date-time" }, - "thumbSignature": { - "minLength": 1, + "rawContent": { "type": "string" }, - "thumbnailUri": { + "reason": { "minLength": 1, - "type": "string" - } - } - }, - "LocalThumbnailStatus": { - "type": "object", - "properties": { - "phase": { - "type": "string", - "enum": [ - "PENDING", - "SUCCEEDED", - "FAILED" - ] - } - } - }, - "LoginHistory": { - "required": [ - "loginAt", - "sourceIp", - "successful", - "userAgent" - ], - "type": "object", - "properties": { - "loginAt": { "type": "string", - "format": "date-time" + "description": "The name of reason" }, - "reason": { - "type": "string" + "recipient": { + "minLength": 1, + "type": "string", + "description": "The name of user" }, - "sourceIp": { + "title": { + "minLength": 1, "type": "string" }, - "successful": { + "unread": { "type": "boolean" - }, - "userAgent": { - "type": "string" - } - } - }, - "Menu": { - "required": [ - "apiVersion", - "kind", - "metadata", - "spec" - ], - "type": "object", - "properties": { - "apiVersion": { - "type": "string" - }, - "kind": { - "type": "string" - }, - "metadata": { - "$ref": "#/components/schemas/Metadata" - }, - "spec": { - "$ref": "#/components/schemas/MenuSpec" } } }, - "MenuItem": { + "NotificationTemplate": { "required": [ "apiVersion", "kind", - "metadata", - "spec" + "metadata" ], "type": "object", "properties": { @@ -10244,14 +12139,11 @@ "$ref": "#/components/schemas/Metadata" }, "spec": { - "$ref": "#/components/schemas/MenuItemSpec" - }, - "status": { - "$ref": "#/components/schemas/MenuItemStatus" + "$ref": "#/components/schemas/NotificationTemplateSpec" } } }, - "MenuItemList": { + "NotificationTemplateList": { "required": [ "first", "hasNext", @@ -10281,7 +12173,7 @@ "type": "array", "description": "A chunk of items.", "items": { - "$ref": "#/components/schemas/MenuItem" + "$ref": "#/components/schemas/NotificationTemplate" } }, "last": { @@ -10310,62 +12202,40 @@ } } }, - "MenuItemSpec": { + "NotificationTemplateSpec": { "type": "object", "properties": { - "children": { - "uniqueItems": true, - "type": "array", - "description": "Children of this menu item", - "items": { - "type": "string", - "description": "The name of menu item child" - } - }, - "displayName": { - "type": "string", - "description": "The display name of menu item." - }, - "href": { - "type": "string", - "description": "The href of this menu item." - }, - "priority": { - "type": "integer", - "description": "The priority is for ordering.", - "format": "int32" - }, - "target": { - "type": "string", - "description": "The \u003ca\u003e target attribute of this menu item.", - "enum": [ - "_blank", - "_self", - "_parent", - "_top" - ] + "reasonSelector": { + "$ref": "#/components/schemas/ReasonSelector" }, - "targetRef": { - "$ref": "#/components/schemas/Ref" + "template": { + "$ref": "#/components/schemas/TemplateContent" } - }, - "description": "The spec of menu item." + } }, - "MenuItemStatus": { + "NotifierDescriptor": { + "required": [ + "apiVersion", + "kind", + "metadata" + ], "type": "object", "properties": { - "displayName": { - "type": "string", - "description": "Calculated Display name of menu item." + "apiVersion": { + "type": "string" }, - "href": { - "type": "string", - "description": "Calculated href of manu item." + "kind": { + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/Metadata" + }, + "spec": { + "$ref": "#/components/schemas/NotifierDescriptorSpec" } - }, - "description": "The status of menu item." + } }, - "MenuList": { + "NotifierDescriptorList": { "required": [ "first", "hasNext", @@ -10395,7 +12265,7 @@ "type": "array", "description": "A chunk of items.", "items": { - "$ref": "#/components/schemas/Menu" + "$ref": "#/components/schemas/NotifierDescriptor" } }, "last": { @@ -10424,105 +12294,44 @@ } } }, - "MenuSpec": { - "required": [ - "displayName" - ], - "type": "object", - "properties": { - "displayName": { - "type": "string", - "description": "The display name of the menu." - }, - "menuItems": { - "uniqueItems": true, - "type": "array", - "description": "Names of menu children below this menu.", - "items": { - "type": "string", - "description": "Names of menu children below this menu." - } - } - }, - "description": "The spec of menu." - }, - "Metadata": { + "NotifierDescriptorSpec": { "required": [ - "name" + "displayName", + "notifierExtName" ], "type": "object", "properties": { - "annotations": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "creationTimestamp": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "deletionTimestamp": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "finalizers": { - "uniqueItems": true, - "type": "array", - "nullable": true, - "items": { - "type": "string", - "nullable": true - } + "description": { + "type": "string" }, - "generateName": { - "type": "string", - "description": "The name field will be generated automatically according to the given generateName field" + "displayName": { + "minLength": 1, + "type": "string" }, - "labels": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "notifierExtName": { + "minLength": 1, + "type": "string" }, - "name": { - "type": "string", - "description": "Metadata name" + "receiverSettingRef": { + "$ref": "#/components/schemas/NotifierSettingRef" }, - "version": { - "type": "integer", - "format": "int64", - "nullable": true + "senderSettingRef": { + "$ref": "#/components/schemas/NotifierSettingRef" } } }, - "MoveOperation": { + "NotifierSettingRef": { "required": [ - "op", - "from", - "path" + "group", + "name" ], "type": "object", "properties": { - "from": { - "pattern": "^(/[^/~]*(~[01][^/~]*)*)*$", - "type": "string", - "description": "A JSON Pointer path pointing to the location to move/copy from.", - "example": "/a/b/c" - }, - "op": { - "type": "string", - "enum": [ - "move" - ] + "group": { + "type": "string" }, - "path": { - "pattern": "^(/[^/~]*(~[01][^/~]*)*)*$", - "type": "string", - "description": "A JSON Pointer path pointing to the location to move/copy from.", - "example": "/a/b/c" + "name": { + "type": "string" } } }, @@ -11268,83 +13077,364 @@ "releaseSnapshot": { "type": "string" }, - "slug": { - "minLength": 1, + "slug": { + "minLength": 1, + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "template": { + "type": "string" + }, + "title": { + "minLength": 1, + "type": "string" + }, + "visible": { + "type": "string", + "default": "PUBLIC", + "enum": [ + "PUBLIC", + "INTERNAL", + "PRIVATE" + ] + } + } + }, + "PostStatus": { + "type": "object", + "properties": { + "commentsCount": { + "type": "integer", + "format": "int32" + }, + "conditions": { + "type": "array", + "properties": { + "empty": { + "type": "boolean" + } + }, + "items": { + "$ref": "#/components/schemas/Condition" + } + }, + "contributors": { + "type": "array", + "items": { + "type": "string" + } + }, + "excerpt": { + "type": "string" + }, + "hideFromList": { + "type": "boolean" + }, + "inProgress": { + "type": "boolean" + }, + "lastModifyTime": { + "type": "string", + "format": "date-time" + }, + "observedVersion": { + "type": "integer", + "format": "int64" + }, + "permalink": { + "type": "string" + }, + "phase": { + "type": "string" + } + } + }, + "Reason": { + "required": [ + "apiVersion", + "kind", + "metadata" + ], + "type": "object", + "properties": { + "apiVersion": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/Metadata" + }, + "spec": { + "$ref": "#/components/schemas/ReasonSpec" + } + } + }, + "ReasonAttributes": { + "type": "object", + "properties": { + "empty": { + "type": "boolean" + } + }, + "description": "Attributes used to transfer data" + }, + "ReasonList": { + "required": [ + "first", + "hasNext", + "hasPrevious", + "items", + "last", + "page", + "size", + "total", + "totalPages" + ], + "type": "object", + "properties": { + "first": { + "type": "boolean", + "description": "Indicates whether current page is the first page." + }, + "hasNext": { + "type": "boolean", + "description": "Indicates whether current page has previous page." + }, + "hasPrevious": { + "type": "boolean", + "description": "Indicates whether current page has previous page." + }, + "items": { + "type": "array", + "description": "A chunk of items.", + "items": { + "$ref": "#/components/schemas/Reason" + } + }, + "last": { + "type": "boolean", + "description": "Indicates whether current page is the last page." + }, + "page": { + "type": "integer", + "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", + "format": "int32" + }, + "size": { + "type": "integer", + "description": "Size of each page. If not set or equal to 0, it means no pagination.", + "format": "int32" + }, + "total": { + "type": "integer", + "description": "Total elements.", + "format": "int64" + }, + "totalPages": { + "type": "integer", + "description": "Indicates total pages.", + "format": "int64" + } + } + }, + "ReasonProperty": { + "required": [ + "name", + "type" + ], + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "name": { + "minLength": 1, + "type": "string" + }, + "optional": { + "type": "boolean", + "default": false + }, + "type": { + "minLength": 1, + "type": "string" + } + } + }, + "ReasonSelector": { + "required": [ + "language", + "reasonType" + ], + "type": "object", + "properties": { + "language": { + "minLength": 1, + "type": "string", + "default": "default" + }, + "reasonType": { + "minLength": 1, + "type": "string" + } + } + }, + "ReasonSpec": { + "required": [ + "author", + "reasonType", + "subject" + ], + "type": "object", + "properties": { + "attributes": { + "$ref": "#/components/schemas/ReasonAttributes" + }, + "author": { + "type": "string" + }, + "reasonType": { + "type": "string" + }, + "subject": { + "$ref": "#/components/schemas/ReasonSubject" + } + } + }, + "ReasonSubject": { + "required": [ + "apiVersion", + "kind", + "name", + "title" + ], + "type": "object", + "properties": { + "apiVersion": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "name": { "type": "string" }, - "tags": { - "type": "array", - "items": { - "type": "string" - } + "title": { + "type": "string" }, - "template": { + "url": { + "type": "string" + } + } + }, + "ReasonType": { + "required": [ + "apiVersion", + "kind", + "metadata" + ], + "type": "object", + "properties": { + "apiVersion": { "type": "string" }, - "title": { - "minLength": 1, + "kind": { "type": "string" }, - "visible": { - "type": "string", - "default": "PUBLIC", - "enum": [ - "PUBLIC", - "INTERNAL", - "PRIVATE" - ] + "metadata": { + "$ref": "#/components/schemas/Metadata" + }, + "spec": { + "$ref": "#/components/schemas/ReasonTypeSpec" } } }, - "PostStatus": { + "ReasonTypeList": { "required": [ - "phase" + "first", + "hasNext", + "hasPrevious", + "items", + "last", + "page", + "size", + "total", + "totalPages" ], "type": "object", "properties": { - "commentsCount": { - "type": "integer", - "format": "int32" + "first": { + "type": "boolean", + "description": "Indicates whether current page is the first page." }, - "conditions": { - "type": "array", - "properties": { - "empty": { - "type": "boolean" - } - }, - "items": { - "$ref": "#/components/schemas/Condition" - } + "hasNext": { + "type": "boolean", + "description": "Indicates whether current page has previous page." }, - "contributors": { + "hasPrevious": { + "type": "boolean", + "description": "Indicates whether current page has previous page." + }, + "items": { "type": "array", + "description": "A chunk of items.", "items": { - "type": "string" + "$ref": "#/components/schemas/ReasonType" } }, - "excerpt": { - "type": "string" - }, - "hideFromList": { - "type": "boolean" + "last": { + "type": "boolean", + "description": "Indicates whether current page is the last page." }, - "inProgress": { - "type": "boolean" + "page": { + "type": "integer", + "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", + "format": "int32" }, - "lastModifyTime": { - "type": "string", - "format": "date-time" + "size": { + "type": "integer", + "description": "Size of each page. If not set or equal to 0, it means no pagination.", + "format": "int32" }, - "observedVersion": { + "total": { "type": "integer", + "description": "Total elements.", "format": "int64" }, - "permalink": { + "totalPages": { + "type": "integer", + "description": "Indicates total pages.", + "format": "int64" + } + } + }, + "ReasonTypeSpec": { + "required": [ + "description", + "displayName" + ], + "type": "object", + "properties": { + "description": { + "minLength": 1, "type": "string" }, - "phase": { + "displayName": { + "minLength": 1, "type": "string" + }, + "properties": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReasonProperty" + } } } }, @@ -12484,9 +14574,6 @@ } }, "SinglePageStatus": { - "required": [ - "phase" - ], "type": "object", "properties": { "commentsCount": { @@ -12673,6 +14760,124 @@ } } }, + "Subscription": { + "required": [ + "apiVersion", + "kind", + "metadata" + ], + "type": "object", + "properties": { + "apiVersion": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/Metadata" + }, + "spec": { + "$ref": "#/components/schemas/SubscriptionSpec" + } + } + }, + "SubscriptionList": { + "required": [ + "first", + "hasNext", + "hasPrevious", + "items", + "last", + "page", + "size", + "total", + "totalPages" + ], + "type": "object", + "properties": { + "first": { + "type": "boolean", + "description": "Indicates whether current page is the first page." + }, + "hasNext": { + "type": "boolean", + "description": "Indicates whether current page has previous page." + }, + "hasPrevious": { + "type": "boolean", + "description": "Indicates whether current page has previous page." + }, + "items": { + "type": "array", + "description": "A chunk of items.", + "items": { + "$ref": "#/components/schemas/Subscription" + } + }, + "last": { + "type": "boolean", + "description": "Indicates whether current page is the last page." + }, + "page": { + "type": "integer", + "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", + "format": "int32" + }, + "size": { + "type": "integer", + "description": "Size of each page. If not set or equal to 0, it means no pagination.", + "format": "int32" + }, + "total": { + "type": "integer", + "description": "Total elements.", + "format": "int64" + }, + "totalPages": { + "type": "integer", + "description": "Indicates total pages.", + "format": "int64" + } + } + }, + "SubscriptionSpec": { + "required": [ + "reason", + "subscriber", + "unsubscribeToken" + ], + "type": "object", + "properties": { + "disabled": { + "type": "boolean", + "description": "Perhaps users need to unsubscribe and interact without receiving notifications again" + }, + "reason": { + "$ref": "#/components/schemas/InterestReason" + }, + "subscriber": { + "$ref": "#/components/schemas/SubscriptionSubscriber" + }, + "unsubscribeToken": { + "type": "string", + "description": "The token to unsubscribe" + } + } + }, + "SubscriptionSubscriber": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "name": { + "minLength": 1, + "type": "string" + } + }, + "description": "The subscriber to be notified" + }, "Tag": { "required": [ "apiVersion", @@ -12802,6 +15007,24 @@ } } }, + "TemplateContent": { + "required": [ + "title" + ], + "type": "object", + "properties": { + "htmlBody": { + "type": "string" + }, + "rawBody": { + "type": "string" + }, + "title": { + "minLength": 1, + "type": "string" + } + } + }, "TemplateDescriptor": { "required": [ "file", @@ -13248,36 +15471,15 @@ }, "UserConnectionSpec": { "required": [ - "accessToken", - "displayName", "providerUserId", "registrationId", "username" ], "type": "object", "properties": { - "accessToken": { - "type": "string" - }, - "avatarUrl": { - "type": "string" - }, - "displayName": { - "type": "string" - }, - "expiresAt": { - "type": "string", - "format": "date-time" - }, - "profileUrl": { - "type": "string" - }, "providerUserId": { "type": "string" }, - "refreshToken": { - "type": "string" - }, "registrationId": { "type": "string" }, diff --git a/api-docs/openapi/v3_0/apis_public.api_v1alpha1.json b/api-docs/openapi/v3_0/apis_public.api_v1alpha1.json index daa70428a1..672c4c35ce 100644 --- a/api-docs/openapi/v3_0/apis_public.api_v1alpha1.json +++ b/api-docs/openapi/v3_0/apis_public.api_v1alpha1.json @@ -17,10 +17,10 @@ } ], "paths": { - "/apis/api.halo.run/v1alpha1/comments": { + "/apis/api.content.halo.run/v1alpha1/categories": { "get": { - "description": "List comments.", - "operationId": "ListComments", + "description": "Lists categories.", + "operationId": "queryCategories", "parameters": [ { "description": "Page number. Default is 0.", @@ -41,9 +41,9 @@ } }, { - "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", + "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", - "name": "sort", + "name": "labelSelector", "schema": { "type": "array", "items": { @@ -52,55 +52,25 @@ } }, { - "description": "The comment subject group.", - "in": "query", - "name": "group", - "schema": { - "type": "string" - } - }, - { - "description": "The comment subject version.", - "in": "query", - "name": "version", - "required": true, - "schema": { - "type": "string" - } - }, - { - "description": "The comment subject kind.", - "in": "query", - "name": "kind", - "required": true, - "schema": { - "type": "string" - } - }, - { - "description": "The comment subject name.", - "in": "query", - "name": "name", - "required": true, - "schema": { - "type": "string" - } - }, - { - "description": "Whether to include replies. Default is false.", + "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", - "name": "withReplies", + "name": "fieldSelector", "schema": { - "type": "boolean" + "type": "array", + "items": { + "type": "string" + } } }, { - "description": "Reply size of the comment, default is 10, only works when withReplies is true.", + "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", "in": "query", - "name": "replySize", + "name": "sort", "schema": { - "type": "integer", - "format": "int32" + "type": "array", + "items": { + "type": "string" + } } } ], @@ -109,36 +79,7 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/CommentWithReplyVoList" - } - } - }, - "description": "default response" - } - }, - "tags": [ - "CommentV1alpha1Public" - ] - }, - "post": { - "description": "Create a comment.", - "operationId": "CreateComment", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CommentRequest" - } - } - }, - "required": true - }, - "responses": { - "default": { - "content": { - "*/*": { - "schema": { - "$ref": "#/components/schemas/Comment" + "$ref": "#/components/schemas/CategoryVoList" } } }, @@ -146,16 +87,17 @@ } }, "tags": [ - "CommentV1alpha1Public" + "CategoryV1alpha1Public" ] } }, - "/apis/api.halo.run/v1alpha1/comments/{name}": { + "/apis/api.content.halo.run/v1alpha1/categories/{name}": { "get": { - "description": "Get a comment.", - "operationId": "GetComment", + "description": "Gets category by name.", + "operationId": "queryCategoryByName", "parameters": [ { + "description": "Category name", "in": "path", "name": "name", "required": true, @@ -169,7 +111,7 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/CommentVoList" + "$ref": "#/components/schemas/CategoryVo" } } }, @@ -177,16 +119,17 @@ } }, "tags": [ - "CommentV1alpha1Public" + "CategoryV1alpha1Public" ] } }, - "/apis/api.halo.run/v1alpha1/comments/{name}/reply": { + "/apis/api.content.halo.run/v1alpha1/categories/{name}/posts": { "get": { - "description": "List comment replies.", - "operationId": "ListCommentReplies", + "description": "Lists posts by category name.", + "operationId": "queryPostsByCategoryName", "parameters": [ { + "description": "Category name", "in": "path", "name": "name", "required": true, @@ -211,84 +154,47 @@ "type": "integer", "format": "int32" } - } - ], - "responses": { - "default": { - "content": { - "*/*": { - "schema": { - "$ref": "#/components/schemas/ReplyVoList" - } - } - }, - "description": "default response" - } - }, - "tags": [ - "CommentV1alpha1Public" - ] - }, - "post": { - "description": "Create a reply.", - "operationId": "CreateReply", - "parameters": [ + }, { - "in": "path", - "name": "name", - "required": true, + "description": "Label selector. e.g.: hidden!\u003dtrue", + "in": "query", + "name": "labelSelector", "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReplyRequest" + "type": "array", + "items": { + "type": "string" } } }, - "required": true - }, - "responses": { - "default": { - "content": { - "*/*": { - "schema": { - "$ref": "#/components/schemas/Reply" - } - } - }, - "description": "default response" - } - }, - "tags": [ - "CommentV1alpha1Public" - ] - } - }, - "/apis/api.halo.run/v1alpha1/indices/-/search": { - "post": { - "description": "Search indices.", - "operationId": "IndicesSearch", - "requestBody": { - "content": { - "*/*": { - "schema": { - "$ref": "#/components/schemas/SearchOption" + { + "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", + "in": "query", + "name": "fieldSelector", + "schema": { + "type": "array", + "items": { + "type": "string" } } }, - "description": "Please note that the \"filterPublished\", \"filterExposed\" and \"filterRecycled\" fields are ignored in this endpoint." - }, + { + "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", + "in": "query", + "name": "sort", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + ], "responses": { "default": { "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/SearchResult" + "$ref": "#/components/schemas/ListedPostVoList" } } }, @@ -296,48 +202,64 @@ } }, "tags": [ - "IndexV1alpha1Public" + "CategoryV1alpha1Public" ] } }, - "/apis/api.halo.run/v1alpha1/indices/post": { + "/apis/api.content.halo.run/v1alpha1/posts": { "get": { - "deprecated": true, - "description": "Search posts with fuzzy query. This method is deprecated, please use POST /indices/-/search instead.", - "operationId": "SearchPost", + "description": "Lists posts.", + "operationId": "queryPosts", "parameters": [ { - "description": "Keyword to search", + "description": "Page number. Default is 0.", "in": "query", - "name": "keyword", - "required": true, + "name": "page", "schema": { - "type": "string" + "type": "integer", + "format": "int32" } }, { - "description": "Limit of search results", + "description": "Size number. Default is 0.", "in": "query", - "name": "limit", + "name": "size", "schema": { "type": "integer", "format": "int32" } }, { - "description": "Highlight pre tag", + "description": "Label selector. e.g.: hidden!\u003dtrue", "in": "query", - "name": "highlightPreTag", + "name": "labelSelector", "schema": { - "type": "string" + "type": "array", + "items": { + "type": "string" + } } }, { - "description": "Highlight post tag", + "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", "in": "query", - "name": "highlightPostTag", + "name": "fieldSelector", "schema": { - "type": "string" + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", + "in": "query", + "name": "sort", + "schema": { + "type": "array", + "items": { + "type": "string" + } } } ], @@ -346,7 +268,7 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/SearchResult" + "$ref": "#/components/schemas/ListedPostVoList" } } }, @@ -354,20 +276,31 @@ } }, "tags": [ - "IndexV1alpha1Public" + "PostV1alpha1Public" ] } }, - "/apis/api.halo.run/v1alpha1/menus/-": { + "/apis/api.content.halo.run/v1alpha1/posts/{name}": { "get": { - "description": "Gets primary menu.", - "operationId": "queryPrimaryMenu", + "description": "Gets a post by name.", + "operationId": "queryPostByName", + "parameters": [ + { + "description": "Post name", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "default": { "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/MenuVo" + "$ref": "#/components/schemas/PostVo" } } }, @@ -375,17 +308,17 @@ } }, "tags": [ - "MenuV1alpha1Public" + "PostV1alpha1Public" ] } }, - "/apis/api.halo.run/v1alpha1/menus/{name}": { + "/apis/api.content.halo.run/v1alpha1/posts/{name}/navigation": { "get": { - "description": "Gets menu by name.", - "operationId": "queryMenuByName", + "description": "Gets a post navigation by name.", + "operationId": "queryPostNavigationByName", "parameters": [ { - "description": "Menu name", + "description": "Post name", "in": "path", "name": "name", "required": true, @@ -399,7 +332,7 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/MenuVo" + "$ref": "#/components/schemas/NavigationPostVo" } } }, @@ -407,18 +340,707 @@ } }, "tags": [ - "MenuV1alpha1Public" + "PostV1alpha1Public" ] } }, - "/apis/api.halo.run/v1alpha1/stats/-": { + "/apis/api.content.halo.run/v1alpha1/singlepages": { "get": { - "description": "Gets site stats", - "operationId": "queryStats", - "responses": { - "default": { - "content": { - "*/*": { + "description": "Lists single pages", + "operationId": "querySinglePages", + "parameters": [ + { + "description": "Page number. Default is 0.", + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "description": "Size number. Default is 0.", + "in": "query", + "name": "size", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "description": "Label selector. e.g.: hidden!\u003dtrue", + "in": "query", + "name": "labelSelector", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", + "in": "query", + "name": "fieldSelector", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", + "in": "query", + "name": "sort", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + ], + "responses": { + "default": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ListedSinglePageVoList" + } + } + }, + "description": "default response" + } + }, + "tags": [ + "SinglePageV1alpha1Public" + ] + } + }, + "/apis/api.content.halo.run/v1alpha1/singlepages/{name}": { + "get": { + "description": "Gets single page by name", + "operationId": "querySinglePageByName", + "parameters": [ + { + "description": "SinglePage name", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "default": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/SinglePageVo" + } + } + }, + "description": "default response" + } + }, + "tags": [ + "SinglePageV1alpha1Public" + ] + } + }, + "/apis/api.content.halo.run/v1alpha1/tags": { + "get": { + "description": "Lists tags", + "operationId": "queryTags", + "parameters": [ + { + "description": "Page number. Default is 0.", + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "description": "Size number. Default is 0.", + "in": "query", + "name": "size", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "description": "Label selector. e.g.: hidden!\u003dtrue", + "in": "query", + "name": "labelSelector", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", + "in": "query", + "name": "fieldSelector", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", + "in": "query", + "name": "sort", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + ], + "responses": { + "default": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/TagVoList" + } + } + }, + "description": "default response" + } + }, + "tags": [ + "TagV1alpha1Public" + ] + } + }, + "/apis/api.content.halo.run/v1alpha1/tags/{name}": { + "get": { + "description": "Gets tag by name", + "operationId": "queryTagByName", + "parameters": [ + { + "description": "Tag name", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "default": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/TagVo" + } + } + }, + "description": "default response" + } + }, + "tags": [ + "TagV1alpha1Public" + ] + } + }, + "/apis/api.content.halo.run/v1alpha1/tags/{name}/posts": { + "get": { + "description": "Lists posts by tag name", + "operationId": "queryPostsByTagName", + "parameters": [ + { + "description": "Tag name", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Page number. Default is 0.", + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "description": "Size number. Default is 0.", + "in": "query", + "name": "size", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "description": "Label selector. e.g.: hidden!\u003dtrue", + "in": "query", + "name": "labelSelector", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", + "in": "query", + "name": "fieldSelector", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", + "in": "query", + "name": "sort", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + ], + "responses": { + "default": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ListedPostVo" + } + } + }, + "description": "default response" + } + }, + "tags": [ + "TagV1alpha1Public" + ] + } + }, + "/apis/api.halo.run/v1alpha1/comments": { + "get": { + "description": "List comments.", + "operationId": "ListComments", + "parameters": [ + { + "description": "Page number. Default is 0.", + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "description": "Size number. Default is 0.", + "in": "query", + "name": "size", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", + "in": "query", + "name": "sort", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "description": "The comment subject group.", + "in": "query", + "name": "group", + "schema": { + "type": "string" + } + }, + { + "description": "The comment subject version.", + "in": "query", + "name": "version", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "The comment subject kind.", + "in": "query", + "name": "kind", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "The comment subject name.", + "in": "query", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Whether to include replies. Default is false.", + "in": "query", + "name": "withReplies", + "schema": { + "type": "boolean" + } + }, + { + "description": "Reply size of the comment, default is 10, only works when withReplies is true.", + "in": "query", + "name": "replySize", + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "default": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/CommentWithReplyVoList" + } + } + }, + "description": "default response" + } + }, + "tags": [ + "CommentV1alpha1Public" + ] + }, + "post": { + "description": "Create a comment.", + "operationId": "CreateComment", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommentRequest" + } + } + }, + "required": true + }, + "responses": { + "default": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/Comment" + } + } + }, + "description": "default response" + } + }, + "tags": [ + "CommentV1alpha1Public" + ] + } + }, + "/apis/api.halo.run/v1alpha1/comments/{name}": { + "get": { + "description": "Get a comment.", + "operationId": "GetComment", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "default": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/CommentVoList" + } + } + }, + "description": "default response" + } + }, + "tags": [ + "CommentV1alpha1Public" + ] + } + }, + "/apis/api.halo.run/v1alpha1/comments/{name}/reply": { + "get": { + "description": "List comment replies.", + "operationId": "ListCommentReplies", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Page number. Default is 0.", + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "description": "Size number. Default is 0.", + "in": "query", + "name": "size", + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "default": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ReplyVoList" + } + } + }, + "description": "default response" + } + }, + "tags": [ + "CommentV1alpha1Public" + ] + }, + "post": { + "description": "Create a reply.", + "operationId": "CreateReply", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReplyRequest" + } + } + }, + "required": true + }, + "responses": { + "default": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/Reply" + } + } + }, + "description": "default response" + } + }, + "tags": [ + "CommentV1alpha1Public" + ] + } + }, + "/apis/api.halo.run/v1alpha1/indices/-/search": { + "post": { + "description": "Search indices.", + "operationId": "IndicesSearch", + "requestBody": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/SearchOption" + } + } + }, + "description": "Please note that the \"filterPublished\", \"filterExposed\" and \"filterRecycled\" fields are ignored in this endpoint." + }, + "responses": { + "default": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/SearchResult" + } + } + }, + "description": "default response" + } + }, + "tags": [ + "IndexV1alpha1Public" + ] + } + }, + "/apis/api.halo.run/v1alpha1/indices/post": { + "get": { + "deprecated": true, + "description": "Search posts with fuzzy query. This method is deprecated, please use POST /indices/-/search instead.", + "operationId": "SearchPost", + "parameters": [ + { + "description": "Keyword to search", + "in": "query", + "name": "keyword", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Limit of search results", + "in": "query", + "name": "limit", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "description": "Highlight pre tag", + "in": "query", + "name": "highlightPreTag", + "schema": { + "type": "string" + } + }, + { + "description": "Highlight post tag", + "in": "query", + "name": "highlightPostTag", + "schema": { + "type": "string" + } + } + ], + "responses": { + "default": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/SearchResult" + } + } + }, + "description": "default response" + } + }, + "tags": [ + "IndexV1alpha1Public" + ] + } + }, + "/apis/api.halo.run/v1alpha1/menus/-": { + "get": { + "description": "Gets primary menu.", + "operationId": "queryPrimaryMenu", + "responses": { + "default": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/MenuVo" + } + } + }, + "description": "default response" + } + }, + "tags": [ + "MenuV1alpha1Public" + ] + } + }, + "/apis/api.halo.run/v1alpha1/menus/{name}": { + "get": { + "description": "Gets menu by name.", + "operationId": "queryMenuByName", + "parameters": [ + { + "description": "Menu name", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "default": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/MenuVo" + } + } + }, + "description": "default response" + } + }, + "tags": [ + "MenuV1alpha1Public" + ] + } + }, + "/apis/api.halo.run/v1alpha1/stats/-": { + "get": { + "description": "Gets site stats", + "operationId": "queryStats", + "responses": { + "default": { + "content": { + "*/*": { "schema": { "$ref": "#/components/schemas/SiteStatsVo" } @@ -507,76 +1129,68 @@ ] } }, - "/apis/api.halo.run/v1alpha1/users/-/send-password-reset-email": { - "post": { - "description": "Send password reset email when forgot password", - "operationId": "SendPasswordResetEmail", - "requestBody": { - "content": { - "*/*": { - "schema": { - "$ref": "#/components/schemas/PasswordResetEmailRequest" - } + "/apis/api.notification.halo.run/v1alpha1/subscriptions/{name}/unsubscribe": { + "get": { + "description": "Unsubscribe a subscription", + "operationId": "Unsubscribe", + "parameters": [ + { + "description": "Subscription name", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" } }, - "required": true - }, - "responses": { - "204 NO_CONTENT": { - "content": {}, - "description": "default response" - } - }, - "tags": [ - "UserV1alpha1Public" - ] - } - }, - "/apis/api.halo.run/v1alpha1/users/-/send-register-verify-email": { - "post": { - "description": "Send registration verification email, which can be called when mustVerifyEmailOnRegistration in user settings is true", - "operationId": "SendRegisterVerifyEmail", - "requestBody": { - "content": { - "*/*": { - "schema": { - "$ref": "#/components/schemas/RegisterVerifyEmailRequest" - } + { + "description": "Unsubscribe token", + "in": "query", + "name": "token", + "required": true, + "schema": { + "type": "string" } - }, - "required": true - }, + } + ], "responses": { - "204 NO_CONTENT": { - "content": {}, + "default": { + "content": { + "*/*": { + "schema": { + "type": "string" + } + } + }, "description": "default response" } }, "tags": [ - "UserV1alpha1Public" + "NotificationV1alpha1Public" ] } }, - "/apis/api.halo.run/v1alpha1/users/-/signup": { - "post": { - "description": "Sign up a new user", - "operationId": "SignUp", - "requestBody": { - "content": { - "*/*": { - "schema": { - "$ref": "#/components/schemas/SignUpRequest" - } + "/apis/api.plugin.halo.run/v1alpha1/plugins/{name}/available": { + "get": { + "description": "Gets plugin available by name.", + "operationId": "queryPluginAvailableByName", + "parameters": [ + { + "description": "Plugin name", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" } - }, - "required": true - }, + } + ], "responses": { "default": { "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/User" + "type": "boolean" } } }, @@ -584,43 +1198,49 @@ } }, "tags": [ - "UserV1alpha1Public" + "PluginV1alpha1Public" ] } }, - "/apis/api.halo.run/v1alpha1/users/{name}/reset-password": { - "put": { - "description": "Reset password by token", - "operationId": "ResetPasswordByToken", + "/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri": { + "get": { + "description": "Get thumbnail by URI", + "operationId": "GetThumbnailByUri", "parameters": [ { - "description": "The name of the user", - "in": "path", - "name": "name", + "description": "The URI of the image", + "in": "query", + "name": "uri", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "The size of the thumbnail,available values are s,m,l,xl", + "in": "query", + "name": "size", "required": true, "schema": { "type": "string" } } ], - "requestBody": { - "content": { - "*/*": { - "schema": { - "$ref": "#/components/schemas/ResetPasswordRequest" - } - } - }, - "required": true - }, "responses": { - "204 NO_CONTENT": { - "content": {}, + "default": { + "content": { + "*/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, "description": "default response" } }, "tags": [ - "UserV1alpha1Public" + "ThumbnailV1alpha1Public" ] } } @@ -641,14 +1261,159 @@ "add" ] }, - "path": { - "pattern": "^(/[^/~]*(~[01][^/~]*)*)*$", - "type": "string", - "description": "A JSON Pointer path pointing to the location to move/copy from.", - "example": "/a/b/c" + "path": { + "pattern": "^(/[^/~]*(~[01][^/~]*)*)*$", + "type": "string", + "description": "A JSON Pointer path pointing to the location to move/copy from.", + "example": "/a/b/c" + }, + "value": { + "description": "Value can be any JSON value" + } + } + }, + "CategorySpec": { + "required": [ + "displayName", + "priority", + "slug" + ], + "type": "object", + "properties": { + "children": { + "type": "array", + "items": { + "type": "string" + } + }, + "cover": { + "type": "string" + }, + "description": { + "type": "string" + }, + "displayName": { + "minLength": 1, + "type": "string" + }, + "hideFromList": { + "type": "boolean" + }, + "postTemplate": { + "maxLength": 255, + "type": "string" + }, + "preventParentPostCascadeQuery": { + "type": "boolean" + }, + "priority": { + "type": "integer", + "format": "int32", + "default": 0 + }, + "slug": { + "minLength": 1, + "type": "string" + }, + "template": { + "maxLength": 255, + "type": "string" + } + } + }, + "CategoryStatus": { + "type": "object", + "properties": { + "permalink": { + "type": "string" + }, + "postCount": { + "type": "integer", + "format": "int32" + }, + "visiblePostCount": { + "type": "integer", + "format": "int32" + } + } + }, + "CategoryVo": { + "required": [ + "metadata" + ], + "type": "object", + "properties": { + "metadata": { + "$ref": "#/components/schemas/Metadata" + }, + "postCount": { + "type": "integer", + "format": "int32" + }, + "spec": { + "$ref": "#/components/schemas/CategorySpec" + }, + "status": { + "$ref": "#/components/schemas/CategoryStatus" + } + } + }, + "CategoryVoList": { + "required": [ + "first", + "hasNext", + "hasPrevious", + "items", + "last", + "page", + "size", + "total", + "totalPages" + ], + "type": "object", + "properties": { + "first": { + "type": "boolean", + "description": "Indicates whether current page is the first page." + }, + "hasNext": { + "type": "boolean", + "description": "Indicates whether current page has previous page." + }, + "hasPrevious": { + "type": "boolean", + "description": "Indicates whether current page has previous page." + }, + "items": { + "type": "array", + "description": "A chunk of items.", + "items": { + "$ref": "#/components/schemas/CategoryVo" + } + }, + "last": { + "type": "boolean", + "description": "Indicates whether current page is the last page." + }, + "page": { + "type": "integer", + "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", + "format": "int32" + }, + "size": { + "type": "integer", + "description": "Size of each page. If not set or equal to 0, it means no pagination.", + "format": "int32" + }, + "total": { + "type": "integer", + "description": "Total elements.", + "format": "int64" }, - "value": { - "description": "Value can be any JSON value" + "totalPages": { + "type": "integer", + "description": "Indicates total pages.", + "format": "int64" } } }, @@ -1030,6 +1795,79 @@ } } }, + "Condition": { + "required": [ + "lastTransitionTime", + "status", + "type" + ], + "type": "object", + "properties": { + "lastTransitionTime": { + "type": "string", + "format": "date-time" + }, + "message": { + "maxLength": 32768, + "type": "string" + }, + "reason": { + "maxLength": 1024, + "pattern": "^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$", + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "TRUE", + "FALSE", + "UNKNOWN" + ] + }, + "type": { + "maxLength": 316, + "pattern": "^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$", + "type": "string" + } + } + }, + "ContentVo": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "raw": { + "type": "string" + } + } + }, + "ContributorVo": { + "required": [ + "metadata" + ], + "type": "object", + "properties": { + "avatar": { + "type": "string" + }, + "bio": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/Metadata" + }, + "name": { + "type": "string" + }, + "permalink": { + "type": "string" + } + } + }, "CopyOperation": { "required": [ "op", @@ -1084,6 +1922,21 @@ } } }, + "Excerpt": { + "required": [ + "autoGenerate" + ], + "type": "object", + "properties": { + "autoGenerate": { + "type": "boolean", + "default": true + }, + "raw": { + "type": "string" + } + } + }, "HaloDocument": { "required": [ "content", @@ -1185,7 +2038,196 @@ ] } }, - "ListResultReplyVo": { + "ListResultReplyVo": { + "required": [ + "first", + "hasNext", + "hasPrevious", + "items", + "last", + "page", + "size", + "total", + "totalPages" + ], + "type": "object", + "properties": { + "first": { + "type": "boolean", + "description": "Indicates whether current page is the first page." + }, + "hasNext": { + "type": "boolean", + "description": "Indicates whether current page has previous page." + }, + "hasPrevious": { + "type": "boolean", + "description": "Indicates whether current page has previous page." + }, + "items": { + "type": "array", + "description": "A chunk of items.", + "items": { + "$ref": "#/components/schemas/ReplyVo" + } + }, + "last": { + "type": "boolean", + "description": "Indicates whether current page is the last page." + }, + "page": { + "type": "integer", + "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", + "format": "int32" + }, + "size": { + "type": "integer", + "description": "Size of each page. If not set or equal to 0, it means no pagination.", + "format": "int32" + }, + "total": { + "type": "integer", + "description": "Total elements.", + "format": "int64" + }, + "totalPages": { + "type": "integer", + "description": "Indicates total pages.", + "format": "int64" + } + } + }, + "ListedPostVo": { + "required": [ + "metadata" + ], + "type": "object", + "properties": { + "categories": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CategoryVo" + } + }, + "contributors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ContributorVo" + } + }, + "metadata": { + "$ref": "#/components/schemas/Metadata" + }, + "owner": { + "$ref": "#/components/schemas/ContributorVo" + }, + "spec": { + "$ref": "#/components/schemas/PostSpec" + }, + "stats": { + "$ref": "#/components/schemas/StatsVo" + }, + "status": { + "$ref": "#/components/schemas/PostStatus" + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TagVo" + } + } + } + }, + "ListedPostVoList": { + "required": [ + "first", + "hasNext", + "hasPrevious", + "items", + "last", + "page", + "size", + "total", + "totalPages" + ], + "type": "object", + "properties": { + "first": { + "type": "boolean", + "description": "Indicates whether current page is the first page." + }, + "hasNext": { + "type": "boolean", + "description": "Indicates whether current page has previous page." + }, + "hasPrevious": { + "type": "boolean", + "description": "Indicates whether current page has previous page." + }, + "items": { + "type": "array", + "description": "A chunk of items.", + "items": { + "$ref": "#/components/schemas/ListedPostVo" + } + }, + "last": { + "type": "boolean", + "description": "Indicates whether current page is the last page." + }, + "page": { + "type": "integer", + "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", + "format": "int32" + }, + "size": { + "type": "integer", + "description": "Size of each page. If not set or equal to 0, it means no pagination.", + "format": "int32" + }, + "total": { + "type": "integer", + "description": "Total elements.", + "format": "int64" + }, + "totalPages": { + "type": "integer", + "description": "Indicates total pages.", + "format": "int64" + } + } + }, + "ListedSinglePageVo": { + "required": [ + "metadata" + ], + "type": "object", + "properties": { + "contributors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ContributorVo" + } + }, + "metadata": { + "$ref": "#/components/schemas/Metadata" + }, + "owner": { + "$ref": "#/components/schemas/ContributorVo" + }, + "spec": { + "$ref": "#/components/schemas/SinglePageSpec" + }, + "stats": { + "$ref": "#/components/schemas/StatsVo" + }, + "status": { + "$ref": "#/components/schemas/SinglePageStatus" + } + }, + "description": "A chunk of items." + }, + "ListedSinglePageVoList": { "required": [ "first", "hasNext", @@ -1215,7 +2257,7 @@ "type": "array", "description": "A chunk of items.", "items": { - "$ref": "#/components/schemas/ReplyVo" + "$ref": "#/components/schemas/ListedSinglePageVo" } }, "last": { @@ -1244,33 +2286,6 @@ } } }, - "LoginHistory": { - "required": [ - "loginAt", - "sourceIp", - "successful", - "userAgent" - ], - "type": "object", - "properties": { - "loginAt": { - "type": "string", - "format": "date-time" - }, - "reason": { - "type": "string" - }, - "sourceIp": { - "type": "string" - }, - "successful": { - "type": "boolean" - }, - "userAgent": { - "type": "string" - } - } - }, "MenuItemSpec": { "type": "object", "properties": { @@ -1468,6 +2483,20 @@ } } }, + "NavigationPostVo": { + "type": "object", + "properties": { + "current": { + "$ref": "#/components/schemas/PostVo" + }, + "next": { + "$ref": "#/components/schemas/PostVo" + }, + "previous": { + "$ref": "#/components/schemas/PostVo" + } + } + }, "OwnerInfo": { "type": "object", "properties": { @@ -1488,21 +2517,199 @@ } } }, - "PasswordResetEmailRequest": { + "PostSpec": { "required": [ - "email", - "username" + "allowComment", + "deleted", + "excerpt", + "pinned", + "priority", + "publish", + "slug", + "title", + "visible" ], "type": "object", "properties": { - "email": { + "allowComment": { + "type": "boolean", + "default": true + }, + "baseSnapshot": { + "type": "string" + }, + "categories": { + "type": "array", + "items": { + "type": "string" + } + }, + "cover": { + "type": "string" + }, + "deleted": { + "type": "boolean", + "default": false + }, + "excerpt": { + "$ref": "#/components/schemas/Excerpt" + }, + "headSnapshot": { + "type": "string" + }, + "htmlMetas": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "owner": { + "type": "string" + }, + "pinned": { + "type": "boolean", + "default": false + }, + "priority": { + "type": "integer", + "format": "int32", + "default": 0 + }, + "publish": { + "type": "boolean", + "default": false + }, + "publishTime": { + "type": "string", + "format": "date-time" + }, + "releaseSnapshot": { + "type": "string" + }, + "slug": { + "minLength": 1, + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "template": { + "type": "string" + }, + "title": { + "minLength": 1, + "type": "string" + }, + "visible": { + "type": "string", + "default": "PUBLIC", + "enum": [ + "PUBLIC", + "INTERNAL", + "PRIVATE" + ] + } + } + }, + "PostStatus": { + "type": "object", + "properties": { + "commentsCount": { + "type": "integer", + "format": "int32" + }, + "conditions": { + "type": "array", + "properties": { + "empty": { + "type": "boolean" + } + }, + "items": { + "$ref": "#/components/schemas/Condition" + } + }, + "contributors": { + "type": "array", + "items": { + "type": "string" + } + }, + "excerpt": { + "type": "string" + }, + "hideFromList": { + "type": "boolean" + }, + "inProgress": { + "type": "boolean" + }, + "lastModifyTime": { + "type": "string", + "format": "date-time" + }, + "observedVersion": { + "type": "integer", + "format": "int64" + }, + "permalink": { "type": "string" }, - "username": { + "phase": { "type": "string" } } }, + "PostVo": { + "required": [ + "metadata" + ], + "type": "object", + "properties": { + "categories": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CategoryVo" + } + }, + "content": { + "$ref": "#/components/schemas/ContentVo" + }, + "contributors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ContributorVo" + } + }, + "metadata": { + "$ref": "#/components/schemas/Metadata" + }, + "owner": { + "$ref": "#/components/schemas/ContributorVo" + }, + "spec": { + "$ref": "#/components/schemas/PostSpec" + }, + "stats": { + "$ref": "#/components/schemas/StatsVo" + }, + "status": { + "$ref": "#/components/schemas/PostStatus" + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TagVo" + } + } + } + }, "Ref": { "required": [ "name" @@ -1528,17 +2735,6 @@ }, "description": "Extension reference object. The name is mandatory" }, - "RegisterVerifyEmailRequest": { - "required": [ - "email" - ], - "type": "object", - "properties": { - "email": { - "type": "string" - } - } - }, "RemoveOperation": { "required": [ "op", @@ -1800,22 +2996,6 @@ } } }, - "ResetPasswordRequest": { - "required": [ - "newPassword", - "token" - ], - "type": "object", - "properties": { - "newPassword": { - "minLength": 6, - "type": "string" - }, - "token": { - "type": "string" - } - } - }, "SearchOption": { "required": [ "keyword" @@ -1840,88 +3020,236 @@ "highlightPostTag": { "type": "string" }, - "highlightPreTag": { + "highlightPreTag": { + "type": "string" + }, + "includeCategoryNames": { + "type": "array", + "items": { + "type": "string" + } + }, + "includeOwnerNames": { + "type": "array", + "items": { + "type": "string" + } + }, + "includeTagNames": { + "type": "array", + "items": { + "type": "string" + } + }, + "includeTypes": { + "type": "array", + "items": { + "type": "string" + } + }, + "keyword": { + "type": "string" + }, + "limit": { + "maximum": 1000, + "minimum": 1, + "type": "integer", + "format": "int32" + } + } + }, + "SearchResult": { + "type": "object", + "properties": { + "hits": { + "type": "array", + "items": { + "$ref": "#/components/schemas/HaloDocument" + } + }, + "keyword": { + "type": "string" + }, + "limit": { + "type": "integer", + "format": "int32" + }, + "processingTimeMillis": { + "type": "integer", + "format": "int64" + }, + "total": { + "type": "integer", + "format": "int64" + } + } + }, + "SinglePageSpec": { + "required": [ + "allowComment", + "deleted", + "excerpt", + "pinned", + "priority", + "publish", + "slug", + "title", + "visible" + ], + "type": "object", + "properties": { + "allowComment": { + "type": "boolean", + "default": true + }, + "baseSnapshot": { + "type": "string" + }, + "cover": { "type": "string" }, - "includeCategoryNames": { - "type": "array", - "items": { - "type": "string" - } + "deleted": { + "type": "boolean", + "default": false }, - "includeOwnerNames": { - "type": "array", - "items": { - "type": "string" - } + "excerpt": { + "$ref": "#/components/schemas/Excerpt" }, - "includeTagNames": { - "type": "array", - "items": { - "type": "string" - } + "headSnapshot": { + "type": "string" }, - "includeTypes": { + "htmlMetas": { "type": "array", "items": { - "type": "string" + "type": "object", + "additionalProperties": { + "type": "string" + } } }, - "keyword": { + "owner": { "type": "string" }, - "limit": { - "maximum": 1000, - "minimum": 1, + "pinned": { + "type": "boolean", + "default": false + }, + "priority": { "type": "integer", - "format": "int32" + "format": "int32", + "default": 0 + }, + "publish": { + "type": "boolean", + "default": false + }, + "publishTime": { + "type": "string", + "format": "date-time" + }, + "releaseSnapshot": { + "type": "string" + }, + "slug": { + "minLength": 1, + "type": "string" + }, + "template": { + "type": "string" + }, + "title": { + "minLength": 1, + "type": "string" + }, + "visible": { + "type": "string", + "default": "PUBLIC", + "enum": [ + "PUBLIC", + "INTERNAL", + "PRIVATE" + ] } } }, - "SearchResult": { + "SinglePageStatus": { "type": "object", "properties": { - "hits": { + "commentsCount": { + "type": "integer", + "format": "int32" + }, + "conditions": { "type": "array", + "properties": { + "empty": { + "type": "boolean" + } + }, "items": { - "$ref": "#/components/schemas/HaloDocument" + "$ref": "#/components/schemas/Condition" } }, - "keyword": { + "contributors": { + "type": "array", + "items": { + "type": "string" + } + }, + "excerpt": { "type": "string" }, - "limit": { - "type": "integer", - "format": "int32" + "hideFromList": { + "type": "boolean" }, - "processingTimeMillis": { - "type": "integer", - "format": "int64" + "inProgress": { + "type": "boolean" }, - "total": { + "lastModifyTime": { + "type": "string", + "format": "date-time" + }, + "observedVersion": { "type": "integer", "format": "int64" + }, + "permalink": { + "type": "string" + }, + "phase": { + "type": "string" } } }, - "SignUpRequest": { + "SinglePageVo": { "required": [ - "password", - "user" + "metadata" ], "type": "object", "properties": { - "password": { - "minLength": 6, - "type": "string" + "content": { + "$ref": "#/components/schemas/ContentVo" }, - "user": { - "$ref": "#/components/schemas/User" + "contributors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ContributorVo" + } }, - "verifyCode": { - "maxLength": 6, - "minLength": 6, - "type": "string" + "metadata": { + "$ref": "#/components/schemas/Metadata" + }, + "owner": { + "$ref": "#/components/schemas/ContributorVo" + }, + "spec": { + "$ref": "#/components/schemas/SinglePageSpec" + }, + "stats": { + "$ref": "#/components/schemas/StatsVo" + }, + "status": { + "$ref": "#/components/schemas/SinglePageStatus" } } }, @@ -1950,119 +3278,169 @@ } } }, - "TestOperation": { - "required": [ - "op", - "path", - "value" - ], + "StatsVo": { "type": "object", "properties": { - "op": { - "type": "string", - "enum": [ - "test" - ] + "comment": { + "type": "integer", + "format": "int32" }, - "path": { - "pattern": "^(/[^/~]*(~[01][^/~]*)*)*$", - "type": "string", - "description": "A JSON Pointer path pointing to the location to move/copy from.", - "example": "/a/b/c" + "upvote": { + "type": "integer", + "format": "int32" }, - "value": { - "description": "Value can be any JSON value" + "visit": { + "type": "integer", + "format": "int32" } } }, - "User": { + "TagSpec": { "required": [ - "apiVersion", - "kind", - "metadata", - "spec" + "displayName", + "slug" ], "type": "object", "properties": { - "apiVersion": { + "color": { + "pattern": "^#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})$", "type": "string" }, - "kind": { + "cover": { + "type": "string" + }, + "displayName": { + "minLength": 1, + "type": "string" + }, + "slug": { + "minLength": 1, + "type": "string" + } + } + }, + "TagStatus": { + "type": "object", + "properties": { + "observedVersion": { + "type": "integer", + "format": "int64" + }, + "permalink": { "type": "string" }, + "postCount": { + "type": "integer", + "format": "int32" + }, + "visiblePostCount": { + "type": "integer", + "format": "int32" + } + } + }, + "TagVo": { + "required": [ + "metadata" + ], + "type": "object", + "properties": { "metadata": { "$ref": "#/components/schemas/Metadata" }, + "postCount": { + "type": "integer", + "format": "int32" + }, "spec": { - "$ref": "#/components/schemas/UserSpec" + "$ref": "#/components/schemas/TagSpec" }, "status": { - "$ref": "#/components/schemas/UserStatus" + "$ref": "#/components/schemas/TagStatus" } } }, - "UserSpec": { + "TagVoList": { "required": [ - "displayName", - "email" + "first", + "hasNext", + "hasPrevious", + "items", + "last", + "page", + "size", + "total", + "totalPages" ], "type": "object", "properties": { - "avatar": { - "type": "string" - }, - "bio": { - "type": "string" + "first": { + "type": "boolean", + "description": "Indicates whether current page is the first page." }, - "disabled": { - "type": "boolean" + "hasNext": { + "type": "boolean", + "description": "Indicates whether current page has previous page." }, - "displayName": { - "type": "string" + "hasPrevious": { + "type": "boolean", + "description": "Indicates whether current page has previous page." }, - "email": { - "type": "string" + "items": { + "type": "array", + "description": "A chunk of items.", + "items": { + "$ref": "#/components/schemas/TagVo" + } }, - "emailVerified": { - "type": "boolean" + "last": { + "type": "boolean", + "description": "Indicates whether current page is the last page." }, - "loginHistoryLimit": { + "page": { "type": "integer", + "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", "format": "int32" }, - "password": { - "type": "string" - }, - "phone": { - "type": "string" - }, - "registeredAt": { - "type": "string", - "format": "date-time" + "size": { + "type": "integer", + "description": "Size of each page. If not set or equal to 0, it means no pagination.", + "format": "int32" }, - "totpEncryptedSecret": { - "type": "string" + "total": { + "type": "integer", + "description": "Total elements.", + "format": "int64" }, - "twoFactorAuthEnabled": { - "type": "boolean" + "totalPages": { + "type": "integer", + "description": "Indicates total pages.", + "format": "int64" } } }, - "UserStatus": { + "TestOperation": { + "required": [ + "op", + "path", + "value" + ], "type": "object", "properties": { - "lastLoginAt": { + "op": { "type": "string", - "format": "date-time" + "enum": [ + "test" + ] }, - "loginHistories": { - "type": "array", - "items": { - "$ref": "#/components/schemas/LoginHistory" - } + "path": { + "pattern": "^(/[^/~]*(~[01][^/~]*)*)*$", + "type": "string", + "description": "A JSON Pointer path pointing to the location to move/copy from.", + "example": "/a/b/c" }, - "permalink": { - "type": "string" + "value": { + "description": "Value can be any JSON value" } } }, diff --git a/api-docs/openapi/v3_0/apis_uc.api_v1alpha1.json b/api-docs/openapi/v3_0/apis_uc.api_v1alpha1.json index eece516c04..a65865fb95 100644 --- a/api-docs/openapi/v3_0/apis_uc.api_v1alpha1.json +++ b/api-docs/openapi/v3_0/apis_uc.api_v1alpha1.json @@ -17,35 +17,93 @@ } ], "paths": { - "/apis/uc.api.content.halo.run/v1alpha1/attachments": { + "/apis/api.notification.halo.run/v1alpha1/notifiers/{name}/receiver-config": { + "get": { + "description": "Fetch receiver config of notifier", + "operationId": "FetchReceiverConfig", + "parameters": [ + { + "description": "Notifier name", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "default": { + "content": { + "*/*": { + "schema": { + "type": "object" + } + } + }, + "description": "default response" + } + }, + "tags": [ + "NotifierV1alpha1Uc" + ] + }, "post": { - "description": "Create attachment for the given post.", - "operationId": "CreateAttachmentForPost", + "description": "Save receiver config of notifier", + "operationId": "SaveReceiverConfig", "parameters": [ { - "description": "Wait for permalink.", - "in": "query", - "name": "waitForPermalink", + "description": "Notifier name", + "in": "path", + "name": "name", + "required": true, "schema": { - "type": "boolean" + "type": "string" } } ], "requestBody": { "content": { - "multipart/form-data": { + "application/json": { "schema": { - "$ref": "#/components/schemas/PostAttachmentRequest" + "type": "object" } } + }, + "required": true + }, + "responses": { + "default": { + "content": {}, + "description": "default response" } }, + "tags": [ + "NotifierV1alpha1Uc" + ] + } + }, + "/apis/api.notification.halo.run/v1alpha1/userspaces/{username}/notification-preferences": { + "get": { + "description": "List notification preferences for the authenticated user.", + "operationId": "ListUserNotificationPreferences", + "parameters": [ + { + "description": "Username", + "in": "path", + "name": "username", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "default": { "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/Attachment" + "$ref": "#/components/schemas/ReasonTypeNotifierMatrix" } } }, @@ -53,21 +111,144 @@ } }, "tags": [ - "AttachmentV1alpha1Uc" + "NotificationV1alpha1Uc" + ] + }, + "post": { + "description": "Save notification preferences for the authenticated user.", + "operationId": "SaveUserNotificationPreferences", + "parameters": [ + { + "description": "Username", + "in": "path", + "name": "username", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ReasonTypeNotifierCollectionRequest" + } + } + } + }, + "responses": { + "default": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ReasonTypeNotifierMatrix" + } + } + }, + "description": "default response" + } + }, + "tags": [ + "NotificationV1alpha1Uc" ] } }, - "/apis/uc.api.content.halo.run/v1alpha1/attachments/-/upload-from-url": { - "post": { - "description": "Upload attachment from the given URL.", - "operationId": "ExternalTransferAttachment", + "/apis/api.notification.halo.run/v1alpha1/userspaces/{username}/notifications": { + "get": { + "description": "List notifications for the authenticated user.", + "operationId": "ListUserNotifications", "parameters": [ { - "description": "Wait for permalink.", + "description": "Username", + "in": "path", + "name": "username", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Page number. Default is 0.", "in": "query", - "name": "waitForPermalink", + "name": "page", "schema": { - "type": "boolean" + "type": "integer", + "format": "int32" + } + }, + { + "description": "Size number. Default is 0.", + "in": "query", + "name": "size", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "description": "Label selector. e.g.: hidden!\u003dtrue", + "in": "query", + "name": "labelSelector", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", + "in": "query", + "name": "fieldSelector", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", + "in": "query", + "name": "sort", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + ], + "responses": { + "default": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/NotificationList" + } + } + }, + "description": "default response" + } + }, + "tags": [ + "NotificationV1alpha1Uc" + ] + } + }, + "/apis/api.notification.halo.run/v1alpha1/userspaces/{username}/notifications/-/mark-specified-as-read": { + "put": { + "description": "Mark the specified notifications as read.", + "operationId": "MarkNotificationsAsRead", + "parameters": [ + { + "description": "Username", + "in": "path", + "name": "username", + "required": true, + "schema": { + "type": "string" } } ], @@ -75,7 +256,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UploadFromUrlRequest" + "$ref": "#/components/schemas/MarkSpecifiedRequest" } } }, @@ -86,7 +267,10 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/Attachment" + "type": "array", + "items": { + "type": "string" + } } } }, @@ -94,7 +278,124 @@ } }, "tags": [ - "AttachmentV1alpha1Uc" + "NotificationV1alpha1Uc" + ] + } + }, + "/apis/api.notification.halo.run/v1alpha1/userspaces/{username}/notifications/{name}": { + "delete": { + "description": "Delete the specified notification.", + "operationId": "DeleteSpecifiedNotification", + "parameters": [ + { + "description": "Username", + "in": "path", + "name": "username", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Notification name", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "default": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/Notification" + } + } + }, + "description": "default response" + } + }, + "tags": [ + "NotificationV1alpha1Uc" + ] + } + }, + "/apis/api.notification.halo.run/v1alpha1/userspaces/{username}/notifications/{name}/mark-as-read": { + "put": { + "description": "Mark the specified notification as read.", + "operationId": "MarkNotificationAsRead", + "parameters": [ + { + "description": "Username", + "in": "path", + "name": "username", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Notification name", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "default": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/Notification" + } + } + }, + "description": "default response" + } + }, + "tags": [ + "NotificationV1alpha1Uc" + ] + } + }, + "/apis/uc.api.auth.halo.run/v1alpha1/user-connections/{registerId}/disconnect": { + "put": { + "description": "Disconnect my connection from a third-party platform.", + "operationId": "DisconnectMyConnection", + "parameters": [ + { + "description": "The registration ID of the third-party platform.", + "in": "path", + "name": "registerId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "default": { + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserConnection" + } + } + } + }, + "description": "default response" + } + }, + "tags": [ + "UserConnectionV1alpha1Uc" ] } }, @@ -380,10 +681,42 @@ ] } }, - "/apis/uc.api.content.halo.run/v1alpha1/posts/{name}/publish": { - "put": { - "description": "Publish my post.", - "operationId": "PublishMyPost", + "/apis/uc.api.content.halo.run/v1alpha1/posts/{name}/publish": { + "put": { + "description": "Publish my post.", + "operationId": "PublishMyPost", + "parameters": [ + { + "description": "Post name", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "default": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/Post" + } + } + }, + "description": "default response" + } + }, + "tags": [ + "PostV1alpha1Uc" + ] + } + }, + "/apis/uc.api.content.halo.run/v1alpha1/posts/{name}/recycle": { + "delete": { + "description": "Move my post to recycle bin.", + "operationId": "RecycleMyPost", "parameters": [ { "description": "Post name", @@ -833,6 +1166,217 @@ "PersonalAccessTokenV1alpha1Uc" ] } + }, + "/apis/uc.api.storage.halo.run/v1alpha1/attachments": { + "get": { + "description": "List attachments of the current user uploaded.", + "operationId": "ListMyAttachments", + "parameters": [ + { + "description": "Page number. Default is 0.", + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "description": "Size number. Default is 0.", + "in": "query", + "name": "size", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "description": "Label selector. e.g.: hidden!\u003dtrue", + "in": "query", + "name": "labelSelector", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "description": "Field selector. e.g.: metadata.name\u003d\u003dhalo", + "in": "query", + "name": "fieldSelector", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.", + "in": "query", + "name": "sort", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "description": "Filter attachments without group. This parameter will ignore group parameter.", + "in": "query", + "name": "ungrouped", + "schema": { + "type": "boolean" + } + }, + { + "description": "Keyword for searching.", + "in": "query", + "name": "keyword", + "schema": { + "type": "string" + } + }, + { + "description": "Acceptable media types.", + "in": "query", + "name": "accepts", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + ], + "responses": { + "default": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/AttachmentList" + } + } + }, + "description": "default response" + } + }, + "tags": [ + "AttachmentV1alpha1Uc" + ] + }, + "post": { + "description": "Create attachment for the given post.", + "operationId": "CreateAttachmentForPost", + "parameters": [ + { + "description": "Wait for permalink.", + "in": "query", + "name": "waitForPermalink", + "schema": { + "type": "boolean" + } + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PostAttachmentRequest" + } + } + } + }, + "responses": { + "default": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/Attachment" + } + } + }, + "description": "default response" + } + }, + "tags": [ + "AttachmentV1alpha1Uc" + ] + } + }, + "/apis/uc.api.storage.halo.run/v1alpha1/attachments/-/upload": { + "post": { + "description": "Upload attachment to user center storage.", + "operationId": "UploadUcAttachment", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/UcUploadRequest" + } + } + }, + "required": true + }, + "responses": { + "default": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/Attachment" + } + } + }, + "description": "default response" + } + }, + "tags": [ + "AttachmentV1alpha1Uc" + ] + } + }, + "/apis/uc.api.storage.halo.run/v1alpha1/attachments/-/upload-from-url": { + "post": { + "description": "Upload attachment from the given URL.", + "operationId": "ExternalTransferAttachment", + "parameters": [ + { + "description": "Wait for permalink.", + "in": "query", + "name": "waitForPermalink", + "schema": { + "type": "boolean" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UploadFromUrlRequest" + } + } + }, + "required": true + }, + "responses": { + "default": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/Attachment" + } + } + }, + "description": "default response" + } + }, + "tags": [ + "AttachmentV1alpha1Uc" + ] + } } }, "components": { @@ -883,8 +1427,67 @@ "spec": { "$ref": "#/components/schemas/AttachmentSpec" }, - "status": { - "$ref": "#/components/schemas/AttachmentStatus" + "status": { + "$ref": "#/components/schemas/AttachmentStatus" + } + } + }, + "AttachmentList": { + "required": [ + "first", + "hasNext", + "hasPrevious", + "items", + "last", + "page", + "size", + "total", + "totalPages" + ], + "type": "object", + "properties": { + "first": { + "type": "boolean", + "description": "Indicates whether current page is the first page." + }, + "hasNext": { + "type": "boolean", + "description": "Indicates whether current page has previous page." + }, + "hasPrevious": { + "type": "boolean", + "description": "Indicates whether current page has previous page." + }, + "items": { + "type": "array", + "description": "A chunk of items.", + "items": { + "$ref": "#/components/schemas/Attachment" + } + }, + "last": { + "type": "boolean", + "description": "Indicates whether current page is the last page." + }, + "page": { + "type": "integer", + "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", + "format": "int32" + }, + "size": { + "type": "integer", + "description": "Size of each page. If not set or equal to 0, it means no pagination.", + "format": "int32" + }, + "total": { + "type": "integer", + "description": "Total elements.", + "format": "int64" + }, + "totalPages": { + "type": "integer", + "description": "Indicates total pages.", + "format": "int64" } } }, @@ -1330,6 +1933,17 @@ } } }, + "MarkSpecifiedRequest": { + "type": "object", + "properties": { + "names": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "Metadata": { "required": [ "name" @@ -1410,6 +2024,143 @@ } } }, + "Notification": { + "required": [ + "apiVersion", + "kind", + "metadata" + ], + "type": "object", + "properties": { + "apiVersion": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/Metadata" + }, + "spec": { + "$ref": "#/components/schemas/NotificationSpec" + } + } + }, + "NotificationList": { + "required": [ + "first", + "hasNext", + "hasPrevious", + "items", + "last", + "page", + "size", + "total", + "totalPages" + ], + "type": "object", + "properties": { + "first": { + "type": "boolean", + "description": "Indicates whether current page is the first page." + }, + "hasNext": { + "type": "boolean", + "description": "Indicates whether current page has previous page." + }, + "hasPrevious": { + "type": "boolean", + "description": "Indicates whether current page has previous page." + }, + "items": { + "type": "array", + "description": "A chunk of items.", + "items": { + "$ref": "#/components/schemas/Notification" + } + }, + "last": { + "type": "boolean", + "description": "Indicates whether current page is the last page." + }, + "page": { + "type": "integer", + "description": "Page number, starts from 1. If not set or equal to 0, it means no pagination.", + "format": "int32" + }, + "size": { + "type": "integer", + "description": "Size of each page. If not set or equal to 0, it means no pagination.", + "format": "int32" + }, + "total": { + "type": "integer", + "description": "Total elements.", + "format": "int64" + }, + "totalPages": { + "type": "integer", + "description": "Indicates total pages.", + "format": "int64" + } + } + }, + "NotificationSpec": { + "required": [ + "htmlContent", + "rawContent", + "reason", + "recipient", + "title" + ], + "type": "object", + "properties": { + "htmlContent": { + "type": "string" + }, + "lastReadAt": { + "type": "string", + "format": "date-time" + }, + "rawContent": { + "type": "string" + }, + "reason": { + "minLength": 1, + "type": "string", + "description": "The name of reason" + }, + "recipient": { + "minLength": 1, + "type": "string", + "description": "The name of user" + }, + "title": { + "minLength": 1, + "type": "string" + }, + "unread": { + "type": "boolean" + } + } + }, + "NotifierInfo": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "Part": { + "type": "object" + }, "PasswordRequest": { "required": [ "password" @@ -1639,9 +2390,6 @@ } }, "PostStatus": { - "required": [ - "phase" - ], "type": "object", "properties": { "commentsCount": { @@ -1690,6 +2438,81 @@ } } }, + "ReasonTypeInfo": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "name": { + "type": "string" + }, + "uiPermissions": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "ReasonTypeNotifierCollectionRequest": { + "required": [ + "reasonTypeNotifiers" + ], + "type": "object", + "properties": { + "reasonTypeNotifiers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReasonTypeNotifierRequest" + } + } + } + }, + "ReasonTypeNotifierMatrix": { + "type": "object", + "properties": { + "notifiers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotifierInfo" + } + }, + "reasonTypes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReasonTypeInfo" + } + }, + "stateMatrix": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "boolean" + } + } + } + } + }, + "ReasonTypeNotifierRequest": { + "type": "object", + "properties": { + "notifiers": { + "type": "array", + "items": { + "type": "string" + } + }, + "reasonType": { + "type": "string" + } + } + }, "Ref": { "required": [ "name" @@ -1990,6 +2813,39 @@ } } }, + "UcUploadRequest": { + "required": [ + "file" + ], + "type": "object", + "properties": { + "file": { + "type": "string", + "format": "binary" + }, + "formData": { + "type": "object", + "properties": { + "all": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/Part" + }, + "writeOnly": true + }, + "empty": { + "type": "boolean" + } + }, + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Part" + } + } + } + } + }, "UploadFromUrlRequest": { "required": [ "url" @@ -2005,6 +2861,52 @@ } } }, + "UserConnection": { + "required": [ + "apiVersion", + "kind", + "metadata", + "spec" + ], + "type": "object", + "properties": { + "apiVersion": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/Metadata" + }, + "spec": { + "$ref": "#/components/schemas/UserConnectionSpec" + } + } + }, + "UserConnectionSpec": { + "required": [ + "providerUserId", + "registrationId", + "username" + ], + "type": "object", + "properties": { + "providerUserId": { + "type": "string" + }, + "registrationId": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "username": { + "type": "string" + } + } + }, "UserDevice": { "required": [ "active", diff --git a/api/build.gradle b/api/build.gradle index 752acc961a..046529c443 100644 --- a/api/build.gradle +++ b/api/build.gradle @@ -3,6 +3,7 @@ plugins { id 'halo.publish' id 'jacoco' id "io.freefair.lombok" + id "com.github.ben-manes.versions" } group = 'run.halo.app' @@ -67,6 +68,7 @@ dependencies { api "org.thymeleaf.extras:thymeleaf-extras-springsecurity6" api 'org.apache.tika:tika-core' api "org.imgscalr:imgscalr-lib" + api 'com.drewnoakes:metadata-extractor' api "io.github.resilience4j:resilience4j-spring-boot3" api "io.github.resilience4j:resilience4j-reactor" diff --git a/api/src/main/java/run/halo/app/core/extension/AuthProvider.java b/api/src/main/java/run/halo/app/core/extension/AuthProvider.java index 5f496c687f..8cf0aeadb0 100644 --- a/api/src/main/java/run/halo/app/core/extension/AuthProvider.java +++ b/api/src/main/java/run/halo/app/core/extension/AuthProvider.java @@ -6,7 +6,9 @@ import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; +import lombok.Getter; import lombok.ToString; +import org.springframework.lang.NonNull; import run.halo.app.extension.AbstractExtension; import run.halo.app.extension.GVK; @@ -48,17 +50,30 @@ public static class AuthProviderSpec { @Schema(requiredMode = REQUIRED, description = "Authentication url of the auth provider") private String authenticationUrl; + private String method = "GET"; + + private boolean rememberMeSupport = false; + + /** + * Auth type: form or oauth2. + */ + @Getter(onMethod_ = @NonNull) + @Schema(requiredMode = REQUIRED) + private AuthType authType = AuthType.OAUTH2; + private String bindingUrl; private String unbindUrl; - private int priority; - @Schema(requiredMode = NOT_REQUIRED) private SettingRef settingRef; @Schema(requiredMode = NOT_REQUIRED) private ConfigMapRef configMapRef; + + public void setAuthType(AuthType authType) { + this.authType = (authType == null ? AuthType.OAUTH2 : authType); + } } @Data @@ -77,4 +92,9 @@ public static class ConfigMapRef { @Schema(requiredMode = REQUIRED, minLength = 1) private String name; } + + public enum AuthType { + FORM, + OAUTH2 + } } diff --git a/api/src/main/java/run/halo/app/core/extension/UserConnection.java b/api/src/main/java/run/halo/app/core/extension/UserConnection.java index 1b9fb728d6..8639ba6340 100644 --- a/api/src/main/java/run/halo/app/core/extension/UserConnection.java +++ b/api/src/main/java/run/halo/app/core/extension/UserConnection.java @@ -48,36 +48,9 @@ public static class UserConnectionSpec { private String providerUserId; /** - * The display name for the user's connection to the OAuth provider. + * The time when the user connection was last updated. */ - @Schema(requiredMode = REQUIRED) - private String displayName; - - /** - * The URL to the user's profile page on the OAuth provider. - * For example, the user's GitHub profile URL. - */ - private String profileUrl; - - /** - * The URL to the user's avatar image on the OAuth provider. - * For example, the user's GitHub avatar URL. - */ - private String avatarUrl; - - /** - * The access token provided by the OAuth provider. - */ - @Schema(requiredMode = REQUIRED) - private String accessToken; - - /** - * The refresh token provided by the OAuth provider (if applicable). - */ - private String refreshToken; - - private Instant expiresAt; - private Instant updatedAt; + } } diff --git a/api/src/main/java/run/halo/app/core/extension/attachment/Policy.java b/api/src/main/java/run/halo/app/core/extension/attachment/Policy.java index 450548a9f0..de90f29273 100644 --- a/api/src/main/java/run/halo/app/core/extension/attachment/Policy.java +++ b/api/src/main/java/run/halo/app/core/extension/attachment/Policy.java @@ -16,6 +16,7 @@ @GVK(group = Constant.GROUP, version = Constant.VERSION, kind = KIND, plural = "policies", singular = "policy") public class Policy extends AbstractExtension { + public static final String POLICY_OWNER_LABEL = "storage.halo.run/policy-owner"; public static final String KIND = "Policy"; diff --git a/api/src/main/java/run/halo/app/core/extension/content/Post.java b/api/src/main/java/run/halo/app/core/extension/content/Post.java index 0cc785d71f..8e8096b46b 100644 --- a/api/src/main/java/run/halo/app/core/extension/content/Post.java +++ b/api/src/main/java/run/halo/app/core/extension/content/Post.java @@ -157,7 +157,6 @@ public static class PostSpec { @Data public static class PostStatus { - @Schema(requiredMode = RequiredMode.REQUIRED) private String phase; @Schema diff --git a/application/src/main/java/run/halo/app/core/extension/service/RoleService.java b/api/src/main/java/run/halo/app/core/user/service/RoleService.java similarity index 96% rename from application/src/main/java/run/halo/app/core/extension/service/RoleService.java rename to api/src/main/java/run/halo/app/core/user/service/RoleService.java index d22beff912..b6d108047b 100644 --- a/application/src/main/java/run/halo/app/core/extension/service/RoleService.java +++ b/api/src/main/java/run/halo/app/core/user/service/RoleService.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.service; +package run.halo.app.core.user.service; import java.util.Collection; import java.util.Map; diff --git a/api/src/main/java/run/halo/app/core/user/service/SignUpData.java b/api/src/main/java/run/halo/app/core/user/service/SignUpData.java new file mode 100644 index 0000000000..7fab3d4ccc --- /dev/null +++ b/api/src/main/java/run/halo/app/core/user/service/SignUpData.java @@ -0,0 +1,82 @@ +package run.halo.app.core.user.service; + +import jakarta.validation.Constraint; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import jakarta.validation.Payload; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.Objects; +import lombok.Data; +import run.halo.app.infra.ValidationUtils; + +/** + * Sign up data. + * + * @author johnniang + * @since 2.20.0 + */ +@Data +@SignUpData.SignUpDataConstraint +public class SignUpData { + + @NotBlank + @Size(min = 4, max = 63) + @Pattern(regexp = ValidationUtils.NAME_REGEX, + message = "{validation.error.username.pattern}") + private String username; + + @NotBlank + private String displayName; + + @Email + private String email; + + private String emailCode; + + @NotBlank + @Size(min = 5, max = 257) + @Pattern(regexp = ValidationUtils.PASSWORD_REGEX, + message = "{validation.error.password.pattern}") + private String password; + + @NotBlank + private String confirmPassword; + + @Target({ElementType.TYPE}) + @Retention(RetentionPolicy.RUNTIME) + @Constraint(validatedBy = {SignUpDataConstraintValidator.class}) + public @interface SignUpDataConstraint { + + String message() default ""; + + Class[] groups() default {}; + + Class[] payload() default {}; + + } + + private static class SignUpDataConstraintValidator + implements ConstraintValidator { + + @Override + public boolean isValid(SignUpData signUpData, ConstraintValidatorContext context) { + var isValid = Objects.equals(signUpData.getPassword(), signUpData.getConfirmPassword()); + if (!isValid) { + context.disableDefaultConstraintViolation(); + context.buildConstraintViolationWithTemplate( + "{signup.error.confirm-password-not-match}" + ) + .addPropertyNode("confirmPassword") + .addConstraintViolation(); + } + return isValid; + } + } +} diff --git a/api/src/main/java/run/halo/app/core/user/service/UserPostCreatingHandler.java b/api/src/main/java/run/halo/app/core/user/service/UserPostCreatingHandler.java new file mode 100644 index 0000000000..e7ad214fc8 --- /dev/null +++ b/api/src/main/java/run/halo/app/core/user/service/UserPostCreatingHandler.java @@ -0,0 +1,23 @@ +package run.halo.app.core.user.service; + +import org.pf4j.ExtensionPoint; +import reactor.core.publisher.Mono; +import run.halo.app.core.extension.User; + +/** + * User post-creating handler. + * + * @author johnniang + * @since 2.20.8 + */ +public interface UserPostCreatingHandler extends ExtensionPoint { + + /** + * Do something after creating user. + * + * @param user create user. + * @return {@code Mono.empty()} if handling successfully. + */ + Mono postCreating(User user); + +} diff --git a/api/src/main/java/run/halo/app/core/user/service/UserPreCreatingHandler.java b/api/src/main/java/run/halo/app/core/user/service/UserPreCreatingHandler.java new file mode 100644 index 0000000000..775324eda4 --- /dev/null +++ b/api/src/main/java/run/halo/app/core/user/service/UserPreCreatingHandler.java @@ -0,0 +1,23 @@ +package run.halo.app.core.user.service; + +import org.pf4j.ExtensionPoint; +import reactor.core.publisher.Mono; +import run.halo.app.core.extension.User; + +/** + * User pre-creating handler. + * + * @author johnniang + * @since 2.20.8 + */ +public interface UserPreCreatingHandler extends ExtensionPoint { + + /** + * Do something before user creating. + * + * @param user modifiable user detail + * @return {@code Mono.empty()} if handling successfully. + */ + Mono preCreating(User user); + +} diff --git a/application/src/main/java/run/halo/app/core/extension/service/UserService.java b/api/src/main/java/run/halo/app/core/user/service/UserService.java similarity index 87% rename from application/src/main/java/run/halo/app/core/extension/service/UserService.java rename to api/src/main/java/run/halo/app/core/user/service/UserService.java index f252d15360..58039c2f21 100644 --- a/application/src/main/java/run/halo/app/core/extension/service/UserService.java +++ b/api/src/main/java/run/halo/app/core/user/service/UserService.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.service; +package run.halo.app.core.user.service; import java.util.Set; import reactor.core.publisher.Flux; @@ -17,7 +17,7 @@ public interface UserService { Mono grantRoles(String username, Set roles); - Mono signUp(User user, String password); + Mono signUp(SignUpData signUpData); Mono createUser(User user, Set roles); diff --git a/api/src/main/java/run/halo/app/event/user/UserConnectionDisconnectedEvent.java b/api/src/main/java/run/halo/app/event/user/UserConnectionDisconnectedEvent.java new file mode 100644 index 0000000000..65d3aa42a7 --- /dev/null +++ b/api/src/main/java/run/halo/app/event/user/UserConnectionDisconnectedEvent.java @@ -0,0 +1,25 @@ +package run.halo.app.event.user; + +import lombok.Getter; +import org.springframework.context.ApplicationEvent; +import run.halo.app.core.extension.UserConnection; +import run.halo.app.plugin.SharedEvent; + +/** + * An event that will be triggered after a user connection is disconnected. + * + * @author johnniang + * @since 2.20.0 + */ +@SharedEvent +public class UserConnectionDisconnectedEvent extends ApplicationEvent { + + @Getter + private final UserConnection userConnection; + + public UserConnectionDisconnectedEvent(Object source, UserConnection userConnection) { + super(source); + this.userConnection = userConnection; + } + +} diff --git a/api/src/main/java/run/halo/app/extension/Unstructured.java b/api/src/main/java/run/halo/app/extension/Unstructured.java index 2ea80b5c5a..cf2902ed75 100644 --- a/api/src/main/java/run/halo/app/extension/Unstructured.java +++ b/api/src/main/java/run/halo/app/extension/Unstructured.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.DeserializationContext; import com.fasterxml.jackson.databind.JsonDeserializer; import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.MapperFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializerProvider; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; @@ -35,7 +36,12 @@ @SuppressWarnings("rawtypes") public class Unstructured implements Extension { - public static final ObjectMapper OBJECT_MAPPER = Json.mapper(); + @SuppressWarnings("deprecation") + public static final ObjectMapper OBJECT_MAPPER = Json.mapper() + // We don't want to change the default mapper + // so we copy a new one and configure it + .copy() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS); private final Map data; @@ -66,7 +72,7 @@ public MetadataOperator getMetadata() { return new UnstructuredMetadata(); } - @EqualsAndHashCode(exclude = "version") + @EqualsAndHashCode(exclude = "tatersion") class UnstructuredMetadata implements MetadataOperator { @Override diff --git a/api/src/main/java/run/halo/app/extension/index/KeyComparator.java b/api/src/main/java/run/halo/app/extension/index/KeyComparator.java index b94d4c7ef3..286bd21800 100644 --- a/api/src/main/java/run/halo/app/extension/index/KeyComparator.java +++ b/api/src/main/java/run/halo/app/extension/index/KeyComparator.java @@ -60,14 +60,6 @@ private int compareNumbers(String a, String b, int startA, int startB) { int i = startA; int j = startB; - // Skip leading zeros for both numbers - while (i < a.length() && a.charAt(i) == '0') { - i++; - } - while (j < b.length() && b.charAt(j) == '0') { - j++; - } - // Compare lengths of remaining digits int lengthA = countDigits(a, i); int lengthB = countDigits(b, j); @@ -114,13 +106,6 @@ private int compareIntegerPart(String a, String b, int startA, int startB, int p int i = startA; int j = startB; - while (i < pointA && a.charAt(i) == '0') { - i++; - } - while (j < pointB && b.charAt(j) == '0') { - j++; - } - int lengthA = pointA - i; int lengthB = pointB - j; if (lengthA != lengthB) { @@ -150,6 +135,7 @@ private int compareFractionalPart(String a, String b, int i, int j) { j++; } + // If one number has more digits left, and they're not all zeroes, it is larger while (i < a.length() && Character.isDigit(a.charAt(i))) { if (a.charAt(i) != '0') { return 1; diff --git a/api/src/main/java/run/halo/app/infra/ExternalLinkProcessor.java b/api/src/main/java/run/halo/app/infra/ExternalLinkProcessor.java index ea653c709b..99dae5bcbd 100644 --- a/api/src/main/java/run/halo/app/infra/ExternalLinkProcessor.java +++ b/api/src/main/java/run/halo/app/infra/ExternalLinkProcessor.java @@ -1,5 +1,9 @@ package run.halo.app.infra; +import java.net.URI; +import org.springframework.http.HttpRequest; +import reactor.core.publisher.Mono; + /** * {@link ExternalLinkProcessor} to process an in-site link to an external link. * @@ -17,4 +21,18 @@ public interface ExternalLinkProcessor { * @return processed link or original link */ String processLink(String link); + + /** + * Process the URI to an external URL. + *

+ * If the URI is an in-site link, then process it to an external link with + * {@link ExternalUrlSupplier#getRaw()} or {@link ExternalUrlSupplier#getURL(HttpRequest)}, + * otherwise return the original URI. + *

+ * + * @param uri uri to process + * @return processed URI or original URI + */ + Mono processLink(URI uri); + } diff --git a/api/src/main/java/run/halo/app/infra/SystemSetting.java b/api/src/main/java/run/halo/app/infra/SystemSetting.java index c548486249..163b709ea6 100644 --- a/api/src/main/java/run/halo/app/infra/SystemSetting.java +++ b/api/src/main/java/run/halo/app/infra/SystemSetting.java @@ -2,8 +2,11 @@ import java.util.LinkedHashMap; import java.util.LinkedHashSet; +import java.util.List; import java.util.Set; +import java.util.stream.Collectors; import lombok.Data; +import lombok.experimental.Accessors; import org.springframework.boot.convert.ApplicationConversionService; import run.halo.app.extension.ConfigMap; import run.halo.app.infra.utils.JsonUtils; @@ -67,10 +70,11 @@ public static class Basic { @Data public static class User { public static final String GROUP = "user"; - Boolean allowRegistration; - Boolean mustVerifyEmailOnRegistration; + boolean allowRegistration; + boolean mustVerifyEmailOnRegistration; String defaultRole; String avatarPolicy; + String ucAttachmentPolicy; } @Data @@ -112,7 +116,42 @@ public static class Menu { @Data public static class AuthProvider { public static final String GROUP = "authProvider"; + /** + * Currently keep it to be compatible with the reference of the plugin. + * + * @deprecated Use {@link #getStates()} instead. + */ + @Deprecated(since = "2.20.0", forRemoval = true) private Set enabled; + + private List states; + + /** + *

To be compatible with the old version of the enabled field and retained, + * since 2.20.0 version, we uses the states field, so the data needs to be synchronized + * to the enabled field, and this method needs to be deleted when the enabled field is + * removed.

+ * + * @deprecated Use {@link #getStates()} instead. + */ + @Deprecated(since = "2.20.0", forRemoval = true) + public Set getEnabled() { + if (states == null) { + return enabled; + } + return this.states.stream() + .filter(AuthProviderState::isEnabled) + .map(AuthProviderState::getName) + .collect(Collectors.toSet()); + } + } + + @Data + @Accessors(chain = true) + public static class AuthProviderState { + private String name; + private boolean enabled; + private int priority; } /** diff --git a/api/src/main/java/run/halo/app/infra/ValidationUtils.java b/api/src/main/java/run/halo/app/infra/ValidationUtils.java new file mode 100644 index 0000000000..e0b5b16c5d --- /dev/null +++ b/api/src/main/java/run/halo/app/infra/ValidationUtils.java @@ -0,0 +1,43 @@ +package run.halo.app.infra; + +import java.util.regex.Pattern; +import lombok.experimental.UtilityClass; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.validation.BeanPropertyBindingResult; +import org.springframework.validation.BindingResult; +import org.springframework.validation.Validator; +import org.springframework.web.server.ServerWebExchange; + +@UtilityClass +public class ValidationUtils { + public static final String NAME_REGEX = + "^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$"; + public static final Pattern NAME_PATTERN = Pattern.compile(NAME_REGEX); + + /** + * {@code A-Z, a-z, 0-9, !@#$%^&*} are allowed. + */ + public static final String PASSWORD_REGEX = "^[A-Za-z0-9!@#$%^&*]+$"; + + public static final Pattern PASSWORD_PATTERN = Pattern.compile(PASSWORD_REGEX); + + /** + * Validate the target object with given locale context. + */ + public static BindingResult validate(Object target, String objectName, + Validator validator, ServerWebExchange exchange) { + BindingResult bindingResult = new BeanPropertyBindingResult(target, objectName); + try { + LocaleContextHolder.setLocaleContext(exchange.getLocaleContext()); + validator.validate(target, bindingResult); + return bindingResult; + } finally { + LocaleContextHolder.resetLocaleContext(); + } + } + + public static BindingResult validate(Object target, Validator validator, + ServerWebExchange exchange) { + return validate(target, "form", validator, exchange); + } +} diff --git a/api/src/main/java/run/halo/app/infra/utils/FileTypeDetectUtils.java b/api/src/main/java/run/halo/app/infra/utils/FileTypeDetectUtils.java index f9326863ee..7166260d4f 100644 --- a/api/src/main/java/run/halo/app/infra/utils/FileTypeDetectUtils.java +++ b/api/src/main/java/run/halo/app/infra/utils/FileTypeDetectUtils.java @@ -1,29 +1,52 @@ package run.halo.app.infra.utils; +import java.io.BufferedInputStream; import java.io.IOException; import java.io.InputStream; import lombok.experimental.UtilityClass; -import org.apache.tika.Tika; +import org.apache.tika.detect.DefaultDetector; +import org.apache.tika.detect.Detector; +import org.apache.tika.metadata.Metadata; +import org.apache.tika.metadata.TikaCoreProperties; import org.apache.tika.mime.MimeTypeException; import org.apache.tika.mime.MimeTypes; +import org.springframework.util.Assert; @UtilityClass public class FileTypeDetectUtils { - private static final Tika tika = new Tika(); + private static final Detector detector = new DefaultDetector(); + + /** + *

Detects the media type of the given document.

+ *

The type detection is based on the content of the given document stream and the name of + * the document.

+ * + * @param inputStream the document stream must not be null + * @throws IOException if the stream can not be read + */ + public static String detectMimeType(InputStream inputStream, String name) throws IOException { + Assert.notNull(name, "The name of the document must not be null"); + var metadata = new Metadata(); + metadata.set(TikaCoreProperties.RESOURCE_NAME_KEY, name); + return doDetectMimeType(inputStream, metadata); + } /** * Detect mime type. * - * @param inputStream input stream will be closed after detection. + * @param inputStream input stream will be closed after detection, must not be null */ public static String detectMimeType(InputStream inputStream) throws IOException { - try { - return tika.detect(inputStream); - } finally { - if (inputStream != null) { - inputStream.close(); - } + return doDetectMimeType(inputStream, new Metadata()); + } + + private static String doDetectMimeType(InputStream inputStream, Metadata metadata) + throws IOException { + Assert.notNull(inputStream, "The inputStream must not be null"); + try (var stream = (!inputStream.markSupported() + ? new BufferedInputStream(inputStream) : inputStream)) { + return detector.detect(stream, metadata).toString(); } } diff --git a/api/src/main/java/run/halo/app/plugin/extensionpoint/ExtensionGetter.java b/api/src/main/java/run/halo/app/plugin/extensionpoint/ExtensionGetter.java index 58615ec72c..964bf1a78d 100644 --- a/api/src/main/java/run/halo/app/plugin/extensionpoint/ExtensionGetter.java +++ b/api/src/main/java/run/halo/app/plugin/extensionpoint/ExtensionGetter.java @@ -1,5 +1,6 @@ package run.halo.app.plugin.extensionpoint; +import java.util.List; import org.pf4j.ExtensionPoint; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -34,4 +35,14 @@ public interface ExtensionGetter { * @return a bunch of extension points. */ Flux getExtensions(Class extensionPointClass); + + /** + * Get all extensions according to extension point class. + * + * @param extensionPointClass extension point class + * @param type of extension point + * @return a bunch of extension points. + */ + List getExtensionList(Class extensionPointClass); + } diff --git a/api/src/main/java/run/halo/app/security/HttpBasicSecurityWebFilter.java b/api/src/main/java/run/halo/app/security/HttpBasicSecurityWebFilter.java new file mode 100644 index 0000000000..7043a10e48 --- /dev/null +++ b/api/src/main/java/run/halo/app/security/HttpBasicSecurityWebFilter.java @@ -0,0 +1,14 @@ +package run.halo.app.security; + +import org.pf4j.ExtensionPoint; +import org.springframework.web.server.WebFilter; + +/** + * Security web filter for HTTP basic. + * + * @author johnniang + * @since 2.20.0 + */ +public interface HttpBasicSecurityWebFilter extends WebFilter, ExtensionPoint { + +} diff --git a/api/src/main/java/run/halo/app/security/OAuth2AuthorizationCodeSecurityWebFilter.java b/api/src/main/java/run/halo/app/security/OAuth2AuthorizationCodeSecurityWebFilter.java new file mode 100644 index 0000000000..2aa0f28ebc --- /dev/null +++ b/api/src/main/java/run/halo/app/security/OAuth2AuthorizationCodeSecurityWebFilter.java @@ -0,0 +1,14 @@ +package run.halo.app.security; + +import org.pf4j.ExtensionPoint; +import org.springframework.web.server.WebFilter; + +/** + * Security web filter for OAuth2 authorization code. + * + * @author johnniang + * @since 2.20.0 + */ +public interface OAuth2AuthorizationCodeSecurityWebFilter extends WebFilter, ExtensionPoint { + +} diff --git a/application/src/main/java/run/halo/app/security/authentication/CryptoService.java b/api/src/main/java/run/halo/app/security/authentication/CryptoService.java similarity index 100% rename from application/src/main/java/run/halo/app/security/authentication/CryptoService.java rename to api/src/main/java/run/halo/app/security/authentication/CryptoService.java diff --git a/api/src/main/java/run/halo/app/security/authentication/oauth2/HaloOAuth2AuthenticationToken.java b/api/src/main/java/run/halo/app/security/authentication/oauth2/HaloOAuth2AuthenticationToken.java new file mode 100644 index 0000000000..fd344740c2 --- /dev/null +++ b/api/src/main/java/run/halo/app/security/authentication/oauth2/HaloOAuth2AuthenticationToken.java @@ -0,0 +1,96 @@ +package run.halo.app.security.authentication.oauth2; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import lombok.Getter; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.oauth2.core.user.OAuth2User; + +/** + * Halo OAuth2 authentication token which combines {@link UserDetails} and original + * {@link OAuth2AuthenticationToken}. + * + * @author johnniang + * @since 2.20.0 + */ +public class HaloOAuth2AuthenticationToken extends AbstractAuthenticationToken { + + @Getter + private final UserDetails userDetails; + + @Getter + private final OAuth2AuthenticationToken original; + + /** + * Constructs an {@code HaloOAuth2AuthenticationToken} using {@link UserDetails} and original + * {@link OAuth2AuthenticationToken}. + * + * @param userDetails the {@link UserDetails} + * @param original the original {@link OAuth2AuthenticationToken} + */ + public HaloOAuth2AuthenticationToken(UserDetails userDetails, + OAuth2AuthenticationToken original) { + super(combineAuthorities(userDetails, original)); + this.userDetails = userDetails; + this.original = original; + setAuthenticated(true); + } + + @Override + public String getName() { + return userDetails.getUsername(); + } + + @Override + public Collection getAuthorities() { + var originalAuthorities = super.getAuthorities(); + var userDetailsAuthorities = getUserDetails().getAuthorities(); + var authorities = new ArrayList( + originalAuthorities.size() + userDetailsAuthorities.size() + ); + authorities.addAll(originalAuthorities); + authorities.addAll(userDetailsAuthorities); + return Collections.unmodifiableList(authorities); + } + + @Override + public Object getCredentials() { + return ""; + } + + @Override + public OAuth2User getPrincipal() { + return original.getPrincipal(); + } + + /** + * Creates an authenticated {@link HaloOAuth2AuthenticationToken} using {@link UserDetails} and + * original {@link OAuth2AuthenticationToken}. + * + * @param userDetails the {@link UserDetails} + * @param original the original {@link OAuth2AuthenticationToken} + * @return an authenticated {@link HaloOAuth2AuthenticationToken} + */ + public static HaloOAuth2AuthenticationToken authenticated( + UserDetails userDetails, OAuth2AuthenticationToken original + ) { + return new HaloOAuth2AuthenticationToken(userDetails, original); + } + + private static Collection combineAuthorities( + UserDetails userDetails, OAuth2AuthenticationToken original) { + var userDetailsAuthorities = userDetails.getAuthorities(); + var originalAuthorities = original.getAuthorities(); + var authorities = new ArrayList( + originalAuthorities.size() + userDetailsAuthorities.size() + ); + authorities.addAll(originalAuthorities); + authorities.addAll(userDetailsAuthorities); + return Collections.unmodifiableList(authorities); + } + +} diff --git a/api/src/main/java/run/halo/app/theme/dialect/ElementTagPostProcessor.java b/api/src/main/java/run/halo/app/theme/dialect/ElementTagPostProcessor.java new file mode 100644 index 0000000000..77a295faae --- /dev/null +++ b/api/src/main/java/run/halo/app/theme/dialect/ElementTagPostProcessor.java @@ -0,0 +1,41 @@ +package run.halo.app.theme.dialect; + +import org.pf4j.ExtensionPoint; +import org.thymeleaf.context.ITemplateContext; +import org.thymeleaf.model.IProcessableElementTag; +import reactor.core.publisher.Mono; + +/** + * An extension point for post-processing element tag. + * + * @author johnniang + * @since 2.20.0 + */ +public interface ElementTagPostProcessor extends ExtensionPoint { + + /** + *

+ * Execute the processor. + *

+ *

+ * The {@link IProcessableElementTag} object argument is immutable, so all modifications to + * this object or any + * instructions to be given to the engine should be done through the specified + * {@link org.thymeleaf.model.IModelFactory} model factory in context. + *

+ *

+ * Don't forget to return the new tag after processing or + * {@link reactor.core.publisher.Mono#empty()} if not processable. + *

+ * + * @param context the template context. + * @param tag the event this processor is executing on. + * @return a {@link reactor.core.publisher.Mono} that will complete when processing finishes + * or empty mono if not support. + */ + Mono process( + ITemplateContext context, + final IProcessableElementTag tag + ); + +} diff --git a/api/src/main/java/run/halo/app/theme/router/ModelConst.java b/api/src/main/java/run/halo/app/theme/router/ModelConst.java index 86af7e07ee..e6ac84bba4 100644 --- a/api/src/main/java/run/halo/app/theme/router/ModelConst.java +++ b/api/src/main/java/run/halo/app/theme/router/ModelConst.java @@ -10,5 +10,11 @@ public enum ModelConst { ; public static final String TEMPLATE_ID = "_templateId"; public static final String POWERED_BY_HALO_TEMPLATE_ENGINE = "poweredByHaloTemplateEngine"; + + /** + * This key is used to prevent caching from cache plugins. + */ + public static final String NO_CACHE = "HALO_TEMPLATE_ENGINE.NO_CACHE"; + public static final Integer DEFAULT_PAGE_SIZE = 10; } diff --git a/api/src/test/java/run/halo/app/extension/index/KeyComparatorTest.java b/api/src/test/java/run/halo/app/extension/index/KeyComparatorTest.java index 959980a0aa..acca24d7da 100644 --- a/api/src/test/java/run/halo/app/extension/index/KeyComparatorTest.java +++ b/api/src/test/java/run/halo/app/extension/index/KeyComparatorTest.java @@ -388,6 +388,13 @@ public void pureNumbersTest() { assertThat(comparator.compare("124", "123")).isGreaterThan(0); // Leading zeros assertThat(comparator.compare("00123", "123") > 0).isTrue(); + assertThat(comparator.compare("0", "0")).isEqualTo(0); + assertThat(comparator.compare("0", "0000")).isLessThan(0); + assertThat(comparator.compare("0x", "0")).isGreaterThan(0); + assertThat(comparator.compare("0", "1")).isLessThan(0); + assertThat(comparator.compare("1", "0")).isGreaterThan(0); + assertThat(comparator.compare("001", "0")).isGreaterThan(0); + assertThat(comparator.compare("0x5e", "0000")).isLessThan(0); } @Test @@ -430,6 +437,7 @@ public void decimalStringsTest() { assertThat(comparator.compare("123.46", "123.45")).isGreaterThan(0); // Decimal equivalence assertThat(comparator.compare("123.5", "123.50")).isLessThan(0); + assertThat(comparator.compare("123.0005", "123.00050")).isLessThan(0); } @Test diff --git a/application/build.gradle b/application/build.gradle index f0cc3488bf..05ffad7454 100644 --- a/application/build.gradle +++ b/application/build.gradle @@ -13,6 +13,7 @@ plugins { id "io.freefair.lombok" id 'org.gradle.crypto.checksum' id 'org.springdoc.openapi-gradle-plugin' + id "com.github.ben-manes.versions" } group = 'run.halo.app' @@ -80,6 +81,10 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' testImplementation 'io.projectreactor:reactor-test' + + // webjars + runtimeOnly 'org.webjars.npm:jsencrypt:3.3.2' + runtimeOnly 'org.webjars.npm:normalize.css:8.0.1' } tasks.register('createChecksums', Checksum) { @@ -123,7 +128,7 @@ ext.presetPluginUrls = [ // Currently, plugin-app-store is not open source, so we need to download it from the official website. // Please see https://github.com/halo-dev/plugin-app-store/issues/55 // https://www.halo.run/store/apps/app-VYJbF - 'https://www.halo.run/store/apps/app-VYJbF/releases/download/app-release-BpYuv/assets/app-release-BpYuv-LTHgb': 'appstore.jar', + 'https://www.halo.run/store/apps/app-VYJbF/releases/download/app-release-dEEUO/assets/app-release-dEEUO-ZRgkG': 'appstore.jar', ] tasks.register('downloadPluginPresets', Download) { @@ -164,4 +169,4 @@ tasks.named('generateOpenApiDocs') { outputs.upToDateWhen { false } -} +} \ No newline at end of file diff --git a/application/src/main/java/run/halo/app/content/PostService.java b/application/src/main/java/run/halo/app/content/PostService.java index cf4c0b44a3..d9344a3de5 100644 --- a/application/src/main/java/run/halo/app/content/PostService.java +++ b/application/src/main/java/run/halo/app/content/PostService.java @@ -50,4 +50,6 @@ public interface PostService { Mono revertToSpecifiedSnapshot(String postName, String snapshotName); Mono deleteContent(String postName, String snapshotName); + + Mono recycleBy(String postName, String username); } diff --git a/application/src/main/java/run/halo/app/content/comment/AbstractCommentService.java b/application/src/main/java/run/halo/app/content/comment/AbstractCommentService.java index 175d1817f4..9c054ea399 100644 --- a/application/src/main/java/run/halo/app/content/comment/AbstractCommentService.java +++ b/application/src/main/java/run/halo/app/content/comment/AbstractCommentService.java @@ -5,14 +5,14 @@ import org.springframework.security.core.context.ReactiveSecurityContextHolder; import org.springframework.util.Assert; import reactor.core.publisher.Mono; +import run.halo.app.core.counter.CounterService; +import run.halo.app.core.counter.MeterUtils; import run.halo.app.core.extension.User; import run.halo.app.core.extension.content.Comment; import run.halo.app.core.extension.content.Reply; -import run.halo.app.core.extension.service.RoleService; -import run.halo.app.core.extension.service.UserService; +import run.halo.app.core.user.service.RoleService; +import run.halo.app.core.user.service.UserService; import run.halo.app.extension.ReactiveExtensionClient; -import run.halo.app.metrics.CounterService; -import run.halo.app.metrics.MeterUtils; import run.halo.app.security.authorization.AuthorityUtils; @RequiredArgsConstructor diff --git a/application/src/main/java/run/halo/app/content/comment/CommentServiceImpl.java b/application/src/main/java/run/halo/app/content/comment/CommentServiceImpl.java index a7510bdab2..d6059ec644 100644 --- a/application/src/main/java/run/halo/app/content/comment/CommentServiceImpl.java +++ b/application/src/main/java/run/halo/app/content/comment/CommentServiceImpl.java @@ -16,9 +16,10 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.util.retry.Retry; +import run.halo.app.core.counter.CounterService; import run.halo.app.core.extension.content.Comment; -import run.halo.app.core.extension.service.RoleService; -import run.halo.app.core.extension.service.UserService; +import run.halo.app.core.user.service.RoleService; +import run.halo.app.core.user.service.UserService; import run.halo.app.extension.Extension; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ListResult; @@ -29,7 +30,6 @@ import run.halo.app.extension.router.selector.FieldSelector; import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; import run.halo.app.infra.exception.AccessDeniedException; -import run.halo.app.metrics.CounterService; import run.halo.app.plugin.extensionpoint.ExtensionGetter; /** @@ -58,7 +58,7 @@ public Mono> listComment(CommentQuery commentQuery) { commentQuery.toPageRequest()) .flatMap(comments -> Flux.fromStream(comments.get() .map(this::toListedComment)) - .concatMap(Function.identity()) + .flatMapSequential(Function.identity()) .collectList() .map(list -> new ListResult<>(comments.getPage(), comments.getSize(), comments.getTotal(), list) diff --git a/application/src/main/java/run/halo/app/content/comment/ReplyServiceImpl.java b/application/src/main/java/run/halo/app/content/comment/ReplyServiceImpl.java index a59bbd3479..26567274a1 100644 --- a/application/src/main/java/run/halo/app/content/comment/ReplyServiceImpl.java +++ b/application/src/main/java/run/halo/app/content/comment/ReplyServiceImpl.java @@ -1,5 +1,6 @@ package run.halo.app.content.comment; +import static org.apache.commons.lang3.BooleanUtils.isTrue; import static run.halo.app.extension.index.query.QueryFactory.and; import static run.halo.app.extension.index.query.QueryFactory.equal; import static run.halo.app.extension.index.query.QueryFactory.isNull; @@ -8,8 +9,8 @@ import java.time.Instant; import java.util.ArrayList; import java.util.function.Function; +import java.util.function.Supplier; import java.util.function.UnaryOperator; -import org.apache.commons.lang3.BooleanUtils; import org.apache.commons.lang3.StringUtils; import org.reactivestreams.Publisher; import org.springframework.dao.OptimisticLockingFailureException; @@ -19,17 +20,18 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.util.retry.Retry; +import run.halo.app.core.counter.CounterService; import run.halo.app.core.extension.content.Comment; import run.halo.app.core.extension.content.Reply; -import run.halo.app.core.extension.service.RoleService; -import run.halo.app.core.extension.service.UserService; +import run.halo.app.core.user.service.RoleService; +import run.halo.app.core.user.service.UserService; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ListResult; import run.halo.app.extension.PageRequest; import run.halo.app.extension.PageRequestImpl; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.router.selector.FieldSelector; -import run.halo.app.metrics.CounterService; +import run.halo.app.infra.exception.RequestRestrictedException; /** * A default implementation of {@link ReplyService}. @@ -40,6 +42,9 @@ @Service public class ReplyServiceImpl extends AbstractCommentService implements ReplyService { + private final Supplier requestRestrictedExceptionSupplier = + () -> new RequestRestrictedException("problemDetail.comment.waitingForApproval"); + public ReplyServiceImpl(RoleService roleService, ReactiveExtensionClient client, UserService userService, CounterService counterService) { super(roleService, client, userService, counterService); @@ -48,26 +53,32 @@ public ReplyServiceImpl(RoleService roleService, ReactiveExtensionClient client, @Override public Mono create(String commentName, Reply reply) { return client.get(Comment.class, commentName) - .flatMap(comment -> prepareReply(commentName, reply) - .flatMap(client::create) - .flatMap(createdReply -> { - var quotedReply = createdReply.getSpec().getQuoteReply(); - if (StringUtils.isBlank(quotedReply)) { - return Mono.just(createdReply); - } - return approveReply(quotedReply) - .thenReturn(createdReply); - }) - .flatMap(createdReply -> approveComment(comment) - .thenReturn(createdReply) - ) - ); + .flatMap(this::approveComment) + .filter(comment -> isTrue(comment.getSpec().getApproved())) + .switchIfEmpty(Mono.error(requestRestrictedExceptionSupplier)) + .flatMap(comment -> prepareReply(commentName, reply)) + .flatMap(this::doCreateReply); + } + + private Mono doCreateReply(Reply prepared) { + var quotedReply = prepared.getSpec().getQuoteReply(); + if (StringUtils.isBlank(quotedReply)) { + return client.create(prepared); + } + return approveReply(quotedReply) + .filter(reply -> isTrue(reply.getSpec().getApproved())) + .switchIfEmpty(Mono.error(requestRestrictedExceptionSupplier)) + .flatMap(approvedQuoteReply -> client.create(prepared)); } private Mono approveComment(Comment comment) { return hasCommentManagePermission() - .filter(Boolean::booleanValue) - .flatMap(hasPermission -> doApproveComment(comment)); + .flatMap(hasPermission -> { + if (hasPermission) { + return doApproveComment(comment); + } + return Mono.just(comment); + }); } private Mono doApproveComment(Comment comment) { @@ -81,14 +92,18 @@ private Mono doApproveComment(Comment comment) { e -> updateCommentWithRetry(comment.getMetadata().getName(), updateFunc)); } - private Mono approveReply(String replyName) { + private Mono approveReply(String replyName) { return hasCommentManagePermission() - .filter(Boolean::booleanValue) - .flatMap(hasPermission -> doApproveReply(replyName)); + .flatMap(hasPermission -> { + if (hasPermission) { + return doApproveReply(replyName); + } + return client.get(Reply.class, replyName); + }); } - private Mono doApproveReply(String replyName) { - return Mono.defer(() -> client.fetch(Reply.class, replyName) + private Mono doApproveReply(String replyName) { + return Mono.defer(() -> client.get(Reply.class, replyName) .flatMap(reply -> { reply.getSpec().setApproved(true); reply.getSpec().setApprovedTime(Instant.now()); @@ -96,8 +111,7 @@ private Mono doApproveReply(String replyName) { }) ) .retryWhen(Retry.backoff(8, Duration.ofMillis(100)) - .filter(OptimisticLockingFailureException.class::isInstance)) - .then(); + .filter(OptimisticLockingFailureException.class::isInstance)); } private Mono updateCommentWithRetry(String name, UnaryOperator updateFunc) { @@ -123,7 +137,7 @@ private Mono prepareReply(String commentName, Reply reply) { if (reply.getSpec().getApproved() == null) { reply.getSpec().setApproved(false); } - if (BooleanUtils.isTrue(reply.getSpec().getApproved()) + if (isTrue(reply.getSpec().getApproved()) && reply.getSpec().getApprovedTime() == null) { reply.getSpec().setApprovedTime(Instant.now()); } @@ -152,7 +166,7 @@ public Mono> list(ReplyQuery query) { return client.listBy(Reply.class, query.toListOptions(), query.toPageRequest()) .flatMap(list -> Flux.fromStream(list.get() .map(this::toListedReply)) - .concatMap(Function.identity()) + .flatMapSequential(Function.identity()) .collectList() .map(listedReplies -> new ListResult<>(list.getPage(), list.getSize(), list.getTotal(), listedReplies)) diff --git a/application/src/main/java/run/halo/app/content/impl/PostServiceImpl.java b/application/src/main/java/run/halo/app/content/impl/PostServiceImpl.java index bf535a9fa1..6ce70871ec 100644 --- a/application/src/main/java/run/halo/app/content/impl/PostServiceImpl.java +++ b/application/src/main/java/run/halo/app/content/impl/PostServiceImpl.java @@ -30,11 +30,13 @@ import run.halo.app.content.PostRequest; import run.halo.app.content.PostService; import run.halo.app.content.Stats; +import run.halo.app.core.counter.CounterService; +import run.halo.app.core.counter.MeterUtils; import run.halo.app.core.extension.content.Category; import run.halo.app.core.extension.content.Post; import run.halo.app.core.extension.content.Snapshot; import run.halo.app.core.extension.content.Tag; -import run.halo.app.core.extension.service.UserService; +import run.halo.app.core.user.service.UserService; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ListResult; import run.halo.app.extension.MetadataOperator; @@ -43,8 +45,6 @@ import run.halo.app.extension.router.selector.FieldSelector; import run.halo.app.infra.Condition; import run.halo.app.infra.ConditionStatus; -import run.halo.app.metrics.CounterService; -import run.halo.app.metrics.MeterUtils; /** * A default implementation of {@link PostService}. @@ -77,7 +77,7 @@ public Mono> listPost(PostQuery query) { ) .flatMap(listResult -> Flux.fromStream(listResult.get()) .map(this::getListedPost) - .concatMap(Function.identity()) + .flatMapSequential(Function.identity()) .collectList() .map(listedPosts -> new ListResult<>(listResult.getPage(), listResult.getSize(), listResult.getTotal(), listedPosts) @@ -175,7 +175,7 @@ private Flux listContributors(List usernames) { return Flux.empty(); } return Flux.fromIterable(usernames) - .concatMap(userService::getUserOrGhost) + .flatMapSequential(userService::getUserOrGhost) .map(user -> { Contributor contributor = new Contributor(); contributor.setName(user.getMetadata().getName()); @@ -379,6 +379,15 @@ public Mono deleteContent(String postName, String snapshotName) }); } + @Override + public Mono recycleBy(String postName, String username) { + return getByUsername(postName, username) + .flatMap(post -> updatePostWithRetry(post, record -> { + record.getSpec().setDeleted(true); + return record; + })); + } + private Mono updatePostWithRetry(Post post, UnaryOperator func) { return client.update(func.apply(post)) .onErrorResume(OptimisticLockingFailureException.class, diff --git a/application/src/main/java/run/halo/app/content/impl/SinglePageServiceImpl.java b/application/src/main/java/run/halo/app/content/impl/SinglePageServiceImpl.java index ab055b67da..f279e7fdf1 100644 --- a/application/src/main/java/run/halo/app/content/impl/SinglePageServiceImpl.java +++ b/application/src/main/java/run/halo/app/content/impl/SinglePageServiceImpl.java @@ -25,17 +25,17 @@ import run.halo.app.content.SinglePageRequest; import run.halo.app.content.SinglePageService; import run.halo.app.content.Stats; +import run.halo.app.core.counter.CounterService; +import run.halo.app.core.counter.MeterUtils; import run.halo.app.core.extension.content.Post; import run.halo.app.core.extension.content.SinglePage; import run.halo.app.core.extension.content.Snapshot; -import run.halo.app.core.extension.service.UserService; +import run.halo.app.core.user.service.UserService; import run.halo.app.extension.ListResult; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.Ref; import run.halo.app.infra.Condition; import run.halo.app.infra.ConditionStatus; -import run.halo.app.metrics.CounterService; -import run.halo.app.metrics.MeterUtils; /** * Single page service implementation. @@ -88,7 +88,7 @@ public Flux listSnapshots(String pageName) { public Mono> list(SinglePageQuery query) { return client.listBy(SinglePage.class, query.toListOptions(), query.toPageRequest()) .flatMap(listResult -> Flux.fromStream(listResult.get().map(this::getListedSinglePage)) - .concatMap(Function.identity()) + .flatMapSequential(Function.identity()) .collectList() .map(listedSinglePages -> new ListResult<>( listResult.getPage(), diff --git a/application/src/main/java/run/halo/app/metrics/PostStatsUpdater.java b/application/src/main/java/run/halo/app/content/stats/PostStatsUpdater.java similarity index 98% rename from application/src/main/java/run/halo/app/metrics/PostStatsUpdater.java rename to application/src/main/java/run/halo/app/content/stats/PostStatsUpdater.java index f417c6e1ca..2a61889743 100644 --- a/application/src/main/java/run/halo/app/metrics/PostStatsUpdater.java +++ b/application/src/main/java/run/halo/app/content/stats/PostStatsUpdater.java @@ -1,4 +1,4 @@ -package run.halo.app.metrics; +package run.halo.app.content.stats; import java.time.Duration; import java.time.Instant; diff --git a/application/src/main/java/run/halo/app/metrics/ReplyEventReconciler.java b/application/src/main/java/run/halo/app/content/stats/ReplyEventReconciler.java similarity index 99% rename from application/src/main/java/run/halo/app/metrics/ReplyEventReconciler.java rename to application/src/main/java/run/halo/app/content/stats/ReplyEventReconciler.java index 9f0c5f4624..3a20f7af84 100644 --- a/application/src/main/java/run/halo/app/metrics/ReplyEventReconciler.java +++ b/application/src/main/java/run/halo/app/content/stats/ReplyEventReconciler.java @@ -1,4 +1,4 @@ -package run.halo.app.metrics; +package run.halo.app.content.stats; import static org.apache.commons.lang3.ObjectUtils.defaultIfNull; import static run.halo.app.extension.index.query.QueryFactory.and; diff --git a/application/src/main/java/run/halo/app/content/TagPostCountUpdater.java b/application/src/main/java/run/halo/app/content/stats/TagPostCountUpdater.java similarity index 98% rename from application/src/main/java/run/halo/app/content/TagPostCountUpdater.java rename to application/src/main/java/run/halo/app/content/stats/TagPostCountUpdater.java index 481da4559e..6221b2b03b 100644 --- a/application/src/main/java/run/halo/app/content/TagPostCountUpdater.java +++ b/application/src/main/java/run/halo/app/content/stats/TagPostCountUpdater.java @@ -1,4 +1,4 @@ -package run.halo.app.content; +package run.halo.app.content.stats; import static org.apache.commons.lang3.ObjectUtils.defaultIfNull; import static run.halo.app.extension.index.query.QueryFactory.and; @@ -12,6 +12,7 @@ import org.apache.commons.lang3.StringUtils; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; +import run.halo.app.content.AbstractEventReconciler; import run.halo.app.core.extension.content.Post; import run.halo.app.core.extension.content.Tag; import run.halo.app.core.extension.content.Tag.TagStatus; diff --git a/application/src/main/java/run/halo/app/metrics/VisitedEventReconciler.java b/application/src/main/java/run/halo/app/content/stats/VisitedEventReconciler.java similarity index 98% rename from application/src/main/java/run/halo/app/metrics/VisitedEventReconciler.java rename to application/src/main/java/run/halo/app/content/stats/VisitedEventReconciler.java index 968777a2cb..42b1932dd0 100644 --- a/application/src/main/java/run/halo/app/metrics/VisitedEventReconciler.java +++ b/application/src/main/java/run/halo/app/content/stats/VisitedEventReconciler.java @@ -1,4 +1,4 @@ -package run.halo.app.metrics; +package run.halo.app.content.stats; import java.time.Duration; import java.time.Instant; @@ -14,6 +14,7 @@ import org.springframework.scheduling.annotation.Async; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; +import run.halo.app.core.counter.MeterUtils; import run.halo.app.core.extension.Counter; import run.halo.app.event.post.VisitedEvent; import run.halo.app.extension.ExtensionClient; diff --git a/application/src/main/java/run/halo/app/metrics/VotedEventReconciler.java b/application/src/main/java/run/halo/app/content/stats/VotedEventReconciler.java similarity index 98% rename from application/src/main/java/run/halo/app/metrics/VotedEventReconciler.java rename to application/src/main/java/run/halo/app/content/stats/VotedEventReconciler.java index 915d498f4b..ed60a4130d 100644 --- a/application/src/main/java/run/halo/app/metrics/VotedEventReconciler.java +++ b/application/src/main/java/run/halo/app/content/stats/VotedEventReconciler.java @@ -1,4 +1,4 @@ -package run.halo.app.metrics; +package run.halo.app.content.stats; import java.time.Duration; import java.time.Instant; @@ -10,6 +10,7 @@ import org.springframework.context.event.EventListener; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; +import run.halo.app.core.counter.MeterUtils; import run.halo.app.core.extension.Counter; import run.halo.app.event.post.DownvotedEvent; import run.halo.app.event.post.UpvotedEvent; diff --git a/application/src/main/java/run/halo/app/core/attachment/AttachmentLister.java b/application/src/main/java/run/halo/app/core/attachment/AttachmentLister.java new file mode 100644 index 0000000000..ed57380428 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/attachment/AttachmentLister.java @@ -0,0 +1,10 @@ +package run.halo.app.core.attachment; + +import reactor.core.publisher.Mono; +import run.halo.app.core.extension.attachment.Attachment; +import run.halo.app.extension.ListResult; + +public interface AttachmentLister { + + Mono> listBy(SearchRequest searchRequest); +} diff --git a/application/src/main/java/run/halo/app/core/attachment/LocalThumbnailService.java b/application/src/main/java/run/halo/app/core/attachment/LocalThumbnailService.java index 050650ddb3..a372446e85 100644 --- a/application/src/main/java/run/halo/app/core/attachment/LocalThumbnailService.java +++ b/application/src/main/java/run/halo/app/core/attachment/LocalThumbnailService.java @@ -6,7 +6,7 @@ import org.springframework.core.io.Resource; import org.springframework.lang.NonNull; import reactor.core.publisher.Mono; -import run.halo.app.core.extension.attachment.LocalThumbnail; +import run.halo.app.core.attachment.extension.LocalThumbnail; import run.halo.app.infra.ExternalLinkProcessor; import run.halo.app.infra.exception.NotFoundException; diff --git a/application/src/main/java/run/halo/app/core/attachment/PolicyConfigChangeDetector.java b/application/src/main/java/run/halo/app/core/attachment/PolicyConfigChangeDetector.java new file mode 100644 index 0000000000..e8c43bbb92 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/attachment/PolicyConfigChangeDetector.java @@ -0,0 +1,141 @@ +package run.halo.app.core.attachment; + +import static run.halo.app.extension.index.query.QueryFactory.equal; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.context.SmartLifecycle; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Component; +import run.halo.app.core.extension.attachment.Attachment; +import run.halo.app.core.extension.attachment.Policy; +import run.halo.app.extension.ConfigMap; +import run.halo.app.extension.ExtensionClient; +import run.halo.app.extension.ExtensionMatcher; +import run.halo.app.extension.GroupVersionKind; +import run.halo.app.extension.ListOptions; +import run.halo.app.extension.MetadataUtil; +import run.halo.app.extension.controller.Controller; +import run.halo.app.extension.controller.ControllerBuilder; +import run.halo.app.extension.controller.DefaultController; +import run.halo.app.extension.controller.DefaultQueue; +import run.halo.app.extension.controller.Reconciler; +import run.halo.app.extension.controller.RequestQueue; + +/** + *

Detects changes to {@link ConfigMap} that are referenced by {@link Policy} and updates the + * {@link Attachment} with the {@link Policy} reference to reflect the change.

+ *

Without this, the link to the attachment corresponding to the storage policy configuration + * change may not be correctly updated and only the service can be restarted.

+ * + * @author guqing + * @since 2.20.0 + */ +@Component +@RequiredArgsConstructor +public class PolicyConfigChangeDetector implements Reconciler { + static final String POLICY_UPDATED_AT = "storage.halo.run/policy-updated-at"; + private final GroupVersionKind attachmentGvk = GroupVersionKind.fromExtension(Attachment.class); + private final ExtensionClient client; + private final AttachmentUpdateTrigger attachmentUpdateTrigger; + + @Override + public Result reconcile(Request request) { + client.fetch(ConfigMap.class, request.name()) + .ifPresent(configMap -> { + var labels = configMap.getMetadata().getLabels(); + if (labels == null || !labels.containsKey(Policy.POLICY_OWNER_LABEL)) { + return; + } + var policyName = labels.get(Policy.POLICY_OWNER_LABEL); + var attachmentNames = client.indexedQueryEngine() + .retrieveAll(attachmentGvk, ListOptions.builder() + .andQuery(equal("spec.policyName", policyName)) + .build(), + Sort.unsorted() + ); + attachmentUpdateTrigger.addAll(attachmentNames); + }); + return Result.doNotRetry(); + } + + @Override + public Controller setupWith(ControllerBuilder builder) { + ExtensionMatcher matcher = extension -> { + var configMap = (ConfigMap) extension; + var labels = configMap.getMetadata().getLabels(); + return labels != null && labels.containsKey(Policy.POLICY_OWNER_LABEL); + }; + return builder + .extension(new ConfigMap()) + .syncAllOnStart(false) + .onAddMatcher(matcher) + .onUpdateMatcher(matcher) + .onDeleteMatcher(matcher) + .build(); + } + + @Component + static class AttachmentUpdateTrigger implements Reconciler, SmartLifecycle { + private final RequestQueue queue; + + private final Controller controller; + + private volatile boolean running = false; + + private final ExtensionClient client; + + public AttachmentUpdateTrigger(ExtensionClient client) { + this.client = client; + this.queue = new DefaultQueue<>(Instant::now); + this.controller = this.setupWith(null); + } + + @Override + public Result reconcile(String name) { + client.fetch(Attachment.class, name).ifPresent(attachment -> { + var annotations = MetadataUtil.nullSafeAnnotations(attachment); + annotations.put(POLICY_UPDATED_AT, Instant.now().toString()); + client.update(attachment); + }); + return Result.doNotRetry(); + } + + void addAll(List names) { + for (String name : names) { + queue.addImmediately(name); + } + } + + @Override + public Controller setupWith(ControllerBuilder builder) { + return new DefaultController<>( + "PolicyChangeAttachmentUpdater", + this, + queue, + null, + Duration.ofMillis(100), + Duration.ofMinutes(10) + ); + } + + @Override + public void start() { + controller.start(); + running = true; + } + + @Override + public void stop() { + running = false; + controller.dispose(); + } + + @Override + public boolean isRunning() { + return running; + } + } +} diff --git a/application/src/main/java/run/halo/app/core/attachment/SearchRequest.java b/application/src/main/java/run/halo/app/core/attachment/SearchRequest.java new file mode 100644 index 0000000000..05ead06ba5 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/attachment/SearchRequest.java @@ -0,0 +1,110 @@ +package run.halo.app.core.attachment; + +import static org.springdoc.core.fn.builders.arrayschema.Builder.arraySchemaBuilder; +import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; +import static org.springdoc.core.fn.builders.schema.Builder.schemaBuilder; +import static org.springframework.boot.convert.ApplicationConversionService.getSharedInstance; +import static run.halo.app.extension.index.query.QueryFactory.contains; +import static run.halo.app.extension.index.query.QueryFactory.in; +import static run.halo.app.extension.index.query.QueryFactory.isNull; +import static run.halo.app.extension.index.query.QueryFactory.not; +import static run.halo.app.extension.index.query.QueryFactory.startsWith; + +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import java.util.List; +import java.util.Optional; +import org.springdoc.core.fn.builders.operation.Builder; +import org.springframework.util.CollectionUtils; +import org.springframework.util.StringUtils; +import org.springframework.web.reactive.function.server.ServerRequest; +import run.halo.app.extension.ListOptions; +import run.halo.app.extension.index.query.QueryFactory; +import run.halo.app.extension.router.IListRequest; +import run.halo.app.extension.router.QueryParamBuildUtil; +import run.halo.app.extension.router.SortableRequest; + +public class SearchRequest extends SortableRequest { + + public SearchRequest(ServerRequest request) { + super(request.exchange()); + } + + public Optional getKeyword() { + return Optional.ofNullable(queryParams.getFirst("keyword")) + .filter(StringUtils::hasText); + } + + public Optional getUngrouped() { + return Optional.ofNullable(queryParams.getFirst("ungrouped")) + .map(ungroupedStr -> getSharedInstance().convert(ungroupedStr, Boolean.class)); + } + + public Optional> getAccepts() { + return Optional.ofNullable(queryParams.get("accepts")) + .filter(accepts -> !accepts.isEmpty() + && !accepts.contains("*") + && !accepts.contains("*/*") + ); + } + + public ListOptions toListOptions(List hiddenGroups) { + var builder = ListOptions.builder(super.toListOptions()); + + getKeyword().ifPresent(keyword -> { + builder.andQuery(contains("spec.displayName", keyword)); + }); + + getUngrouped() + .filter(ungrouped -> ungrouped) + .ifPresent(ungrouped -> builder.andQuery(isNull("spec.groupName"))); + + if (!CollectionUtils.isEmpty(hiddenGroups)) { + builder.andQuery(not(in("spec.groupName", hiddenGroups))); + } + + getAccepts().flatMap(accepts -> accepts.stream() + .filter(StringUtils::hasText) + .map(accept -> accept.replace("/*", "/").toLowerCase()) + .distinct() + .map(accept -> startsWith("spec.mediaType", accept)) + .reduce(QueryFactory::or) + ) + .ifPresent(builder::andQuery); + + return builder.build(); + } + + public static void buildParameters(Builder builder) { + IListRequest.buildParameters(builder); + builder.parameter(QueryParamBuildUtil.sortParameter()) + .parameter(parameterBuilder() + .in(ParameterIn.QUERY) + .name("ungrouped") + .required(false) + .description(""" + Filter attachments without group. This parameter will ignore group \ + parameter.\ + """) + .implementation(Boolean.class)) + .parameter(parameterBuilder() + .in(ParameterIn.QUERY) + .name("keyword") + .required(false) + .description("Keyword for searching.") + .implementation(String.class)) + .parameter(parameterBuilder() + .in(ParameterIn.QUERY) + .name("accepts") + .required(false) + .description("Acceptable media types.") + .array( + arraySchemaBuilder() + .uniqueItems(true) + .schema(schemaBuilder() + .implementation(String.class) + .example("image/*")) + ) + .implementationArray(String.class) + ); + } +} diff --git a/application/src/main/java/run/halo/app/core/attachment/ThumbnailGenerator.java b/application/src/main/java/run/halo/app/core/attachment/ThumbnailGenerator.java index 1bf189d903..30b2d7455e 100644 --- a/application/src/main/java/run/halo/app/core/attachment/ThumbnailGenerator.java +++ b/application/src/main/java/run/halo/app/core/attachment/ThumbnailGenerator.java @@ -2,10 +2,15 @@ import static java.nio.file.StandardCopyOption.REPLACE_EXISTING; +import com.drew.imaging.ImageMetadataReader; +import com.drew.metadata.Directory; +import com.drew.metadata.Metadata; +import com.drew.metadata.exif.ExifIFD0Directory; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; +import java.net.HttpURLConnection; import java.net.MalformedURLException; import java.net.URISyntaxException; import java.net.URL; @@ -13,12 +18,14 @@ import java.nio.file.Path; import java.util.Iterator; import java.util.Optional; +import java.util.Set; import javax.imageio.ImageIO; import javax.imageio.ImageReader; import javax.imageio.stream.ImageInputStream; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.imgscalr.Scalr; +import org.springframework.lang.NonNull; @Slf4j @AllArgsConstructor @@ -29,6 +36,8 @@ public class ThumbnailGenerator { */ static final int MAX_FILE_SIZE = 30 * 1024 * 1024; + private static final Set UNSUPPORTED_FORMATS = Set.of("gif", "svg", "webp"); + private final ImageDownloader imageDownloader = new ImageDownloader(); private final ThumbnailSize size; private final Path storePath; @@ -70,13 +79,16 @@ private void generateThumbnail(Path tempImagePath) throws IOException { if (file.length() > MAX_FILE_SIZE) { throw new IOException("File size exceeds the limit: " + MAX_FILE_SIZE); } - var formatNameOpt = getFormatName(file); + String formatName = getFormatName(file) + .orElseThrow(() -> new UnsupportedOperationException("Unknown format")); + if (isUnsupportedFormat(formatName)) { + throw new UnsupportedOperationException("Unsupported image format for: " + formatName); + } + var img = ImageIO.read(file); if (img == null) { - throw new UnsupportedOperationException( - "Unsupported image format for: " + formatNameOpt.orElse("unknown")); + throw new UnsupportedOperationException("Cannot read image file: " + file); } - var formatName = formatNameOpt.orElse("jpg"); var thumbnailFile = getThumbnailFile(formatName); if (img.getWidth() <= size.getWidth()) { Files.copy(tempImagePath, thumbnailFile.toPath(), REPLACE_EXISTING); @@ -84,9 +96,42 @@ private void generateThumbnail(Path tempImagePath) throws IOException { } var thumbnail = Scalr.resize(img, Scalr.Method.AUTOMATIC, Scalr.Mode.FIT_TO_WIDTH, size.getWidth()); + // Rotate image if needed + var orientation = readExifOrientation(file); + if (orientation != null) { + thumbnail = Scalr.rotate(thumbnail, orientation); + } ImageIO.write(thumbnail, formatName, thumbnailFile); } + private static Scalr.Rotation readExifOrientation(File inputFile) { + try { + Metadata metadata = ImageMetadataReader.readMetadata(inputFile); + Directory directory = metadata.getFirstDirectoryOfType(ExifIFD0Directory.class); + if (directory != null && directory.containsTag(ExifIFD0Directory.TAG_ORIENTATION)) { + return getScalrRotationFromExifOrientation( + directory.getInt(ExifIFD0Directory.TAG_ORIENTATION)); + } + } catch (Exception e) { + log.debug("Failed to read EXIF orientation from file: {}", inputFile, e); + } + return null; + } + + private static Scalr.Rotation getScalrRotationFromExifOrientation(int orientation) { + // https://www.media.mit.edu/pia/Research/deepview/exif.html#:~:text=0x0112-,Orientation,-unsigned%20short + return switch (orientation) { + case 3 -> Scalr.Rotation.CW_180; + case 6 -> Scalr.Rotation.CW_90; + case 8 -> Scalr.Rotation.CW_270; + default -> null; + }; + } + + private static boolean isUnsupportedFormat(@NonNull String formatName) { + return UNSUPPORTED_FORMATS.contains(formatName.toLowerCase()); + } + private File getThumbnailFile(String formatName) { return Optional.of(storePath) .map(path -> { @@ -161,7 +206,11 @@ Path downloadFileInternal(URL url) throws IOException { File tempFile = File.createTempFile("halo-image-thumb-", ".tmp"); long totalBytesDownloaded = 0; var tempFilePath = tempFile.toPath(); - try (InputStream inputStream = url.openStream(); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestProperty("User-Agent", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)" + + " Chrome/92.0.4515.131 Safari/537.36"); + try (InputStream inputStream = connection.getInputStream(); FileOutputStream outputStream = new FileOutputStream(tempFile)) { byte[] buffer = new byte[4096]; diff --git a/application/src/main/java/run/halo/app/core/extension/attachment/endpoint/AttachmentEndpoint.java b/application/src/main/java/run/halo/app/core/attachment/endpoint/AttachmentEndpoint.java similarity index 55% rename from application/src/main/java/run/halo/app/core/extension/attachment/endpoint/AttachmentEndpoint.java rename to application/src/main/java/run/halo/app/core/attachment/endpoint/AttachmentEndpoint.java index 3e9c5b35d4..6ecdd19e9a 100644 --- a/application/src/main/java/run/halo/app/core/extension/attachment/endpoint/AttachmentEndpoint.java +++ b/application/src/main/java/run/halo/app/core/attachment/endpoint/AttachmentEndpoint.java @@ -1,38 +1,24 @@ -package run.halo.app.core.extension.attachment.endpoint; +package run.halo.app.core.attachment.endpoint; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; -import static org.springdoc.core.fn.builders.arrayschema.Builder.arraySchemaBuilder; import static org.springdoc.core.fn.builders.content.Builder.contentBuilder; -import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder; import static org.springdoc.core.fn.builders.schema.Builder.schemaBuilder; -import static org.springframework.boot.convert.ApplicationConversionService.getSharedInstance; import static org.springframework.web.reactive.function.server.RequestPredicates.contentType; import static run.halo.app.extension.ListResult.generateGenericClass; -import static run.halo.app.extension.index.query.QueryFactory.contains; -import static run.halo.app.extension.index.query.QueryFactory.in; -import static run.halo.app.extension.index.query.QueryFactory.isNull; -import static run.halo.app.extension.index.query.QueryFactory.not; -import static run.halo.app.extension.index.query.QueryFactory.startsWith; -import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.media.Schema; import java.net.URL; -import java.util.List; import java.util.Objects; -import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springdoc.core.fn.builders.operation.Builder; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; -import org.springframework.data.domain.Sort; import org.springframework.http.MediaType; import org.springframework.http.codec.multipart.FilePart; import org.springframework.http.codec.multipart.FormFieldPart; import org.springframework.http.codec.multipart.Part; import org.springframework.stereotype.Component; -import org.springframework.util.CollectionUtils; import org.springframework.util.MultiValueMap; import org.springframework.util.StringUtils; import org.springframework.web.reactive.function.BodyExtractors; @@ -41,18 +27,11 @@ import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.server.ServerWebInputException; import reactor.core.publisher.Mono; +import run.halo.app.core.attachment.AttachmentLister; +import run.halo.app.core.attachment.SearchRequest; import run.halo.app.core.extension.attachment.Attachment; -import run.halo.app.core.extension.attachment.Group; import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.core.extension.service.AttachmentService; -import run.halo.app.extension.ListOptions; -import run.halo.app.extension.PageRequestImpl; -import run.halo.app.extension.ReactiveExtensionClient; -import run.halo.app.extension.index.query.QueryFactory; -import run.halo.app.extension.router.IListRequest; -import run.halo.app.extension.router.QueryParamBuildUtil; -import run.halo.app.extension.router.SortableRequest; -import run.halo.app.extension.router.selector.LabelSelector; @Slf4j @Component @@ -61,7 +40,7 @@ public class AttachmentEndpoint implements CustomEndpoint { private final AttachmentService attachmentService; - private final ReactiveExtensionClient client; + private final AttachmentLister attachmentLister; @Override public RouterFunction endpoint() { @@ -131,112 +110,13 @@ public RouterFunction endpoint() { Mono search(ServerRequest request) { var searchRequest = new SearchRequest(request); - var groupListOptions = new ListOptions(); - groupListOptions.setLabelSelector(LabelSelector.builder() - .exists(Group.HIDDEN_LABEL) - .build()); - return client.listAll(Group.class, groupListOptions, Sort.unsorted()) - .map(group -> group.getMetadata().getName()) - .collectList() - .defaultIfEmpty(List.of()) - .flatMap(hiddenGroups -> client.listBy(Attachment.class, - searchRequest.toListOptions(hiddenGroups), - PageRequestImpl.of(searchRequest.getPage(), searchRequest.getSize(), - searchRequest.getSort()) - ) - .flatMap(listResult -> ServerResponse.ok() - .contentType(MediaType.APPLICATION_JSON) - .bodyValue(listResult) - ) + return attachmentLister.listBy(searchRequest) + .flatMap(listResult -> ServerResponse.ok() + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(listResult) ); } - public static class SearchRequest extends SortableRequest { - - public SearchRequest(ServerRequest request) { - super(request.exchange()); - } - - public Optional getKeyword() { - return Optional.ofNullable(queryParams.getFirst("keyword")) - .filter(StringUtils::hasText); - } - - public Optional getUngrouped() { - return Optional.ofNullable(queryParams.getFirst("ungrouped")) - .map(ungroupedStr -> getSharedInstance().convert(ungroupedStr, Boolean.class)); - } - - public Optional> getAccepts() { - return Optional.ofNullable(queryParams.get("accepts")) - .filter(accepts -> !accepts.isEmpty() - && !accepts.contains("*") - && !accepts.contains("*/*") - ); - } - - public ListOptions toListOptions(List hiddenGroups) { - var builder = ListOptions.builder(super.toListOptions()); - - getKeyword().ifPresent(keyword -> { - builder.andQuery(contains("spec.displayName", keyword)); - }); - - getUngrouped() - .filter(ungrouped -> ungrouped) - .ifPresent(ungrouped -> builder.andQuery(isNull("spec.groupName"))); - - if (!CollectionUtils.isEmpty(hiddenGroups)) { - builder.andQuery(not(in("spec.groupName", hiddenGroups))); - } - - getAccepts().flatMap(accepts -> accepts.stream() - .filter(StringUtils::hasText) - .map(accept -> accept.replace("/*", "/").toLowerCase()) - .distinct() - .map(accept -> startsWith("spec.mediaType", accept)) - .reduce(QueryFactory::or) - ) - .ifPresent(builder::andQuery); - - return builder.build(); - } - - public static void buildParameters(Builder builder) { - IListRequest.buildParameters(builder); - builder.parameter(QueryParamBuildUtil.sortParameter()) - .parameter(parameterBuilder() - .in(ParameterIn.QUERY) - .name("ungrouped") - .required(false) - .description(""" - Filter attachments without group. This parameter will ignore group \ - parameter.\ - """) - .implementation(Boolean.class)) - .parameter(parameterBuilder() - .in(ParameterIn.QUERY) - .name("keyword") - .required(false) - .description("Keyword for searching.") - .implementation(String.class)) - .parameter(parameterBuilder() - .in(ParameterIn.QUERY) - .name("accepts") - .required(false) - .description("Acceptable media types.") - .array( - arraySchemaBuilder() - .uniqueItems(true) - .schema(schemaBuilder() - .implementation(String.class) - .example("image/*")) - ) - .implementationArray(String.class) - ); - } - } - public record UploadFromUrlRequest(@Schema(requiredMode = REQUIRED) URL url, @Schema(requiredMode = REQUIRED) String policyName, String groupName, diff --git a/application/src/main/java/run/halo/app/core/extension/attachment/endpoint/LocalAttachmentUploadHandler.java b/application/src/main/java/run/halo/app/core/attachment/endpoint/LocalAttachmentUploadHandler.java similarity index 93% rename from application/src/main/java/run/halo/app/core/extension/attachment/endpoint/LocalAttachmentUploadHandler.java rename to application/src/main/java/run/halo/app/core/attachment/endpoint/LocalAttachmentUploadHandler.java index b0db21fc81..2c4351112f 100644 --- a/application/src/main/java/run/halo/app/core/extension/attachment/endpoint/LocalAttachmentUploadHandler.java +++ b/application/src/main/java/run/halo/app/core/attachment/endpoint/LocalAttachmentUploadHandler.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.attachment.endpoint; +package run.halo.app.core.attachment.endpoint; import static java.nio.file.StandardOpenOption.CREATE_NEW; import static run.halo.app.infra.utils.FileNameUtils.randomFileName; @@ -6,6 +6,7 @@ import static run.halo.app.infra.utils.FileUtils.deleteFileSilently; import java.io.IOException; +import java.io.InputStream; import java.net.URI; import java.nio.charset.StandardCharsets; import java.nio.file.FileAlreadyExistsException; @@ -25,6 +26,7 @@ import org.springframework.core.io.buffer.DataBufferUtils; import org.springframework.http.MediaType; import org.springframework.http.codec.multipart.FilePart; +import org.springframework.lang.NonNull; import org.springframework.stereotype.Component; import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; @@ -41,6 +43,7 @@ import run.halo.app.core.extension.attachment.Attachment.AttachmentSpec; import run.halo.app.core.extension.attachment.Constant; import run.halo.app.core.extension.attachment.Policy; +import run.halo.app.core.extension.attachment.endpoint.AttachmentHandler; import run.halo.app.extension.ConfigMap; import run.halo.app.extension.Metadata; import run.halo.app.infra.ExternalUrlSupplier; @@ -155,19 +158,14 @@ private Mono validateFile(FilePart file, PolicySetting setting) { var typeValidator = file.content() .next() .handle((dataBuffer, sink) -> { - var mimeType = "Unknown"; - try { - mimeType = FileTypeDetectUtils.detectMimeType(dataBuffer.asInputStream()); - var isAllow = setting.getAllowedFileTypes() - .stream() - .map(FileCategoryMatcher::of) - .anyMatch(matcher -> matcher.match(file.filename())); - if (isAllow) { - sink.next(dataBuffer); - return; - } - } catch (IOException e) { - log.warn("Failed to detect file type", e); + var mimeType = detectMimeType(dataBuffer.asInputStream(), file.name()); + var isAllow = setting.getAllowedFileTypes() + .stream() + .map(FileCategoryMatcher::of) + .anyMatch(matcher -> matcher.match(mimeType)); + if (isAllow) { + sink.next(dataBuffer); + return; } sink.error(new FileTypeNotAllowedException("File type is not allowed", "problemDetail.attachment.upload.fileTypeNotSupported", @@ -179,6 +177,16 @@ private Mono validateFile(FilePart file, PolicySetting setting) { return Mono.when(validations); } + @NonNull + private String detectMimeType(InputStream inputStream, String name) { + try { + return FileTypeDetectUtils.detectMimeType(inputStream, name); + } catch (IOException e) { + log.warn("Failed to detect file type", e); + return "Unknown"; + } + } + @Override public Mono delete(DeleteContext deleteContext) { return Mono.just(deleteContext) diff --git a/application/src/main/java/run/halo/app/core/extension/attachment/LocalThumbnail.java b/application/src/main/java/run/halo/app/core/attachment/extension/LocalThumbnail.java similarity index 98% rename from application/src/main/java/run/halo/app/core/extension/attachment/LocalThumbnail.java rename to application/src/main/java/run/halo/app/core/attachment/extension/LocalThumbnail.java index a4378853a4..97ee3974b1 100644 --- a/application/src/main/java/run/halo/app/core/extension/attachment/LocalThumbnail.java +++ b/application/src/main/java/run/halo/app/core/attachment/extension/LocalThumbnail.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.attachment; +package run.halo.app.core.attachment.extension; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; diff --git a/application/src/main/java/run/halo/app/core/extension/attachment/Thumbnail.java b/application/src/main/java/run/halo/app/core/attachment/extension/Thumbnail.java similarity index 96% rename from application/src/main/java/run/halo/app/core/extension/attachment/Thumbnail.java rename to application/src/main/java/run/halo/app/core/attachment/extension/Thumbnail.java index 759a362f52..0228cc99d9 100644 --- a/application/src/main/java/run/halo/app/core/extension/attachment/Thumbnail.java +++ b/application/src/main/java/run/halo/app/core/attachment/extension/Thumbnail.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.attachment; +package run.halo.app.core.attachment.extension; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; diff --git a/application/src/main/java/run/halo/app/core/attachment/impl/AttachmentListerImpl.java b/application/src/main/java/run/halo/app/core/attachment/impl/AttachmentListerImpl.java new file mode 100644 index 0000000000..3f0511d5d6 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/attachment/impl/AttachmentListerImpl.java @@ -0,0 +1,37 @@ +package run.halo.app.core.attachment.impl; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; +import run.halo.app.core.attachment.AttachmentLister; +import run.halo.app.core.attachment.SearchRequest; +import run.halo.app.core.extension.attachment.Attachment; +import run.halo.app.core.extension.attachment.Group; +import run.halo.app.extension.ListOptions; +import run.halo.app.extension.ListResult; +import run.halo.app.extension.ReactiveExtensionClient; + +@Component +@RequiredArgsConstructor +public class AttachmentListerImpl implements AttachmentLister { + private final ReactiveExtensionClient client; + + @Override + public Mono> listBy(SearchRequest searchRequest) { + var groupListOptions = ListOptions.builder() + .labelSelector() + .exists(Group.HIDDEN_LABEL) + .end() + .build(); + return client.listAll(Group.class, groupListOptions, Sort.unsorted()) + .map(group -> group.getMetadata().getName()) + .collectList() + .defaultIfEmpty(List.of()) + .flatMap(hiddenGroups -> client.listBy(Attachment.class, + searchRequest.toListOptions(hiddenGroups), + searchRequest.toPageRequest() + )); + } +} diff --git a/application/src/main/java/run/halo/app/core/attachment/impl/LocalThumbnailServiceImpl.java b/application/src/main/java/run/halo/app/core/attachment/impl/LocalThumbnailServiceImpl.java index 747606253c..5b25d4b012 100644 --- a/application/src/main/java/run/halo/app/core/attachment/impl/LocalThumbnailServiceImpl.java +++ b/application/src/main/java/run/halo/app/core/attachment/impl/LocalThumbnailServiceImpl.java @@ -38,7 +38,7 @@ import run.halo.app.core.attachment.ThumbnailGenerator; import run.halo.app.core.attachment.ThumbnailSigner; import run.halo.app.core.attachment.ThumbnailSize; -import run.halo.app.core.extension.attachment.LocalThumbnail; +import run.halo.app.core.attachment.extension.LocalThumbnail; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ListResult; import run.halo.app.extension.Metadata; diff --git a/application/src/main/java/run/halo/app/core/attachment/impl/ThumbnailServiceImpl.java b/application/src/main/java/run/halo/app/core/attachment/impl/ThumbnailServiceImpl.java index 11cc3cadf7..a499ebc006 100644 --- a/application/src/main/java/run/halo/app/core/attachment/impl/ThumbnailServiceImpl.java +++ b/application/src/main/java/run/halo/app/core/attachment/impl/ThumbnailServiceImpl.java @@ -20,7 +20,7 @@ import run.halo.app.core.attachment.ThumbnailService; import run.halo.app.core.attachment.ThumbnailSigner; import run.halo.app.core.attachment.ThumbnailSize; -import run.halo.app.core.extension.attachment.Thumbnail; +import run.halo.app.core.attachment.extension.Thumbnail; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ListResult; import run.halo.app.extension.Metadata; diff --git a/application/src/main/java/run/halo/app/core/attachment/reconciler/AttachmentReconciler.java b/application/src/main/java/run/halo/app/core/attachment/reconciler/AttachmentReconciler.java index 0441390cc5..cc99fb226c 100644 --- a/application/src/main/java/run/halo/app/core/attachment/reconciler/AttachmentReconciler.java +++ b/application/src/main/java/run/halo/app/core/attachment/reconciler/AttachmentReconciler.java @@ -14,7 +14,6 @@ import org.apache.commons.lang3.StringUtils; import org.springframework.stereotype.Component; import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; import run.halo.app.core.attachment.AttachmentUtils; import run.halo.app.core.attachment.ThumbnailService; import run.halo.app.core.attachment.ThumbnailSize; @@ -28,6 +27,7 @@ import run.halo.app.extension.controller.ControllerBuilder; import run.halo.app.extension.controller.Reconciler; import run.halo.app.extension.controller.Reconciler.Request; +import run.halo.app.extension.controller.RequeueException; @Slf4j @Component @@ -57,18 +57,15 @@ public Result reconcile(Request request) { var annotations = attachment.getMetadata().getAnnotations(); if (annotations != null) { - attachmentService.getPermalink(attachment) - .map(URI::toString) - .switchIfEmpty(Mono.fromSupplier(() -> { - // Only for back-compatibility - return annotations.get(Constant.EXTERNAL_LINK_ANNO_KEY); - })) - .doOnNext(permalink -> { - log.debug("Set permalink {} for attachment {}", permalink, request.name()); - var status = nullSafeStatus(attachment); - status.setPermalink(permalink); - }) - .blockOptional(); + var permalink = attachmentService.getPermalink(attachment) + .map(URI::toASCIIString) + .blockOptional() + .orElseThrow(() -> new RequeueException(new Result(true, null), + "Attachment handler is unavailable, requeue the request" + )); + log.debug("Set permalink {} for attachment {}", permalink, request.name()); + var status = nullSafeStatus(attachment); + status.setPermalink(permalink); } var permalink = nullSafeStatus(attachment).getPermalink(); if (StringUtils.isNotBlank(permalink) && AttachmentUtils.isImage(attachment)) { diff --git a/application/src/main/java/run/halo/app/core/attachment/reconciler/LocalThumbnailsReconciler.java b/application/src/main/java/run/halo/app/core/attachment/reconciler/LocalThumbnailsReconciler.java index 1f1eb7b38f..900f57ff20 100644 --- a/application/src/main/java/run/halo/app/core/attachment/reconciler/LocalThumbnailsReconciler.java +++ b/application/src/main/java/run/halo/app/core/attachment/reconciler/LocalThumbnailsReconciler.java @@ -1,7 +1,7 @@ package run.halo.app.core.attachment.reconciler; import static org.springframework.data.domain.Sort.Order.desc; -import static run.halo.app.core.extension.attachment.LocalThumbnail.REQUEST_TO_GENERATE_ANNO; +import static run.halo.app.core.attachment.extension.LocalThumbnail.REQUEST_TO_GENERATE_ANNO; import static run.halo.app.extension.MetadataUtil.nullSafeAnnotations; import static run.halo.app.extension.index.query.QueryFactory.and; import static run.halo.app.extension.index.query.QueryFactory.equal; @@ -23,10 +23,11 @@ import run.halo.app.core.attachment.AttachmentUtils; import run.halo.app.core.attachment.LocalThumbnailService; import run.halo.app.core.attachment.ThumbnailGenerator; +import run.halo.app.core.attachment.extension.LocalThumbnail; import run.halo.app.core.extension.attachment.Attachment; import run.halo.app.core.extension.attachment.Constant; -import run.halo.app.core.extension.attachment.LocalThumbnail; import run.halo.app.extension.ExtensionClient; +import run.halo.app.extension.ExtensionUtil; import run.halo.app.extension.ListOptions; import run.halo.app.extension.PageRequestImpl; import run.halo.app.extension.controller.Controller; @@ -47,6 +48,9 @@ public class LocalThumbnailsReconciler implements Reconciler public Result reconcile(Request request) { client.fetch(LocalThumbnail.class, request.name()) .ifPresent(thumbnail -> { + if (ExtensionUtil.isDeleted(thumbnail)) { + return; + } if (shouldGenerate(thumbnail)) { requestGenerateThumbnail(thumbnail); nullSafeAnnotations(thumbnail).remove(REQUEST_TO_GENERATE_ANNO); diff --git a/application/src/main/java/run/halo/app/core/attachment/reconciler/PolicyReconciler.java b/application/src/main/java/run/halo/app/core/attachment/reconciler/PolicyReconciler.java new file mode 100644 index 0000000000..000b5bd102 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/attachment/reconciler/PolicyReconciler.java @@ -0,0 +1,44 @@ +package run.halo.app.core.attachment.reconciler; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import run.halo.app.core.extension.attachment.Policy; +import run.halo.app.extension.ConfigMap; +import run.halo.app.extension.ExtensionClient; +import run.halo.app.extension.MetadataUtil; +import run.halo.app.extension.controller.Controller; +import run.halo.app.extension.controller.ControllerBuilder; +import run.halo.app.extension.controller.Reconciler; + +@Component +@RequiredArgsConstructor +public class PolicyReconciler implements Reconciler { + private final ExtensionClient client; + + @Override + public Result reconcile(Request request) { + client.fetch(Policy.class, request.name()) + .ifPresent(this::checkOwnerLabel); + return Result.doNotRetry(); + } + + private void checkOwnerLabel(Policy policy) { + var policyName = policy.getMetadata().getName(); + var configMapName = policy.getSpec().getConfigMapName(); + client.fetch(ConfigMap.class, configMapName) + .ifPresent(configMap -> { + var labels = MetadataUtil.nullSafeLabels(configMap); + labels.put(Policy.POLICY_OWNER_LABEL, policyName); + client.update(configMap); + }); + } + + @Override + public Controller setupWith(ControllerBuilder builder) { + return builder + .extension(new Policy()) + // sync on start for compatible with previous data + .syncAllOnStart(true) + .build(); + } +} diff --git a/application/src/main/java/run/halo/app/metrics/CounterService.java b/application/src/main/java/run/halo/app/core/counter/CounterService.java similarity index 88% rename from application/src/main/java/run/halo/app/metrics/CounterService.java rename to application/src/main/java/run/halo/app/core/counter/CounterService.java index 8dcf3b9597..f3940c796f 100644 --- a/application/src/main/java/run/halo/app/metrics/CounterService.java +++ b/application/src/main/java/run/halo/app/core/counter/CounterService.java @@ -1,4 +1,4 @@ -package run.halo.app.metrics; +package run.halo.app.core.counter; import reactor.core.publisher.Mono; import run.halo.app.core.extension.Counter; diff --git a/application/src/main/java/run/halo/app/metrics/CounterServiceImpl.java b/application/src/main/java/run/halo/app/core/counter/CounterServiceImpl.java similarity index 95% rename from application/src/main/java/run/halo/app/metrics/CounterServiceImpl.java rename to application/src/main/java/run/halo/app/core/counter/CounterServiceImpl.java index 64727b5be2..d1be85fc77 100644 --- a/application/src/main/java/run/halo/app/metrics/CounterServiceImpl.java +++ b/application/src/main/java/run/halo/app/core/counter/CounterServiceImpl.java @@ -1,4 +1,4 @@ -package run.halo.app.metrics; +package run.halo.app.core.counter; import org.springframework.stereotype.Service; import reactor.core.publisher.Mono; diff --git a/application/src/main/java/run/halo/app/metrics/MeterUtils.java b/application/src/main/java/run/halo/app/core/counter/MeterUtils.java similarity index 99% rename from application/src/main/java/run/halo/app/metrics/MeterUtils.java rename to application/src/main/java/run/halo/app/core/counter/MeterUtils.java index c61c77345f..49b5356f31 100644 --- a/application/src/main/java/run/halo/app/metrics/MeterUtils.java +++ b/application/src/main/java/run/halo/app/core/counter/MeterUtils.java @@ -1,4 +1,4 @@ -package run.halo.app.metrics; +package run.halo.app.core.counter; import io.micrometer.core.instrument.Counter; import io.micrometer.core.instrument.MeterRegistry; diff --git a/application/src/main/java/run/halo/app/core/endpoint/WebSocketHandlerMapping.java b/application/src/main/java/run/halo/app/core/endpoint/WebSocketHandlerMapping.java index 0105c03b38..c76fa0de25 100644 --- a/application/src/main/java/run/halo/app/core/endpoint/WebSocketHandlerMapping.java +++ b/application/src/main/java/run/halo/app/core/endpoint/WebSocketHandlerMapping.java @@ -19,7 +19,7 @@ import org.springframework.web.server.ServerWebExchange; import org.springframework.web.util.pattern.PathPattern; import reactor.core.publisher.Mono; -import run.halo.app.console.WebSocketUtils; +import run.halo.app.infra.console.WebSocketUtils; public class WebSocketHandlerMapping extends AbstractHandlerMapping implements WebSocketEndpointManager, InitializingBean { diff --git a/application/src/main/java/run/halo/app/core/extension/endpoint/AuthProviderEndpoint.java b/application/src/main/java/run/halo/app/core/endpoint/console/AuthProviderEndpoint.java similarity index 97% rename from application/src/main/java/run/halo/app/core/extension/endpoint/AuthProviderEndpoint.java rename to application/src/main/java/run/halo/app/core/endpoint/console/AuthProviderEndpoint.java index a58b922fe5..e1ecbbc1be 100644 --- a/application/src/main/java/run/halo/app/core/extension/endpoint/AuthProviderEndpoint.java +++ b/application/src/main/java/run/halo/app/core/endpoint/console/AuthProviderEndpoint.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.endpoint; +package run.halo.app.core.endpoint.console; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; @@ -12,6 +12,7 @@ import org.springframework.web.reactive.function.server.ServerResponse; import reactor.core.publisher.Mono; import run.halo.app.core.extension.AuthProvider; +import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.security.AuthProviderService; import run.halo.app.security.ListedAuthProvider; diff --git a/application/src/main/java/run/halo/app/core/extension/endpoint/CommentEndpoint.java b/application/src/main/java/run/halo/app/core/endpoint/console/CommentEndpoint.java similarity index 98% rename from application/src/main/java/run/halo/app/core/extension/endpoint/CommentEndpoint.java rename to application/src/main/java/run/halo/app/core/endpoint/console/CommentEndpoint.java index 18c904c539..e1efd293bf 100644 --- a/application/src/main/java/run/halo/app/core/extension/endpoint/CommentEndpoint.java +++ b/application/src/main/java/run/halo/app/core/endpoint/console/CommentEndpoint.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.endpoint; +package run.halo.app.core.endpoint.console; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static org.springdoc.core.fn.builders.content.Builder.contentBuilder; @@ -23,6 +23,7 @@ import run.halo.app.content.comment.ReplyService; import run.halo.app.core.extension.content.Comment; import run.halo.app.core.extension.content.Reply; +import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.extension.ListResult; import run.halo.app.infra.utils.HaloUtils; import run.halo.app.infra.utils.IpAddressUtils; diff --git a/application/src/main/java/run/halo/app/core/extension/endpoint/CustomEndpointsBuilder.java b/application/src/main/java/run/halo/app/core/endpoint/console/CustomEndpointsBuilder.java similarity index 94% rename from application/src/main/java/run/halo/app/core/extension/endpoint/CustomEndpointsBuilder.java rename to application/src/main/java/run/halo/app/core/endpoint/console/CustomEndpointsBuilder.java index 6b8d7cf064..b328709a64 100644 --- a/application/src/main/java/run/halo/app/core/extension/endpoint/CustomEndpointsBuilder.java +++ b/application/src/main/java/run/halo/app/core/endpoint/console/CustomEndpointsBuilder.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.endpoint; +package run.halo.app.core.endpoint.console; import java.util.HashMap; import java.util.LinkedList; @@ -9,6 +9,7 @@ import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerResponse; import reactor.core.publisher.Mono; +import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.extension.GroupVersion; public class CustomEndpointsBuilder { diff --git a/application/src/main/java/run/halo/app/core/extension/endpoint/PluginEndpoint.java b/application/src/main/java/run/halo/app/core/endpoint/console/PluginEndpoint.java similarity index 88% rename from application/src/main/java/run/halo/app/core/extension/endpoint/PluginEndpoint.java rename to application/src/main/java/run/halo/app/core/endpoint/console/PluginEndpoint.java index a05a216bdd..15dc2c24b0 100644 --- a/application/src/main/java/run/halo/app/core/extension/endpoint/PluginEndpoint.java +++ b/application/src/main/java/run/halo/app/core/endpoint/console/PluginEndpoint.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.endpoint; +package run.halo.app.core.endpoint.console; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; @@ -17,6 +17,7 @@ import static run.halo.app.extension.router.QueryParamBuildUtil.sortParameter; import static run.halo.app.infra.utils.FileUtils.deleteFileSilently; +import com.fasterxml.jackson.databind.node.ObjectNode; import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.media.Schema; import java.io.FileNotFoundException; @@ -41,6 +42,7 @@ import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.data.domain.Sort; import org.springframework.http.CacheControl; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.codec.multipart.FilePart; import org.springframework.http.codec.multipart.FormFieldPart; @@ -60,15 +62,16 @@ import reactor.util.retry.Retry; import run.halo.app.core.extension.Plugin; import run.halo.app.core.extension.Setting; -import run.halo.app.core.extension.service.PluginService; -import run.halo.app.core.extension.theme.SettingUtils; +import run.halo.app.core.extension.endpoint.CustomEndpoint; +import run.halo.app.core.user.service.SettingConfigService; import run.halo.app.extension.ConfigMap; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.router.IListRequest; import run.halo.app.extension.router.SortableRequest; import run.halo.app.infra.ReactiveUrlDataBufferFetcher; -import run.halo.app.plugin.PluginNotFoundException; +import run.halo.app.infra.utils.SettingUtils; +import run.halo.app.plugin.PluginService; @Slf4j @Component @@ -80,6 +83,8 @@ public class PluginEndpoint implements CustomEndpoint, InitializingBean { private final ReactiveUrlDataBufferFetcher reactiveUrlDataBufferFetcher; + private final SettingConfigService settingConfigService; + private final WebProperties webProperties; private final Scheduler scheduler = Schedulers.boundedElastic(); @@ -91,10 +96,12 @@ public class PluginEndpoint implements CustomEndpoint, InitializingBean { public PluginEndpoint(ReactiveExtensionClient client, PluginService pluginService, ReactiveUrlDataBufferFetcher reactiveUrlDataBufferFetcher, + SettingConfigService settingConfigService, WebProperties webProperties) { this.client = client; this.pluginService = pluginService; this.reactiveUrlDataBufferFetcher = reactiveUrlDataBufferFetcher; + this.settingConfigService = settingConfigService; this.webProperties = webProperties; } @@ -157,9 +164,28 @@ public RouterFunction endpoint() { .content(contentBuilder().mediaType(MediaType.MULTIPART_FORM_DATA_VALUE) .schema(schemaBuilder().implementation(InstallRequest.class)))) ) + .PUT("plugins/{name}/json-config", this::updatePluginJsonConfig, + builder -> builder.operationId("updatePluginJsonConfig") + .description("Update the config of plugin setting.") + .tag(tag) + .parameter(parameterBuilder() + .name("name") + .in(ParameterIn.PATH) + .required(true) + .implementation(String.class) + ) + .requestBody(requestBodyBuilder() + .required(true) + .content(contentBuilder().mediaType(MediaType.APPLICATION_JSON_VALUE) + .schema(schemaBuilder().implementation(ObjectNode.class)))) + .response(responseBuilder() + .responseCode(String.valueOf(HttpStatus.NO_CONTENT.value())) + .implementation(Void.class)) + ) .PUT("plugins/{name}/config", this::updatePluginConfig, builder -> builder.operationId("updatePluginConfig") - .description("Update the configMap of plugin setting.") + .description( + "Update the configMap of plugin setting, it is deprecated since 2.20.0") .tag(tag) .parameter(parameterBuilder() .name("name") @@ -167,6 +193,7 @@ public RouterFunction endpoint() { .required(true) .implementation(String.class) ) + .deprecated(true) .requestBody(requestBodyBuilder() .required(true) .content(contentBuilder().mediaType(MediaType.APPLICATION_JSON_VALUE) @@ -243,7 +270,9 @@ public RouterFunction endpoint() { ) .GET("plugins/{name}/config", this::fetchPluginConfig, builder -> builder.operationId("fetchPluginConfig") - .description("Fetch configMap of plugin by configured configMapName.") + .description( + "Fetch configMap of plugin by configured configMapName. it is deprecated " + + "since 2.20.0") .tag(tag) .parameter(parameterBuilder() .name("name") @@ -254,11 +283,19 @@ public RouterFunction endpoint() { .response(responseBuilder() .implementation(ConfigMap.class)) ) - .GET("plugin-presets", this::listPresets, - builder -> builder.operationId("ListPluginPresets") - .description("List all plugin presets in the system.") + .GET("plugins/{name}/json-config", this::fetchPluginJsonConfig, + builder -> builder.operationId("fetchPluginJsonConfig") + .description( + "Fetch converted json config of plugin by configured configMapName.") .tag(tag) - .response(responseBuilder().implementationArray(Plugin.class)) + .parameter(parameterBuilder() + .name("name") + .in(ParameterIn.PATH) + .required(true) + .implementation(String.class) + ) + .response(responseBuilder() + .implementation(ObjectNode.class)) ) .GET("plugins/-/bundle.js", this::fetchJsBundle, builder -> builder.operationId("fetchJsBundle") @@ -275,6 +312,35 @@ public RouterFunction endpoint() { .build(); } + private Mono fetchPluginJsonConfig(ServerRequest request) { + final var name = request.pathVariable("name"); + return client.fetch(Plugin.class, name) + .mapNotNull(plugin -> plugin.getSpec().getConfigMapName()) + .flatMap(settingConfigService::fetchConfig) + .flatMap(json -> ServerResponse.ok().bodyValue(json)); + } + + private Mono updatePluginJsonConfig(ServerRequest request) { + final var pluginName = request.pathVariable("name"); + return client.fetch(Plugin.class, pluginName) + .doOnNext(plugin -> { + String configMapName = plugin.getSpec().getConfigMapName(); + if (!StringUtils.hasText(configMapName)) { + throw new ServerWebInputException( + "Unable to complete the request because the plugin configMapName is blank"); + } + }) + .flatMap(plugin -> { + final String configMapName = plugin.getSpec().getConfigMapName(); + return request.bodyToMono(ObjectNode.class) + .switchIfEmpty( + Mono.error(new ServerWebInputException("Required request body is missing"))) + .flatMap(configMapJsonData -> + settingConfigService.upsertConfig(configMapName, configMapJsonData)); + }) + .then(ServerResponse.noContent().build()); + } + Mono changePluginRunningState(ServerRequest request) { final var name = request.pathVariable("name"); return request.bodyToMono(RunningStateRequest.class) @@ -399,10 +465,6 @@ private Mono reload(ServerRequest serverRequest) { return ServerResponse.ok().body(pluginService.reload(name), Plugin.class); } - private Mono listPresets(ServerRequest request) { - return ServerResponse.ok().body(pluginService.getPresets(), Plugin.class); - } - private Mono fetchPluginConfig(ServerRequest request) { final var name = request.pathVariable("name"); return client.fetch(Plugin.class, name) @@ -491,10 +553,6 @@ private Mono install(ServerRequest request) { if (InstallSource.FILE.equals(source)) { return installFromFile(installRequest.getFile(), pluginService::install); } - if (InstallSource.PRESET.equals(source)) { - return installFromPreset(installRequest.getPresetName(), - pluginService::install); - } return Mono.error( new UnsupportedOperationException("Unsupported install source " + source)); })) @@ -513,10 +571,6 @@ private Mono upgrade(ServerRequest request) { return installFromFile(installRequest.getFile(), path -> pluginService.upgrade(pluginName, path)); } - if (InstallSource.PRESET.equals(source)) { - return installFromPreset(installRequest.getPresetName(), - path -> pluginService.upgrade(pluginName, path)); - } return Mono.error( new UnsupportedOperationException("Unsupported install source " + source)); })) @@ -533,16 +587,6 @@ private Mono installFromFile(FilePart filePart, this::deleteFileIfExists); } - private Mono installFromPreset(Mono presetNameMono, - Function> resourceClosure) { - return presetNameMono.flatMap(pluginService::getPreset) - .switchIfEmpty( - Mono.error(() -> new PluginNotFoundException("Plugin preset was not found."))) - .map(pluginPreset -> pluginPreset.getStatus().getLoadLocation()) - .map(Path::of) - .flatMap(resourceClosure); - } - public static class ListRequest extends SortableRequest { public ListRequest(ServerRequest request) { diff --git a/application/src/main/java/run/halo/app/core/extension/endpoint/PostEndpoint.java b/application/src/main/java/run/halo/app/core/endpoint/console/PostEndpoint.java similarity index 99% rename from application/src/main/java/run/halo/app/core/extension/endpoint/PostEndpoint.java rename to application/src/main/java/run/halo/app/core/endpoint/console/PostEndpoint.java index 07049041ad..689a0f2018 100644 --- a/application/src/main/java/run/halo/app/core/extension/endpoint/PostEndpoint.java +++ b/application/src/main/java/run/halo/app/core/endpoint/console/PostEndpoint.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.endpoint; +package run.halo.app.core.endpoint.console; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static org.springdoc.core.fn.builders.content.Builder.contentBuilder; @@ -37,6 +37,7 @@ import run.halo.app.content.PostRequest; import run.halo.app.content.PostService; import run.halo.app.core.extension.content.Post; +import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.extension.ListResult; import run.halo.app.extension.MetadataUtil; import run.halo.app.extension.ReactiveExtensionClient; diff --git a/application/src/main/java/run/halo/app/core/extension/endpoint/ReplyEndpoint.java b/application/src/main/java/run/halo/app/core/endpoint/console/ReplyEndpoint.java similarity index 94% rename from application/src/main/java/run/halo/app/core/extension/endpoint/ReplyEndpoint.java rename to application/src/main/java/run/halo/app/core/endpoint/console/ReplyEndpoint.java index 61e2fb589a..29a6cd9cc6 100644 --- a/application/src/main/java/run/halo/app/core/extension/endpoint/ReplyEndpoint.java +++ b/application/src/main/java/run/halo/app/core/endpoint/console/ReplyEndpoint.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.endpoint; +package run.halo.app.core.endpoint.console; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; @@ -12,6 +12,7 @@ import run.halo.app.content.comment.ReplyQuery; import run.halo.app.content.comment.ReplyService; import run.halo.app.core.extension.content.Reply; +import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.extension.ListResult; /** diff --git a/application/src/main/java/run/halo/app/core/extension/endpoint/SinglePageEndpoint.java b/application/src/main/java/run/halo/app/core/endpoint/console/SinglePageEndpoint.java similarity index 99% rename from application/src/main/java/run/halo/app/core/extension/endpoint/SinglePageEndpoint.java rename to application/src/main/java/run/halo/app/core/endpoint/console/SinglePageEndpoint.java index 0c86c01649..56f8ce014b 100644 --- a/application/src/main/java/run/halo/app/core/extension/endpoint/SinglePageEndpoint.java +++ b/application/src/main/java/run/halo/app/core/endpoint/console/SinglePageEndpoint.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.endpoint; +package run.halo.app.core.endpoint.console; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static org.springdoc.core.fn.builders.content.Builder.contentBuilder; @@ -33,6 +33,7 @@ import run.halo.app.content.SinglePageService; import run.halo.app.core.extension.content.Post; import run.halo.app.core.extension.content.SinglePage; +import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.extension.ListResult; import run.halo.app.extension.MetadataUtil; import run.halo.app.extension.ReactiveExtensionClient; diff --git a/application/src/main/java/run/halo/app/core/extension/endpoint/StatsEndpoint.java b/application/src/main/java/run/halo/app/core/endpoint/console/StatsEndpoint.java similarity index 97% rename from application/src/main/java/run/halo/app/core/extension/endpoint/StatsEndpoint.java rename to application/src/main/java/run/halo/app/core/endpoint/console/StatsEndpoint.java index 84881b73d3..6be262e688 100644 --- a/application/src/main/java/run/halo/app/core/extension/endpoint/StatsEndpoint.java +++ b/application/src/main/java/run/halo/app/core/endpoint/console/StatsEndpoint.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.endpoint; +package run.halo.app.core.endpoint.console; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static run.halo.app.extension.index.query.QueryFactory.and; @@ -15,6 +15,7 @@ import run.halo.app.core.extension.Counter; import run.halo.app.core.extension.User; import run.halo.app.core.extension.content.Post; +import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.extension.ListOptions; import run.halo.app.extension.PageRequestImpl; import run.halo.app.extension.ReactiveExtensionClient; diff --git a/application/src/main/java/run/halo/app/core/endpoint/console/SystemConfigEndpoint.java b/application/src/main/java/run/halo/app/core/endpoint/console/SystemConfigEndpoint.java new file mode 100644 index 0000000000..de6bd4fb07 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/endpoint/console/SystemConfigEndpoint.java @@ -0,0 +1,102 @@ +package run.halo.app.core.endpoint.console; + +import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; +import static org.springdoc.core.fn.builders.content.Builder.contentBuilder; +import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; +import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder; + +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import java.time.Duration; +import lombok.RequiredArgsConstructor; +import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; +import org.springframework.dao.OptimisticLockingFailureException; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.ServerRequest; +import org.springframework.web.reactive.function.server.ServerResponse; +import reactor.core.publisher.Mono; +import reactor.util.retry.Retry; +import run.halo.app.core.extension.endpoint.CustomEndpoint; +import run.halo.app.extension.GroupVersion; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; +import run.halo.app.infra.utils.JsonUtils; + +@Component +@RequiredArgsConstructor +public class SystemConfigEndpoint implements CustomEndpoint { + private final SystemConfigurableEnvironmentFetcher configurableEnvironmentFetcher; + private final ReactiveExtensionClient client; + + @Override + public RouterFunction endpoint() { + final var tag = "SystemConfigV1alpha1Console"; + return SpringdocRouteBuilder.route() + .GET("/systemconfigs/{group}", this::getConfigByGroup, + builder -> builder.operationId("getSystemConfigByGroup") + .description("Get system config by group") + .tag(tag) + .response(responseBuilder() + .content(contentBuilder() + .mediaType(MediaType.APPLICATION_JSON_VALUE) + ) + .implementation(ObjectNode.class)) + .parameter(parameterBuilder() + .in(ParameterIn.PATH) + .name("group") + .required(true) + .description("Group of the system config") + ) + ) + .PUT("/systemconfigs/{group}", this::updateConfigByGroup, + builder -> builder.operationId("updateSystemConfigByGroup") + .description("Update system config by group") + .tag(tag) + .parameter(parameterBuilder() + .in(ParameterIn.PATH) + .name("group") + .required(true) + .description("Group of the system config") + ) + .requestBody(requestBodyBuilder() + .implementation(ObjectNode.class) + ) + .response(responseBuilder() + .responseCode(String.valueOf(HttpStatus.NO_CONTENT)) + .implementation(Void.class) + ) + ) + .build(); + } + + private Mono updateConfigByGroup(ServerRequest request) { + final var group = request.pathVariable("group"); + return request.bodyToMono(ObjectNode.class) + .flatMap(objectNode -> configurableEnvironmentFetcher.getConfigMap() + .flatMap(configMap -> { + var data = configMap.getData(); + data.put(group, JsonUtils.objectToJson(objectNode)); + return client.update(configMap); + }) + ) + .retryWhen(Retry.backoff(5, Duration.ofMillis(100)) + .filter(OptimisticLockingFailureException.class::isInstance)) + .then(ServerResponse.noContent().build()); + } + + private Mono getConfigByGroup(ServerRequest request) { + final var group = request.pathVariable("group"); + return configurableEnvironmentFetcher.fetch(group, ObjectNode.class) + .switchIfEmpty(Mono.fromSupplier(JsonNodeFactory.instance::objectNode)) + .flatMap(json -> ServerResponse.ok().bodyValue(json)); + } + + @Override + public GroupVersion groupVersion() { + return new GroupVersion("console.api.halo.run", "v1alpha1"); + } +} diff --git a/application/src/main/java/run/halo/app/core/extension/endpoint/TagEndpoint.java b/application/src/main/java/run/halo/app/core/endpoint/console/TagEndpoint.java similarity index 97% rename from application/src/main/java/run/halo/app/core/extension/endpoint/TagEndpoint.java rename to application/src/main/java/run/halo/app/core/endpoint/console/TagEndpoint.java index ca768092fe..612113413f 100644 --- a/application/src/main/java/run/halo/app/core/extension/endpoint/TagEndpoint.java +++ b/application/src/main/java/run/halo/app/core/endpoint/console/TagEndpoint.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.endpoint; +package run.halo.app.core.endpoint.console; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; @@ -19,6 +19,7 @@ import org.springframework.web.reactive.function.server.ServerResponse; import reactor.core.publisher.Mono; import run.halo.app.core.extension.content.Tag; +import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ListResult; import run.halo.app.extension.PageRequestImpl; diff --git a/application/src/main/java/run/halo/app/core/extension/endpoint/TrackerEndpoint.java b/application/src/main/java/run/halo/app/core/endpoint/console/TrackerEndpoint.java similarity index 98% rename from application/src/main/java/run/halo/app/core/extension/endpoint/TrackerEndpoint.java rename to application/src/main/java/run/halo/app/core/endpoint/console/TrackerEndpoint.java index 8ada7725f2..a69a30150c 100644 --- a/application/src/main/java/run/halo/app/core/extension/endpoint/TrackerEndpoint.java +++ b/application/src/main/java/run/halo/app/core/endpoint/console/TrackerEndpoint.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.endpoint; +package run.halo.app.core.endpoint.console; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static org.springdoc.core.fn.builders.content.Builder.contentBuilder; @@ -16,6 +16,7 @@ import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import reactor.core.publisher.Mono; +import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.event.post.DownvotedEvent; import run.halo.app.event.post.UpvotedEvent; import run.halo.app.event.post.VisitedEvent; diff --git a/application/src/main/java/run/halo/app/core/extension/endpoint/UserEndpoint.java b/application/src/main/java/run/halo/app/core/endpoint/console/UserEndpoint.java similarity index 94% rename from application/src/main/java/run/halo/app/core/extension/endpoint/UserEndpoint.java rename to application/src/main/java/run/halo/app/core/endpoint/console/UserEndpoint.java index 74d2bb0b5b..662e6f20e8 100644 --- a/application/src/main/java/run/halo/app/core/extension/endpoint/UserEndpoint.java +++ b/application/src/main/java/run/halo/app/core/endpoint/console/UserEndpoint.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.endpoint; +package run.halo.app.core.endpoint.console; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import static org.apache.commons.lang3.ObjectUtils.defaultIfNull; @@ -24,6 +24,7 @@ import io.github.resilience4j.reactor.ratelimiter.operator.RateLimiterOperator; import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; import java.security.Principal; import java.time.Duration; import java.util.Collection; @@ -58,6 +59,7 @@ import org.springframework.util.CollectionUtils; import org.springframework.util.MultiValueMap; import org.springframework.util.unit.DataSize; +import org.springframework.validation.Validator; import org.springframework.web.reactive.function.BodyExtractors; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerRequest; @@ -65,15 +67,15 @@ import org.springframework.web.server.ServerWebInputException; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; -import reactor.util.function.Tuples; import reactor.util.retry.Retry; import run.halo.app.core.extension.Role; import run.halo.app.core.extension.User; import run.halo.app.core.extension.attachment.Attachment; +import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.core.extension.service.AttachmentService; -import run.halo.app.core.extension.service.EmailVerificationService; -import run.halo.app.core.extension.service.RoleService; -import run.halo.app.core.extension.service.UserService; +import run.halo.app.core.user.service.EmailVerificationService; +import run.halo.app.core.user.service.RoleService; +import run.halo.app.core.user.service.UserService; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ListResult; import run.halo.app.extension.Metadata; @@ -87,7 +89,6 @@ import run.halo.app.infra.exception.RateLimitExceededException; import run.halo.app.infra.exception.UnsatisfiedAttributeValueException; import run.halo.app.infra.utils.JsonUtils; -import run.halo.app.security.authentication.twofactor.TwoFactorAuthentication; @Component @RequiredArgsConstructor @@ -104,6 +105,7 @@ public class UserEndpoint implements CustomEndpoint { private final EmailVerificationService emailVerificationService; private final RateLimiterRegistry rateLimiterRegistry; private final SystemConfigurableEnvironmentFetcher environmentFetcher; + private final Validator validator; @Override public RouterFunction endpoint() { @@ -280,7 +282,9 @@ private Mono verifyEmailCode(String username, String code) { .onErrorMap(RequestNotPermitted.class, RateLimitExceededException::new); } - public record EmailVerifyRequest(@Schema(requiredMode = REQUIRED) String email) { + public record EmailVerifyRequest(@Schema(requiredMode = REQUIRED) + @Email + String email) { } public record VerifyCodeRequest( @@ -289,25 +293,23 @@ public record VerifyCodeRequest( } private Mono sendEmailVerificationCode(ServerRequest request) { - return request.bodyToMono(EmailVerifyRequest.class) + var emailMono = request.bodyToMono(EmailVerifyRequest.class) .switchIfEmpty(Mono.error( () -> new ServerWebInputException("Request body is required.")) ) - .doOnNext(emailRequest -> { - if (!ValidationUtils.isValidEmail(emailRequest.email())) { - throw new ServerWebInputException("Invalid email address."); + .doOnNext(emailReq -> { + var bindingResult = + ValidationUtils.validate(emailReq, validator, request.exchange()); + if (bindingResult.hasErrors()) { + // only email field is validated + throw new ServerWebInputException("validation.error.email.pattern"); } }) - .flatMap(emailRequest -> { - var email = emailRequest.email(); - return ReactiveSecurityContextHolder.getContext() - .map(SecurityContext::getAuthentication) - .map(Principal::getName) - .map(username -> Tuples.of(username, email)); - }) + .map(EmailVerifyRequest::email); + return Mono.zip(emailMono, getAuthenticatedUserName()) .flatMap(tuple -> { - var username = tuple.getT1(); - var email = tuple.getT2(); + var email = tuple.getT1(); + var username = tuple.getT2(); return Mono.just(username) .transformDeferred(sendEmailVerificationCodeRateLimiter(username, email)) .flatMap(u -> emailVerificationService.sendVerificationCode(username, email)) @@ -346,9 +348,7 @@ private Mono getUserOrSelf(String name) { if (!SELF_USER.equals(name)) { return client.get(User.class, name); } - return ReactiveSecurityContextHolder.getContext() - .map(SecurityContext::getAuthentication) - .map(Authentication::getName) + return getAuthenticatedUserName() .flatMap(currentUserName -> client.get(User.class, currentUserName)); } @@ -501,9 +501,7 @@ public static User from(CreateUserRequest userRequest) { } private Mono updateProfile(ServerRequest request) { - return ReactiveSecurityContextHolder.getContext() - .map(SecurityContext::getAuthentication) - .map(Authentication::getName) + return getAuthenticatedUserName() .flatMap(currentUserName -> client.get(User.class, currentUserName)) .flatMap(currentUser -> request.bodyToMono(User.class) .filter(user -> user.getMetadata() != null @@ -538,6 +536,12 @@ private Mono updateProfile(ServerRequest request) { .flatMap(updatedUser -> ServerResponse.ok().bodyValue(updatedUser)); } + private static Mono getAuthenticatedUserName() { + return ReactiveSecurityContextHolder.getContext() + .map(SecurityContext::getAuthentication) + .map(Authentication::getName); + } + Mono changeAnyonePasswordForAdmin(ServerRequest request) { final var nameInPath = request.pathVariable("name"); return ReactiveSecurityContextHolder.getContext() @@ -586,12 +590,21 @@ Mono changeOwnPassword(ServerRequest request) { record ChangeOwnPasswordRequest( @Schema(description = "Old password.", requiredMode = REQUIRED) String oldPassword, - @Schema(description = "New password.", requiredMode = REQUIRED, minLength = 6) + @Schema(description = "New password.", requiredMode = REQUIRED, minLength = 5) String password) { + + public ChangeOwnPasswordRequest { + if (password == null || password.length() < 5 || password.length() > 257) { + throw new UnsatisfiedAttributeValueException( + "password is required.", + "validation.error.password.size", + new Object[] {5, 257}); + } + } } record ChangePasswordRequest( - @Schema(description = "New password.", requiredMode = REQUIRED, minLength = 6) + @Schema(description = "New password.", requiredMode = REQUIRED, minLength = 5) String password) { } @@ -599,7 +612,7 @@ record ChangePasswordRequest( Mono me(ServerRequest request) { return ReactiveSecurityContextHolder.getContext() .map(SecurityContext::getAuthentication) - .filter(auth -> !(auth instanceof TwoFactorAuthentication)) + .filter(Authentication::isAuthenticated) .flatMap(auth -> userService.getUser(auth.getName()) .flatMap(user -> { var roleNames = authoritiesToRoles(auth.getAuthorities()); @@ -771,7 +784,11 @@ private Mono> toListedUser(ListResult listResult) { .map(user -> { var username = user.getMetadata().getName(); var roles = Optional.ofNullable(usernameRolesMap.get(username)) - .map(roleNames -> roleNames.stream().map(roleMap::get).toList()) + .map(roleNames -> roleNames.stream() + .map(roleMap::get) + .filter(Objects::nonNull) + .toList() + ) .orElseGet(List::of); return new ListedUser(user, roles); }) diff --git a/application/src/main/java/run/halo/app/theme/endpoint/CategoryQueryEndpoint.java b/application/src/main/java/run/halo/app/core/endpoint/theme/CategoryQueryEndpoint.java similarity index 97% rename from application/src/main/java/run/halo/app/theme/endpoint/CategoryQueryEndpoint.java rename to application/src/main/java/run/halo/app/core/endpoint/theme/CategoryQueryEndpoint.java index 1183211a33..85587ad405 100644 --- a/application/src/main/java/run/halo/app/theme/endpoint/CategoryQueryEndpoint.java +++ b/application/src/main/java/run/halo/app/core/endpoint/theme/CategoryQueryEndpoint.java @@ -1,8 +1,8 @@ -package run.halo.app.theme.endpoint; +package run.halo.app.core.endpoint.theme; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; -import static run.halo.app.theme.endpoint.PublicApiUtils.toAnotherListResult; +import static run.halo.app.core.endpoint.theme.PublicApiUtils.toAnotherListResult; import io.swagger.v3.oas.annotations.enums.ParameterIn; import lombok.RequiredArgsConstructor; diff --git a/application/src/main/java/run/halo/app/theme/endpoint/CommentFinderEndpoint.java b/application/src/main/java/run/halo/app/core/endpoint/theme/CommentFinderEndpoint.java similarity index 99% rename from application/src/main/java/run/halo/app/theme/endpoint/CommentFinderEndpoint.java rename to application/src/main/java/run/halo/app/core/endpoint/theme/CommentFinderEndpoint.java index edfd4c4ae3..2e4169ff1b 100644 --- a/application/src/main/java/run/halo/app/theme/endpoint/CommentFinderEndpoint.java +++ b/application/src/main/java/run/halo/app/core/endpoint/theme/CommentFinderEndpoint.java @@ -1,4 +1,4 @@ -package run.halo.app.theme.endpoint; +package run.halo.app.core.endpoint.theme; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import static org.apache.commons.lang3.BooleanUtils.isFalse; diff --git a/application/src/main/java/run/halo/app/theme/endpoint/MenuQueryEndpoint.java b/application/src/main/java/run/halo/app/core/endpoint/theme/MenuQueryEndpoint.java similarity index 98% rename from application/src/main/java/run/halo/app/theme/endpoint/MenuQueryEndpoint.java rename to application/src/main/java/run/halo/app/core/endpoint/theme/MenuQueryEndpoint.java index b43eff0492..f0ac37df10 100644 --- a/application/src/main/java/run/halo/app/theme/endpoint/MenuQueryEndpoint.java +++ b/application/src/main/java/run/halo/app/core/endpoint/theme/MenuQueryEndpoint.java @@ -1,4 +1,4 @@ -package run.halo.app.theme.endpoint; +package run.halo.app.core.endpoint.theme; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; diff --git a/application/src/main/java/run/halo/app/theme/endpoint/PluginQueryEndpoint.java b/application/src/main/java/run/halo/app/core/endpoint/theme/PluginQueryEndpoint.java similarity index 98% rename from application/src/main/java/run/halo/app/theme/endpoint/PluginQueryEndpoint.java rename to application/src/main/java/run/halo/app/core/endpoint/theme/PluginQueryEndpoint.java index 2de30247dd..d649f79619 100644 --- a/application/src/main/java/run/halo/app/theme/endpoint/PluginQueryEndpoint.java +++ b/application/src/main/java/run/halo/app/core/endpoint/theme/PluginQueryEndpoint.java @@ -1,4 +1,4 @@ -package run.halo.app.theme.endpoint; +package run.halo.app.core.endpoint.theme; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; diff --git a/application/src/main/java/run/halo/app/theme/endpoint/PostPublicQuery.java b/application/src/main/java/run/halo/app/core/endpoint/theme/PostPublicQuery.java similarity index 92% rename from application/src/main/java/run/halo/app/theme/endpoint/PostPublicQuery.java rename to application/src/main/java/run/halo/app/core/endpoint/theme/PostPublicQuery.java index c880186a14..2817481087 100644 --- a/application/src/main/java/run/halo/app/theme/endpoint/PostPublicQuery.java +++ b/application/src/main/java/run/halo/app/core/endpoint/theme/PostPublicQuery.java @@ -1,4 +1,4 @@ -package run.halo.app.theme.endpoint; +package run.halo.app.core.endpoint.theme; import org.springdoc.core.fn.builders.operation.Builder; import org.springframework.web.server.ServerWebExchange; diff --git a/application/src/main/java/run/halo/app/theme/endpoint/PostQueryEndpoint.java b/application/src/main/java/run/halo/app/core/endpoint/theme/PostQueryEndpoint.java similarity index 99% rename from application/src/main/java/run/halo/app/theme/endpoint/PostQueryEndpoint.java rename to application/src/main/java/run/halo/app/core/endpoint/theme/PostQueryEndpoint.java index 9136eea7ef..9588265f90 100644 --- a/application/src/main/java/run/halo/app/theme/endpoint/PostQueryEndpoint.java +++ b/application/src/main/java/run/halo/app/core/endpoint/theme/PostQueryEndpoint.java @@ -1,4 +1,4 @@ -package run.halo.app.theme.endpoint; +package run.halo.app.core.endpoint.theme; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; diff --git a/application/src/main/java/run/halo/app/theme/endpoint/PublicApiUtils.java b/application/src/main/java/run/halo/app/core/endpoint/theme/PublicApiUtils.java similarity index 98% rename from application/src/main/java/run/halo/app/theme/endpoint/PublicApiUtils.java rename to application/src/main/java/run/halo/app/core/endpoint/theme/PublicApiUtils.java index 142b72055c..5a622e85d1 100644 --- a/application/src/main/java/run/halo/app/theme/endpoint/PublicApiUtils.java +++ b/application/src/main/java/run/halo/app/core/endpoint/theme/PublicApiUtils.java @@ -1,4 +1,4 @@ -package run.halo.app.theme.endpoint; +package run.halo.app.core.endpoint.theme; import java.util.Collection; import java.util.List; diff --git a/application/src/main/java/run/halo/app/theme/endpoint/SinglePageQueryEndpoint.java b/application/src/main/java/run/halo/app/core/endpoint/theme/SinglePageQueryEndpoint.java similarity index 98% rename from application/src/main/java/run/halo/app/theme/endpoint/SinglePageQueryEndpoint.java rename to application/src/main/java/run/halo/app/core/endpoint/theme/SinglePageQueryEndpoint.java index 933f3516da..8813cd273c 100644 --- a/application/src/main/java/run/halo/app/theme/endpoint/SinglePageQueryEndpoint.java +++ b/application/src/main/java/run/halo/app/core/endpoint/theme/SinglePageQueryEndpoint.java @@ -1,4 +1,4 @@ -package run.halo.app.theme.endpoint; +package run.halo.app.core.endpoint.theme; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; diff --git a/application/src/main/java/run/halo/app/theme/endpoint/SiteStatsQueryEndpoint.java b/application/src/main/java/run/halo/app/core/endpoint/theme/SiteStatsQueryEndpoint.java similarity index 97% rename from application/src/main/java/run/halo/app/theme/endpoint/SiteStatsQueryEndpoint.java rename to application/src/main/java/run/halo/app/core/endpoint/theme/SiteStatsQueryEndpoint.java index 40792d06ba..13ed5c0a70 100644 --- a/application/src/main/java/run/halo/app/theme/endpoint/SiteStatsQueryEndpoint.java +++ b/application/src/main/java/run/halo/app/core/endpoint/theme/SiteStatsQueryEndpoint.java @@ -1,4 +1,4 @@ -package run.halo.app.theme.endpoint; +package run.halo.app.core.endpoint.theme; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; diff --git a/application/src/main/java/run/halo/app/theme/endpoint/TagQueryEndpoint.java b/application/src/main/java/run/halo/app/core/endpoint/theme/TagQueryEndpoint.java similarity index 99% rename from application/src/main/java/run/halo/app/theme/endpoint/TagQueryEndpoint.java rename to application/src/main/java/run/halo/app/core/endpoint/theme/TagQueryEndpoint.java index a54120d7a4..b25f3cdae4 100644 --- a/application/src/main/java/run/halo/app/theme/endpoint/TagQueryEndpoint.java +++ b/application/src/main/java/run/halo/app/core/endpoint/theme/TagQueryEndpoint.java @@ -1,4 +1,4 @@ -package run.halo.app.theme.endpoint; +package run.halo.app.core.endpoint.theme; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; diff --git a/application/src/main/java/run/halo/app/theme/endpoint/ThumbnailEndpoint.java b/application/src/main/java/run/halo/app/core/endpoint/theme/ThumbnailEndpoint.java similarity index 99% rename from application/src/main/java/run/halo/app/theme/endpoint/ThumbnailEndpoint.java rename to application/src/main/java/run/halo/app/core/endpoint/theme/ThumbnailEndpoint.java index aeb332b0d9..012bdd249c 100644 --- a/application/src/main/java/run/halo/app/theme/endpoint/ThumbnailEndpoint.java +++ b/application/src/main/java/run/halo/app/core/endpoint/theme/ThumbnailEndpoint.java @@ -1,4 +1,4 @@ -package run.halo.app.theme.endpoint; +package run.halo.app.core.endpoint.theme; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; diff --git a/application/src/main/java/run/halo/app/endpoint/uc/content/UcPostAttachmentEndpoint.java b/application/src/main/java/run/halo/app/core/endpoint/uc/UcAttachmentEndpoint.java similarity index 69% rename from application/src/main/java/run/halo/app/endpoint/uc/content/UcPostAttachmentEndpoint.java rename to application/src/main/java/run/halo/app/core/endpoint/uc/UcAttachmentEndpoint.java index 47063a4551..48f5cbdd0c 100644 --- a/application/src/main/java/run/halo/app/endpoint/uc/content/UcPostAttachmentEndpoint.java +++ b/application/src/main/java/run/halo/app/core/endpoint/uc/UcAttachmentEndpoint.java @@ -1,4 +1,4 @@ -package run.halo.app.endpoint.uc.content; +package run.halo.app.core.endpoint.uc; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; @@ -9,13 +9,17 @@ import static org.springdoc.core.fn.builders.schema.Builder.schemaBuilder; import static org.springdoc.webflux.core.fn.SpringdocRouteBuilder.route; import static org.springframework.web.reactive.function.server.RequestPredicates.contentType; +import static run.halo.app.extension.index.query.QueryFactory.equal; import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.media.Schema; import java.net.URL; import java.util.HashMap; +import java.util.List; import java.util.Objects; import java.util.function.Consumer; +import lombok.Builder; +import lombok.Getter; import org.apache.commons.lang3.StringUtils; import org.springframework.http.MediaType; import org.springframework.http.codec.multipart.FilePart; @@ -25,6 +29,7 @@ import org.springframework.security.core.context.ReactiveSecurityContextHolder; import org.springframework.security.core.context.SecurityContext; import org.springframework.stereotype.Component; +import org.springframework.util.Assert; import org.springframework.util.MultiValueMap; import org.springframework.web.reactive.function.BodyExtractors; import org.springframework.web.reactive.function.server.RouterFunction; @@ -32,31 +37,40 @@ import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.server.ServerWebInputException; import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; import run.halo.app.content.PostService; +import run.halo.app.core.attachment.AttachmentLister; +import run.halo.app.core.attachment.SearchRequest; import run.halo.app.core.extension.attachment.Attachment; import run.halo.app.core.extension.content.Post; import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.core.extension.service.AttachmentService; import run.halo.app.extension.GroupVersion; +import run.halo.app.extension.ListOptions; +import run.halo.app.extension.ListResult; import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; import run.halo.app.infra.SystemSetting; import run.halo.app.infra.exception.NotFoundException; @Component -public class UcPostAttachmentEndpoint implements CustomEndpoint { +public class UcAttachmentEndpoint implements CustomEndpoint { public static final String POST_NAME_LABEL = "content.halo.run/post-name"; public static final String SINGLE_PAGE_NAME_LABEL = "content.halo.run/single-page-name"; private final AttachmentService attachmentService; + private final AttachmentLister attachmentLister; + private final PostService postService; private final SystemConfigurableEnvironmentFetcher systemSettingFetcher; - public UcPostAttachmentEndpoint(AttachmentService attachmentService, PostService postService, + public UcAttachmentEndpoint(AttachmentService attachmentService, + AttachmentLister attachmentLister, PostService postService, SystemConfigurableEnvironmentFetcher systemSettingFetcher) { this.attachmentService = attachmentService; + this.attachmentLister = attachmentLister; this.postService = postService; this.systemSettingFetcher = systemSettingFetcher; } @@ -82,6 +96,19 @@ public RouterFunction endpoint() { ) .response(responseBuilder().implementation(Attachment.class)) ) + .POST("/attachments/-/upload", contentType(MediaType.MULTIPART_FORM_DATA), + this::uploadAttachment, builder -> builder + .operationId("UploadUcAttachment") + .description("Upload attachment to user center storage.") + .tag(tag) + .requestBody(requestBodyBuilder() + .required(true) + .content(contentBuilder() + .mediaType(MediaType.MULTIPART_FORM_DATA_VALUE) + .schema(schemaBuilder().implementation(UcUploadRequest.class)) + )) + .response(responseBuilder().implementation(Attachment.class)) + ) .POST("/attachments/-/upload-from-url", contentType(MediaType.APPLICATION_JSON), this::uploadFromUrlForPost, builder -> builder @@ -103,9 +130,94 @@ public RouterFunction endpoint() { .response(responseBuilder().implementation(Attachment.class)) .build() ) + .GET("/attachments", this::listMyAttachments, builder -> { + builder.operationId("ListMyAttachments") + .description("List attachments of the current user uploaded.") + .tag(tag) + .response(responseBuilder() + .implementation(ListResult.generateGenericClass(Attachment.class)) + ); + SearchRequest.buildParameters(builder); + }) .build(); } + private Mono uploadAttachment(ServerRequest request) { + var builder = UploadContext.builder(); + var filePartMono = request.body(BodyExtractors.toMultipartData()) + .map(UcUploadRequest::new) + .switchIfEmpty( + Mono.error(new ServerWebInputException("Required request body is missing."))) + .doOnNext(uploadRequest -> builder.filePart(uploadRequest.getFile())) + .subscribeOn(Schedulers.boundedElastic()); + + var ownerMono = getCurrentUser() + .doOnNext(builder::owner); + + var storagePolicyMono = + systemSettingFetcher.fetch(SystemSetting.User.GROUP, SystemSetting.User.class) + .mapNotNull(SystemSetting.User::getUcAttachmentPolicy) + .filter(StringUtils::isNotBlank) + .switchIfEmpty(Mono.error(new ServerWebInputException( + "Please contact the administrator to configure the storage policy.")) + ) + .doOnNext(builder::storagePolicy) + .subscribeOn(Schedulers.boundedElastic()); + + return Mono.when(filePartMono, storagePolicyMono, ownerMono) + .then(Mono.fromSupplier(builder::build)) + .flatMap(context -> attachmentService.upload(context.owner(), + context.storagePolicy(), null, context.filePart(), null) + ) + .flatMap(attachment -> ServerResponse.ok().bodyValue(attachment)); + } + + private Mono listMyAttachments(ServerRequest request) { + return getCurrentUser() + .flatMap(username -> { + var searchRequest = new UcSearchRequest(request, username); + return attachmentLister.listBy(searchRequest) + .flatMap(listResult -> ServerResponse.ok() + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(listResult) + ); + }); + } + + @Builder + record UploadContext(String owner, String storagePolicy, FilePart filePart) { + } + + public record UcUploadRequest(MultiValueMap formData) { + + @Schema(description = "The file to upload.", requiredMode = REQUIRED) + public FilePart getFile() { + if (formData.getFirst("file") instanceof FilePart file) { + return file; + } + throw new ServerWebInputException("Invalid part of file"); + } + } + + @Getter + public static class UcSearchRequest extends SearchRequest { + private final String owner; + + public UcSearchRequest(ServerRequest request, String owner) { + super(request); + Assert.state(StringUtils.isNotBlank(owner), "Owner must not be blank."); + this.owner = owner; + } + + @Override + public ListOptions toListOptions(List hiddenGroups) { + var listOptions = super.toListOptions(hiddenGroups); + return ListOptions.builder(listOptions) + .andQuery((equal("spec.ownerName", owner))) + .build(); + } + } + private Mono uploadFromUrlForPost(ServerRequest request) { var uploadFromUrlRequestMono = request.bodyToMono(UploadFromUrlRequest.class); @@ -218,7 +330,7 @@ private Mono getCurrentUser() { @Override public GroupVersion groupVersion() { - return GroupVersion.parseAPIVersion("uc.api.content.halo.run/v1alpha1"); + return GroupVersion.parseAPIVersion("uc.api.storage.halo.run/v1alpha1"); } public record UploadFromUrlRequest(@Schema(requiredMode = REQUIRED) URL url, diff --git a/application/src/main/java/run/halo/app/endpoint/uc/content/UcPostEndpoint.java b/application/src/main/java/run/halo/app/core/endpoint/uc/UcPostEndpoint.java similarity index 95% rename from application/src/main/java/run/halo/app/endpoint/uc/content/UcPostEndpoint.java rename to application/src/main/java/run/halo/app/core/endpoint/uc/UcPostEndpoint.java index 0b30aec497..87ca780193 100644 --- a/application/src/main/java/run/halo/app/endpoint/uc/content/UcPostEndpoint.java +++ b/application/src/main/java/run/halo/app/core/endpoint/uc/UcPostEndpoint.java @@ -1,4 +1,4 @@ -package run.halo.app.endpoint.uc.content; +package run.halo.app.core.endpoint.uc; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; @@ -124,13 +124,27 @@ public RouterFunction endpoint() { .operationId("UnpublishMyPost") .description("Unpublish my post.") .parameter(namePathParam) - .response(responseBuilder().implementation(Post.class))) + .response(responseBuilder().implementation(Post.class)) + ) + .DELETE("/{name}/recycle", this::recycleMyPost, builder -> builder.tag(tag) + .operationId("RecycleMyPost") + .description("Move my post to recycle bin.") + .parameter(namePathParam) + .response(responseBuilder().implementation(Post.class)) + ) .build(), builder -> { }) .build(); } + private Mono recycleMyPost(ServerRequest request) { + final var name = request.pathVariable("name"); + return getCurrentUser() + .flatMap(username -> postService.recycleBy(name, username)) + .flatMap(post -> ServerResponse.ok().bodyValue(post)); + } + private Mono getMyPostDraft(ServerRequest request) { var name = request.pathVariable("name"); var patched = request.queryParam("patched").map(Boolean::valueOf).orElse(false); diff --git a/application/src/main/java/run/halo/app/endpoint/uc/content/UcSnapshotEndpoint.java b/application/src/main/java/run/halo/app/core/endpoint/uc/UcSnapshotEndpoint.java similarity index 99% rename from application/src/main/java/run/halo/app/endpoint/uc/content/UcSnapshotEndpoint.java rename to application/src/main/java/run/halo/app/core/endpoint/uc/UcSnapshotEndpoint.java index 1f196bc66c..f674d84ccd 100644 --- a/application/src/main/java/run/halo/app/endpoint/uc/content/UcSnapshotEndpoint.java +++ b/application/src/main/java/run/halo/app/core/endpoint/uc/UcSnapshotEndpoint.java @@ -1,4 +1,4 @@ -package run.halo.app.endpoint.uc.content; +package run.halo.app.core.endpoint.uc; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; diff --git a/application/src/main/java/run/halo/app/core/endpoint/uc/UserConnectionEndpoint.java b/application/src/main/java/run/halo/app/core/endpoint/uc/UserConnectionEndpoint.java new file mode 100644 index 0000000000..f8fd923caa --- /dev/null +++ b/application/src/main/java/run/halo/app/core/endpoint/uc/UserConnectionEndpoint.java @@ -0,0 +1,74 @@ +package run.halo.app.core.endpoint.uc; + +import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; + +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import org.springdoc.core.fn.builders.parameter.Builder; +import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; +import org.springframework.security.authentication.AuthenticationTrustResolver; +import org.springframework.security.authentication.AuthenticationTrustResolverImpl; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.ReactiveSecurityContextHolder; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.ServerResponse; +import run.halo.app.core.extension.UserConnection; +import run.halo.app.core.extension.endpoint.CustomEndpoint; +import run.halo.app.core.user.service.UserConnectionService; +import run.halo.app.extension.GroupVersion; + +/** + * User connection endpoint. + * + * @author johnniang + * @since 2.20.0 + */ +@Component +public class UserConnectionEndpoint implements CustomEndpoint { + + private final UserConnectionService connectionService; + + private final AuthenticationTrustResolver authenticationTrustResolver = + new AuthenticationTrustResolverImpl(); + + public UserConnectionEndpoint(UserConnectionService connectionService) { + this.connectionService = connectionService; + } + + @Override + public RouterFunction endpoint() { + var tag = "UserConnectionV1alpha1Uc"; + return SpringdocRouteBuilder.route() + .PUT( + "/user-connections/{registerId}/disconnect", + request -> { + var removedUserConnections = ReactiveSecurityContextHolder.getContext() + .map(SecurityContext::getAuthentication) + .filter(authenticationTrustResolver::isAuthenticated) + .map(Authentication::getName) + .flatMapMany(username -> connectionService.removeUserConnection( + request.pathVariable("registerId"), username) + ); + return ServerResponse.ok().body(removedUserConnections, UserConnection.class); + }, + builder -> builder.operationId("DisconnectMyConnection") + .description("Disconnect my connection from a third-party platform.") + .tag(tag) + .parameter(Builder.parameterBuilder() + .in(ParameterIn.PATH) + .name("registerId") + .description("The registration ID of the third-party platform.") + .required(true) + .implementation(String.class) + ) + .response(responseBuilder().implementationArray(UserConnection.class)) + ) + .build(); + } + + @Override + public GroupVersion groupVersion() { + return GroupVersion.parseAPIVersion("uc.api.auth.halo.run/v1alpha1"); + } +} diff --git a/application/src/main/java/run/halo/app/core/extension/endpoint/SystemInitializationEndpoint.java b/application/src/main/java/run/halo/app/core/extension/endpoint/SystemInitializationEndpoint.java deleted file mode 100644 index 37477cc816..0000000000 --- a/application/src/main/java/run/halo/app/core/extension/endpoint/SystemInitializationEndpoint.java +++ /dev/null @@ -1,146 +0,0 @@ -package run.halo.app.core.extension.endpoint; - -import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; -import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; -import static org.springdoc.core.fn.builders.header.Builder.headerBuilder; -import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder; - -import io.swagger.v3.oas.annotations.media.Schema; -import java.net.URI; -import java.time.Duration; -import java.util.LinkedHashMap; -import java.util.Map; -import lombok.Data; -import lombok.RequiredArgsConstructor; -import org.apache.commons.lang3.StringUtils; -import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; -import org.springframework.dao.OptimisticLockingFailureException; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Component; -import org.springframework.web.reactive.function.server.RouterFunction; -import org.springframework.web.reactive.function.server.ServerRequest; -import org.springframework.web.reactive.function.server.ServerResponse; -import org.springframework.web.server.ResponseStatusException; -import org.springframework.web.server.ServerWebInputException; -import reactor.core.publisher.Mono; -import reactor.util.retry.Retry; -import run.halo.app.extension.ConfigMap; -import run.halo.app.extension.ReactiveExtensionClient; -import run.halo.app.infra.InitializationStateGetter; -import run.halo.app.infra.SystemSetting; -import run.halo.app.infra.ValidationUtils; -import run.halo.app.infra.exception.UnsatisfiedAttributeValueException; -import run.halo.app.infra.utils.JsonUtils; -import run.halo.app.security.SuperAdminInitializer; - -/** - * System initialization endpoint. - * - * @author guqing - * @since 2.9.0 - */ -@Component -@RequiredArgsConstructor -public class SystemInitializationEndpoint implements CustomEndpoint { - - private final ReactiveExtensionClient client; - private final SuperAdminInitializer superAdminInitializer; - private final InitializationStateGetter initializationStateSupplier; - - @Override - public RouterFunction endpoint() { - var tag = "SystemV1alpha1Console"; - // define a non-resource api - return SpringdocRouteBuilder.route() - .POST("/system/initialize", this::initialize, - builder -> builder.operationId("initialize") - .description("Initialize system") - .tag(tag) - .requestBody(requestBodyBuilder() - .implementation(SystemInitializationRequest.class)) - .response(responseBuilder() - .responseCode(HttpStatus.CREATED.value() + "") - .description("System initialization successfully.") - .header(headerBuilder() - .name(HttpHeaders.LOCATION) - .description("Redirect URL.") - ) - ) - ) - .build(); - } - - private Mono initialize(ServerRequest request) { - return request.bodyToMono(SystemInitializationRequest.class) - .switchIfEmpty( - Mono.error(new ServerWebInputException("Request body must not be empty")) - ) - .doOnNext(requestBody -> { - if (!ValidationUtils.validateName(requestBody.getUsername())) { - throw new UnsatisfiedAttributeValueException( - "The username does not meet the specifications", - "problemDetail.user.username.unsatisfied", null); - } - if (StringUtils.isBlank(requestBody.getPassword())) { - throw new UnsatisfiedAttributeValueException( - "The password does not meet the specifications", - "problemDetail.user.password.unsatisfied", null); - } - }) - .flatMap(requestBody -> initializationStateSupplier.userInitialized() - .flatMap(result -> { - if (result) { - return Mono.error(new ResponseStatusException(HttpStatus.CONFLICT, - "System has been initialized")); - } - return initializeSystem(requestBody); - }) - ) - .then(ServerResponse.created(URI.create("/console")).build()); - } - - private Mono initializeSystem(SystemInitializationRequest requestBody) { - Mono initializeAdminUser = superAdminInitializer.initialize( - SuperAdminInitializer.InitializationParam.builder() - .username(requestBody.getUsername()) - .password(requestBody.getPassword()) - .email(requestBody.getEmail()) - .build()); - - Mono siteSetting = - Mono.defer(() -> client.get(ConfigMap.class, SystemSetting.SYSTEM_CONFIG) - .flatMap(config -> { - Map data = config.getData(); - if (data == null) { - data = new LinkedHashMap<>(); - config.setData(data); - } - String basic = data.getOrDefault(SystemSetting.Basic.GROUP, "{}"); - SystemSetting.Basic basicSetting = - JsonUtils.jsonToObject(basic, SystemSetting.Basic.class); - basicSetting.setTitle(requestBody.getSiteTitle()); - data.put(SystemSetting.Basic.GROUP, JsonUtils.objectToJson(basicSetting)); - return client.update(config); - })) - .retryWhen(Retry.backoff(5, Duration.ofMillis(100)) - .filter(t -> t instanceof OptimisticLockingFailureException) - ) - .then(); - return Mono.when(initializeAdminUser, siteSetting); - } - - @Data - public static class SystemInitializationRequest { - - @Schema(requiredMode = REQUIRED, minLength = 1) - private String username; - - @Schema(requiredMode = REQUIRED, minLength = 3) - private String password; - - private String email; - - private String siteTitle; - } -} diff --git a/application/src/main/java/run/halo/app/core/extension/service/impl/EmailPasswordRecoveryServiceImpl.java b/application/src/main/java/run/halo/app/core/extension/service/impl/EmailPasswordRecoveryServiceImpl.java deleted file mode 100644 index 4fbe8c859e..0000000000 --- a/application/src/main/java/run/halo/app/core/extension/service/impl/EmailPasswordRecoveryServiceImpl.java +++ /dev/null @@ -1,208 +0,0 @@ -package run.halo.app.core.extension.service.impl; - -import com.google.common.cache.Cache; -import com.google.common.cache.CacheBuilder; -import java.time.Duration; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; -import lombok.Data; -import lombok.RequiredArgsConstructor; -import lombok.experimental.Accessors; -import org.apache.commons.lang3.RandomStringUtils; -import org.apache.commons.lang3.StringUtils; -import org.springframework.dao.OptimisticLockingFailureException; -import org.springframework.stereotype.Component; -import org.springframework.util.Assert; -import reactor.core.publisher.Mono; -import reactor.util.retry.Retry; -import run.halo.app.core.extension.User; -import run.halo.app.core.extension.notification.Reason; -import run.halo.app.core.extension.notification.Subscription; -import run.halo.app.core.extension.service.EmailPasswordRecoveryService; -import run.halo.app.core.extension.service.UserService; -import run.halo.app.extension.GroupVersion; -import run.halo.app.extension.ReactiveExtensionClient; -import run.halo.app.infra.ExternalLinkProcessor; -import run.halo.app.infra.exception.AccessDeniedException; -import run.halo.app.infra.exception.RateLimitExceededException; -import run.halo.app.notification.NotificationCenter; -import run.halo.app.notification.NotificationReasonEmitter; -import run.halo.app.notification.UserIdentity; - -/** - * A default implementation for {@link EmailPasswordRecoveryService}. - * - * @author guqing - * @since 2.11.0 - */ -@Component -@RequiredArgsConstructor -public class EmailPasswordRecoveryServiceImpl implements EmailPasswordRecoveryService { - public static final int MAX_ATTEMPTS = 5; - public static final long LINK_EXPIRATION_MINUTES = 30; - static final String RESET_PASSWORD_BY_EMAIL_REASON_TYPE = "reset-password-by-email"; - - private final ResetPasswordVerificationManager resetPasswordVerificationManager = - new ResetPasswordVerificationManager(); - private final ExternalLinkProcessor externalLinkProcessor; - private final ReactiveExtensionClient client; - private final NotificationReasonEmitter reasonEmitter; - private final NotificationCenter notificationCenter; - private final UserService userService; - - @Override - public Mono sendPasswordResetEmail(String username, String email) { - return client.fetch(User.class, username) - .flatMap(user -> { - var userEmail = user.getSpec().getEmail(); - if (!StringUtils.equals(userEmail, email)) { - return Mono.empty(); - } - if (!user.getSpec().isEmailVerified()) { - return Mono.empty(); - } - return sendResetPasswordNotification(username, email); - }); - } - - @Override - public Mono changePassword(String username, String newPassword, String token) { - Assert.state(StringUtils.isNotBlank(username), "Username must not be blank"); - Assert.state(StringUtils.isNotBlank(newPassword), "NewPassword must not be blank"); - Assert.state(StringUtils.isNotBlank(token), "Token for reset password must not be blank"); - var verified = resetPasswordVerificationManager.verifyToken(username, token); - if (!verified) { - return Mono.error(AccessDeniedException::new); - } - return userService.updateWithRawPassword(username, newPassword) - .retryWhen(Retry.backoff(8, Duration.ofMillis(100)) - .filter(OptimisticLockingFailureException.class::isInstance)) - .flatMap(user -> { - resetPasswordVerificationManager.removeToken(username); - return unSubscribeResetPasswordEmailNotification(user.getSpec().getEmail()); - }) - .then(); - } - - Mono unSubscribeResetPasswordEmailNotification(String email) { - if (StringUtils.isBlank(email)) { - return Mono.empty(); - } - var subscriber = new Subscription.Subscriber(); - subscriber.setName(UserIdentity.anonymousWithEmail(email).name()); - return notificationCenter.unsubscribe(subscriber, createInterestReason(email)) - .retryWhen(Retry.backoff(8, Duration.ofMillis(100)) - .filter(OptimisticLockingFailureException.class::isInstance)); - } - - Mono sendResetPasswordNotification(String username, String email) { - var token = resetPasswordVerificationManager.generateToken(username); - var link = getResetPasswordLink(username, token); - - var subscribeNotification = autoSubscribeResetPasswordEmailNotification(email); - var interestReasonSubject = createInterestReason(email).getSubject(); - var emitReasonMono = reasonEmitter.emit(RESET_PASSWORD_BY_EMAIL_REASON_TYPE, - builder -> builder.attribute("expirationAtMinutes", LINK_EXPIRATION_MINUTES) - .attribute("username", username) - .attribute("link", link) - .author(UserIdentity.of(username)) - .subject(Reason.Subject.builder() - .apiVersion(interestReasonSubject.getApiVersion()) - .kind(interestReasonSubject.getKind()) - .name(interestReasonSubject.getName()) - .title("使用邮箱地址重置密码:" + email) - .build() - ) - ); - return Mono.when(subscribeNotification).then(emitReasonMono); - } - - Mono autoSubscribeResetPasswordEmailNotification(String email) { - var subscriber = new Subscription.Subscriber(); - subscriber.setName(UserIdentity.anonymousWithEmail(email).name()); - var interestReason = createInterestReason(email); - return notificationCenter.subscribe(subscriber, interestReason) - .then(); - } - - Subscription.InterestReason createInterestReason(String email) { - var interestReason = new Subscription.InterestReason(); - interestReason.setReasonType(RESET_PASSWORD_BY_EMAIL_REASON_TYPE); - interestReason.setSubject(Subscription.ReasonSubject.builder() - .apiVersion(new GroupVersion(User.GROUP, User.KIND).toString()) - .kind(User.KIND) - .name(UserIdentity.anonymousWithEmail(email).name()) - .build()); - return interestReason; - } - - private String getResetPasswordLink(String username, String token) { - return externalLinkProcessor.processLink( - "/uc/reset-password/" + username + "?reset_password_token=" + token); - } - - static class ResetPasswordVerificationManager { - private final Cache userTokenCache = - CacheBuilder.newBuilder() - .expireAfterWrite(LINK_EXPIRATION_MINUTES, TimeUnit.MINUTES) - .maximumSize(10000) - .build(); - - private final Cache - blackListCache = CacheBuilder.newBuilder() - .expireAfterWrite(Duration.ofHours(2)) - .maximumSize(1000) - .build(); - - public boolean verifyToken(String username, String token) { - var verification = userTokenCache.getIfPresent(username); - if (verification == null) { - // expired or not generated - return false; - } - if (blackListCache.getIfPresent(username) != null) { - // in blacklist - throw new RateLimitExceededException(null); - } - synchronized (verification) { - if (verification.getAttempts().get() >= MAX_ATTEMPTS) { - // add to blacklist to prevent brute force attack - blackListCache.put(username, true); - return false; - } - if (!verification.getToken().equals(token)) { - verification.getAttempts().incrementAndGet(); - return false; - } - } - return true; - } - - public void removeToken(String username) { - userTokenCache.invalidate(username); - } - - public String generateToken(String username) { - Assert.state(StringUtils.isNotBlank(username), "Username must not be blank"); - var verification = new Verification(); - verification.setToken(RandomStringUtils.randomAlphanumeric(20)); - verification.setAttempts(new AtomicInteger(0)); - userTokenCache.put(username, verification); - return verification.getToken(); - } - - /** - * Only for test. - */ - boolean contains(String username) { - return userTokenCache.getIfPresent(username) != null; - } - - @Data - @Accessors(chain = true) - static class Verification { - private String token; - private AtomicInteger attempts; - } - } -} diff --git a/application/src/main/java/run/halo/app/core/extension/reconciler/AnnotationSettingReconciler.java b/application/src/main/java/run/halo/app/core/reconciler/AnnotationSettingReconciler.java similarity index 97% rename from application/src/main/java/run/halo/app/core/extension/reconciler/AnnotationSettingReconciler.java rename to application/src/main/java/run/halo/app/core/reconciler/AnnotationSettingReconciler.java index 8b6fce8324..fb314e260d 100644 --- a/application/src/main/java/run/halo/app/core/extension/reconciler/AnnotationSettingReconciler.java +++ b/application/src/main/java/run/halo/app/core/reconciler/AnnotationSettingReconciler.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.reconciler; +package run.halo.app.core.reconciler; import java.util.Map; import lombok.AllArgsConstructor; diff --git a/application/src/main/java/run/halo/app/core/extension/reconciler/AuthProviderReconciler.java b/application/src/main/java/run/halo/app/core/reconciler/AuthProviderReconciler.java similarity index 97% rename from application/src/main/java/run/halo/app/core/extension/reconciler/AuthProviderReconciler.java rename to application/src/main/java/run/halo/app/core/reconciler/AuthProviderReconciler.java index 5176f8a3fe..b43c2fdaf9 100644 --- a/application/src/main/java/run/halo/app/core/extension/reconciler/AuthProviderReconciler.java +++ b/application/src/main/java/run/halo/app/core/reconciler/AuthProviderReconciler.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.reconciler; +package run.halo.app.core.reconciler; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.BooleanUtils; diff --git a/application/src/main/java/run/halo/app/core/extension/reconciler/CategoryReconciler.java b/application/src/main/java/run/halo/app/core/reconciler/CategoryReconciler.java similarity index 98% rename from application/src/main/java/run/halo/app/core/extension/reconciler/CategoryReconciler.java rename to application/src/main/java/run/halo/app/core/reconciler/CategoryReconciler.java index d4abf4862f..a64d462fa0 100644 --- a/application/src/main/java/run/halo/app/core/extension/reconciler/CategoryReconciler.java +++ b/application/src/main/java/run/halo/app/core/reconciler/CategoryReconciler.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.reconciler; +package run.halo.app.core.reconciler; import static run.halo.app.extension.ExtensionUtil.addFinalizers; import static run.halo.app.extension.ExtensionUtil.removeFinalizers; diff --git a/application/src/main/java/run/halo/app/core/extension/reconciler/CommentReconciler.java b/application/src/main/java/run/halo/app/core/reconciler/CommentReconciler.java similarity index 98% rename from application/src/main/java/run/halo/app/core/extension/reconciler/CommentReconciler.java rename to application/src/main/java/run/halo/app/core/reconciler/CommentReconciler.java index 4e9ce5237e..a651a09b5a 100644 --- a/application/src/main/java/run/halo/app/core/extension/reconciler/CommentReconciler.java +++ b/application/src/main/java/run/halo/app/core/reconciler/CommentReconciler.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.reconciler; +package run.halo.app.core.reconciler; import static org.apache.commons.lang3.ObjectUtils.defaultIfNull; import static run.halo.app.extension.ExtensionUtil.addFinalizers; @@ -18,6 +18,7 @@ import org.springframework.stereotype.Component; import run.halo.app.content.comment.ReplyNotificationSubscriptionHelper; import run.halo.app.content.comment.ReplyService; +import run.halo.app.core.counter.MeterUtils; import run.halo.app.core.extension.Counter; import run.halo.app.core.extension.content.Comment; import run.halo.app.core.extension.content.Constant; @@ -36,7 +37,6 @@ import run.halo.app.extension.controller.Reconciler; import run.halo.app.extension.index.query.Query; import run.halo.app.extension.router.selector.FieldSelector; -import run.halo.app.metrics.MeterUtils; /** * Reconciler for {@link Comment}. diff --git a/application/src/main/java/run/halo/app/core/extension/reconciler/MenuItemReconciler.java b/application/src/main/java/run/halo/app/core/reconciler/MenuItemReconciler.java similarity index 99% rename from application/src/main/java/run/halo/app/core/extension/reconciler/MenuItemReconciler.java rename to application/src/main/java/run/halo/app/core/reconciler/MenuItemReconciler.java index 9d27680367..ba267bb5f7 100644 --- a/application/src/main/java/run/halo/app/core/extension/reconciler/MenuItemReconciler.java +++ b/application/src/main/java/run/halo/app/core/reconciler/MenuItemReconciler.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.reconciler; +package run.halo.app.core.reconciler; import java.time.Duration; import java.util.Objects; diff --git a/application/src/main/java/run/halo/app/core/extension/reconciler/PluginReconciler.java b/application/src/main/java/run/halo/app/core/reconciler/PluginReconciler.java similarity index 99% rename from application/src/main/java/run/halo/app/core/extension/reconciler/PluginReconciler.java rename to application/src/main/java/run/halo/app/core/reconciler/PluginReconciler.java index 145c7df775..5a5a2ecd21 100644 --- a/application/src/main/java/run/halo/app/core/extension/reconciler/PluginReconciler.java +++ b/application/src/main/java/run/halo/app/core/reconciler/PluginReconciler.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.reconciler; +package run.halo.app.core.reconciler; import static run.halo.app.core.extension.Plugin.PluginStatus.nullSafeConditions; import static run.halo.app.extension.ExtensionUtil.addFinalizers; @@ -44,7 +44,6 @@ import run.halo.app.core.extension.Plugin; import run.halo.app.core.extension.ReverseProxy; import run.halo.app.core.extension.Setting; -import run.halo.app.core.extension.theme.SettingUtils; import run.halo.app.extension.ConfigMap; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.ExtensionUtil; @@ -59,6 +58,7 @@ import run.halo.app.infra.ConditionList; import run.halo.app.infra.ConditionStatus; import run.halo.app.infra.utils.PathUtils; +import run.halo.app.infra.utils.SettingUtils; import run.halo.app.infra.utils.YamlUnstructuredLoader; import run.halo.app.plugin.PluginConst; import run.halo.app.plugin.PluginProperties; diff --git a/application/src/main/java/run/halo/app/core/extension/reconciler/PostCounterReconciler.java b/application/src/main/java/run/halo/app/core/reconciler/PostCounterReconciler.java similarity index 95% rename from application/src/main/java/run/halo/app/core/extension/reconciler/PostCounterReconciler.java rename to application/src/main/java/run/halo/app/core/reconciler/PostCounterReconciler.java index a6986a54c7..1e43f0c387 100644 --- a/application/src/main/java/run/halo/app/core/extension/reconciler/PostCounterReconciler.java +++ b/application/src/main/java/run/halo/app/core/reconciler/PostCounterReconciler.java @@ -1,10 +1,11 @@ -package run.halo.app.core.extension.reconciler; +package run.halo.app.core.reconciler; import static run.halo.app.extension.index.query.QueryFactory.startsWith; import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Component; +import run.halo.app.core.counter.MeterUtils; import run.halo.app.core.extension.Counter; import run.halo.app.core.extension.content.Post; import run.halo.app.event.post.PostStatsChangedEvent; @@ -14,7 +15,6 @@ import run.halo.app.extension.controller.ControllerBuilder; import run.halo.app.extension.controller.Reconciler; import run.halo.app.extension.router.selector.FieldSelector; -import run.halo.app.metrics.MeterUtils; @Component @RequiredArgsConstructor diff --git a/application/src/main/java/run/halo/app/core/extension/reconciler/PostReconciler.java b/application/src/main/java/run/halo/app/core/reconciler/PostReconciler.java similarity index 99% rename from application/src/main/java/run/halo/app/core/extension/reconciler/PostReconciler.java rename to application/src/main/java/run/halo/app/core/reconciler/PostReconciler.java index 29d546944f..9ada817e00 100644 --- a/application/src/main/java/run/halo/app/core/extension/reconciler/PostReconciler.java +++ b/application/src/main/java/run/halo/app/core/reconciler/PostReconciler.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.reconciler; +package run.halo.app.core.reconciler; import static java.nio.charset.StandardCharsets.UTF_8; import static org.apache.commons.lang3.BooleanUtils.TRUE; @@ -39,6 +39,8 @@ import run.halo.app.content.PostService; import run.halo.app.content.comment.CommentService; import run.halo.app.content.permalinks.PostPermalinkPolicy; +import run.halo.app.core.counter.CounterService; +import run.halo.app.core.counter.MeterUtils; import run.halo.app.core.extension.content.Constant; import run.halo.app.core.extension.content.Post; import run.halo.app.core.extension.content.Post.PostPhase; @@ -65,8 +67,6 @@ import run.halo.app.infra.Condition; import run.halo.app.infra.ConditionStatus; import run.halo.app.infra.utils.HaloUtils; -import run.halo.app.metrics.CounterService; -import run.halo.app.metrics.MeterUtils; import run.halo.app.notification.NotificationCenter; import run.halo.app.plugin.extensionpoint.ExtensionGetter; diff --git a/application/src/main/java/run/halo/app/core/extension/reconciler/ReplyReconciler.java b/application/src/main/java/run/halo/app/core/reconciler/ReplyReconciler.java similarity index 98% rename from application/src/main/java/run/halo/app/core/extension/reconciler/ReplyReconciler.java rename to application/src/main/java/run/halo/app/core/reconciler/ReplyReconciler.java index 3dc0b89c91..9a3886dc96 100644 --- a/application/src/main/java/run/halo/app/core/extension/reconciler/ReplyReconciler.java +++ b/application/src/main/java/run/halo/app/core/reconciler/ReplyReconciler.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.reconciler; +package run.halo.app.core.reconciler; import static org.apache.commons.lang3.ObjectUtils.defaultIfNull; import static run.halo.app.extension.ExtensionUtil.addFinalizers; diff --git a/application/src/main/java/run/halo/app/core/extension/reconciler/ReverseProxyReconciler.java b/application/src/main/java/run/halo/app/core/reconciler/ReverseProxyReconciler.java similarity index 98% rename from application/src/main/java/run/halo/app/core/extension/reconciler/ReverseProxyReconciler.java rename to application/src/main/java/run/halo/app/core/reconciler/ReverseProxyReconciler.java index e0a34ca771..7c2620baad 100644 --- a/application/src/main/java/run/halo/app/core/extension/reconciler/ReverseProxyReconciler.java +++ b/application/src/main/java/run/halo/app/core/reconciler/ReverseProxyReconciler.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.reconciler; +package run.halo.app.core.reconciler; import java.util.HashSet; import java.util.Map; diff --git a/application/src/main/java/run/halo/app/core/extension/reconciler/RoleReconciler.java b/application/src/main/java/run/halo/app/core/reconciler/RoleReconciler.java similarity index 97% rename from application/src/main/java/run/halo/app/core/extension/reconciler/RoleReconciler.java rename to application/src/main/java/run/halo/app/core/reconciler/RoleReconciler.java index e3d631139e..2473004642 100644 --- a/application/src/main/java/run/halo/app/core/extension/reconciler/RoleReconciler.java +++ b/application/src/main/java/run/halo/app/core/reconciler/RoleReconciler.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.reconciler; +package run.halo.app.core.reconciler; import static java.util.Objects.deepEquals; diff --git a/application/src/main/java/run/halo/app/core/extension/reconciler/SinglePageReconciler.java b/application/src/main/java/run/halo/app/core/reconciler/SinglePageReconciler.java similarity index 99% rename from application/src/main/java/run/halo/app/core/extension/reconciler/SinglePageReconciler.java rename to application/src/main/java/run/halo/app/core/reconciler/SinglePageReconciler.java index b803b6df5b..a09d2b6966 100644 --- a/application/src/main/java/run/halo/app/core/extension/reconciler/SinglePageReconciler.java +++ b/application/src/main/java/run/halo/app/core/reconciler/SinglePageReconciler.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.reconciler; +package run.halo.app.core.reconciler; import static java.nio.charset.StandardCharsets.UTF_8; import static org.springframework.web.util.UriUtils.encodePath; @@ -23,6 +23,8 @@ import run.halo.app.content.NotificationReasonConst; import run.halo.app.content.SinglePageService; import run.halo.app.content.comment.CommentService; +import run.halo.app.core.counter.CounterService; +import run.halo.app.core.counter.MeterUtils; import run.halo.app.core.extension.content.Post; import run.halo.app.core.extension.content.SinglePage; import run.halo.app.core.extension.content.Snapshot; @@ -43,8 +45,6 @@ import run.halo.app.infra.ConditionStatus; import run.halo.app.infra.ExternalUrlSupplier; import run.halo.app.infra.utils.JsonUtils; -import run.halo.app.metrics.CounterService; -import run.halo.app.metrics.MeterUtils; import run.halo.app.notification.NotificationCenter; import run.halo.app.plugin.extensionpoint.ExtensionGetter; diff --git a/application/src/main/java/run/halo/app/core/extension/reconciler/SystemSettingReconciler.java b/application/src/main/java/run/halo/app/core/reconciler/SystemSettingReconciler.java similarity index 99% rename from application/src/main/java/run/halo/app/core/extension/reconciler/SystemSettingReconciler.java rename to application/src/main/java/run/halo/app/core/reconciler/SystemSettingReconciler.java index f3f7638fd5..ea920d839a 100644 --- a/application/src/main/java/run/halo/app/core/extension/reconciler/SystemSettingReconciler.java +++ b/application/src/main/java/run/halo/app/core/reconciler/SystemSettingReconciler.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.reconciler; +package run.halo.app.core.reconciler; import java.util.HashMap; import java.util.HashSet; diff --git a/application/src/main/java/run/halo/app/core/extension/reconciler/TagReconciler.java b/application/src/main/java/run/halo/app/core/reconciler/TagReconciler.java similarity index 98% rename from application/src/main/java/run/halo/app/core/extension/reconciler/TagReconciler.java rename to application/src/main/java/run/halo/app/core/reconciler/TagReconciler.java index 1318e6c28d..4660597909 100644 --- a/application/src/main/java/run/halo/app/core/extension/reconciler/TagReconciler.java +++ b/application/src/main/java/run/halo/app/core/reconciler/TagReconciler.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.reconciler; +package run.halo.app.core.reconciler; import static run.halo.app.extension.ExtensionUtil.addFinalizers; import static run.halo.app.extension.ExtensionUtil.removeFinalizers; diff --git a/application/src/main/java/run/halo/app/core/extension/reconciler/ThemeReconciler.java b/application/src/main/java/run/halo/app/core/reconciler/ThemeReconciler.java similarity index 98% rename from application/src/main/java/run/halo/app/core/extension/reconciler/ThemeReconciler.java rename to application/src/main/java/run/halo/app/core/reconciler/ThemeReconciler.java index 0899e0a5e6..e0e086b9c8 100644 --- a/application/src/main/java/run/halo/app/core/extension/reconciler/ThemeReconciler.java +++ b/application/src/main/java/run/halo/app/core/reconciler/ThemeReconciler.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.reconciler; +package run.halo.app.core.reconciler; import static org.apache.commons.lang3.ObjectUtils.defaultIfNull; @@ -17,7 +17,6 @@ import run.halo.app.core.extension.AnnotationSetting; import run.halo.app.core.extension.Setting; import run.halo.app.core.extension.Theme; -import run.halo.app.core.extension.theme.SettingUtils; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.MetadataUtil; import run.halo.app.extension.controller.Controller; @@ -30,6 +29,7 @@ import run.halo.app.infra.ThemeRootGetter; import run.halo.app.infra.exception.ThemeUninstallException; import run.halo.app.infra.utils.JsonUtils; +import run.halo.app.infra.utils.SettingUtils; import run.halo.app.infra.utils.VersionUtils; /** diff --git a/application/src/main/java/run/halo/app/core/extension/reconciler/UserReconciler.java b/application/src/main/java/run/halo/app/core/reconciler/UserReconciler.java similarity index 97% rename from application/src/main/java/run/halo/app/core/extension/reconciler/UserReconciler.java rename to application/src/main/java/run/halo/app/core/reconciler/UserReconciler.java index c74d087b16..397b89afd3 100644 --- a/application/src/main/java/run/halo/app/core/extension/reconciler/UserReconciler.java +++ b/application/src/main/java/run/halo/app/core/reconciler/UserReconciler.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.reconciler; +package run.halo.app.core.reconciler; import static run.halo.app.extension.ExtensionUtil.addFinalizers; import static run.halo.app.extension.ExtensionUtil.defaultSort; @@ -23,8 +23,8 @@ import run.halo.app.core.extension.UserConnection; import run.halo.app.core.extension.attachment.Attachment; import run.halo.app.core.extension.service.AttachmentService; -import run.halo.app.core.extension.service.RoleService; -import run.halo.app.core.extension.service.UserService; +import run.halo.app.core.user.service.RoleService; +import run.halo.app.core.user.service.UserService; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.ListOptions; import run.halo.app.extension.controller.Controller; diff --git a/application/src/main/java/run/halo/app/core/extension/service/DefaultRoleService.java b/application/src/main/java/run/halo/app/core/user/service/DefaultRoleService.java similarity index 99% rename from application/src/main/java/run/halo/app/core/extension/service/DefaultRoleService.java rename to application/src/main/java/run/halo/app/core/user/service/DefaultRoleService.java index b029541130..0ac6672d34 100644 --- a/application/src/main/java/run/halo/app/core/extension/service/DefaultRoleService.java +++ b/application/src/main/java/run/halo/app/core/user/service/DefaultRoleService.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.service; +package run.halo.app.core.user.service; import static run.halo.app.extension.ExtensionUtil.defaultSort; import static run.halo.app.extension.ExtensionUtil.notDeleting; diff --git a/application/src/main/java/run/halo/app/core/extension/service/EmailPasswordRecoveryService.java b/application/src/main/java/run/halo/app/core/user/service/EmailPasswordRecoveryService.java similarity index 85% rename from application/src/main/java/run/halo/app/core/extension/service/EmailPasswordRecoveryService.java rename to application/src/main/java/run/halo/app/core/user/service/EmailPasswordRecoveryService.java index 3e1a97723a..2770cec886 100644 --- a/application/src/main/java/run/halo/app/core/extension/service/EmailPasswordRecoveryService.java +++ b/application/src/main/java/run/halo/app/core/user/service/EmailPasswordRecoveryService.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.service; +package run.halo.app.core.user.service; import reactor.core.publisher.Mono; import run.halo.app.infra.exception.AccessDeniedException; @@ -22,17 +22,21 @@ public interface EmailPasswordRecoveryService { */ Mono sendPasswordResetEmail(String username, String email); + Mono sendPasswordResetEmail(String email); + /** *

Reset password by token.

* if the token is invalid, it will return {@link Mono#error(Throwable)}} * if the token is valid, but the username is not the same, it will return * {@link Mono#error(Throwable)} * - * @param username username to reset password * @param newPassword new password * @param token token to validate the user * @return {@link Mono#empty()} if the token is invalid or the username is not the same. * @throws AccessDeniedException if the token is invalid */ - Mono changePassword(String username, String newPassword, String token); + Mono changePassword(String newPassword, String token); + + Mono getValidResetToken(String token); + } diff --git a/application/src/main/java/run/halo/app/core/extension/service/EmailVerificationService.java b/application/src/main/java/run/halo/app/core/user/service/EmailVerificationService.java similarity index 96% rename from application/src/main/java/run/halo/app/core/extension/service/EmailVerificationService.java rename to application/src/main/java/run/halo/app/core/user/service/EmailVerificationService.java index 762f92afe1..f2c2336884 100644 --- a/application/src/main/java/run/halo/app/core/extension/service/EmailVerificationService.java +++ b/application/src/main/java/run/halo/app/core/user/service/EmailVerificationService.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.service; +package run.halo.app.core.user.service; import reactor.core.publisher.Mono; import run.halo.app.infra.exception.EmailVerificationFailed; diff --git a/application/src/main/java/run/halo/app/core/user/service/InMemoryResetTokenRepository.java b/application/src/main/java/run/halo/app/core/user/service/InMemoryResetTokenRepository.java new file mode 100644 index 0000000000..1c8b3a2ac9 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/user/service/InMemoryResetTokenRepository.java @@ -0,0 +1,54 @@ +package run.halo.app.core.user.service; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import java.time.Duration; +import java.util.Objects; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; + +/** + * In-memory reset token repository. + * + * @author johnniang + * @since 2.20.0 + */ +@Component +public class InMemoryResetTokenRepository implements ResetTokenRepository { + + /** + * Key: Token Hash. + */ + private final Cache tokens; + + public InMemoryResetTokenRepository() { + this.tokens = Caffeine.newBuilder() + .expireAfterWrite(Duration.ofDays(1)) + .maximumSize(10000) + .build(); + } + + @Override + public Mono save(ResetToken resetToken) { + return Mono.defer(() -> { + var savedResetToken = tokens.get(resetToken.tokenHash(), k -> resetToken); + if (Objects.equals(savedResetToken, resetToken)) { + return Mono.empty(); + } + // should never happen + return Mono.error(new DuplicateKeyException("Reset token already exists")); + }); + } + + @Override + public Mono findByTokenHash(String tokenHash) { + return Mono.fromSupplier(() -> tokens.getIfPresent(tokenHash)); + } + + @Override + public Mono removeByTokenHash(String tokenHash) { + return Mono.fromRunnable(() -> tokens.invalidate(tokenHash)); + } + +} diff --git a/application/src/main/java/run/halo/app/core/user/service/InvalidResetTokenException.java b/application/src/main/java/run/halo/app/core/user/service/InvalidResetTokenException.java new file mode 100644 index 0000000000..597c90af66 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/user/service/InvalidResetTokenException.java @@ -0,0 +1,17 @@ +package run.halo.app.core.user.service; + +import org.springframework.web.server.ServerWebInputException; + +/** + * Invalid reset token exception. + * + * @author johnniang + * @since 2.20.0 + */ +public class InvalidResetTokenException extends ServerWebInputException { + + public InvalidResetTokenException() { + super("Invalid reset token"); + } + +} diff --git a/application/src/main/java/run/halo/app/core/user/service/ResetToken.java b/application/src/main/java/run/halo/app/core/user/service/ResetToken.java new file mode 100644 index 0000000000..31a4161059 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/user/service/ResetToken.java @@ -0,0 +1,15 @@ +package run.halo.app.core.user.service; + +import java.time.Instant; + +/** + * Reset token data. + * + * @param tokenHash The token hash + * @param username The username + * @param expiresAt The expires at + * @author johnniang + * @since 2.20.0 + */ +public record ResetToken(String tokenHash, String username, Instant expiresAt) { +} diff --git a/application/src/main/java/run/halo/app/core/user/service/ResetTokenRepository.java b/application/src/main/java/run/halo/app/core/user/service/ResetTokenRepository.java new file mode 100644 index 0000000000..0dcc67b3e6 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/user/service/ResetTokenRepository.java @@ -0,0 +1,38 @@ +package run.halo.app.core.user.service; + +import reactor.core.publisher.Mono; + +/** + * Reset token repository. + * + * @author johnniang + * @since 2.20.0 + */ +public interface ResetTokenRepository { + + /** + * Save reset token. + * + * @param resetToken reset token + * @return empty mono if saved successfully. + * @throws org.springframework.dao.DuplicateKeyException if token already exists. + */ + Mono save(ResetToken resetToken); + + /** + * Find reset token by token hash. + * + * @param tokenHash token hash + * @return reset token if found, or empty mono. + */ + Mono findByTokenHash(String tokenHash); + + /** + * Remove reset token by token hash. + * + * @param tokenHash token hash + * @return empty mono if removed successfully. + */ + Mono removeByTokenHash(String tokenHash); + +} diff --git a/application/src/main/java/run/halo/app/core/user/service/SettingConfigService.java b/application/src/main/java/run/halo/app/core/user/service/SettingConfigService.java new file mode 100644 index 0000000000..a02bc718c0 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/user/service/SettingConfigService.java @@ -0,0 +1,19 @@ +package run.halo.app.core.user.service; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import reactor.core.publisher.Mono; +import run.halo.app.core.extension.Setting; +import run.halo.app.extension.ConfigMap; + +/** + * {@link Setting} related {@link ConfigMap} service. + * + * @author guqing + * @since 2.20.0 + */ +public interface SettingConfigService { + + Mono upsertConfig(String configMapName, ObjectNode configJsonData); + + Mono fetchConfig(String configMapName); +} diff --git a/application/src/main/java/run/halo/app/core/user/service/UserConnectionService.java b/application/src/main/java/run/halo/app/core/user/service/UserConnectionService.java new file mode 100644 index 0000000000..2bbb260c1f --- /dev/null +++ b/application/src/main/java/run/halo/app/core/user/service/UserConnectionService.java @@ -0,0 +1,45 @@ +package run.halo.app.core.user.service; + +import org.springframework.security.oauth2.core.user.OAuth2User; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import run.halo.app.core.extension.UserConnection; + +public interface UserConnectionService { + + /** + * Create user connection. + * + * @param username Username + * @param registrationId Registration id + * @param oauth2User OAuth2 user + * @return Created user connection + */ + Mono createUserConnection( + String username, + String registrationId, + OAuth2User oauth2User + ); + + /** + * Update the user connection if present. + * If found, update updatedAt timestamp of the user connection. + * + * @param registrationId Registration id + * @param oauth2User OAuth2 user + * @return Updated user connection or empty + */ + Mono updateUserConnectionIfPresent( + String registrationId, OAuth2User oauth2User + ); + + /** + * Remove user connection. + * + * @param registrationId Registration ID + * @param username Username + * @return A list of user connections + */ + Flux removeUserConnection(String registrationId, String username); + +} diff --git a/application/src/main/java/run/halo/app/core/extension/service/impl/DefaultAttachmentService.java b/application/src/main/java/run/halo/app/core/user/service/impl/DefaultAttachmentService.java similarity index 99% rename from application/src/main/java/run/halo/app/core/extension/service/impl/DefaultAttachmentService.java rename to application/src/main/java/run/halo/app/core/user/service/impl/DefaultAttachmentService.java index 50486eb2cb..525ed17e75 100644 --- a/application/src/main/java/run/halo/app/core/extension/service/impl/DefaultAttachmentService.java +++ b/application/src/main/java/run/halo/app/core/user/service/impl/DefaultAttachmentService.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.service.impl; +package run.halo.app.core.user.service.impl; import java.net.URI; import java.net.URL; diff --git a/application/src/main/java/run/halo/app/core/user/service/impl/EmailPasswordRecoveryServiceImpl.java b/application/src/main/java/run/halo/app/core/user/service/impl/EmailPasswordRecoveryServiceImpl.java new file mode 100644 index 0000000000..b75ac59f60 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/user/service/impl/EmailPasswordRecoveryServiceImpl.java @@ -0,0 +1,169 @@ +package run.halo.app.core.user.service.impl; + +import java.time.Clock; +import java.time.Duration; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.RandomStringUtils; +import org.apache.commons.lang3.StringUtils; +import org.springframework.dao.OptimisticLockingFailureException; +import org.springframework.security.core.token.Sha512DigestUtils; +import org.springframework.stereotype.Component; +import org.springframework.util.Assert; +import org.springframework.web.util.UriComponentsBuilder; +import reactor.core.publisher.Mono; +import reactor.util.retry.Retry; +import run.halo.app.core.extension.User; +import run.halo.app.core.extension.notification.Reason; +import run.halo.app.core.extension.notification.Subscription; +import run.halo.app.core.user.service.EmailPasswordRecoveryService; +import run.halo.app.core.user.service.InvalidResetTokenException; +import run.halo.app.core.user.service.ResetToken; +import run.halo.app.core.user.service.ResetTokenRepository; +import run.halo.app.core.user.service.UserService; +import run.halo.app.extension.GroupVersion; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.infra.ExternalLinkProcessor; +import run.halo.app.notification.NotificationCenter; +import run.halo.app.notification.NotificationReasonEmitter; +import run.halo.app.notification.UserIdentity; + +/** + * A default implementation for {@link EmailPasswordRecoveryService}. + * + * @author guqing + * @since 2.11.0 + */ +@Component +@RequiredArgsConstructor +public class EmailPasswordRecoveryServiceImpl implements EmailPasswordRecoveryService { + + public static final int MAX_ATTEMPTS = 5; + public static final long LINK_EXPIRATION_MINUTES = 30; + private static final Duration RESET_TOKEN_LIFE_TIME = + Duration.ofMinutes(LINK_EXPIRATION_MINUTES); + static final String RESET_PASSWORD_BY_EMAIL_REASON_TYPE = "reset-password-by-email"; + + private final ExternalLinkProcessor externalLinkProcessor; + private final ReactiveExtensionClient client; + private final NotificationReasonEmitter reasonEmitter; + private final NotificationCenter notificationCenter; + private final UserService userService; + private final ResetTokenRepository resetTokenRepository; + + private Clock clock = Clock.systemDefaultZone(); + + @Override + public Mono sendPasswordResetEmail(String username, String email) { + return client.fetch(User.class, username) + .flatMap(user -> { + var userEmail = user.getSpec().getEmail(); + if (!StringUtils.equals(userEmail, email)) { + return Mono.empty(); + } + if (!user.getSpec().isEmailVerified()) { + return Mono.empty(); + } + return sendResetPasswordNotification(username, email); + }); + } + + @Override + public Mono sendPasswordResetEmail(String email) { + if (StringUtils.isBlank(email)) { + return Mono.empty(); + } + return userService.listByEmail(email) + .filter(user -> user.getSpec().isEmailVerified()) + .next() + .flatMap(user -> sendResetPasswordNotification(user.getMetadata().getName(), email)); + } + + @Override + public Mono changePassword(String newPassword, String token) { + Assert.state(StringUtils.isNotBlank(newPassword), "NewPassword must not be blank"); + Assert.state(StringUtils.isNotBlank(token), "Token for reset password must not be blank"); + var tokenHash = hashToken(token); + return getValidResetToken(token).flatMap(resetToken -> + userService.updateWithRawPassword(resetToken.username(), newPassword) + .flatMap(user -> unSubscribeResetPasswordEmailNotification( + user.getSpec().getEmail()) + ) + .then(resetTokenRepository.removeByTokenHash(tokenHash)) + ); + } + + @Override + public Mono getValidResetToken(String token) { + return resetTokenRepository.findByTokenHash(hashToken(token)) + .filter(resetToken -> clock.instant().isBefore(resetToken.expiresAt())) + .switchIfEmpty(Mono.error(InvalidResetTokenException::new)); + } + + Mono unSubscribeResetPasswordEmailNotification(String email) { + if (StringUtils.isBlank(email)) { + return Mono.empty(); + } + var subscriber = new Subscription.Subscriber(); + subscriber.setName(UserIdentity.anonymousWithEmail(email).name()); + return notificationCenter.unsubscribe(subscriber, createInterestReason(email)) + .retryWhen(Retry.backoff(8, Duration.ofMillis(100)) + .filter(OptimisticLockingFailureException.class::isInstance)); + } + + private Mono sendResetPasswordNotification(String username, String email) { + var token = generateToken(); + var tokenHash = hashToken(token); + var expiresAt = clock.instant().plus(RESET_TOKEN_LIFE_TIME); + var uri = UriComponentsBuilder.fromUriString("/") + .pathSegment("password-reset", "email", token) + .build(true) + .toUri(); + var resetToken = new ResetToken(tokenHash, username, expiresAt); + return resetTokenRepository.save(resetToken) + .then(externalLinkProcessor.processLink(uri).flatMap(link -> { + var interestReasonSubject = createInterestReason(email).getSubject(); + var emitReasonMono = reasonEmitter.emit(RESET_PASSWORD_BY_EMAIL_REASON_TYPE, + builder -> builder.attribute("expirationAtMinutes", LINK_EXPIRATION_MINUTES) + .attribute("username", username) + .attribute("link", link) + .author(UserIdentity.of(username)) + .subject(Reason.Subject.builder() + .apiVersion(interestReasonSubject.getApiVersion()) + .kind(interestReasonSubject.getKind()) + .name(interestReasonSubject.getName()) + .title("使用邮箱地址重置密码:" + email) + .build() + ) + ); + return autoSubscribeResetPasswordEmailNotification(email).then(emitReasonMono); + })); + } + + Mono autoSubscribeResetPasswordEmailNotification(String email) { + var subscriber = new Subscription.Subscriber(); + subscriber.setName(UserIdentity.anonymousWithEmail(email).name()); + var interestReason = createInterestReason(email); + return notificationCenter.subscribe(subscriber, interestReason) + .then(); + } + + Subscription.InterestReason createInterestReason(String email) { + var interestReason = new Subscription.InterestReason(); + interestReason.setReasonType(RESET_PASSWORD_BY_EMAIL_REASON_TYPE); + interestReason.setSubject(Subscription.ReasonSubject.builder() + .apiVersion(new GroupVersion(User.GROUP, User.KIND).toString()) + .kind(User.KIND) + .name(UserIdentity.anonymousWithEmail(email).name()) + .build()); + return interestReason; + } + + private static String hashToken(String token) { + return Sha512DigestUtils.shaHex(token); + } + + private static String generateToken() { + return RandomStringUtils.secure().nextAlphanumeric(64); + } + +} diff --git a/application/src/main/java/run/halo/app/core/extension/service/impl/EmailVerificationServiceImpl.java b/application/src/main/java/run/halo/app/core/user/service/impl/EmailVerificationServiceImpl.java similarity index 93% rename from application/src/main/java/run/halo/app/core/extension/service/impl/EmailVerificationServiceImpl.java rename to application/src/main/java/run/halo/app/core/user/service/impl/EmailVerificationServiceImpl.java index 857b4647cc..01dfeff7cd 100644 --- a/application/src/main/java/run/halo/app/core/extension/service/impl/EmailVerificationServiceImpl.java +++ b/application/src/main/java/run/halo/app/core/user/service/impl/EmailVerificationServiceImpl.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.service.impl; +package run.halo.app.core.user.service.impl; import com.google.common.cache.Cache; import com.google.common.cache.CacheBuilder; @@ -15,15 +15,18 @@ import org.springframework.util.Assert; import org.springframework.web.server.ServerWebInputException; import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; import reactor.util.retry.Retry; import run.halo.app.core.extension.User; import run.halo.app.core.extension.notification.Reason; import run.halo.app.core.extension.notification.Subscription; -import run.halo.app.core.extension.service.EmailVerificationService; -import run.halo.app.core.extension.service.UserService; +import run.halo.app.core.user.service.EmailVerificationService; +import run.halo.app.extension.ExtensionUtil; import run.halo.app.extension.GroupVersion; +import run.halo.app.extension.ListOptions; import run.halo.app.extension.MetadataUtil; import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.extension.index.query.QueryFactory; import run.halo.app.infra.exception.EmailVerificationFailed; import run.halo.app.notification.NotificationCenter; import run.halo.app.notification.NotificationReasonEmitter; @@ -47,7 +50,6 @@ public class EmailVerificationServiceImpl implements EmailVerificationService { private final ReactiveExtensionClient client; private final NotificationReasonEmitter reasonEmitter; private final NotificationCenter notificationCenter; - private final UserService userService; @Override public Mono sendVerificationCode(String username, String email) { @@ -121,7 +123,10 @@ private Mono verifyUserEmail(User user, String code) { } Mono isEmailInUse(String username, String emailToVerify) { - return userService.listByEmail(emailToVerify) + var listOptions = ListOptions.builder() + .andQuery(QueryFactory.equal("spec.email", emailToVerify)) + .build(); + return client.listAll(User.class, listOptions, ExtensionUtil.defaultSort()) .filter(user -> user.getSpec().isEmailVerified()) .filter(user -> !user.getMetadata().getName().equals(username)) .hasElements(); @@ -137,7 +142,9 @@ public Mono sendRegisterVerificationCode(String email) { public Mono verifyRegisterVerificationCode(String email, String code) { Assert.state(StringUtils.isNotBlank(email), "Username must not be blank"); Assert.state(StringUtils.isNotBlank(code), "Code must not be blank"); - return Mono.just(emailVerificationManager.verifyCode(email, email, code)); + return Mono.fromSupplier(() -> emailVerificationManager.verifyCode(email, email, code)) + // Why use boundedElastic? Because the verification uses synchronized block. + .subscribeOn(Schedulers.boundedElastic()); } Mono sendVerificationNotification(String username, String email) { diff --git a/application/src/main/java/run/halo/app/core/user/service/impl/SettingConfigServiceImpl.java b/application/src/main/java/run/halo/app/core/user/service/impl/SettingConfigServiceImpl.java new file mode 100644 index 0000000000..a42a137ca5 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/user/service/impl/SettingConfigServiceImpl.java @@ -0,0 +1,57 @@ +package run.halo.app.core.user.service.impl; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.time.Duration; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.OptimisticLockingFailureException; +import org.springframework.stereotype.Component; +import org.springframework.util.Assert; +import reactor.core.publisher.Mono; +import reactor.util.retry.Retry; +import run.halo.app.core.extension.Setting; +import run.halo.app.core.user.service.SettingConfigService; +import run.halo.app.extension.ConfigMap; +import run.halo.app.extension.Metadata; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.infra.utils.SettingUtils; + +/** + * {@link Setting} related {@link ConfigMap} service implementation. + * + * @author guqing + * @since 2.20.0 + */ +@Component +@RequiredArgsConstructor +public class SettingConfigServiceImpl implements SettingConfigService { + private final ReactiveExtensionClient client; + + @Override + public Mono upsertConfig(String configMapName, ObjectNode configJsonData) { + Assert.notNull(configMapName, "Config map name must not be null"); + Assert.notNull(configJsonData, "Config json data must not be null"); + var data = SettingUtils.settingConfigJsonToMap(configJsonData); + return Mono.defer(() -> client.fetch(ConfigMap.class, configMapName) + .flatMap(persisted -> { + persisted.setData(data); + return client.update(persisted); + })) + .retryWhen(Retry.backoff(5, Duration.ofMillis(100)) + .filter(OptimisticLockingFailureException.class::isInstance) + ) + .switchIfEmpty(Mono.defer(() -> { + var configMap = new ConfigMap(); + configMap.setMetadata(new Metadata()); + configMap.getMetadata().setName(configMapName); + configMap.setData(data); + return client.create(configMap); + })) + .then(); + } + + @Override + public Mono fetchConfig(String configMapName) { + return client.fetch(ConfigMap.class, configMapName) + .map(SettingUtils::settingConfigToJson); + } +} diff --git a/application/src/main/java/run/halo/app/core/user/service/impl/UserConnectionServiceImpl.java b/application/src/main/java/run/halo/app/core/user/service/impl/UserConnectionServiceImpl.java new file mode 100644 index 0000000000..2b6e414981 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/user/service/impl/UserConnectionServiceImpl.java @@ -0,0 +1,125 @@ +package run.halo.app.core.user.service.impl; + +import static run.halo.app.extension.ExtensionUtil.defaultSort; +import static run.halo.app.extension.index.query.QueryFactory.and; +import static run.halo.app.extension.index.query.QueryFactory.equal; + +import java.time.Clock; +import java.util.HashMap; +import java.util.Optional; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import run.halo.app.core.extension.UserConnection; +import run.halo.app.core.extension.UserConnection.UserConnectionSpec; +import run.halo.app.core.user.service.UserConnectionService; +import run.halo.app.event.user.UserConnectionDisconnectedEvent; +import run.halo.app.extension.ListOptions; +import run.halo.app.extension.Metadata; +import run.halo.app.extension.MetadataOperator; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.infra.exception.OAuth2UserAlreadyBoundException; +import run.halo.app.infra.utils.JsonUtils; + +@Service +public class UserConnectionServiceImpl implements UserConnectionService { + + private final ReactiveExtensionClient client; + + private final ApplicationEventPublisher eventPublisher; + + private Clock clock = Clock.systemDefaultZone(); + + public UserConnectionServiceImpl(ReactiveExtensionClient client, + ApplicationEventPublisher eventPublisher) { + this.client = client; + this.eventPublisher = eventPublisher; + } + + void setClock(Clock clock) { + this.clock = clock; + } + + @Override + public Mono createUserConnection( + String username, + String registrationId, + OAuth2User oauth2User + ) { + return getUserConnection(registrationId, username) + .flatMap(connection -> Mono.error( + () -> new OAuth2UserAlreadyBoundException(connection)) + ) + .switchIfEmpty(Mono.defer(() -> { + var connection = new UserConnection(); + connection.setMetadata(new Metadata()); + var metadata = connection.getMetadata(); + updateUserInfo(metadata, oauth2User); + metadata.setGenerateName(username + "-"); + connection.setSpec(new UserConnectionSpec()); + var spec = connection.getSpec(); + spec.setUsername(username); + spec.setProviderUserId(oauth2User.getName()); + spec.setRegistrationId(registrationId); + spec.setUpdatedAt(clock.instant()); + return client.create(connection); + })); + } + + private Mono updateUserConnection(UserConnection connection, + OAuth2User oauth2User) { + connection.getSpec().setUpdatedAt(clock.instant()); + updateUserInfo(connection.getMetadata(), oauth2User); + return client.update(connection); + } + + private Mono getUserConnection(String registrationId, String username) { + var listOptions = ListOptions.builder() + .fieldQuery(and( + equal("spec.registrationId", registrationId), + equal("spec.username", username) + )) + .build(); + return client.listAll(UserConnection.class, listOptions, defaultSort()).next(); + } + + @Override + public Mono updateUserConnectionIfPresent(String registrationId, + OAuth2User oauth2User) { + var listOptions = ListOptions.builder() + .fieldQuery(and( + equal("spec.registrationId", registrationId), + equal("spec.providerUserId", oauth2User.getName()) + )) + .build(); + return client.listAll(UserConnection.class, listOptions, defaultSort()).next() + .flatMap(connection -> updateUserConnection(connection, oauth2User)); + } + + @Override + public Flux removeUserConnection(String registrationId, String username) { + var listOptions = ListOptions.builder() + .fieldQuery(and( + equal("spec.registrationId", registrationId), + equal("spec.username", username) + )) + .build(); + return client.listAll(UserConnection.class, listOptions, defaultSort()) + .flatMap(client::delete) + .doOnNext(deleted -> + eventPublisher.publishEvent(new UserConnectionDisconnectedEvent(this, deleted)) + ); + } + + private void updateUserInfo(MetadataOperator metadata, OAuth2User oauth2User) { + var annotations = Optional.ofNullable(metadata.getAnnotations()) + .orElseGet(HashMap::new); + metadata.setAnnotations(annotations); + annotations.put( + "auth.halo.run/oauth2-user-info", + JsonUtils.objectToJson(oauth2User.getAttributes()) + ); + } +} diff --git a/application/src/main/java/run/halo/app/core/extension/service/UserServiceImpl.java b/application/src/main/java/run/halo/app/core/user/service/impl/UserServiceImpl.java similarity index 67% rename from application/src/main/java/run/halo/app/core/extension/service/UserServiceImpl.java rename to application/src/main/java/run/halo/app/core/user/service/impl/UserServiceImpl.java index eb55946376..86a4414247 100644 --- a/application/src/main/java/run/halo/app/core/extension/service/UserServiceImpl.java +++ b/application/src/main/java/run/halo/app/core/user/service/impl/UserServiceImpl.java @@ -1,7 +1,6 @@ -package run.halo.app.core.extension.service; +package run.halo.app.core.user.service.impl; -import static org.springframework.data.domain.Sort.Order.asc; -import static org.springframework.data.domain.Sort.Order.desc; +import static run.halo.app.extension.ExtensionUtil.defaultSort; import static run.halo.app.extension.index.query.QueryFactory.equal; import java.time.Clock; @@ -12,10 +11,8 @@ import java.util.Optional; import java.util.Set; import lombok.RequiredArgsConstructor; -import org.apache.commons.lang3.BooleanUtils; import org.springframework.context.ApplicationEventPublisher; import org.springframework.dao.OptimisticLockingFailureException; -import org.springframework.data.domain.Sort; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.util.Assert; @@ -28,20 +25,31 @@ import run.halo.app.core.extension.Role; import run.halo.app.core.extension.RoleBinding; import run.halo.app.core.extension.User; +import run.halo.app.core.user.service.EmailVerificationService; +import run.halo.app.core.user.service.RoleService; +import run.halo.app.core.user.service.SignUpData; +import run.halo.app.core.user.service.UserPostCreatingHandler; +import run.halo.app.core.user.service.UserPreCreatingHandler; +import run.halo.app.core.user.service.UserService; import run.halo.app.event.user.PasswordChangedEvent; import run.halo.app.extension.ListOptions; +import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.exception.ExtensionNotFoundException; import run.halo.app.extension.router.selector.FieldSelector; import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; import run.halo.app.infra.SystemSetting; -import run.halo.app.infra.exception.AccessDeniedException; +import run.halo.app.infra.ValidationUtils; import run.halo.app.infra.exception.DuplicateNameException; +import run.halo.app.infra.exception.EmailVerificationFailed; +import run.halo.app.infra.exception.UnsatisfiedAttributeValueException; import run.halo.app.infra.exception.UserNotFoundException; +import run.halo.app.plugin.extensionpoint.ExtensionGetter; @Service @RequiredArgsConstructor public class UserServiceImpl implements UserService { + public static final String GHOST_USER_NAME = "ghost"; private final ReactiveExtensionClient client; @@ -54,6 +62,10 @@ public class UserServiceImpl implements UserService { private final RoleService roleService; + private final EmailVerificationService emailVerificationService; + + private final ExtensionGetter extensionGetter; + private Clock clock = Clock.systemUTC(); void setClock(Clock clock) { @@ -85,6 +97,10 @@ public Mono updatePassword(String username, String newPassword) { @Override public Mono updateWithRawPassword(String username, String rawPassword) { + if (!ValidationUtils.PASSWORD_PATTERN.matcher(rawPassword).matches()) { + return Mono.error( + new UnsatisfiedAttributeValueException("validation.error.password.pattern")); + } return getUser(username) .filter(user -> { if (!StringUtils.hasText(user.getSpec().getPassword())) { @@ -133,6 +149,7 @@ public Mono grantRoles(String username, Set roles) { var mutableRoles = new HashSet<>(roles); mutableRoles.removeAll(existingRoles); return mutableRoles.stream() + .filter(StringUtils::hasText) .map(roleName -> RoleBinding.create(username, roleName)); }).flatMap(client::create)) .then(Mono.defer(() -> { @@ -146,29 +163,49 @@ public Mono grantRoles(String username, Set roles) { } @Override - public Mono signUp(User user, String password) { - if (!StringUtils.hasText(password)) { - throw new IllegalArgumentException("Password must not be blank"); - } + public Mono signUp(SignUpData signUpData) { return environmentFetcher.fetch(SystemSetting.User.GROUP, SystemSetting.User.class) - .switchIfEmpty(Mono.error(new IllegalStateException("User setting is not configured"))) - .flatMap(userSetting -> { - Boolean allowRegistration = userSetting.getAllowRegistration(); - if (BooleanUtils.isFalse(allowRegistration)) { - return Mono.error(new AccessDeniedException("Registration is not allowed", - "problemDetail.user.signUpFailed.disallowed", - null)); - } - String defaultRole = userSetting.getDefaultRole(); - if (!StringUtils.hasText(defaultRole)) { - return Mono.error(new AccessDeniedException( - "Default registration role is not configured by admin", - "problemDetail.user.signUpFailed.disallowed", - null)); + .filter(SystemSetting.User::isAllowRegistration) + .switchIfEmpty(Mono.error(() -> new ServerWebInputException( + "The registration is not allowed by the administrator." + ))) + .filter(setting -> StringUtils.hasText(setting.getDefaultRole())) + .switchIfEmpty(Mono.error(() -> new ServerWebInputException( + "The default role is not configured by the administrator." + ))) + .flatMap(setting -> { + var user = new User(); + user.setMetadata(new Metadata()); + var metadata = user.getMetadata(); + metadata.setName(signUpData.getUsername()); + user.setSpec(new User.UserSpec()); + var spec = user.getSpec(); + spec.setPassword(passwordEncoder.encode(signUpData.getPassword())); + spec.setEmailVerified(false); + spec.setRegisteredAt(clock.instant()); + spec.setEmail(signUpData.getEmail()); + spec.setDisplayName(signUpData.getDisplayName()); + Mono verifyEmail = Mono.empty(); + if (setting.isMustVerifyEmailOnRegistration()) { + if (!StringUtils.hasText(signUpData.getEmailCode())) { + return Mono.error( + new EmailVerificationFailed("Email captcha is required", null) + ); + } + verifyEmail = emailVerificationService.verifyRegisterVerificationCode( + signUpData.getEmail(), signUpData.getEmailCode() + ) + .filter(Boolean::booleanValue) + .switchIfEmpty(Mono.error(() -> + new EmailVerificationFailed("Invalid email captcha.", null) + )) + .doOnNext(spec::setEmailVerified) + .then(); } - String encodedPassword = passwordEncoder.encode(password); - user.getSpec().setPassword(encodedPassword); - return createUser(user, Set.of(defaultRole)); + return verifyEmail.then(Mono.defer(() -> { + var defaultRole = setting.getDefaultRole(); + return createUser(user, Set.of(defaultRole)); + })); }); } @@ -194,13 +231,20 @@ public Mono createUser(User user, Set roleNames) { ) .then(); }) - .then(Mono.defer(() -> client.create(user) - .flatMap(newUser -> grantRoles(user.getMetadata().getName(), roleNames) - .retryWhen( - Retry.backoff(5, Duration.ofMillis(100)) + .then(extensionGetter.getExtensions(UserPreCreatingHandler.class) + .concatMap(handler -> handler.preCreating(user)) + .then(Mono.defer(() -> client.create(user) + .flatMap(newUser -> grantRoles(user.getMetadata().getName(), roleNames) + .retryWhen(Retry.backoff(5, Duration.ofMillis(100)) .filter(OptimisticLockingFailureException.class::isInstance) + ) ) )) + .flatMap(createdUser -> extensionGetter.getExtensions(UserPostCreatingHandler.class) + .concatMap(handler -> handler.postCreating(createdUser)) + .then() + .thenReturn(createdUser) + ) ); } @@ -224,9 +268,7 @@ public Mono confirmPassword(String username, String rawPassword) { public Flux listByEmail(String email) { var listOptions = new ListOptions(); listOptions.setFieldSelector(FieldSelector.of(equal("spec.email", email))); - return client.listAll(User.class, listOptions, Sort.by(desc("metadata.creationTimestamp"), - asc("metadata.name")) - ); + return client.listAll(User.class, listOptions, defaultSort()); } @Override diff --git a/application/src/main/java/run/halo/app/event/post/PostStatsChangedEvent.java b/application/src/main/java/run/halo/app/event/post/PostStatsChangedEvent.java index aa936b821d..f574bca8e5 100644 --- a/application/src/main/java/run/halo/app/event/post/PostStatsChangedEvent.java +++ b/application/src/main/java/run/halo/app/event/post/PostStatsChangedEvent.java @@ -3,9 +3,9 @@ import lombok.Getter; import org.apache.commons.lang3.StringUtils; import org.springframework.context.ApplicationEvent; +import run.halo.app.core.counter.MeterUtils; import run.halo.app.core.extension.Counter; import run.halo.app.core.extension.content.Post; -import run.halo.app.metrics.MeterUtils; @Getter public class PostStatsChangedEvent extends ApplicationEvent { diff --git a/application/src/main/java/run/halo/app/extension/JSONExtensionConverter.java b/application/src/main/java/run/halo/app/extension/JSONExtensionConverter.java index 8aa2304357..67238b9827 100644 --- a/application/src/main/java/run/halo/app/extension/JSONExtensionConverter.java +++ b/application/src/main/java/run/halo/app/extension/JSONExtensionConverter.java @@ -3,14 +3,15 @@ import static org.openapi4j.core.validation.ValidationSeverity.ERROR; import static org.springframework.util.StringUtils.arrayToCommaDelimitedString; import static run.halo.app.extension.ExtensionStoreUtil.buildStoreName; +import static run.halo.app.extension.Unstructured.OBJECT_MAPPER; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import io.swagger.v3.core.util.Json; import java.io.IOException; import java.net.MalformedURLException; import java.net.URL; import java.util.Optional; +import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.openapi4j.core.exception.ResolutionException; import org.openapi4j.core.model.v3.OAI3; @@ -36,17 +37,14 @@ @Component public class JSONExtensionConverter implements ExtensionConverter { + @Getter public final ObjectMapper objectMapper; private final SchemeManager schemeManager; public JSONExtensionConverter(SchemeManager schemeManager) { this.schemeManager = schemeManager; - this.objectMapper = Json.mapper(); - } - - public ObjectMapper getObjectMapper() { - return objectMapper; + this.objectMapper = OBJECT_MAPPER; } @Override diff --git a/application/src/main/java/run/halo/app/extension/ReactiveExtensionClientImpl.java b/application/src/main/java/run/halo/app/extension/ReactiveExtensionClientImpl.java index 0962d64de2..38377c297e 100644 --- a/application/src/main/java/run/halo/app/extension/ReactiveExtensionClientImpl.java +++ b/application/src/main/java/run/halo/app/extension/ReactiveExtensionClientImpl.java @@ -19,6 +19,8 @@ import lombok.NonNull; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.availability.AvailabilityChangeEvent; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.event.ContextRefreshedEvent; import org.springframework.context.event.EventListener; import org.springframework.dao.DataIntegrityViolationException; @@ -30,6 +32,7 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.util.retry.Retry; +import run.halo.app.extension.availability.IndexBuildState; import run.halo.app.extension.exception.ExtensionNotFoundException; import run.halo.app.extension.index.DefaultExtensionIterator; import run.halo.app.extension.index.ExtensionIterator; @@ -443,6 +446,7 @@ class IndexBuildsManager { private final ExtensionConverter converter; private final ReactiveExtensionStoreClient client; private final SchemeWatcherManager schemeWatcherManager; + private final ApplicationEventPublisher eventPublisher; @NonNull private ExtensionIterator createExtensionIterator(Scheme scheme) { @@ -459,6 +463,8 @@ private ExtensionIterator createExtensionIterator(Scheme scheme) { @EventListener(ContextRefreshedEvent.class) public void startBuildingIndex() { + AvailabilityChangeEvent.publish(eventPublisher, this, IndexBuildState.BUILDING); + final long startTimeMs = System.currentTimeMillis(); log.info("Start building index for all extensions, please wait..."); schemeManager.schemes() @@ -474,6 +480,8 @@ public void startBuildingIndex() { indexerFactory.removeIndexer(scheme); } }); + + AvailabilityChangeEvent.publish(eventPublisher, this, IndexBuildState.BUILT); log.info("Successfully built index in {}ms, Preparing to lunch application...", System.currentTimeMillis() - startTimeMs); } diff --git a/application/src/main/java/run/halo/app/extension/availability/IndexBuildState.java b/application/src/main/java/run/halo/app/extension/availability/IndexBuildState.java new file mode 100644 index 0000000000..909d46bff8 --- /dev/null +++ b/application/src/main/java/run/halo/app/extension/availability/IndexBuildState.java @@ -0,0 +1,8 @@ +package run.halo.app.extension.availability; + +import org.springframework.boot.availability.AvailabilityState; + +public enum IndexBuildState implements AvailabilityState { + BUILDING, + BUILT; +} diff --git a/application/src/main/java/run/halo/app/extension/availability/IndexBuildStateHealthIndicator.java b/application/src/main/java/run/halo/app/extension/availability/IndexBuildStateHealthIndicator.java new file mode 100644 index 0000000000..20c5a5dbca --- /dev/null +++ b/application/src/main/java/run/halo/app/extension/availability/IndexBuildStateHealthIndicator.java @@ -0,0 +1,22 @@ +package run.halo.app.extension.availability; + +import org.springframework.boot.actuate.availability.AvailabilityStateHealthIndicator; +import org.springframework.boot.actuate.health.Status; +import org.springframework.boot.availability.ApplicationAvailability; +import org.springframework.stereotype.Component; + +@Component +public class IndexBuildStateHealthIndicator extends AvailabilityStateHealthIndicator { + /** + * Create a {@link IndexBuildStateHealthIndicator} instance by {@link ApplicationAvailability}. + * Mapping {@link IndexBuildState} to {@link Status}. + * + * @see IndexBuildState + */ + public IndexBuildStateHealthIndicator(ApplicationAvailability availability) { + super(availability, IndexBuildState.class, (statusMappings) -> { + statusMappings.add(IndexBuildState.BUILT, Status.UP); + statusMappings.add(IndexBuildState.BUILDING, Status.OUT_OF_SERVICE); + }); + } +} diff --git a/application/src/main/java/run/halo/app/infra/DefaultExternalLinkProcessor.java b/application/src/main/java/run/halo/app/infra/DefaultExternalLinkProcessor.java index 50f7bc7d0e..c1c0bd963b 100644 --- a/application/src/main/java/run/halo/app/infra/DefaultExternalLinkProcessor.java +++ b/application/src/main/java/run/halo/app/infra/DefaultExternalLinkProcessor.java @@ -1,8 +1,14 @@ package run.halo.app.infra; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Optional; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; import org.springframework.stereotype.Component; +import org.springframework.web.filter.reactive.ServerWebExchangeContextFilter; +import org.springframework.web.util.UriComponentsBuilder; +import reactor.core.publisher.Mono; import run.halo.app.infra.utils.PathUtils; /** @@ -25,6 +31,34 @@ public String processLink(String link) { return append(externalLink.toString(), link); } + @Override + public Mono processLink(URI uri) { + if (uri.isAbsolute()) { + return Mono.just(uri); + } + return Mono.deferContextual(contextView -> Mono.fromSupplier( + () -> ServerWebExchangeContextFilter.getExchange(contextView) + .map(exchange -> externalUrlSupplier.getURL(exchange.getRequest())) + .or(() -> Optional.ofNullable(externalUrlSupplier.getRaw())) + .map(externalUrl -> { + try { + var uriComponents = UriComponentsBuilder.fromUriString(uri.toASCIIString()) + .build(true); + return UriComponentsBuilder.fromUri(externalUrl.toURI()) + .pathSegment(uriComponents.getPathSegments().toArray(new String[0])) + .queryParams(uriComponents.getQueryParams()) + .fragment(uriComponents.getFragment()) + .build(true) + .toUri(); + } catch (URISyntaxException e) { + // should never happen + return uri; + } + }) + .orElse(uri) + )); + } + String append(String externalLink, String link) { return StringUtils.removeEnd(externalLink, "/") + StringUtils.prependIfMissing(link, "/"); diff --git a/application/src/main/java/run/halo/app/infra/DefaultSystemVersionSupplier.java b/application/src/main/java/run/halo/app/infra/DefaultSystemVersionSupplier.java index f91b48e732..806070a6bf 100644 --- a/application/src/main/java/run/halo/app/infra/DefaultSystemVersionSupplier.java +++ b/application/src/main/java/run/halo/app/infra/DefaultSystemVersionSupplier.java @@ -26,9 +26,9 @@ public DefaultSystemVersionSupplier(ObjectProvider buildPropert public Version get() { var properties = buildProperties.getIfUnique(); if (properties == null) { - return Version.valueOf(DEFAULT_VERSION); + return Version.parse(DEFAULT_VERSION); } var projectVersion = Objects.toString(properties.getVersion(), DEFAULT_VERSION); - return Version.valueOf(projectVersion); + return Version.parse(projectVersion); } } diff --git a/application/src/main/java/run/halo/app/infra/DefaultThemeInitializer.java b/application/src/main/java/run/halo/app/infra/DefaultThemeInitializer.java deleted file mode 100644 index b186923665..0000000000 --- a/application/src/main/java/run/halo/app/infra/DefaultThemeInitializer.java +++ /dev/null @@ -1,65 +0,0 @@ -package run.halo.app.infra; - -import java.io.IOException; -import lombok.extern.slf4j.Slf4j; -import org.springframework.boot.context.event.ApplicationStartedEvent; -import org.springframework.context.ApplicationListener; -import org.springframework.core.io.UrlResource; -import org.springframework.core.io.buffer.DataBufferUtils; -import org.springframework.core.io.buffer.DefaultDataBufferFactory; -import org.springframework.stereotype.Component; -import org.springframework.util.ResourceUtils; -import org.springframework.util.StreamUtils; -import run.halo.app.core.extension.theme.ThemeService; -import run.halo.app.infra.properties.HaloProperties; -import run.halo.app.infra.properties.ThemeProperties; -import run.halo.app.infra.utils.FileUtils; - -@Slf4j -@Component -public class DefaultThemeInitializer implements ApplicationListener { - - private final ThemeService themeService; - - private final ThemeRootGetter themeRoot; - - private final ThemeProperties themeProps; - - public DefaultThemeInitializer(ThemeService themeService, ThemeRootGetter themeRoot, - HaloProperties haloProps) { - this.themeService = themeService; - this.themeRoot = themeRoot; - this.themeProps = haloProps.getTheme(); - } - - @Override - public void onApplicationEvent(ApplicationStartedEvent event) { - if (themeProps.getInitializer().isDisabled()) { - log.debug("Skipped initializing default theme due to disabled"); - return; - } - var themeRoot = this.themeRoot.get(); - var location = themeProps.getInitializer().getLocation(); - try { - // TODO Checking if any themes are installed here in the future might be better? - if (!FileUtils.isEmpty(themeRoot)) { - log.debug("Skipped initializing default theme because there are themes " - + "inside theme root"); - return; - } - log.info("Initializing default theme from {}", location); - var themeUrl = ResourceUtils.getURL(location); - var content = DataBufferUtils.read(new UrlResource(themeUrl), - DefaultDataBufferFactory.sharedInstance, - StreamUtils.BUFFER_SIZE); - var theme = themeService.install(content).block(); - log.info("Initialized default theme: {}", theme); - // Because default active theme is default, we don't need to enabled it manually. - } catch (IOException e) { - // we should skip the initialization error at here - log.warn("Failed to initialize theme from " + location, e); - } - } - - -} diff --git a/application/src/main/java/run/halo/app/infra/SchemeInitializer.java b/application/src/main/java/run/halo/app/infra/SchemeInitializer.java index fbc933e213..49ab3d0a64 100644 --- a/application/src/main/java/run/halo/app/infra/SchemeInitializer.java +++ b/application/src/main/java/run/halo/app/infra/SchemeInitializer.java @@ -13,13 +13,14 @@ import java.util.Set; import java.util.stream.Collectors; import org.apache.commons.lang3.BooleanUtils; -import org.apache.commons.lang3.ObjectUtils; import org.apache.commons.lang3.StringUtils; import org.springframework.boot.context.event.ApplicationContextInitializedEvent; import org.springframework.context.ApplicationListener; import org.springframework.lang.NonNull; import org.springframework.stereotype.Component; import run.halo.app.content.Stats; +import run.halo.app.core.attachment.extension.LocalThumbnail; +import run.halo.app.core.attachment.extension.Thumbnail; import run.halo.app.core.extension.AnnotationSetting; import run.halo.app.core.extension.AuthProvider; import run.halo.app.core.extension.Counter; @@ -38,10 +39,8 @@ import run.halo.app.core.extension.UserConnection.UserConnectionSpec; import run.halo.app.core.extension.attachment.Attachment; import run.halo.app.core.extension.attachment.Group; -import run.halo.app.core.extension.attachment.LocalThumbnail; import run.halo.app.core.extension.attachment.Policy; import run.halo.app.core.extension.attachment.PolicyTemplate; -import run.halo.app.core.extension.attachment.Thumbnail; import run.halo.app.core.extension.content.Category; import run.halo.app.core.extension.content.Comment; import run.halo.app.core.extension.content.Post; @@ -288,7 +287,7 @@ public void onApplicationEvent(@NonNull ApplicationContextInitializedEvent event return "0"; } var stats = JsonUtils.jsonToObject(statsStr, Stats.class); - return ObjectUtils.defaultIfNull(stats.getVisit(), 0).toString(); + return defaultIfNull(stats.getVisit(), 0).toString(); }))); indexSpecs.add(new IndexSpec() @@ -300,7 +299,7 @@ public void onApplicationEvent(@NonNull ApplicationContextInitializedEvent event return "0"; } var stats = JsonUtils.jsonToObject(statsStr, Stats.class); - return ObjectUtils.defaultIfNull(stats.getTotalComment(), 0).toString(); + return defaultIfNull(stats.getTotalComment(), 0).toString(); }))); }); schemeManager.register(Category.class, indexSpecs -> { @@ -497,6 +496,13 @@ public void onApplicationEvent(@NonNull ApplicationContextInitializedEvent event .orElse(null)) ) ); + is.add(new IndexSpec() + .setName("spec.deleted") + .setIndexFunc(simpleAttribute(SinglePage.class, page -> { + var deleted = defaultIfNull(page.getSpec().getDeleted(), false); + return String.valueOf(deleted); + })) + ); is.add(new IndexSpec() .setName("spec.visible") .setIndexFunc( @@ -630,6 +636,22 @@ public void onApplicationEvent(@NonNull ApplicationContextInitializedEvent event .map(UserConnectionSpec::getUsername) .orElse(null) ))); + is.add(new IndexSpec() + .setName("spec.registrationId") + .setIndexFunc(simpleAttribute(UserConnection.class, + connection -> Optional.ofNullable(connection.getSpec()) + .map(UserConnectionSpec::getRegistrationId) + .orElse(null) + )) + ); + is.add(new IndexSpec() + .setName("spec.providerUserId") + .setIndexFunc(simpleAttribute(UserConnection.class, + connection -> Optional.ofNullable(connection.getSpec()) + .map(UserConnectionSpec::getProviderUserId) + .orElse(null) + )) + ); }); // security.halo.run diff --git a/application/src/main/java/run/halo/app/infra/SecureRequestMappingHandlerAdapter.java b/application/src/main/java/run/halo/app/infra/SecureRequestMappingHandlerAdapter.java new file mode 100644 index 0000000000..78b75531de --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/SecureRequestMappingHandlerAdapter.java @@ -0,0 +1,26 @@ +package run.halo.app.infra; + +import org.springframework.lang.NonNull; +import org.springframework.web.reactive.HandlerResult; +import org.springframework.web.reactive.result.method.annotation.RequestMappingHandlerAdapter; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +/** + * Secure request mapping handler adapter. + * + * @author johnniang + * @since 2.20.0 + */ +public class SecureRequestMappingHandlerAdapter extends RequestMappingHandlerAdapter { + + @Override + @NonNull + public Mono handle( + @NonNull ServerWebExchange exchange, + @NonNull Object handler + ) { + return super.handle(new SecureServerWebExchange(exchange), handler); + } + +} diff --git a/application/src/main/java/run/halo/app/infra/SecureServerRequest.java b/application/src/main/java/run/halo/app/infra/SecureServerRequest.java new file mode 100644 index 0000000000..851b8140c6 --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/SecureServerRequest.java @@ -0,0 +1,31 @@ +package run.halo.app.infra; + +import org.springframework.lang.NonNull; +import org.springframework.web.reactive.function.server.ServerRequest; +import org.springframework.web.reactive.function.server.support.ServerRequestWrapper; +import org.springframework.web.server.ServerWebExchange; + +/** + * Secure server request without application context available. + * + * @author johnniang + * @since 2.20.0 + */ +public class SecureServerRequest extends ServerRequestWrapper { + + /** + * Create a new {@code ServerRequestWrapper} that wraps the given request. + * + * @param delegate the request to wrap + */ + public SecureServerRequest(ServerRequest delegate) { + super(delegate); + } + + @Override + @NonNull + public ServerWebExchange exchange() { + return new SecureServerWebExchange(super.exchange()); + } + +} diff --git a/application/src/main/java/run/halo/app/infra/SecureServerWebExchange.java b/application/src/main/java/run/halo/app/infra/SecureServerWebExchange.java new file mode 100644 index 0000000000..4d244532fb --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/SecureServerWebExchange.java @@ -0,0 +1,25 @@ +package run.halo.app.infra; + +import org.springframework.context.ApplicationContext; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.ServerWebExchangeDecorator; + +/** + * Secure server web exchange without application context available. + * + * @author johnniang + * @since 2.20.0 + */ +public class SecureServerWebExchange extends ServerWebExchangeDecorator { + + public SecureServerWebExchange(ServerWebExchange delegate) { + super(delegate); + } + + @Override + public ApplicationContext getApplicationContext() { + // Always return null to prevent access to application context + return null; + } + +} diff --git a/application/src/main/java/run/halo/app/infra/SystemState.java b/application/src/main/java/run/halo/app/infra/SystemState.java index 1403a15ccb..03e8a37e02 100644 --- a/application/src/main/java/run/halo/app/infra/SystemState.java +++ b/application/src/main/java/run/halo/app/infra/SystemState.java @@ -3,11 +3,19 @@ import com.fasterxml.jackson.databind.JsonNode; import com.github.fge.jsonpatch.JsonPatchException; import com.github.fge.jsonpatch.mergepatch.JsonMergePatch; +import java.time.Duration; +import java.util.HashMap; import java.util.LinkedHashMap; import java.util.Map; +import java.util.function.Consumer; import lombok.Data; +import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.lang.NonNull; +import reactor.core.publisher.Mono; +import reactor.util.retry.Retry; import run.halo.app.extension.ConfigMap; +import run.halo.app.extension.Metadata; +import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.utils.JsonParseException; import run.halo.app.infra.utils.JsonUtils; @@ -68,6 +76,33 @@ public static void update(@NonNull SystemState systemState, @NonNull ConfigMap c } } + /** + *

Update system state by the given {@link Consumer}.

+ *

if the system state config map does not exist, it will create a new one.

+ */ + public static Mono upsetSystemState(ReactiveExtensionClient client, + Consumer consumer) { + return Mono.defer(() -> client.fetch(ConfigMap.class, SYSTEM_STATES_CONFIGMAP) + .switchIfEmpty(Mono.defer(() -> { + ConfigMap configMap = new ConfigMap(); + configMap.setMetadata(new Metadata()); + configMap.getMetadata().setName(SYSTEM_STATES_CONFIGMAP); + configMap.setData(new HashMap<>()); + return client.create(configMap); + })) + .flatMap(configMap -> { + SystemState systemState = deserialize(configMap); + consumer.accept(systemState); + update(systemState, configMap); + return client.update(configMap); + }) + ) + .retryWhen(Retry.backoff(5, Duration.ofMillis(100)) + .filter(OptimisticLockingFailureException.class::isInstance) + ) + .then(); + } + private static String emptyJsonObject() { return "{}"; } diff --git a/application/src/main/java/run/halo/app/infra/ValidationUtils.java b/application/src/main/java/run/halo/app/infra/ValidationUtils.java deleted file mode 100644 index bb52132052..0000000000 --- a/application/src/main/java/run/halo/app/infra/ValidationUtils.java +++ /dev/null @@ -1,40 +0,0 @@ -package run.halo.app.infra; - -import java.util.regex.Pattern; -import lombok.experimental.UtilityClass; -import org.apache.commons.lang3.StringUtils; - -@UtilityClass -public class ValidationUtils { - public static final Pattern NAME_PATTERN = - Pattern.compile("^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$"); - - public static final String EMAIL_REGEX = - "^[a-zA-Z0-9_+&*-]+(?:\\.[a-zA-Z0-9_+&*-]+)*@(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,7}$"; - - public static final String NAME_VALIDATION_MESSAGE = """ - Super administrator username must be a valid subdomain name, the name must: - 1. contain no more than 63 characters - 2. contain only lowercase alphanumeric characters, '-' or '.' - 3. start with an alphanumeric character - 4. end with an alphanumeric character - """; - - /** - * Validates the name. - * - * @param name name for validation - * @return true if the name is valid - */ - public static boolean validateName(String name) { - if (StringUtils.isBlank(name)) { - return false; - } - boolean matches = NAME_PATTERN.matcher(name).matches(); - return matches && name.length() <= 63; - } - - public static boolean isValidEmail(String email) { - return StringUtils.isNotBlank(email) && email.matches(EMAIL_REGEX); - } -} diff --git a/application/src/main/java/run/halo/app/actuator/DatabaseInfoContributor.java b/application/src/main/java/run/halo/app/infra/actuator/DatabaseInfoContributor.java similarity index 97% rename from application/src/main/java/run/halo/app/actuator/DatabaseInfoContributor.java rename to application/src/main/java/run/halo/app/infra/actuator/DatabaseInfoContributor.java index e0aa0b5cd1..66d88d783f 100644 --- a/application/src/main/java/run/halo/app/actuator/DatabaseInfoContributor.java +++ b/application/src/main/java/run/halo/app/infra/actuator/DatabaseInfoContributor.java @@ -1,4 +1,4 @@ -package run.halo.app.actuator; +package run.halo.app.infra.actuator; import io.r2dbc.spi.Connection; import io.r2dbc.spi.ConnectionFactory; diff --git a/application/src/main/java/run/halo/app/infra/actuator/GlobalInfo.java b/application/src/main/java/run/halo/app/infra/actuator/GlobalInfo.java new file mode 100644 index 0000000000..30c5c36feb --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/actuator/GlobalInfo.java @@ -0,0 +1,62 @@ +package run.halo.app.infra.actuator; + +import java.net.URL; +import java.util.List; +import java.util.Locale; +import java.util.TimeZone; +import lombok.Data; + +/** + * Global info. + * + * @author johnniang + * @since 2.20.0 + */ +@Data +public class GlobalInfo { + + private URL externalUrl; + + private boolean useAbsolutePermalink; + + private TimeZone timeZone; + + private Locale locale; + + private boolean allowComments; + + private boolean allowAnonymousComments; + + private boolean allowRegistration; + + private String favicon; + + private boolean userInitialized; + + private boolean dataInitialized; + + private String postSlugGenerationStrategy; + + private List socialAuthProviders; + + private Boolean mustVerifyEmailOnRegistration; + + private String siteTitle; + + @Data + public static class SocialAuthProvider { + private String name; + + private String displayName; + + private String description; + + private String logo; + + private String website; + + private String authenticationUrl; + + private String bindingUrl; + } +} diff --git a/application/src/main/java/run/halo/app/infra/actuator/GlobalInfoEndpoint.java b/application/src/main/java/run/halo/app/infra/actuator/GlobalInfoEndpoint.java new file mode 100644 index 0000000000..dec60c0cc6 --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/actuator/GlobalInfoEndpoint.java @@ -0,0 +1,26 @@ +package run.halo.app.infra.actuator; + +import java.time.Duration; +import org.springframework.boot.actuate.endpoint.annotation.ReadOperation; +import org.springframework.boot.actuate.endpoint.web.annotation.WebEndpoint; +import org.springframework.stereotype.Component; + +/** + * Global info endpoint. + */ +@WebEndpoint(id = "globalinfo") +@Component +public class GlobalInfoEndpoint { + + private final GlobalInfoService globalInfoService; + + public GlobalInfoEndpoint(GlobalInfoService globalInfoService) { + this.globalInfoService = globalInfoService; + } + + @ReadOperation + public GlobalInfo globalInfo() { + return globalInfoService.getGlobalInfo().block(Duration.ofMinutes(1)); + } + +} diff --git a/application/src/main/java/run/halo/app/infra/actuator/GlobalInfoService.java b/application/src/main/java/run/halo/app/infra/actuator/GlobalInfoService.java new file mode 100644 index 0000000000..7f5ef15d1a --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/actuator/GlobalInfoService.java @@ -0,0 +1,20 @@ +package run.halo.app.infra.actuator; + +import reactor.core.publisher.Mono; + +/** + * Global info service. + * + * @author johnniang + * @since 2.20.0 + */ +public interface GlobalInfoService { + + /** + * Get global info. + * + * @return global info + */ + Mono getGlobalInfo(); + +} diff --git a/application/src/main/java/run/halo/app/actuator/GlobalInfoEndpoint.java b/application/src/main/java/run/halo/app/infra/actuator/GlobalInfoServiceImpl.java similarity index 50% rename from application/src/main/java/run/halo/app/actuator/GlobalInfoEndpoint.java rename to application/src/main/java/run/halo/app/infra/actuator/GlobalInfoServiceImpl.java index 36ede07f18..f671f0c975 100644 --- a/application/src/main/java/run/halo/app/actuator/GlobalInfoEndpoint.java +++ b/application/src/main/java/run/halo/app/infra/actuator/GlobalInfoServiceImpl.java @@ -1,34 +1,31 @@ -package run.halo.app.actuator; +package run.halo.app.infra.actuator; import static org.apache.commons.lang3.BooleanUtils.isTrue; -import java.net.URL; -import java.util.List; +import java.util.ArrayList; import java.util.Locale; +import java.util.Optional; import java.util.TimeZone; -import lombok.Data; -import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; +import org.reactivestreams.Publisher; import org.springframework.beans.factory.ObjectProvider; -import org.springframework.boot.actuate.endpoint.annotation.ReadOperation; -import org.springframework.boot.actuate.endpoint.web.annotation.WebEndpoint; -import org.springframework.stereotype.Component; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; import run.halo.app.extension.ConfigMap; import run.halo.app.infra.InitializationStateGetter; import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; import run.halo.app.infra.SystemSetting; -import run.halo.app.infra.SystemSetting.Basic; -import run.halo.app.infra.SystemSetting.Comment; -import run.halo.app.infra.SystemSetting.User; import run.halo.app.infra.properties.HaloProperties; import run.halo.app.security.AuthProviderService; -@WebEndpoint(id = "globalinfo") -@Component -@RequiredArgsConstructor -public class GlobalInfoEndpoint { - - private final ObjectProvider systemConfigFetcher; +/** + * Global info service implementation. + * + * @author johnniang + * @since 2.20.0 + */ +@Service +public class GlobalInfoServiceImpl implements GlobalInfoService { private final HaloProperties haloProperties; @@ -36,78 +33,56 @@ public class GlobalInfoEndpoint { private final InitializationStateGetter initializationStateGetter; - @ReadOperation - public GlobalInfo globalInfo() { + private final ObjectProvider + systemConfigFetcher; + + public GlobalInfoServiceImpl(HaloProperties haloProperties, + AuthProviderService authProviderService, + InitializationStateGetter initializationStateGetter, + ObjectProvider systemConfigFetcher) { + this.haloProperties = haloProperties; + this.authProviderService = authProviderService; + this.initializationStateGetter = initializationStateGetter; + this.systemConfigFetcher = systemConfigFetcher; + } + + @Override + public Mono getGlobalInfo() { final var info = new GlobalInfo(); info.setExternalUrl(haloProperties.getExternalUrl()); info.setUseAbsolutePermalink(haloProperties.isUseAbsolutePermalink()); info.setLocale(Locale.getDefault()); info.setTimeZone(TimeZone.getDefault()); - info.setUserInitialized(initializationStateGetter.userInitialized() - .blockOptional().orElse(false)); - info.setDataInitialized(initializationStateGetter.dataInitialized() - .blockOptional().orElse(false)); - handleSocialAuthProvider(info); - systemConfigFetcher.ifAvailable(fetcher -> fetcher.getConfigMapBlocking() - .ifPresent(configMap -> { - handleCommentSetting(info, configMap); - handleUserSetting(info, configMap); - handleBasicSetting(info, configMap); - handlePostSlugGenerationStrategy(info, configMap); - })); - return info; - } - - @Data - public static class GlobalInfo { - private URL externalUrl; - - private boolean useAbsolutePermalink; - - private TimeZone timeZone; - - private Locale locale; - - private boolean allowComments; - - private boolean allowAnonymousComments; - - private boolean allowRegistration; - - private String favicon; - - private boolean userInitialized; - - private boolean dataInitialized; - private String postSlugGenerationStrategy; - - private List socialAuthProviders; - - private Boolean mustVerifyEmailOnRegistration; - - private String siteTitle; + var publishers = new ArrayList>(4); + publishers.add( + initializationStateGetter.userInitialized().doOnNext(info::setUserInitialized) + ); + publishers.add( + initializationStateGetter.dataInitialized().doOnNext(info::setDataInitialized) + ); + publishers.add(handleSocialAuthProvider(info)); + publishers.add(handleSettings(info)); + return Mono.when(publishers).then(Mono.just(info)); } - @Data - public static class SocialAuthProvider { - private String name; - - private String displayName; - - private String description; - - private String logo; - - private String website; - - private String authenticationUrl; - - private String bindingUrl; + private Mono handleSettings(GlobalInfo info) { + return Optional.ofNullable(systemConfigFetcher.getIfUnique()) + .map(fetcher -> fetcher.getConfigMap() + .doOnNext(configMap -> { + handleCommentSetting(info, configMap); + handleUserSetting(info, configMap); + handleBasicSetting(info, configMap); + handlePostSlugGenerationStrategy(info, configMap); + }) + .then() + ) + .orElseGet(Mono::empty); } private void handleCommentSetting(GlobalInfo info, ConfigMap configMap) { - var comment = SystemSetting.get(configMap, Comment.GROUP, Comment.class); + var comment = + SystemSetting.get(configMap, SystemSetting.Comment.GROUP, SystemSetting.Comment.class); if (comment == null) { info.setAllowComments(true); info.setAllowAnonymousComments(true); @@ -119,18 +94,19 @@ private void handleCommentSetting(GlobalInfo info, ConfigMap configMap) { } private void handleUserSetting(GlobalInfo info, ConfigMap configMap) { - var userSetting = SystemSetting.get(configMap, User.GROUP, User.class); + var userSetting = + SystemSetting.get(configMap, SystemSetting.User.GROUP, SystemSetting.User.class); if (userSetting == null) { info.setAllowRegistration(false); info.setMustVerifyEmailOnRegistration(false); } else { - info.setAllowRegistration( - userSetting.getAllowRegistration() != null && userSetting.getAllowRegistration()); - info.setMustVerifyEmailOnRegistration(userSetting.getMustVerifyEmailOnRegistration()); + info.setAllowRegistration(userSetting.isAllowRegistration()); + info.setMustVerifyEmailOnRegistration(userSetting.isMustVerifyEmailOnRegistration()); } } - private void handlePostSlugGenerationStrategy(GlobalInfo info, ConfigMap configMap) { + private void handlePostSlugGenerationStrategy(GlobalInfo info, + ConfigMap configMap) { var post = SystemSetting.get(configMap, SystemSetting.Post.GROUP, SystemSetting.Post.class); if (post != null) { info.setPostSlugGenerationStrategy(post.getSlugGenerationStrategy()); @@ -138,20 +114,22 @@ private void handlePostSlugGenerationStrategy(GlobalInfo info, ConfigMap configM } private void handleBasicSetting(GlobalInfo info, ConfigMap configMap) { - var basic = SystemSetting.get(configMap, Basic.GROUP, Basic.class); + var basic = + SystemSetting.get(configMap, SystemSetting.Basic.GROUP, SystemSetting.Basic.class); if (basic != null) { info.setFavicon(basic.getFavicon()); info.setSiteTitle(basic.getTitle()); } } - private void handleSocialAuthProvider(GlobalInfo info) { - List providers = authProviderService.listAll() + private Mono handleSocialAuthProvider(GlobalInfo info) { + return authProviderService.listAll() .map(listedAuthProviders -> listedAuthProviders.stream() .filter(provider -> isTrue(provider.getEnabled())) .filter(provider -> StringUtils.isNotBlank(provider.getBindingUrl())) .map(provider -> { - SocialAuthProvider socialAuthProvider = new SocialAuthProvider(); + GlobalInfo.SocialAuthProvider socialAuthProvider = + new GlobalInfo.SocialAuthProvider(); socialAuthProvider.setName(provider.getName()); socialAuthProvider.setDisplayName(provider.getDisplayName()); socialAuthProvider.setDescription(provider.getDescription()); @@ -163,9 +141,7 @@ private void handleSocialAuthProvider(GlobalInfo info) { }) .toList() ) - .block(); - - info.setSocialAuthProviders(providers); + .doOnNext(info::setSocialAuthProviders) + .then(); } - } diff --git a/application/src/main/java/run/halo/app/actuator/RestartEndpoint.java b/application/src/main/java/run/halo/app/infra/actuator/RestartEndpoint.java similarity index 98% rename from application/src/main/java/run/halo/app/actuator/RestartEndpoint.java rename to application/src/main/java/run/halo/app/infra/actuator/RestartEndpoint.java index 02289ee342..2155850d33 100644 --- a/application/src/main/java/run/halo/app/actuator/RestartEndpoint.java +++ b/application/src/main/java/run/halo/app/infra/actuator/RestartEndpoint.java @@ -1,4 +1,4 @@ -package run.halo.app.actuator; +package run.halo.app.infra.actuator; import java.io.Closeable; import java.io.IOException; diff --git a/application/src/main/java/run/halo/app/config/ExtensionConfiguration.java b/application/src/main/java/run/halo/app/infra/config/ExtensionConfiguration.java similarity index 97% rename from application/src/main/java/run/halo/app/config/ExtensionConfiguration.java rename to application/src/main/java/run/halo/app/infra/config/ExtensionConfiguration.java index b9133cd9c0..cb4d8d4e11 100644 --- a/application/src/main/java/run/halo/app/config/ExtensionConfiguration.java +++ b/application/src/main/java/run/halo/app/infra/config/ExtensionConfiguration.java @@ -1,4 +1,4 @@ -package run.halo.app.config; +package run.halo.app.infra.config; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; diff --git a/application/src/main/java/run/halo/app/config/HaloConfiguration.java b/application/src/main/java/run/halo/app/infra/config/HaloConfiguration.java similarity index 97% rename from application/src/main/java/run/halo/app/config/HaloConfiguration.java rename to application/src/main/java/run/halo/app/infra/config/HaloConfiguration.java index b3fa4a0822..37c98a8d52 100644 --- a/application/src/main/java/run/halo/app/config/HaloConfiguration.java +++ b/application/src/main/java/run/halo/app/infra/config/HaloConfiguration.java @@ -1,4 +1,4 @@ -package run.halo.app.config; +package run.halo.app.infra.config; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.databind.MapperFeature; diff --git a/application/src/main/java/run/halo/app/config/SwaggerConfig.java b/application/src/main/java/run/halo/app/infra/config/SwaggerConfig.java similarity index 86% rename from application/src/main/java/run/halo/app/config/SwaggerConfig.java rename to application/src/main/java/run/halo/app/infra/config/SwaggerConfig.java index 5cd98b346d..e930901fa8 100644 --- a/application/src/main/java/run/halo/app/config/SwaggerConfig.java +++ b/application/src/main/java/run/halo/app/infra/config/SwaggerConfig.java @@ -1,4 +1,4 @@ -package run.halo.app.config; +package run.halo.app.infra.config; import static org.springdoc.core.utils.Constants.SPRINGDOC_ENABLED; @@ -72,19 +72,25 @@ GroupedOpenApi aggregatedV1alpha1Api() { .pathsToMatch( "/apis/*/v1alpha1/**", "/api/v1alpha1/**", - "/login/**" + "/login/**", + "/system/setup" ) .build(); } - @Bean GroupedOpenApi publicV1alpha1Api() { return GroupedOpenApi.builder() .group("apis_public.api_v1alpha1") .displayName("Public API V1alpha1") .pathsToMatch( - "/apis/api.halo.run/**" + "/apis/api.*/**" + ) + .pathsToExclude( + "/apis/api.console.*/v1alpha1/**", + // compatible with legacy issues + "/apis/api.notification.halo.run/v1alpha1/userspaces/**", + "/apis/api.notification.halo.run/v1alpha1/notifiers/**" ) .build(); } @@ -108,7 +114,10 @@ GroupedOpenApi ucV1alpha1Api() { .group("apis_uc.api_v1alpha1") .displayName("User-center API V1alpha1") .pathsToMatch( - "/apis/uc.api.*/v1alpha1/**" + "/apis/uc.api.*/v1alpha1/**", + // compatible with legacy issues + "/apis/api.notification.halo.run/v1alpha1/userspaces/**", + "/apis/api.notification.halo.run/v1alpha1/notifiers/**" ) .build(); } @@ -128,7 +137,9 @@ GroupedOpenApi extensionV1alpha1Api() { "/apis/auth.halo.run/v1alpha1/**", "/apis/metrics.halo.run/v1alpha1/**", "/apis/storage.halo.run/v1alpha1/**", - "/apis/plugin.halo.run/v1alpha1/**" + "/apis/plugin.halo.run/v1alpha1/**", + "/apis/notification.halo.run/**", + "/apis/migration.halo.run/**" ) .build(); } diff --git a/application/src/main/java/run/halo/app/config/WebFluxConfig.java b/application/src/main/java/run/halo/app/infra/config/WebFluxConfig.java similarity index 78% rename from application/src/main/java/run/halo/app/config/WebFluxConfig.java rename to application/src/main/java/run/halo/app/infra/config/WebFluxConfig.java index 1a864ca635..4acfecc70e 100644 --- a/application/src/main/java/run/halo/app/config/WebFluxConfig.java +++ b/application/src/main/java/run/halo/app/infra/config/WebFluxConfig.java @@ -1,10 +1,8 @@ -package run.halo.app.config; +package run.halo.app.infra.config; import static org.springframework.util.ResourceUtils.FILE_URL_PREFIX; import static org.springframework.web.reactive.function.server.RequestPredicates.accept; -import static org.springframework.web.reactive.function.server.RequestPredicates.method; import static org.springframework.web.reactive.function.server.RequestPredicates.path; -import static org.springframework.web.reactive.function.server.RouterFunctions.route; import static run.halo.app.infra.utils.FileUtils.checkDirectoryTraversal; import com.fasterxml.jackson.databind.ObjectMapper; @@ -12,12 +10,12 @@ import java.util.Objects; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.web.WebProperties; +import org.springframework.boot.autoconfigure.web.reactive.WebFluxRegistrations; import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.annotation.Order; import org.springframework.http.CacheControl; -import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.http.codec.CodecConfigurer; import org.springframework.http.codec.HttpMessageWriter; @@ -25,26 +23,28 @@ import org.springframework.http.codec.json.Jackson2JsonDecoder; import org.springframework.http.codec.json.Jackson2JsonEncoder; import org.springframework.lang.NonNull; +import org.springframework.web.filter.reactive.ServerWebExchangeContextFilter; import org.springframework.web.reactive.config.ResourceHandlerRegistration; import org.springframework.web.reactive.config.ResourceHandlerRegistry; import org.springframework.web.reactive.config.WebFluxConfigurer; -import org.springframework.web.reactive.function.BodyInserters; import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.RouterFunctions; import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.reactive.resource.EncodedResourceResolver; import org.springframework.web.reactive.resource.PathResourceResolver; +import org.springframework.web.reactive.result.method.annotation.RequestMappingHandlerAdapter; import org.springframework.web.reactive.result.view.ViewResolutionResultHandler; import org.springframework.web.reactive.result.view.ViewResolver; -import reactor.core.publisher.Mono; -import run.halo.app.console.ProxyFilter; -import run.halo.app.console.WebSocketRequestPredicate; import run.halo.app.core.endpoint.WebSocketHandlerMapping; +import run.halo.app.core.endpoint.console.CustomEndpointsBuilder; import run.halo.app.core.extension.endpoint.CustomEndpoint; -import run.halo.app.core.extension.endpoint.CustomEndpointsBuilder; +import run.halo.app.infra.SecureRequestMappingHandlerAdapter; +import run.halo.app.infra.console.ProxyFilter; +import run.halo.app.infra.console.WebSocketRequestPredicate; import run.halo.app.infra.properties.AttachmentProperties; import run.halo.app.infra.properties.HaloProperties; +import run.halo.app.infra.webfilter.AdditionalWebFilterChainProxy; import run.halo.app.plugin.extensionpoint.ExtensionGetter; -import run.halo.app.webfilter.AdditionalWebFilterChainProxy; @Configuration public class WebFluxConfig implements WebFluxConfigurer { @@ -67,6 +67,19 @@ public WebFluxConfig(ObjectMapper objectMapper, this.applicationContext = applicationContext; } + @Bean + WebFluxRegistrations webFluxRegistrations() { + return new WebFluxRegistrations() { + @Override + public RequestMappingHandlerAdapter getRequestMappingHandlerAdapter() { + // Because we have no chance to customize ServerWebExchangeMethodArgumentResolver, + // we have to use SecureRequestMappingHandlerAdapter to replace a secure + // ServerWebExchange. + return new SecureRequestMappingHandlerAdapter(); + } + }; + } + @Bean ServerResponse.Context context(CodecConfigurer codec, ViewResolutionResultHandler resultHandler) { @@ -109,34 +122,33 @@ public WebSocketHandlerMapping webSocketHandlerMapping() { } @Bean - RouterFunction consoleIndexRedirection() { - var consolePredicate = method(HttpMethod.GET) - .and(path("/console/**").and(path("/console/assets/**").negate())) + RouterFunction consoleEndpoints() { + var consolePredicate = path("/console/**").and(path("/console/assets/**").negate()) .and(accept(MediaType.TEXT_HTML)) .and(new WebSocketRequestPredicate().negate()); - return route(consolePredicate, - request -> this.serveIndex(haloProp.getConsole().getLocation() + "index.html")); - } - @Bean - RouterFunction ucIndexRedirect() { - var consolePredicate = method(HttpMethod.GET) - .and(path("/uc/**").and(path("/uc/assets/**").negate())) + var ucPredicate = path("/uc/**").and(path("/uc/assets/**").negate()) .and(accept(MediaType.TEXT_HTML)) .and(new WebSocketRequestPredicate().negate()); - return route(consolePredicate, - request -> this.serveIndex(haloProp.getUc().getLocation() + "index.html")); - } - private Mono serveIndex(String indexLocation) { - var indexResource = applicationContext.getResource(indexLocation); - try { - return ServerResponse.ok() - .cacheControl(CacheControl.noStore()) - .body(BodyInserters.fromResource(indexResource)); - } catch (Throwable e) { - return Mono.error(e); - } + var consoleIndexHtml = + applicationContext.getResource(haloProp.getConsole().getLocation() + "index.html"); + + var ucIndexHtml = + applicationContext.getResource(haloProp.getUc().getLocation() + "index.html"); + + return RouterFunctions.route() + .GET(consolePredicate, + request -> ServerResponse.ok() + .cacheControl(CacheControl.noStore()) + .bodyValue(consoleIndexHtml) + ) + .GET(ucPredicate, + request -> ServerResponse.ok() + .cacheControl(CacheControl.noStore()) + .bodyValue(ucIndexHtml) + ) + .build(); } @Override @@ -236,4 +248,11 @@ AdditionalWebFilterChainProxy additionalWebFilterChainProxy(ExtensionGetter exte return new AdditionalWebFilterChainProxy(extensionGetter); } + @Bean + // We expect this filter to be executed before AdditionalWebFilterChainProxy + @Order(-102) + ServerWebExchangeContextFilter serverWebExchangeContextFilter() { + return new ServerWebExchangeContextFilter(); + } + } diff --git a/application/src/main/java/run/halo/app/config/WebServerSecurityConfig.java b/application/src/main/java/run/halo/app/infra/config/WebServerSecurityConfig.java similarity index 68% rename from application/src/main/java/run/halo/app/config/WebServerSecurityConfig.java rename to application/src/main/java/run/halo/app/infra/config/WebServerSecurityConfig.java index 06c191707d..9ff7a74b2f 100644 --- a/application/src/main/java/run/halo/app/config/WebServerSecurityConfig.java +++ b/application/src/main/java/run/halo/app/infra/config/WebServerSecurityConfig.java @@ -1,7 +1,5 @@ -package run.halo.app.config; +package run.halo.app.infra.config; -import static org.springframework.security.config.Customizer.withDefaults; -import static org.springframework.security.web.server.authentication.ServerWebExchangeDelegatingReactiveAuthenticationManagerResolver.builder; import static org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers.pathMatchers; import java.util.concurrent.ConcurrentHashMap; @@ -11,6 +9,7 @@ import org.springframework.boot.autoconfigure.web.ServerProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; import org.springframework.security.config.web.server.ServerHttpSecurity; import org.springframework.security.crypto.factory.PasswordEncoderFactories; @@ -18,24 +17,22 @@ import org.springframework.security.web.server.SecurityWebFilterChain; import org.springframework.security.web.server.context.ServerSecurityContextRepository; import org.springframework.security.web.server.context.WebSessionServerSecurityContextRepository; +import org.springframework.security.web.server.savedrequest.ServerRequestCache; +import org.springframework.security.web.server.util.matcher.AndServerWebExchangeMatcher; +import org.springframework.security.web.server.util.matcher.NegatedServerWebExchangeMatcher; import org.springframework.session.MapSession; import org.springframework.session.config.annotation.web.server.EnableSpringWebSession; -import org.springframework.web.reactive.function.server.RouterFunction; -import org.springframework.web.reactive.function.server.ServerResponse; -import run.halo.app.core.extension.service.RoleService; -import run.halo.app.core.extension.service.UserService; +import run.halo.app.core.user.service.RoleService; +import run.halo.app.core.user.service.UserService; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.AnonymousUserConst; import run.halo.app.infra.properties.HaloProperties; import run.halo.app.security.DefaultUserDetailService; +import run.halo.app.security.HaloServerRequestCache; import run.halo.app.security.authentication.CryptoService; import run.halo.app.security.authentication.SecurityConfigurer; import run.halo.app.security.authentication.impl.RsaKeyService; -import run.halo.app.security.authentication.login.PublicKeyRouteBuilder; -import run.halo.app.security.authentication.pat.PatAuthenticationManager; -import run.halo.app.security.authentication.pat.PatServerWebExchangeMatcher; -import run.halo.app.security.authentication.twofactor.TwoFactorAuthorizationManager; -import run.halo.app.security.authorization.RequestInfoAuthorizationManager; +import run.halo.app.security.authorization.AuthorityUtils; import run.halo.app.security.session.InMemoryReactiveIndexedSessionRepository; import run.halo.app.security.session.ReactiveIndexedSessionRepository; @@ -57,37 +54,36 @@ SecurityWebFilterChain filterChain(ServerHttpSecurity http, ServerSecurityContextRepository securityContextRepository, ReactiveExtensionClient client, CryptoService cryptoService, - HaloProperties haloProperties) { + HaloProperties haloProperties, + ServerRequestCache serverRequestCache) { - http.securityMatcher(pathMatchers("/**")) - .authorizeExchange(spec -> spec.pathMatchers( - "/api/**", - "/apis/**", - "/oauth2/**", - "/login/**", - "/logout", - "/actuator/**" - ) - .access( - new TwoFactorAuthorizationManager( - new RequestInfoAuthorizationManager(roleService) - ) - ) - .anyExchange().permitAll()) + var pathMatcher = pathMatchers("/**"); + var staticResourcesMatcher = pathMatchers(HttpMethod.GET, + "/console/assets/**", + "/uc/assets/**", + "/themes/{themeName}/assets/{*resourcePaths}", + "/plugins/{pluginName}/assets/**", + "/upload/**", + "/webjars/**", + "/js/**", + "/styles/**", + "/halo-tracker.js", + "/images/**" + ); + + var securityMatcher = new AndServerWebExchangeMatcher(pathMatcher, + new NegatedServerWebExchangeMatcher(staticResourcesMatcher)); + + http.securityMatcher(securityMatcher) .anonymous(spec -> { - spec.authorities(AnonymousUserConst.Role); + spec.authorities(AuthorityUtils.ROLE_PREFIX + AnonymousUserConst.Role); spec.principal(AnonymousUserConst.PRINCIPAL); }) .securityContextRepository(securityContextRepository) - .httpBasic(withDefaults()) - .oauth2ResourceServer(oauth2 -> { - var authManagerResolver = builder().add( - new PatServerWebExchangeMatcher(), - new PatAuthenticationManager(client, cryptoService) - ) - // TODO Add other authentication mangers here. e.g.: JwtAuthenticationManager. - .build(); - oauth2.authenticationManagerResolver(authManagerResolver); + .httpBasic(basic -> { + if (haloProperties.getSecurity().getBasicAuth().isDisabled()) { + basic.disable(); + } }) .headers(headerSpec -> headerSpec .frameOptions(frameSpec -> { @@ -101,7 +97,8 @@ SecurityWebFilterChain filterChain(ServerHttpSecurity http, haloProperties.getSecurity().getReferrerOptions().getPolicy()) ) .hsts(hstsSpec -> hstsSpec.includeSubdomains(false)) - ); + ) + .requestCache(spec -> spec.requestCache(serverRequestCache)); // Integrate with other configurers separately securityConfigurers.orderedStream() @@ -109,6 +106,11 @@ SecurityWebFilterChain filterChain(ServerHttpSecurity http, return http.build(); } + @Bean + ServerRequestCache serverRequestCache() { + return new HaloServerRequestCache(); + } + @Bean ServerSecurityContextRepository securityContextRepository() { return new WebSessionServerSecurityContextRepository(); @@ -140,14 +142,10 @@ PasswordEncoder passwordEncoder() { return PasswordEncoderFactories.createDelegatingPasswordEncoder(); } - @Bean - RouterFunction publicKeyRoute(CryptoService cryptoService) { - return new PublicKeyRouteBuilder(cryptoService).build(); - } - @Bean CryptoService cryptoService(HaloProperties haloProperties) { return new RsaKeyService(haloProperties.getWorkDir().resolve("keys")); } + } diff --git a/application/src/main/java/run/halo/app/console/ProxyFilter.java b/application/src/main/java/run/halo/app/infra/console/ProxyFilter.java similarity index 98% rename from application/src/main/java/run/halo/app/console/ProxyFilter.java rename to application/src/main/java/run/halo/app/infra/console/ProxyFilter.java index 854d810ab1..707be01b10 100644 --- a/application/src/main/java/run/halo/app/console/ProxyFilter.java +++ b/application/src/main/java/run/halo/app/infra/console/ProxyFilter.java @@ -1,4 +1,4 @@ -package run.halo.app.console; +package run.halo.app.infra.console; import lombok.extern.slf4j.Slf4j; import org.springframework.core.io.buffer.DataBuffer; diff --git a/application/src/main/java/run/halo/app/console/WebSocketRequestPredicate.java b/application/src/main/java/run/halo/app/infra/console/WebSocketRequestPredicate.java similarity index 78% rename from application/src/main/java/run/halo/app/console/WebSocketRequestPredicate.java rename to application/src/main/java/run/halo/app/infra/console/WebSocketRequestPredicate.java index 647f98ecba..8d3914e02a 100644 --- a/application/src/main/java/run/halo/app/console/WebSocketRequestPredicate.java +++ b/application/src/main/java/run/halo/app/infra/console/WebSocketRequestPredicate.java @@ -1,6 +1,6 @@ -package run.halo.app.console; +package run.halo.app.infra.console; -import static run.halo.app.console.WebSocketUtils.isWebSocketUpgrade; +import static run.halo.app.infra.console.WebSocketUtils.isWebSocketUpgrade; import org.springframework.web.reactive.function.server.RequestPredicate; import org.springframework.web.reactive.function.server.ServerRequest; diff --git a/application/src/main/java/run/halo/app/console/WebSocketServerWebExchangeMatcher.java b/application/src/main/java/run/halo/app/infra/console/WebSocketServerWebExchangeMatcher.java similarity index 85% rename from application/src/main/java/run/halo/app/console/WebSocketServerWebExchangeMatcher.java rename to application/src/main/java/run/halo/app/infra/console/WebSocketServerWebExchangeMatcher.java index 17bdbde51c..e9e9bd04ed 100644 --- a/application/src/main/java/run/halo/app/console/WebSocketServerWebExchangeMatcher.java +++ b/application/src/main/java/run/halo/app/infra/console/WebSocketServerWebExchangeMatcher.java @@ -1,8 +1,8 @@ -package run.halo.app.console; +package run.halo.app.infra.console; import static org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher.MatchResult.match; import static org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher.MatchResult.notMatch; -import static run.halo.app.console.WebSocketUtils.isWebSocketUpgrade; +import static run.halo.app.infra.console.WebSocketUtils.isWebSocketUpgrade; import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; import org.springframework.web.server.ServerWebExchange; diff --git a/application/src/main/java/run/halo/app/console/WebSocketUtils.java b/application/src/main/java/run/halo/app/infra/console/WebSocketUtils.java similarity index 94% rename from application/src/main/java/run/halo/app/console/WebSocketUtils.java rename to application/src/main/java/run/halo/app/infra/console/WebSocketUtils.java index 29ba096aeb..afec871646 100644 --- a/application/src/main/java/run/halo/app/console/WebSocketUtils.java +++ b/application/src/main/java/run/halo/app/infra/console/WebSocketUtils.java @@ -1,4 +1,4 @@ -package run.halo.app.console; +package run.halo.app.infra.console; import java.util.Objects; import org.springframework.http.HttpHeaders; diff --git a/application/src/main/java/run/halo/app/infra/exception/OAuth2UserAlreadyBoundException.java b/application/src/main/java/run/halo/app/infra/exception/OAuth2UserAlreadyBoundException.java new file mode 100644 index 0000000000..3c5cca3458 --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/exception/OAuth2UserAlreadyBoundException.java @@ -0,0 +1,23 @@ +package run.halo.app.infra.exception; + +import org.springframework.web.server.ServerWebInputException; +import run.halo.app.core.extension.UserConnection; + +/** + * An exception that the user has been bound to another OAuth2 user. + * + * @author johnniang + * @since 2.20.0 + */ +public class OAuth2UserAlreadyBoundException extends ServerWebInputException { + + public OAuth2UserAlreadyBoundException(UserConnection connection) { + super("The user has been bound to another account", null, null, null, new Object[] { + connection.getSpec().getUsername(), + connection.getSpec().getProviderUserId(), + connection.getSpec().getRegistrationId(), + connection.getSpec().getUpdatedAt() + }); + } + +} diff --git a/application/src/main/java/run/halo/app/infra/exception/RequestBodyValidationException.java b/application/src/main/java/run/halo/app/infra/exception/RequestBodyValidationException.java index 353f2fb997..4bcdbefce7 100644 --- a/application/src/main/java/run/halo/app/infra/exception/RequestBodyValidationException.java +++ b/application/src/main/java/run/halo/app/infra/exception/RequestBodyValidationException.java @@ -3,6 +3,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Locale; +import lombok.Getter; import org.springframework.context.MessageSource; import org.springframework.context.MessageSourceResolvable; import org.springframework.http.ProblemDetail; @@ -11,6 +12,7 @@ import org.springframework.web.server.ServerWebInputException; import org.springframework.web.util.BindErrorUtils; +@Getter public class RequestBodyValidationException extends ServerWebInputException { private final Errors errors; diff --git a/application/src/main/java/run/halo/app/infra/exception/RequestRestrictedException.java b/application/src/main/java/run/halo/app/infra/exception/RequestRestrictedException.java new file mode 100644 index 0000000000..1d86edffca --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/exception/RequestRestrictedException.java @@ -0,0 +1,24 @@ +package run.halo.app.infra.exception; + +/** + *

{@link RequestRestrictedException} indicates that the client's request was denied because + * it did not meet certain required conditions.

+ *

Typically, this exception is thrown when a user attempts to perform an action that + * requires prior approval or validation, such as replying to a comment that has not yet been + * approved.

+ *

The server understands the request but refuses to process it due to the lack of + * necessary approval.

+ * + * @author guqing + * @since 2.20.0 + */ +public class RequestRestrictedException extends AccessDeniedException { + + public RequestRestrictedException(String reason) { + super(reason); + } + + public RequestRestrictedException(String reason, String detailCode, Object[] detailArgs) { + super(reason, detailCode, detailArgs); + } +} diff --git a/application/src/main/java/run/halo/app/infra/exception/UnsatisfiedAttributeValueException.java b/application/src/main/java/run/halo/app/infra/exception/UnsatisfiedAttributeValueException.java index 4b74dd9985..bc960653d8 100644 --- a/application/src/main/java/run/halo/app/infra/exception/UnsatisfiedAttributeValueException.java +++ b/application/src/main/java/run/halo/app/infra/exception/UnsatisfiedAttributeValueException.java @@ -13,6 +13,10 @@ */ public class UnsatisfiedAttributeValueException extends ServerWebInputException { + public UnsatisfiedAttributeValueException(String reason) { + super(reason); + } + public UnsatisfiedAttributeValueException(String reason, @Nullable String messageDetailCode, @Null Object[] messageDetailArguments) { super(reason, null, null, messageDetailCode, messageDetailArguments); diff --git a/application/src/main/java/run/halo/app/infra/properties/ConsoleProperties.java b/application/src/main/java/run/halo/app/infra/properties/ConsoleProperties.java index 8bf245a459..b98a99e892 100644 --- a/application/src/main/java/run/halo/app/infra/properties/ConsoleProperties.java +++ b/application/src/main/java/run/halo/app/infra/properties/ConsoleProperties.java @@ -2,6 +2,7 @@ import jakarta.validation.Valid; import lombok.Data; +import org.springframework.boot.context.properties.NestedConfigurationProperty; @Data public class ConsoleProperties { @@ -9,6 +10,7 @@ public class ConsoleProperties { private String location = "classpath:/console/"; @Valid + @NestedConfigurationProperty private ProxyProperties proxy = new ProxyProperties(); } diff --git a/application/src/main/java/run/halo/app/infra/properties/HaloProperties.java b/application/src/main/java/run/halo/app/infra/properties/HaloProperties.java index 2a296dffb8..b492788d22 100644 --- a/application/src/main/java/run/halo/app/infra/properties/HaloProperties.java +++ b/application/src/main/java/run/halo/app/infra/properties/HaloProperties.java @@ -2,12 +2,14 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; +import java.net.URISyntaxException; import java.net.URL; import java.nio.file.Path; import java.util.HashSet; import java.util.Set; import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; import org.springframework.validation.Errors; import org.springframework.validation.Validator; import org.springframework.validation.annotation.Validated; @@ -44,21 +46,27 @@ public class HaloProperties implements Validator { private boolean requiredExtensionDisabled; @Valid + @NestedConfigurationProperty private final ExtensionProperties extension = new ExtensionProperties(); @Valid + @NestedConfigurationProperty private final SecurityProperties security = new SecurityProperties(); @Valid + @NestedConfigurationProperty private final ConsoleProperties console = new ConsoleProperties(); @Valid + @NestedConfigurationProperty private final UcProperties uc = new UcProperties(); @Valid + @NestedConfigurationProperty private final ThemeProperties theme = new ThemeProperties(); @Valid + @NestedConfigurationProperty private final AttachmentProperties attachment = new AttachmentProperties(); @Override @@ -69,9 +77,26 @@ public boolean supports(Class clazz) { @Override public void validate(Object target, Errors errors) { var props = (HaloProperties) target; - if (props.isUseAbsolutePermalink() && props.getExternalUrl() == null) { + var externalUrl = props.getExternalUrl(); + if (props.isUseAbsolutePermalink() && externalUrl == null) { errors.rejectValue("externalUrl", "external-url.required.when-using-absolute-permalink", "External URL is required when property `use-absolute-permalink` is set to true."); } + // check if the external URL is a http or https URL and is not an opaque URL. + if (externalUrl != null && !isValidExternalUrl(externalUrl)) { + errors.rejectValue("externalUrl", "external-url.invalid-format", + "External URL must be a http or https URL."); + } + } + + private boolean isValidExternalUrl(URL externalUrl) { + try { + var uri = externalUrl.toURI(); + return !uri.isOpaque() + && uri.getAuthority() != null + && Set.of("http", "https").contains(uri.getScheme()); + } catch (URISyntaxException e) { + return false; + } } } diff --git a/application/src/main/java/run/halo/app/infra/properties/SecurityProperties.java b/application/src/main/java/run/halo/app/infra/properties/SecurityProperties.java index d67b49549a..5911ac2846 100644 --- a/application/src/main/java/run/halo/app/infra/properties/SecurityProperties.java +++ b/application/src/main/java/run/halo/app/infra/properties/SecurityProperties.java @@ -2,7 +2,10 @@ import static org.springframework.security.web.server.header.ReferrerPolicyServerHttpHeadersWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN; +import java.net.URI; import java.time.Duration; +import java.util.ArrayList; +import java.util.List; import lombok.Data; import org.springframework.security.web.server.header.ReferrerPolicyServerHttpHeadersWriter.ReferrerPolicy; import org.springframework.security.web.server.header.XFrameOptionsServerHttpHeadersWriter.Mode; @@ -18,6 +21,18 @@ public class SecurityProperties { private final TwoFactorAuthOptions twoFactorAuth = new TwoFactorAuthOptions(); + private final BasicAuthOptions basicAuth = new BasicAuthOptions(); + + private final List passwordResetMethods = new ArrayList<>(); + + @Data + public static class BasicAuthOptions { + /** + * Whether basic authentication is disabled. + */ + private boolean disabled = true; + } + @Data public static class TwoFactorAuthOptions { @@ -47,4 +62,15 @@ public static class ReferrerOptions { public static class RememberMeOptions { private Duration tokenValidity = Duration.ofDays(14); } + + @Data + public static class PasswordResetMethod { + + private String name; + + private URI href; + + private URI icon; + + } } diff --git a/application/src/main/java/run/halo/app/infra/utils/FileUtils.java b/application/src/main/java/run/halo/app/infra/utils/FileUtils.java index ca790bfde1..ffaac5cbe7 100644 --- a/application/src/main/java/run/halo/app/infra/utils/FileUtils.java +++ b/application/src/main/java/run/halo/app/infra/utils/FileUtils.java @@ -25,6 +25,7 @@ import java.util.zip.ZipOutputStream; import lombok.extern.slf4j.Slf4j; import org.reactivestreams.Publisher; +import org.springframework.core.io.Resource; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.lang.NonNull; import org.springframework.util.AntPathMatcher; @@ -296,6 +297,14 @@ public static Mono deleteFileSilently(Path file, Scheduler scheduler) { .subscribeOn(scheduler); } + public static void copyResource(Resource resource, Path path) { + try (var inputStream = resource.getInputStream()) { + Files.copy(inputStream, path, REPLACE_EXISTING); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + public static void copy(Path source, Path dest, CopyOption... options) { try { Files.copy(source, dest, options); diff --git a/application/src/main/java/run/halo/app/infra/utils/HaloUtils.java b/application/src/main/java/run/halo/app/infra/utils/HaloUtils.java index 08d9e2cc01..170b1cdbe7 100644 --- a/application/src/main/java/run/halo/app/infra/utils/HaloUtils.java +++ b/application/src/main/java/run/halo/app/infra/utils/HaloUtils.java @@ -5,6 +5,8 @@ import java.nio.charset.StandardCharsets; import java.time.Instant; import java.time.ZoneId; +import java.util.function.UnaryOperator; +import lombok.experimental.UtilityClass; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.core.io.ClassPathResource; @@ -12,14 +14,25 @@ import org.springframework.util.Assert; import org.springframework.util.StreamUtils; import org.springframework.web.reactive.function.server.ServerRequest; +import run.halo.app.theme.router.ModelConst; /** + * Halo utilities. + * * @author guqing - * @date 2022-04-12 + * @since 2.0.0 */ @Slf4j +@UtilityClass public class HaloUtils { + /** + * Check if the request is an XMLHttpRequest. + */ + public static boolean isXhr(HttpHeaders headers) { + return headers.getOrEmpty("X-Requested-With").contains("XMLHttpRequest"); + } + /** *

Read the file under the classpath as a string.

* @@ -51,7 +64,7 @@ public static String userAgentFrom(ServerRequest request) { // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-CH-UA userAgent = httpHeaders.getFirst("Sec-CH-UA"); } - return StringUtils.defaultString(userAgent, "unknown"); + return StringUtils.defaultIfBlank(userAgent, "unknown"); } public static String getDayText(Instant instant) { @@ -70,4 +83,16 @@ public static String getYearText(Instant instant) { Assert.notNull(instant, "Instant must not be null"); return String.valueOf(instant.atZone(ZoneId.systemDefault()).getYear()); } + + /** + * Mark the response as no cache. + * + * @return the server request operator + */ + public static UnaryOperator noCache() { + return request -> { + request.exchange().getAttributes().put(ModelConst.NO_CACHE, true); + return request; + }; + } } diff --git a/application/src/main/java/run/halo/app/infra/utils/ReactiveUtils.java b/application/src/main/java/run/halo/app/infra/utils/ReactiveUtils.java new file mode 100644 index 0000000000..c87b0e03b6 --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/utils/ReactiveUtils.java @@ -0,0 +1,62 @@ +package run.halo.app.infra.utils; + +import java.time.Duration; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * Utility class for reactive. + * + * @author johnniang + * @since 2.20.0 + */ +public enum ReactiveUtils { + ; + + private static final Duration DEFAULT_TIMEOUT = Duration.ofMinutes(1); + + /** + * Resolve reactive value by blocking operation. + * + * @param value the normal value or reactive value + * @return the resolved value + */ + @Nullable + public static Object blockReactiveValue(@Nullable Object value) { + return blockReactiveValue(value, DEFAULT_TIMEOUT); + } + + /** + * Resolve reactive value by blocking operation. + * + * @param value the normal value or reactive value + * @param timeout the timeout of blocking operation + * @return the resolved value + */ + @Nullable + public static Object blockReactiveValue(@Nullable Object value, @NonNull Duration timeout) { + if (value == null) { + return null; + } + Class clazz = value.getClass(); + if (Mono.class.isAssignableFrom(clazz)) { + return ((Mono) value).blockOptional(timeout).orElse(null); + } + if (Flux.class.isAssignableFrom(clazz)) { + return ((Flux) value).collectList().block(timeout); + } + return value; + } + + /** + * Check if the class is a reactive type. + * + * @param clazz the class to check + * @return true if the class is a reactive type, false otherwise + */ + public static boolean isReactiveType(@NonNull Class clazz) { + return Mono.class.isAssignableFrom(clazz) || Flux.class.isAssignableFrom(clazz); + } +} diff --git a/application/src/main/java/run/halo/app/core/extension/theme/SettingUtils.java b/application/src/main/java/run/halo/app/infra/utils/SettingUtils.java similarity index 89% rename from application/src/main/java/run/halo/app/core/extension/theme/SettingUtils.java rename to application/src/main/java/run/halo/app/infra/utils/SettingUtils.java index 0a3b33bdfb..48d9a51b51 100644 --- a/application/src/main/java/run/halo/app/core/extension/theme/SettingUtils.java +++ b/application/src/main/java/run/halo/app/infra/utils/SettingUtils.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.theme; +package run.halo.app.infra.utils; import static org.apache.commons.lang3.ObjectUtils.defaultIfNull; @@ -21,8 +21,6 @@ import run.halo.app.extension.ConfigMap; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.Metadata; -import run.halo.app.infra.utils.JsonParseException; -import run.halo.app.infra.utils.JsonUtils; @UtilityClass public class SettingUtils { @@ -129,7 +127,30 @@ public static Map mergePatch(Map modified, } } - JsonNode mapToJsonNode(Map map) { + /** + * Convert {@link Setting} related configMap data to JsonNode. + * + * @param configMap {@link ConfigMap} instance + * @return JsonNode + */ + public static ObjectNode settingConfigToJson(ConfigMap configMap) { + if (configMap.getData() == null) { + return JsonNodeFactory.instance.objectNode(); + } + return mapToJsonNode(configMap.getData()); + } + + /** + * Convert the result of {@link #settingConfigToJson(ConfigMap)} in reverse to Map. + * + * @param node JsonNode object + * @return {@link ConfigMap#getData()} + */ + public static Map settingConfigJsonToMap(ObjectNode node) { + return jsonNodeToStringMap(node); + } + + ObjectNode mapToJsonNode(Map map) { ObjectNode objectNode = JsonNodeFactory.instance.objectNode(); map.forEach((k, v) -> { if (isJson(v)) { diff --git a/application/src/main/java/run/halo/app/infra/utils/VersionUtils.java b/application/src/main/java/run/halo/app/infra/utils/VersionUtils.java index 09441ac08e..2f537b9441 100644 --- a/application/src/main/java/run/halo/app/infra/utils/VersionUtils.java +++ b/application/src/main/java/run/halo/app/infra/utils/VersionUtils.java @@ -41,7 +41,7 @@ public static boolean checkVersionConstraint(String version, String constraint) try { return StringUtils.isBlank(constraint) || "*".equals(constraint) - || Version.valueOf(version).satisfies(constraint); + || Version.parse(version).satisfies(constraint); } catch (Exception e) { throw new ServerWebInputException("Illegal requires version expression.", null, e); } diff --git a/application/src/main/java/run/halo/app/webfilter/AdditionalWebFilterChainProxy.java b/application/src/main/java/run/halo/app/infra/webfilter/AdditionalWebFilterChainProxy.java similarity index 97% rename from application/src/main/java/run/halo/app/webfilter/AdditionalWebFilterChainProxy.java rename to application/src/main/java/run/halo/app/infra/webfilter/AdditionalWebFilterChainProxy.java index 7a8ce77ead..57fea8dcae 100644 --- a/application/src/main/java/run/halo/app/webfilter/AdditionalWebFilterChainProxy.java +++ b/application/src/main/java/run/halo/app/infra/webfilter/AdditionalWebFilterChainProxy.java @@ -1,4 +1,4 @@ -package run.halo.app.webfilter; +package run.halo.app.infra.webfilter; import lombok.Setter; import org.springframework.core.annotation.AnnotationAwareOrderComparator; diff --git a/application/src/main/java/run/halo/app/infra/webfilter/LocaleChangeWebFilter.java b/application/src/main/java/run/halo/app/infra/webfilter/LocaleChangeWebFilter.java new file mode 100644 index 0000000000..5f1885a59f --- /dev/null +++ b/application/src/main/java/run/halo/app/infra/webfilter/LocaleChangeWebFilter.java @@ -0,0 +1,62 @@ +package run.halo.app.infra.webfilter; + +import static run.halo.app.theme.ThemeLocaleContextResolver.LANGUAGE_COOKIE_NAME; +import static run.halo.app.theme.ThemeLocaleContextResolver.LANGUAGE_PARAMETER_NAME; + +import java.util.Locale; +import java.util.Set; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseCookie; +import org.springframework.lang.NonNull; +import org.springframework.security.web.server.util.matcher.AndServerWebExchangeMatcher; +import org.springframework.security.web.server.util.matcher.MediaTypeServerWebExchangeMatcher; +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher.MatchResult; +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilter; +import org.springframework.web.server.WebFilterChain; +import reactor.core.publisher.Mono; + +@Component +public class LocaleChangeWebFilter implements WebFilter { + + private final ServerWebExchangeMatcher matcher; + + public LocaleChangeWebFilter() { + var pathMatcher = ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, "/**"); + var textHtmlMatcher = new MediaTypeServerWebExchangeMatcher(MediaType.TEXT_HTML); + textHtmlMatcher.setIgnoredMediaTypes(Set.of(MediaType.ALL)); + matcher = new AndServerWebExchangeMatcher(pathMatcher, textHtmlMatcher); + } + + @Override + @NonNull + public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { + var request = exchange.getRequest(); + return matcher.matches(exchange) + .filter(MatchResult::isMatch) + .doOnNext(result -> { + var language = request + .getQueryParams() + .getFirst(LANGUAGE_PARAMETER_NAME); + if (StringUtils.hasText(language)) { + var locale = Locale.forLanguageTag(language); + setLanguageCookie(exchange, locale); + } + }) + .then(Mono.defer(() -> chain.filter(exchange))); + } + + void setLanguageCookie(ServerWebExchange exchange, Locale locale) { + var cookie = ResponseCookie.from(LANGUAGE_COOKIE_NAME, locale.toLanguageTag()) + .path("/") + .secure("https".equalsIgnoreCase(exchange.getRequest().getURI().getScheme())) + .sameSite("Lax") + .build(); + exchange.getResponse().getCookies().set(LANGUAGE_COOKIE_NAME, cookie); + } +} diff --git a/application/src/main/java/run/halo/app/migration/impl/MigrationServiceImpl.java b/application/src/main/java/run/halo/app/migration/impl/MigrationServiceImpl.java index 700bbd0766..a699a39cbd 100644 --- a/application/src/main/java/run/halo/app/migration/impl/MigrationServiceImpl.java +++ b/application/src/main/java/run/halo/app/migration/impl/MigrationServiceImpl.java @@ -64,12 +64,14 @@ public class MigrationServiceImpl implements MigrationService, InitializingBean "backups/**", "db/**", "logs/**", + "indices/**", "docker-compose.yaml", "docker-compose.yml", "mysql/**", "mysqlBackup/**", "**/.idea/**", - "**/.vscode/**" + "**/.vscode/**", + "attachments/thumbnails/**" ); private final DateTimeFormatter dateTimeFormatter; @@ -139,7 +141,14 @@ public Mono restore(Publisher content) { return Mono.usingWhen( createTempDir("halo-restore-", scheduler), tempDir -> unpackBackup(content, tempDir) - .then(Mono.defer(() -> restoreExtensions(tempDir))) + .then(Mono.defer(() -> + // This step skips index verification such as unique index. + // In order to avoid index conflicts after recovery or + // OptimisticLockingFailureException when updating the same record, + // so we need to truncate all extension stores before saving(create or update). + repository.deleteAll() + .then(restoreExtensions(tempDir))) + ) .then(Mono.defer(() -> restoreWorkdir(tempDir))), tempDir -> deleteRecursivelyAndSilently(tempDir, scheduler) ); @@ -239,13 +248,9 @@ private Mono restoreExtensions(Path backupRoot) { sink.complete(); }) // reset version - .doOnNext(extensionStore -> extensionStore.setVersion(null)).buffer(100) - // We might encounter OptimisticLockingFailureException when saving extension - // store, - // So we have to delete all extension stores before saving. - .flatMap(extensionStores -> repository.deleteAll(extensionStores) - .thenMany(repository.saveAll(extensionStores)) - ) + .doOnNext(extensionStore -> extensionStore.setVersion(null)) + .buffer(100) + .flatMap(repository::saveAll) .doOnNext(extensionStore -> log.info("Restored extension store: {}", extensionStore.getName())) .then(), diff --git a/application/src/main/java/run/halo/app/notification/DefaultNotificationTemplateRender.java b/application/src/main/java/run/halo/app/notification/DefaultNotificationTemplateRender.java index b15ff738e7..08c150523a 100644 --- a/application/src/main/java/run/halo/app/notification/DefaultNotificationTemplateRender.java +++ b/application/src/main/java/run/halo/app/notification/DefaultNotificationTemplateRender.java @@ -5,7 +5,9 @@ import java.util.HashMap; import java.util.Locale; import java.util.Map; +import java.util.Optional; import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; import org.springframework.stereotype.Component; import org.thymeleaf.TemplateEngine; import org.thymeleaf.context.Context; @@ -40,13 +42,16 @@ public class DefaultNotificationTemplateRender implements NotificationTemplateRe @Override public Mono render(String template, Map model) { var context = new Context(Locale.getDefault(), model); + var externalUrl = Optional.ofNullable(externalUrlSupplier.getRaw()) + .map(url -> StringUtils.removeEnd(url.toString(), "/")) + .orElse(StringUtils.EMPTY); var globalAttributeMono = getBasicSetting() .doOnNext(basic -> { var site = new HashMap<>(); site.put("title", basic.getTitle()); site.put("logo", basic.getLogo()); site.put("subtitle", basic.getSubtitle()); - site.put("url", externalUrlSupplier.getRaw()); + site.put("url", externalUrl); context.setVariable("site", site); }); return Mono.when(globalAttributeMono) diff --git a/application/src/main/java/run/halo/app/notification/endpoint/SubscriptionRouter.java b/application/src/main/java/run/halo/app/notification/endpoint/SubscriptionRouter.java index 7344613ab0..e577cfac66 100644 --- a/application/src/main/java/run/halo/app/notification/endpoint/SubscriptionRouter.java +++ b/application/src/main/java/run/halo/app/notification/endpoint/SubscriptionRouter.java @@ -39,10 +39,11 @@ public class SubscriptionRouter { @Bean RouterFunction notificationSubscriptionRouter() { + final var tag = "NotificationV1alpha1Public"; return SpringdocRouteBuilder.route() .GET(UNSUBSCRIBE_PATTERN, this::unsubscribe, builder -> { builder.operationId("Unsubscribe") - .tag("api.notification.halo.run/v1alpha1/Subscription") + .tag(tag) .description("Unsubscribe a subscription") .parameter(parameterBuilder() .in(ParameterIn.PATH) diff --git a/application/src/main/java/run/halo/app/notification/endpoint/UserNotificationPreferencesEndpoint.java b/application/src/main/java/run/halo/app/notification/endpoint/UserNotificationPreferencesEndpoint.java index d3d7be08ab..8c3757be0a 100644 --- a/application/src/main/java/run/halo/app/notification/endpoint/UserNotificationPreferencesEndpoint.java +++ b/application/src/main/java/run/halo/app/notification/endpoint/UserNotificationPreferencesEndpoint.java @@ -174,6 +174,11 @@ Mono listReasonTypeNotifierMatrix(String username) { var notifierNames = reasonTypeNotifierMap.getNotifiers(reasonType.name()); for (String notifierName : notifierNames) { + // Skip if the notifier enabled in the user preference does not + // exist to avoid null index + if (!notifierIndexMap.containsKey(notifierName)) { + continue; + } var notifierIndex = notifierIndexMap.get(notifierName); stateMatrix[reasonTypeIndex][notifierIndex] = true; } diff --git a/application/src/main/java/run/halo/app/plugin/AggregatedRouterFunction.java b/application/src/main/java/run/halo/app/plugin/AggregatedRouterFunction.java index 396c2d49d6..7c09cdd4a4 100644 --- a/application/src/main/java/run/halo/app/plugin/AggregatedRouterFunction.java +++ b/application/src/main/java/run/halo/app/plugin/AggregatedRouterFunction.java @@ -7,8 +7,9 @@ import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import reactor.core.publisher.Mono; +import run.halo.app.core.endpoint.console.CustomEndpointsBuilder; import run.halo.app.core.extension.endpoint.CustomEndpoint; -import run.halo.app.core.extension.endpoint.CustomEndpointsBuilder; +import run.halo.app.infra.SecureServerRequest; /** * Aggregated router function built from all custom endpoints. @@ -28,7 +29,7 @@ public AggregatedRouterFunction(ObjectProvider customEndpoints) @Override public Mono> route(ServerRequest request) { - return aggregated.route(request); + return aggregated.route(new SecureServerRequest(request)); } @Override diff --git a/application/src/main/java/run/halo/app/plugin/DefaultPluginRouterFunctionRegistry.java b/application/src/main/java/run/halo/app/plugin/DefaultPluginRouterFunctionRegistry.java index 602fd56063..547a07a547 100644 --- a/application/src/main/java/run/halo/app/plugin/DefaultPluginRouterFunctionRegistry.java +++ b/application/src/main/java/run/halo/app/plugin/DefaultPluginRouterFunctionRegistry.java @@ -11,6 +11,7 @@ import org.springframework.web.reactive.function.server.ServerResponse; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import run.halo.app.infra.SecureServerRequest; /** * A composite {@link RouterFunction} implementation for plugin. @@ -31,8 +32,9 @@ public DefaultPluginRouterFunctionRegistry() { @Override @NonNull public Mono> route(@NonNull ServerRequest request) { + var secureRequest = new SecureServerRequest(request); return Flux.fromIterable(this.routerFunctions) - .concatMap(routerFunction -> routerFunction.route(request)) + .concatMap(routerFunction -> routerFunction.route(secureRequest)) .next(); } diff --git a/application/src/main/java/run/halo/app/plugin/DefaultReactiveSettingFetcher.java b/application/src/main/java/run/halo/app/plugin/DefaultReactiveSettingFetcher.java index 397e3ff9cf..835f5bb253 100644 --- a/application/src/main/java/run/halo/app/plugin/DefaultReactiveSettingFetcher.java +++ b/application/src/main/java/run/halo/app/plugin/DefaultReactiveSettingFetcher.java @@ -8,6 +8,7 @@ import java.util.LinkedHashMap; import java.util.Map; import java.util.Objects; +import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.BeansException; import org.springframework.beans.factory.DisposableBean; @@ -26,7 +27,6 @@ import run.halo.app.extension.controller.Controller; import run.halo.app.extension.controller.ControllerBuilder; import run.halo.app.extension.controller.Reconciler; -import run.halo.app.infra.utils.JsonParseException; import run.halo.app.infra.utils.JsonUtils; /** @@ -35,6 +35,7 @@ * @author guqing * @since 2.0.0 */ +@Slf4j public class DefaultReactiveSettingFetcher implements ReactiveSettingFetcher, Reconciler, DisposableBean, ApplicationContextAware { @@ -130,12 +131,21 @@ private JsonNode readTree(String json) { try { return JsonUtils.DEFAULT_JSON_MAPPER.readTree(json); } catch (JsonProcessingException e) { - throw new JsonParseException(e); + // ignore + log.error("Failed to parse plugin [{}] config json: [{}]", pluginName, json, e); } + return JsonNodeFactory.instance.missingNode(); } private T convertValue(JsonNode jsonNode, Class clazz) { - return JsonUtils.DEFAULT_JSON_MAPPER.convertValue(jsonNode, clazz); + try { + return JsonUtils.DEFAULT_JSON_MAPPER.convertValue(jsonNode, clazz); + } catch (IllegalArgumentException e) { + // ignore + log.error("Failed to convert plugin [{}] configMap [{}] to class [{}]", + pluginName, configMapName, clazz, e); + } + return null; } @NonNull diff --git a/application/src/main/java/run/halo/app/core/extension/service/PluginService.java b/application/src/main/java/run/halo/app/plugin/PluginService.java similarity index 92% rename from application/src/main/java/run/halo/app/core/extension/service/PluginService.java rename to application/src/main/java/run/halo/app/plugin/PluginService.java index 7c86444899..3e8bb6e601 100644 --- a/application/src/main/java/run/halo/app/core/extension/service/PluginService.java +++ b/application/src/main/java/run/halo/app/plugin/PluginService.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.service; +package run.halo.app.plugin; import java.nio.file.Path; import org.springframework.core.io.Resource; @@ -10,15 +10,7 @@ public interface PluginService { - Flux getPresets(); - - /** - * Gets a plugin information by preset name from plugin presets. - * - * @param presetName is preset name of plugin. - * @return plugin preset information. - */ - Mono getPreset(String presetName); + Mono installPresetPlugins(); /** * Installs a plugin from a temporary Jar path. diff --git a/application/src/main/java/run/halo/app/core/extension/service/impl/PluginServiceImpl.java b/application/src/main/java/run/halo/app/plugin/PluginServiceImpl.java similarity index 81% rename from application/src/main/java/run/halo/app/core/extension/service/impl/PluginServiceImpl.java rename to application/src/main/java/run/halo/app/plugin/PluginServiceImpl.java index 817ea1d054..2bc3812c0a 100644 --- a/application/src/main/java/run/halo/app/core/extension/service/impl/PluginServiceImpl.java +++ b/application/src/main/java/run/halo/app/plugin/PluginServiceImpl.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.service.impl; +package run.halo.app.plugin; import static java.nio.file.StandardCopyOption.REPLACE_EXISTING; import static java.nio.file.StandardOpenOption.CREATE; @@ -23,6 +23,7 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; import org.pf4j.DependencyResolver; import org.pf4j.PluginDescriptor; import org.pf4j.PluginWrapper; @@ -36,10 +37,12 @@ import org.springframework.core.io.buffer.DataBufferUtils; import org.springframework.core.io.buffer.DefaultDataBufferFactory; import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.stereotype.Component; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; import org.springframework.util.FileSystemUtils; +import org.springframework.util.StreamUtils; import org.springframework.web.server.ServerWebInputException; import reactor.core.Exceptions; import reactor.core.publisher.Flux; @@ -48,7 +51,6 @@ import reactor.core.scheduler.Schedulers; import reactor.util.retry.Retry; import run.halo.app.core.extension.Plugin; -import run.halo.app.core.extension.service.PluginService; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.SystemVersionSupplier; import run.halo.app.infra.exception.PluginAlreadyExistsException; @@ -59,12 +61,6 @@ import run.halo.app.infra.exception.UnsatisfiedAttributeValueException; import run.halo.app.infra.utils.FileUtils; import run.halo.app.infra.utils.VersionUtils; -import run.halo.app.plugin.PluginConst; -import run.halo.app.plugin.PluginUtils; -import run.halo.app.plugin.PluginsRootGetter; -import run.halo.app.plugin.SpringPluginManager; -import run.halo.app.plugin.YamlPluginDescriptorFinder; -import run.halo.app.plugin.YamlPluginFinder; import run.halo.app.plugin.resources.BundleResourceUtils; @Slf4j @@ -116,18 +112,36 @@ void setClock(Clock clock) { } @Override - public Flux getPresets() { - // list presets from classpath - return Flux.defer(() -> getPresetJars() - .map(this::toPath) - .map(path -> new YamlPluginFinder().find(path))); + public Mono installPresetPlugins() { + return getPresetJars() + .flatMap(path -> this.install(path) + .onErrorResume(PluginAlreadyExistsException.class, e -> Mono.empty()) + .flatMap(plugin -> FileUtils.deleteFileSilently(path) + .thenReturn(plugin) + ) + ) + .flatMap(this::enablePlugin) + .subscribeOn(Schedulers.boundedElastic()) + .then(); } - @Override - public Mono getPreset(String presetName) { - return getPresets() - .filter(plugin -> Objects.equals(plugin.getMetadata().getName(), presetName)) - .next(); + private Mono enablePlugin(Plugin plugin) { + plugin.getSpec().setEnabled(true); + return client.update(plugin) + .onErrorResume(OptimisticLockingFailureException.class, + e -> enablePlugin(plugin.getMetadata().getName()) + ); + } + + private Mono enablePlugin(String name) { + return Mono.defer(() -> client.get(Plugin.class, name) + .flatMap(plugin -> { + plugin.getSpec().setEnabled(true); + return client.update(plugin); + }) + ) + .retryWhen(Retry.backoff(8, Duration.ofMillis(100)) + .filter(OptimisticLockingFailureException.class::isInstance)); } @Override @@ -229,61 +243,73 @@ public Mono reload(String name) { @Override public Flux uglifyJsBundle() { var startedPlugins = List.copyOf(pluginManager.getStartedPlugins()); - String plugins = """ - this.enabledPlugins = [%s] - """.formatted(startedPlugins.stream() - .map(plugin -> """ - { - "name": "%s", - "version": "%s" - } - """.formatted(plugin.getPluginId(), plugin.getDescriptor().getVersion()) - ) - .collect(Collectors.joining(", "))); - return Flux.fromIterable(startedPlugins) - .mapNotNull(pluginWrapper -> { - var pluginName = pluginWrapper.getPluginId(); - return BundleResourceUtils.getJsBundleResource(pluginManager, pluginName, - BundleResourceUtils.JS_BUNDLE); - }) - .flatMap(resource -> { - try { - // Specifying bufferSize as resource content length is - // to append line breaks at the end of each plugin - return DataBufferUtils.read(resource, DefaultDataBufferFactory.sharedInstance, - (int) resource.contentLength()) - .doOnNext(dataBuffer -> { - // add a new line after each plugin bundle to avoid syntax error - dataBuffer.write("\n".getBytes(StandardCharsets.UTF_8)); - }); - } catch (IOException e) { - log.error("Failed to read plugin bundle resource", e); - return Flux.empty(); + var dataBufferFactory = DefaultDataBufferFactory.sharedInstance; + var end = Mono.fromSupplier( + () -> { + var sb = new StringBuilder("this.enabledPlugins = ["); + var iterator = startedPlugins.iterator(); + while (iterator.hasNext()) { + var plugin = iterator.next(); + sb.append(""" + {"name":"%s","version":"%s"}\ + """ + .formatted( + plugin.getPluginId(), + plugin.getDescriptor().getVersion() + ) + ); + if (iterator.hasNext()) { + sb.append(','); + } } - }) - .concatWith(Flux.defer(() -> { - var dataBuffer = DefaultDataBufferFactory.sharedInstance - .wrap(plugins.getBytes(StandardCharsets.UTF_8)); - return Flux.just(dataBuffer); - })); + sb.append(']'); + return dataBufferFactory.wrap(sb.toString().getBytes(StandardCharsets.UTF_8)); + }); + var body = Flux.fromIterable(startedPlugins) + .sort(Comparator.comparing(PluginWrapper::getPluginId)) + .flatMapSequential(pluginWrapper -> { + var pluginId = pluginWrapper.getPluginId(); + return Mono.fromSupplier( + () -> BundleResourceUtils.getJsBundleResource( + pluginManager, pluginId, BundleResourceUtils.JS_BUNDLE + ) + ) + .filter(Resource::isReadable) + .flatMapMany(resource -> { + var head = Mono.fromSupplier( + () -> dataBufferFactory.wrap( + ("// Generated from plugin " + pluginId + "\n").getBytes() + )); + var content = DataBufferUtils.read( + resource, dataBufferFactory, StreamUtils.BUFFER_SIZE + ); + var tail = Mono.fromSupplier(() -> dataBufferFactory.wrap("\n".getBytes())); + return Flux.concat(head, content, tail); + }); + }); + return Flux.concat(body, end); } @Override public Flux uglifyCssBundle() { return Flux.fromIterable(pluginManager.getStartedPlugins()) - .mapNotNull(pluginWrapper -> { - String pluginName = pluginWrapper.getPluginId(); - return BundleResourceUtils.getJsBundleResource(pluginManager, pluginName, - BundleResourceUtils.CSS_BUNDLE); - }) - .flatMap(resource -> { - try { - return DataBufferUtils.read(resource, DefaultDataBufferFactory.sharedInstance, - (int) resource.contentLength()); - } catch (IOException e) { - log.error("Failed to read plugin css bundle resource", e); - return Flux.empty(); - } + .sort(Comparator.comparing(PluginWrapper::getPluginId)) + .flatMapSequential(pluginWrapper -> { + var pluginId = pluginWrapper.getPluginId(); + var dataBufferFactory = DefaultDataBufferFactory.sharedInstance; + return Mono.fromSupplier(() -> BundleResourceUtils.getJsBundleResource( + pluginManager, pluginId, BundleResourceUtils.CSS_BUNDLE + )) + .filter(Resource::isReadable) + .flatMapMany(resource -> { + var head = Mono.fromSupplier(() -> dataBufferFactory.wrap( + ("/* Generated from plugin " + pluginId + " */\n").getBytes() + )); + var content = DataBufferUtils.read( + resource, dataBufferFactory, StreamUtils.BUFFER_SIZE); + var tail = Mono.fromSupplier(() -> dataBufferFactory.wrap("\n".getBytes())); + return Flux.concat(head, content, tail); + }); }); } @@ -463,7 +489,7 @@ private void satisfiesRequiresVersion(Plugin newPlugin) { Version version = systemVersion.get(); // validate the plugin version // only use the nominal system version to compare, the format is like MAJOR.MINOR.PATCH - String systemVersion = version.getNormalVersion(); + String systemVersion = version.toStableVersion().toString(); String requires = newPlugin.getSpec().getRequires(); if (!VersionUtils.satisfiesRequires(systemVersion, requires)) { throw new UnsatisfiedAttributeValueException(String.format( @@ -475,21 +501,23 @@ private void satisfiesRequiresVersion(Plugin newPlugin) { } } - private Flux getPresetJars() { + private Flux getPresetJars() { var resolver = new PathMatchingResourcePatternResolver(); try { var resources = resolver.getResources(PRESETS_LOCATION_PATTERN); - return Flux.fromArray(resources); - } catch (IOException e) { - return Flux.error(e); - } - } - - private Path toPath(Resource resource) { - try { - return Path.of(resource.getURI()); + return Flux.fromArray(resources) + .mapNotNull(resource -> { + var filename = resource.getFilename(); + if (StringUtils.isBlank(filename)) { + return null; + } + var path = tempDir.resolve(filename); + FileUtils.copyResource(resource, path); + return path; + }); } catch (IOException e) { - throw Exceptions.propagate(e); + log.debug("Failed to load preset plugins: {}", e.getMessage()); + return Flux.empty(); } } diff --git a/application/src/main/java/run/halo/app/plugin/SharedApplicationContextFactory.java b/application/src/main/java/run/halo/app/plugin/SharedApplicationContextFactory.java index 90b146114d..e8e23316c7 100644 --- a/application/src/main/java/run/halo/app/plugin/SharedApplicationContextFactory.java +++ b/application/src/main/java/run/halo/app/plugin/SharedApplicationContextFactory.java @@ -1,11 +1,16 @@ package run.halo.app.plugin; +import io.github.resilience4j.ratelimiter.RateLimiterRegistry; import org.springframework.cache.CacheManager; import org.springframework.context.ApplicationContext; import org.springframework.context.support.GenericApplicationContext; +import org.springframework.security.core.userdetails.ReactiveUserDetailsService; import org.springframework.security.web.server.context.ServerSecurityContextRepository; +import org.springframework.security.web.server.savedrequest.ServerRequestCache; import run.halo.app.content.PostContentService; import run.halo.app.core.extension.service.AttachmentService; +import run.halo.app.core.user.service.RoleService; +import run.halo.app.core.user.service.UserService; import run.halo.app.extension.DefaultSchemeManager; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.ReactiveExtensionClient; @@ -16,6 +21,7 @@ import run.halo.app.notification.NotificationReasonEmitter; import run.halo.app.plugin.extensionpoint.ExtensionGetter; import run.halo.app.security.LoginHandlerEnhancer; +import run.halo.app.security.authentication.CryptoService; /** * Utility for creating shared application context. @@ -69,6 +75,28 @@ public static ApplicationContext create(ApplicationContext rootContext) { ); beanFactory.registerSingleton("extensionGetter", rootContext.getBean(ExtensionGetter.class)); + rootContext.getBeanProvider(CryptoService.class) + .ifUnique( + cryptoService -> beanFactory.registerSingleton("cryptoService", cryptoService) + ); + rootContext.getBeanProvider(RateLimiterRegistry.class) + .ifUnique(rateLimiterRegistry -> + beanFactory.registerSingleton("rateLimiterRegistry", rateLimiterRegistry) + ); + + // Authentication plugins may need this RequestCache to handle successful login redirect + rootContext.getBeanProvider(ServerRequestCache.class) + .ifUnique(serverRequestCache -> + beanFactory.registerSingleton("serverRequestCache", serverRequestCache) + ); + rootContext.getBeanProvider(UserService.class) + .ifUnique(userService -> beanFactory.registerSingleton("userService", userService)); + rootContext.getBeanProvider(RoleService.class) + .ifUnique(roleService -> beanFactory.registerSingleton("roleService", roleService)); + rootContext.getBeanProvider(ReactiveUserDetailsService.class) + .ifUnique(userDetailsService -> + beanFactory.registerSingleton("userDetailsService", userDetailsService) + ); // TODO add more shared instance here sharedContext.refresh(); diff --git a/application/src/main/java/run/halo/app/plugin/extensionpoint/DefaultExtensionGetter.java b/application/src/main/java/run/halo/app/plugin/extensionpoint/DefaultExtensionGetter.java index 9f5404ab3d..42b833e16d 100644 --- a/application/src/main/java/run/halo/app/plugin/extensionpoint/DefaultExtensionGetter.java +++ b/application/src/main/java/run/halo/app/plugin/extensionpoint/DefaultExtensionGetter.java @@ -2,7 +2,10 @@ import static run.halo.app.extension.index.query.QueryFactory.equal; +import java.util.LinkedList; +import java.util.List; import java.util.Objects; +import java.util.Optional; import lombok.RequiredArgsConstructor; import org.pf4j.ExtensionPoint; import org.pf4j.PluginManager; @@ -41,6 +44,16 @@ public Flux getExtensions(Class extensionPoint) .sort(new AnnotationAwareOrderComparator()); } + @Override + public List getExtensionList(Class extensionPoint) { + var extensions = new LinkedList(); + Optional.ofNullable(pluginManager.getExtensions(extensionPoint)) + .ifPresent(extensions::addAll); + extensions.addAll(beanFactory.getBeanProvider(extensionPoint).orderedStream().toList()); + extensions.sort(new AnnotationAwareOrderComparator()); + return extensions; + } + @Override public Mono getEnabledExtension(Class extensionPoint) { return getEnabledExtensions(extensionPoint).next(); @@ -73,10 +86,10 @@ private Flux getEnabledExtensions(String epdName, } var extensions = getExtensions(extensionPoint).cache(); return Flux.fromIterable(extensionDefNames) - .concatMap(extensionDefName -> + .flatMapSequential(extensionDefName -> client.fetch(ExtensionDefinition.class, extensionDefName) ) - .concatMap(extensionDef -> { + .flatMapSequential(extensionDef -> { var className = extensionDef.getSpec().getClassName(); return extensions.filter( extension -> Objects.equals(extension.getClass().getName(), diff --git a/application/src/main/java/run/halo/app/security/AuthProviderService.java b/application/src/main/java/run/halo/app/security/AuthProviderService.java index c996db4d30..8b99fd0403 100644 --- a/application/src/main/java/run/halo/app/security/AuthProviderService.java +++ b/application/src/main/java/run/halo/app/security/AuthProviderService.java @@ -1,6 +1,7 @@ package run.halo.app.security; import java.util.List; +import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.core.extension.AuthProvider; @@ -17,4 +18,10 @@ public interface AuthProviderService { Mono disable(String name); Mono> listAll(); + + /** + * Return a list of enabled AuthProviders sorted by priority. + */ + Flux getEnabledProviders(); + } diff --git a/application/src/main/java/run/halo/app/security/AuthProviderServiceImpl.java b/application/src/main/java/run/halo/app/security/AuthProviderServiceImpl.java index 4b318ed56c..8cdd9124d4 100644 --- a/application/src/main/java/run/halo/app/security/AuthProviderServiceImpl.java +++ b/application/src/main/java/run/halo/app/security/AuthProviderServiceImpl.java @@ -1,27 +1,36 @@ package run.halo.app.security; +import java.time.Duration; +import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; -import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.Set; -import java.util.function.Consumer; +import java.util.function.Function; import java.util.stream.Collectors; +import lombok.Data; import lombok.RequiredArgsConstructor; +import lombok.experimental.Accessors; import org.apache.commons.lang3.BooleanUtils; import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.lang.NonNull; import org.springframework.security.core.context.ReactiveSecurityContextHolder; import org.springframework.stereotype.Component; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; +import reactor.util.retry.Retry; import run.halo.app.core.extension.AuthProvider; import run.halo.app.core.extension.UserConnection; import run.halo.app.extension.ConfigMap; -import run.halo.app.extension.Metadata; +import run.halo.app.extension.ExtensionUtil; +import run.halo.app.extension.ListOptions; import run.halo.app.extension.MetadataUtil; import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.extension.index.query.QueryFactory; +import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; import run.halo.app.infra.SystemSetting; import run.halo.app.infra.utils.JsonUtils; @@ -35,99 +44,128 @@ @RequiredArgsConstructor public class AuthProviderServiceImpl implements AuthProviderService { private final ReactiveExtensionClient client; + private final ObjectProvider environmentFetcherProvider; @Override public Mono enable(String name) { return client.get(AuthProvider.class, name) - .flatMap(authProvider -> updateAuthProviderEnabled(enabled -> enabled.add(name)) + .flatMap(authProvider -> updateAuthProviderEnabled(name, true) .thenReturn(authProvider) ); } @Override public Mono disable(String name) { - // privileged auth provider cannot be disabled return client.get(AuthProvider.class, name) + // privileged auth provider cannot be disabled .filter(authProvider -> !privileged(authProvider)) - .flatMap(authProvider -> updateAuthProviderEnabled(enabled -> enabled.remove(name)) + .flatMap(authProvider -> updateAuthProviderEnabled(name, false) .thenReturn(authProvider) ); } @Override public Mono> listAll() { - return client.list(AuthProvider.class, provider -> - provider.getMetadata().getDeletionTimestamp() == null, - defaultComparator() - ) - .map(this::convertTo) - .collectList() - .flatMap(providers -> listMyConnections() - .map(connection -> connection.getSpec().getRegistrationId()) + var listOptions = ListOptions.builder() + .andQuery(ExtensionUtil.notDeleting()) + .build(); + var allProvidersMono = + client.listAll(AuthProvider.class, listOptions, ExtensionUtil.defaultSort()) + .map(this::convertTo) .collectList() - .map(connectedNames -> providers.stream() - .peek(provider -> { - boolean isBound = connectedNames.contains(provider.getName()); - provider.setIsBound(isBound); - }) - .collect(Collectors.toList()) - ) - .defaultIfEmpty(providers) - ) - .flatMap(providers -> fetchEnabledAuthProviders() - .map(names -> providers.stream() - .peek(provider -> { - boolean enabled = names.contains(provider.getName()); - provider.setEnabled(enabled); - }) - .collect(Collectors.toList()) - ) - .defaultIfEmpty(providers) - ); - } + .subscribeOn(Schedulers.boundedElastic()); - private Mono> fetchEnabledAuthProviders() { - return client.fetch(ConfigMap.class, SystemSetting.SYSTEM_CONFIG) - .map(configMap -> { - SystemSetting.AuthProvider authProvider = getAuthProvider(configMap); - return authProvider.getEnabled(); + var boundProvidersMono = listMyConnections() + .map(connection -> connection.getSpec().getRegistrationId()) + .collect(Collectors.toSet()) + .subscribeOn(Schedulers.boundedElastic()); + + return Mono.zip(allProvidersMono, boundProvidersMono, fetchProviderStates()) + .map(tuple3 -> { + var allProviders = tuple3.getT1(); + var boundProviderNames = tuple3.getT2(); + var stateMap = tuple3.getT3().stream() + .collect(Collectors.toMap(SystemSetting.AuthProviderState::getName, + Function.identity())); + return allProviders.stream() + .peek(authProvider -> { + authProvider.setIsBound( + boundProviderNames.contains(authProvider.getName())); + authProvider.setEnabled(false); + // set enabled state and priority + var state = stateMap.get(authProvider.getName()); + if (state != null) { + authProvider.setEnabled(state.isEnabled()); + authProvider.setPriority(state.getPriority()); + } + }) + .sorted(Comparator.comparingInt(ListedAuthProvider::getPriority) + .thenComparing(ListedAuthProvider::getName)) + .toList(); }); } - Flux listMyConnections() { - return ReactiveSecurityContextHolder.getContext() - .map(securityContext -> securityContext.getAuthentication().getName()) - .flatMapMany(username -> client.list(UserConnection.class, - persisted -> persisted.getSpec().getUsername().equals(username), - Comparator.comparing(item -> item.getMetadata() - .getCreationTimestamp()) + @Override + public Flux getEnabledProviders() { + return fetchProviderStates().flatMapMany(states -> { + var namePriorityMap = states.stream() + // filter enabled providers + .filter(SystemSetting.AuthProviderState::isEnabled) + .collect(Collectors.toMap(SystemSetting.AuthProviderState::getName, + SystemSetting.AuthProviderState::getPriority)); + + var listOptions = ListOptions.builder() + .andQuery(QueryFactory.in("metadata.name", namePriorityMap.keySet())) + .andQuery(ExtensionUtil.notDeleting()) + .build(); + return client.listAll(AuthProvider.class, listOptions, ExtensionUtil.defaultSort()) + .map(provider -> new AuthProviderWithPriority() + .setAuthProvider(provider) + .setPriority(namePriorityMap.getOrDefault( + provider.getMetadata().getName(), 0) + ) ) - ); + .sort(AuthProviderWithPriority::compareTo) + .map(AuthProviderWithPriority::getAuthProvider); + }); + } + + @Data + @Accessors(chain = true) + static class AuthProviderWithPriority implements Comparable { + private AuthProvider authProvider; + private int priority; + + public String getName() { + return authProvider.getMetadata().getName(); + } + + @Override + public int compareTo(@NonNull AuthProviderWithPriority o) { + return Comparator.comparingInt(AuthProviderWithPriority::getPriority) + .thenComparing(AuthProviderWithPriority::getName) + .compare(this, o); + } } - private static Comparator defaultComparator() { - return Comparator.comparing((AuthProvider item) -> item.getSpec().getPriority()) - .thenComparing(item -> item.getMetadata().getName()) - .thenComparing(item -> item.getMetadata().getCreationTimestamp()); + private Mono> fetchProviderStates() { + return getSystemConfigMap() + .map(AuthProviderServiceImpl::getAuthProviderConfig) + .map(SystemSetting.AuthProvider::getStates) + .defaultIfEmpty(List.of()) + .subscribeOn(Schedulers.boundedElastic()); } - private Mono updateAuthProviderEnabled(Consumer> consumer) { - return client.fetch(ConfigMap.class, SystemSetting.SYSTEM_CONFIG) - .switchIfEmpty(Mono.defer(() -> { - ConfigMap configMap = new ConfigMap(); - configMap.setMetadata(new Metadata()); - configMap.getMetadata().setName(SystemSetting.SYSTEM_CONFIG); - configMap.setData(new HashMap<>()); - return client.create(configMap); - })) - .flatMap(configMap -> { - SystemSetting.AuthProvider authProvider = getAuthProvider(configMap); - consumer.accept(authProvider.getEnabled()); - - final Map data = configMap.getData(); - data.put(SystemSetting.AuthProvider.GROUP, - JsonUtils.objectToJson(authProvider)); - return client.update(configMap); + Flux listMyConnections() { + return ReactiveSecurityContextHolder.getContext() + .map(securityContext -> securityContext.getAuthentication().getName()) + .flatMapMany(username -> { + var listOptions = ListOptions.builder() + .andQuery(QueryFactory.equal("spec.username", username)) + .andQuery(ExtensionUtil.notDeleting()) + .build(); + return client.listAll(UserConnection.class, listOptions, + ExtensionUtil.defaultSort()); }); } @@ -143,6 +181,7 @@ private ListedAuthProvider convertTo(AuthProvider authProvider) { .bindingUrl(authProvider.getSpec().getBindingUrl()) .unbindingUrl(authProvider.getSpec().getUnbindUrl()) .supportsBinding(supportsBinding(authProvider)) + .authType(authProvider.getSpec().getAuthType()) .isBound(false) .enabled(false) .privileged(privileged(authProvider)) @@ -160,7 +199,7 @@ private boolean privileged(AuthProvider authProvider) { } @NonNull - private static SystemSetting.AuthProvider getAuthProvider(ConfigMap configMap) { + private static SystemSetting.AuthProvider getAuthProviderConfig(ConfigMap configMap) { if (configMap.getData() == null) { configMap.setData(new HashMap<>()); } @@ -174,10 +213,46 @@ private static SystemSetting.AuthProvider getAuthProvider(ConfigMap configMap) { authProvider = JsonUtils.jsonToObject(providerGroup, SystemSetting.AuthProvider.class); } - - if (authProvider.getEnabled() == null) { - authProvider.setEnabled(new HashSet<>()); + if (authProvider.getStates() == null) { + authProvider.setStates(new ArrayList<>()); } + return authProvider; } + + private Mono updateAuthProviderEnabled(String name, boolean enabled) { + return Mono.defer(() -> getSystemConfigMap() + .flatMap(configMap -> { + var providerConfig = getAuthProviderConfig(configMap); + var stateToFoundOpt = providerConfig.getStates() + .stream() + .filter(state -> state.getName().equals(name)) + .findFirst(); + if (stateToFoundOpt.isEmpty()) { + var state = new SystemSetting.AuthProviderState() + .setName(name) + .setEnabled(enabled); + providerConfig.getStates().add(state); + } else { + stateToFoundOpt.get().setEnabled(enabled); + } + + configMap.getData().put(SystemSetting.AuthProvider.GROUP, + JsonUtils.objectToJson(providerConfig)); + + return client.update(configMap); + }) + ) + .retryWhen(Retry.backoff(5, Duration.ofMillis(100)) + .filter(OptimisticLockingFailureException.class::isInstance)); + } + + private Mono getSystemConfigMap() { + var systemFetcher = environmentFetcherProvider.getIfUnique(); + if (systemFetcher == null) { + return Mono.error( + new IllegalStateException("No SystemConfigurableEnvironmentFetcher found")); + } + return systemFetcher.getConfigMap(); + } } diff --git a/application/src/main/java/run/halo/app/security/CorsConfigurer.java b/application/src/main/java/run/halo/app/security/CorsConfigurer.java index 8584fb228b..dc91f21797 100644 --- a/application/src/main/java/run/halo/app/security/CorsConfigurer.java +++ b/application/src/main/java/run/halo/app/security/CorsConfigurer.java @@ -2,6 +2,7 @@ import com.google.common.net.HttpHeaders; import java.util.List; +import org.springframework.core.annotation.Order; import org.springframework.security.config.web.server.ServerHttpSecurity; import org.springframework.stereotype.Component; import org.springframework.web.cors.CorsConfiguration; @@ -10,6 +11,7 @@ import run.halo.app.security.authentication.SecurityConfigurer; @Component +@Order(0) public class CorsConfigurer implements SecurityConfigurer { @Override public void configure(ServerHttpSecurity http) { diff --git a/application/src/main/java/run/halo/app/security/CsrfConfigurer.java b/application/src/main/java/run/halo/app/security/CsrfConfigurer.java index 0dc3b25e3b..2d86cc31f9 100644 --- a/application/src/main/java/run/halo/app/security/CsrfConfigurer.java +++ b/application/src/main/java/run/halo/app/security/CsrfConfigurer.java @@ -1,30 +1,35 @@ package run.halo.app.security; -import static org.springframework.security.web.server.csrf.CookieServerCsrfTokenRepository.withHttpOnlyFalse; import static org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers.pathMatchers; +import org.springframework.core.annotation.Order; import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.web.server.csrf.CookieServerCsrfTokenRepository; import org.springframework.security.web.server.csrf.CsrfWebFilter; -import org.springframework.security.web.server.csrf.ServerCsrfTokenRequestAttributeHandler; +import org.springframework.security.web.server.csrf.XorServerCsrfTokenRequestAttributeHandler; import org.springframework.security.web.server.util.matcher.AndServerWebExchangeMatcher; import org.springframework.security.web.server.util.matcher.NegatedServerWebExchangeMatcher; import org.springframework.stereotype.Component; import run.halo.app.security.authentication.SecurityConfigurer; @Component -public class CsrfConfigurer implements SecurityConfigurer { +@Order(0) +class CsrfConfigurer implements SecurityConfigurer { @Override public void configure(ServerHttpSecurity http) { var csrfMatcher = new AndServerWebExchangeMatcher( CsrfWebFilter.DEFAULT_CSRF_MATCHER, - new NegatedServerWebExchangeMatcher(pathMatchers("/api/**", "/apis/**") - )); + new NegatedServerWebExchangeMatcher(pathMatchers( + "/api/**", + "/apis/**", + "/actuator/**", + "/system/setup" + )) + ); http.csrf(csrfSpec -> csrfSpec - .csrfTokenRepository(withHttpOnlyFalse()) - // TODO Use XorServerCsrfTokenRequestAttributeHandler instead when console implements - // the algorithm - .csrfTokenRequestHandler(new ServerCsrfTokenRequestAttributeHandler()) + .csrfTokenRepository(new CookieServerCsrfTokenRepository()) + .csrfTokenRequestHandler(new XorServerCsrfTokenRequestAttributeHandler()) .requireCsrfProtectionMatcher(csrfMatcher)); } diff --git a/application/src/main/java/run/halo/app/security/DefaultServerAuthenticationEntryPoint.java b/application/src/main/java/run/halo/app/security/DefaultServerAuthenticationEntryPoint.java index 9e0af48e5f..dfb6b4fa12 100644 --- a/application/src/main/java/run/halo/app/security/DefaultServerAuthenticationEntryPoint.java +++ b/application/src/main/java/run/halo/app/security/DefaultServerAuthenticationEntryPoint.java @@ -4,8 +4,13 @@ import org.springframework.http.HttpStatus; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.server.ServerAuthenticationEntryPoint; +import org.springframework.security.web.server.authentication.RedirectServerAuthenticationEntryPoint; +import org.springframework.security.web.server.savedrequest.ServerRequestCache; +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher.MatchResult; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; +import run.halo.app.infra.utils.HaloUtils; /** * Default authentication entry point. @@ -17,15 +22,38 @@ */ public class DefaultServerAuthenticationEntryPoint implements ServerAuthenticationEntryPoint { + private final ServerWebExchangeMatcher xhrMatcher = exchange -> { + if (HaloUtils.isXhr(exchange.getRequest().getHeaders())) { + return MatchResult.match(); + } + return MatchResult.notMatch(); + }; + + private final RedirectServerAuthenticationEntryPoint redirectEntryPoint; + + public DefaultServerAuthenticationEntryPoint(ServerRequestCache serverRequestCache) { + var entryPoint = + new RedirectServerAuthenticationEntryPoint("/login?authentication_required"); + entryPoint.setRequestCache(serverRequestCache); + this.redirectEntryPoint = entryPoint; + } + @Override public Mono commence(ServerWebExchange exchange, AuthenticationException ex) { - return Mono.defer(() -> { - var response = exchange.getResponse(); - var wwwAuthenticate = "FormLogin realm=\"console\""; - response.getHeaders().set(HttpHeaders.WWW_AUTHENTICATE, wwwAuthenticate); - response.setStatusCode(HttpStatus.UNAUTHORIZED); - return response.setComplete(); - }); + return xhrMatcher.matches(exchange) + .filter(MatchResult::isMatch) + .switchIfEmpty( + Mono.defer(() -> this.redirectEntryPoint.commence(exchange, ex).then(Mono.empty())) + ) + .flatMap(match -> Mono.defer( + () -> { + var response = exchange.getResponse(); + var wwwAuthenticate = "FormLogin realm=\"console\""; + response.getHeaders().set(HttpHeaders.WWW_AUTHENTICATE, wwwAuthenticate); + response.setStatusCode(HttpStatus.UNAUTHORIZED); + return response.setComplete(); + }).then(Mono.empty()) + ); } } diff --git a/application/src/main/java/run/halo/app/security/DefaultUserDetailService.java b/application/src/main/java/run/halo/app/security/DefaultUserDetailService.java index b1bf1e5648..8bea434bec 100644 --- a/application/src/main/java/run/halo/app/security/DefaultUserDetailService.java +++ b/application/src/main/java/run/halo/app/security/DefaultUserDetailService.java @@ -13,8 +13,8 @@ import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; import reactor.core.publisher.Mono; -import run.halo.app.core.extension.service.RoleService; -import run.halo.app.core.extension.service.UserService; +import run.halo.app.core.user.service.RoleService; +import run.halo.app.core.user.service.UserService; import run.halo.app.infra.exception.UserNotFoundException; import run.halo.app.security.authentication.login.HaloUser; import run.halo.app.security.authentication.twofactor.TwoFactorUtils; diff --git a/application/src/main/java/run/halo/app/security/ExceptionSecurityConfigurer.java b/application/src/main/java/run/halo/app/security/ExceptionSecurityConfigurer.java index bdb5979e75..85a762e574 100644 --- a/application/src/main/java/run/halo/app/security/ExceptionSecurityConfigurer.java +++ b/application/src/main/java/run/halo/app/security/ExceptionSecurityConfigurer.java @@ -1,21 +1,87 @@ package run.halo.app.security; +import static org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers.anyExchange; +import static org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers.pathMatchers; + +import java.util.ArrayList; +import org.springframework.context.MessageSource; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; import org.springframework.security.config.web.server.ServerHttpSecurity; import org.springframework.security.oauth2.server.resource.web.access.server.BearerTokenServerAccessDeniedHandler; +import org.springframework.security.oauth2.server.resource.web.server.authentication.ServerBearerTokenAuthenticationConverter; +import org.springframework.security.web.server.DelegatingServerAuthenticationEntryPoint; +import org.springframework.security.web.server.authentication.AuthenticationConverterServerWebExchangeMatcher; +import org.springframework.security.web.server.authorization.HttpStatusServerAccessDeniedHandler; +import org.springframework.security.web.server.authorization.ServerWebExchangeDelegatingServerAccessDeniedHandler; +import org.springframework.security.web.server.savedrequest.ServerRequestCache; import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.server.ServerResponse; import run.halo.app.security.authentication.SecurityConfigurer; +import run.halo.app.security.authentication.twofactor.TwoFactorAuthenticationEntryPoint; @Component +@Order(0) public class ExceptionSecurityConfigurer implements SecurityConfigurer { + private final MessageSource messageSource; + + private final ServerResponse.Context context; + + private final ServerRequestCache serverRequestCache; + + public ExceptionSecurityConfigurer(MessageSource messageSource, + ServerResponse.Context context, + ServerRequestCache serverRequestCache) { + this.messageSource = messageSource; + this.context = context; + this.serverRequestCache = serverRequestCache; + } + @Override public void configure(ServerHttpSecurity http) { http.exceptionHandling(exception -> { - var accessDeniedHandler = new BearerTokenServerAccessDeniedHandler(); - var entryPoint = new DefaultServerAuthenticationEntryPoint(); - exception - .authenticationEntryPoint(entryPoint) - .accessDeniedHandler(accessDeniedHandler); + var accessDeniedHandlers = + new ArrayList( + 3 + ); + accessDeniedHandlers.add( + new ServerWebExchangeDelegatingServerAccessDeniedHandler.DelegateEntry( + new AuthenticationConverterServerWebExchangeMatcher( + new ServerBearerTokenAuthenticationConverter() + ), + new BearerTokenServerAccessDeniedHandler() + )); + accessDeniedHandlers.add( + new ServerWebExchangeDelegatingServerAccessDeniedHandler.DelegateEntry( + pathMatchers(HttpMethod.GET, "/login", "/signup"), + new RedirectAccessDeniedHandler("/uc") + )); + accessDeniedHandlers.add( + new ServerWebExchangeDelegatingServerAccessDeniedHandler.DelegateEntry( + anyExchange(), + new HttpStatusServerAccessDeniedHandler(HttpStatus.FORBIDDEN) + ) + ); + + var entryPoints = + new ArrayList(2); + entryPoints.add(new DelegatingServerAuthenticationEntryPoint.DelegateEntry( + TwoFactorAuthenticationEntryPoint.MATCHER, + new TwoFactorAuthenticationEntryPoint(messageSource, context) + )); + entryPoints.add(new DelegatingServerAuthenticationEntryPoint.DelegateEntry( + anyExchange(), + new DefaultServerAuthenticationEntryPoint(serverRequestCache) + )); + + exception.authenticationEntryPoint( + new DelegatingServerAuthenticationEntryPoint(entryPoints) + ) + .accessDeniedHandler( + new ServerWebExchangeDelegatingServerAccessDeniedHandler(accessDeniedHandlers) + ); }); } diff --git a/application/src/main/java/run/halo/app/security/HaloServerRequestCache.java b/application/src/main/java/run/halo/app/security/HaloServerRequestCache.java new file mode 100644 index 0000000000..9b786d68c2 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/HaloServerRequestCache.java @@ -0,0 +1,116 @@ +package run.halo.app.security; + +import static org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers.pathMatchers; + +import java.net.URI; +import java.util.Collections; +import java.util.Objects; +import org.apache.commons.lang3.StringUtils; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.server.RequestPath; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.security.web.server.savedrequest.WebSessionServerRequestCache; +import org.springframework.security.web.server.util.matcher.AndServerWebExchangeMatcher; +import org.springframework.security.web.server.util.matcher.MediaTypeServerWebExchangeMatcher; +import org.springframework.security.web.server.util.matcher.NegatedServerWebExchangeMatcher; +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebSession; +import reactor.core.publisher.Mono; + +/** + * Halo server request cache implementation for saving redirect URI from query. + * + * @author johnniang + */ +public class HaloServerRequestCache extends WebSessionServerRequestCache { + + /** + * Currently, we have no idea to customize the sessionAttributeName in + * WebSessionServerRequestCache, so we have to copy the attr into here. + */ + private static final String DEFAULT_SAVED_REQUEST_ATTR = "SPRING_SECURITY_SAVED_REQUEST"; + + private static final String REDIRECT_URI_QUERY = "redirect_uri"; + + private final String sessionAttrName = DEFAULT_SAVED_REQUEST_ATTR; + + public HaloServerRequestCache() { + super(); + setSaveRequestMatcher(createDefaultRequestMatcher()); + } + + @Override + public Mono saveRequest(ServerWebExchange exchange) { + var redirectUriQuery = exchange.getRequest().getQueryParams().getFirst(REDIRECT_URI_QUERY); + if (StringUtils.isNotBlank(redirectUriQuery)) { + // the query value is decoded, so we don't need to decode it again + var redirectUri = URI.create(redirectUriQuery); + return saveRedirectUri(exchange, redirectUri); + } + return super.saveRequest(exchange); + } + + @Override + public Mono getRedirectUri(ServerWebExchange exchange) { + return super.getRedirectUri(exchange); + } + + @Override + public Mono removeMatchingRequest(ServerWebExchange exchange) { + return getRedirectUri(exchange) + .flatMap(redirectUri -> { + if (redirectUri.getFragment() != null) { + var redirectUriInApplication = + uriInApplication(exchange.getRequest(), redirectUri, false); + var uriInApplication = + uriInApplication(exchange.getRequest(), exchange.getRequest().getURI()); + // compare the path and query only + if (!Objects.equals(redirectUriInApplication, uriInApplication)) { + return Mono.empty(); + } + // remove the exchange + return exchange.getSession().map(WebSession::getAttributes) + .doOnNext(attributes -> attributes.remove(this.sessionAttrName)) + .thenReturn(exchange.getRequest()); + } + return super.removeMatchingRequest(exchange); + }); + } + + private Mono saveRedirectUri(ServerWebExchange exchange, URI redirectUri) { + var redirectUriInApplication = uriInApplication(exchange.getRequest(), redirectUri); + return exchange.getSession() + .map(WebSession::getAttributes) + .doOnNext(attributes -> attributes.put(this.sessionAttrName, redirectUriInApplication)) + .then(); + } + + private static String uriInApplication(ServerHttpRequest request, URI uri) { + return uriInApplication(request, uri, true); + } + + private static String uriInApplication( + ServerHttpRequest request, URI uri, boolean appendFragment + ) { + var path = RequestPath.parse(uri, request.getPath().contextPath().value()); + var query = uri.getRawQuery(); + var fragment = uri.getRawFragment(); + return path.pathWithinApplication().value() + + (query == null ? "" : "?" + query) + + (fragment == null || !appendFragment ? "" : "#" + fragment); + } + + private static ServerWebExchangeMatcher createDefaultRequestMatcher() { + var get = pathMatchers(HttpMethod.GET, "/**"); + var notFavicon = new NegatedServerWebExchangeMatcher( + pathMatchers( + "/favicon.*", "/login/**", "/signup/**", "/password-reset/**", "/challenges/**" + )); + var html = new MediaTypeServerWebExchangeMatcher(MediaType.TEXT_HTML); + html.setIgnoredMediaTypes(Collections.singleton(MediaType.ALL)); + return new AndServerWebExchangeMatcher(get, notFavicon, html); + } + +} diff --git a/application/src/main/java/run/halo/app/security/InitializeRedirectionWebFilter.java b/application/src/main/java/run/halo/app/security/InitializeRedirectionWebFilter.java index 08f2e373c2..d4820d9218 100644 --- a/application/src/main/java/run/halo/app/security/InitializeRedirectionWebFilter.java +++ b/application/src/main/java/run/halo/app/security/InitializeRedirectionWebFilter.java @@ -1,13 +1,17 @@ package run.halo.app.security; +import static org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers.pathMatchers; + import java.net.URI; import lombok.Getter; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; import org.springframework.lang.NonNull; import org.springframework.security.web.server.DefaultServerRedirectStrategy; import org.springframework.security.web.server.ServerRedirectStrategy; -import org.springframework.security.web.server.util.matcher.PathPatternParserServerWebExchangeMatcher; +import org.springframework.security.web.server.util.matcher.AndServerWebExchangeMatcher; +import org.springframework.security.web.server.util.matcher.MediaTypeServerWebExchangeMatcher; import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; import org.springframework.stereotype.Component; import org.springframework.util.Assert; @@ -26,9 +30,11 @@ @Component @RequiredArgsConstructor public class InitializeRedirectionWebFilter implements WebFilter { - private final URI location = URI.create("/console"); - private final ServerWebExchangeMatcher redirectMatcher = - new PathPatternParserServerWebExchangeMatcher("/", HttpMethod.GET); + private final URI location = URI.create("/system/setup"); + private final ServerWebExchangeMatcher redirectMatcher = new AndServerWebExchangeMatcher( + pathMatchers(HttpMethod.GET, "/", "/console/**", "/uc/**", "/login", "/signup"), + new MediaTypeServerWebExchangeMatcher(MediaType.TEXT_HTML) + ); private final InitializationStateGetter initializationStateGetter; diff --git a/application/src/main/java/run/halo/app/security/ListedAuthProvider.java b/application/src/main/java/run/halo/app/security/ListedAuthProvider.java index e0484ff576..7fdd1258f7 100644 --- a/application/src/main/java/run/halo/app/security/ListedAuthProvider.java +++ b/application/src/main/java/run/halo/app/security/ListedAuthProvider.java @@ -5,6 +5,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import lombok.Data; +import run.halo.app.core.extension.AuthProvider; /** * A listed value object for {@link run.halo.app.core.extension.AuthProvider}. @@ -35,11 +36,15 @@ public class ListedAuthProvider { String unbindingUrl; + AuthProvider.AuthType authType; + Boolean isBound; Boolean enabled; + int priority; + Boolean supportsBinding; - + Boolean privileged; } diff --git a/application/src/main/java/run/halo/app/security/LoginHandlerEnhancerImpl.java b/application/src/main/java/run/halo/app/security/LoginHandlerEnhancerImpl.java index bb72c7ca0e..b23ed667e5 100644 --- a/application/src/main/java/run/halo/app/security/LoginHandlerEnhancerImpl.java +++ b/application/src/main/java/run/halo/app/security/LoginHandlerEnhancerImpl.java @@ -6,7 +6,10 @@ import org.springframework.stereotype.Component; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; +import run.halo.app.security.authentication.oauth2.OAuth2LoginHandlerEnhancer; +import run.halo.app.security.authentication.rememberme.RememberMeRequestCache; import run.halo.app.security.authentication.rememberme.RememberMeServices; +import run.halo.app.security.authentication.rememberme.WebSessionRememberMeRequestCache; import run.halo.app.security.device.DeviceService; /** @@ -24,11 +27,20 @@ public class LoginHandlerEnhancerImpl implements LoginHandlerEnhancer { private final DeviceService deviceService; + private final RememberMeRequestCache rememberMeRequestCache = + new WebSessionRememberMeRequestCache(); + + private final OAuth2LoginHandlerEnhancer oauth2LoginHandlerEnhancer; + @Override public Mono onLoginSuccess(ServerWebExchange exchange, Authentication successfulAuthentication) { - return rememberMeServices.loginSuccess(exchange, successfulAuthentication) - .then(deviceService.loginSuccess(exchange, successfulAuthentication)); + return Mono.when( + rememberMeServices.loginSuccess(exchange, successfulAuthentication), + deviceService.loginSuccess(exchange, successfulAuthentication), + rememberMeRequestCache.removeRememberMe(exchange), + oauth2LoginHandlerEnhancer.loginSuccess(exchange, successfulAuthentication) + ); } @Override diff --git a/application/src/main/java/run/halo/app/security/LogoutSecurityConfigurer.java b/application/src/main/java/run/halo/app/security/LogoutSecurityConfigurer.java index 79c3974d60..b8254dead2 100644 --- a/application/src/main/java/run/halo/app/security/LogoutSecurityConfigurer.java +++ b/application/src/main/java/run/halo/app/security/LogoutSecurityConfigurer.java @@ -1,28 +1,38 @@ package run.halo.app.security; -import static org.springframework.security.config.web.server.SecurityWebFiltersOrder.LOGOUT_PAGE_GENERATING; import static run.halo.app.security.authentication.WebExchangeMatchers.ignoringMediaTypeAll; import java.net.URI; +import java.util.Map; import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.core.annotation.Order; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.security.config.web.server.ServerHttpSecurity; import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.ReactiveSecurityContextHolder; +import org.springframework.security.core.context.SecurityContext; import org.springframework.security.web.server.WebFilterExchange; import org.springframework.security.web.server.authentication.logout.DelegatingServerLogoutHandler; import org.springframework.security.web.server.authentication.logout.RedirectServerLogoutSuccessHandler; import org.springframework.security.web.server.authentication.logout.ServerLogoutHandler; import org.springframework.security.web.server.authentication.logout.ServerLogoutSuccessHandler; -import org.springframework.security.web.server.ui.LogoutPageGeneratingWebFilter; import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.RouterFunctions; +import org.springframework.web.reactive.function.server.ServerResponse; import reactor.core.publisher.Mono; +import run.halo.app.core.user.service.UserService; +import run.halo.app.infra.actuator.GlobalInfoService; import run.halo.app.security.authentication.SecurityConfigurer; import run.halo.app.security.authentication.rememberme.RememberMeServices; +import run.halo.app.theme.router.ModelConst; @Component @RequiredArgsConstructor +@Order(0) public class LogoutSecurityConfigurer implements SecurityConfigurer { private final RememberMeServices rememberMeServices; private final ApplicationContext applicationContext; @@ -31,8 +41,8 @@ public class LogoutSecurityConfigurer implements SecurityConfigurer { public void configure(ServerHttpSecurity http) { var serverLogoutHandlers = getLogoutHandlers(); http.logout( - logout -> logout.logoutSuccessHandler(new LogoutSuccessHandler(serverLogoutHandlers))); - http.addFilterAt(new LogoutPageGeneratingWebFilter(), LOGOUT_PAGE_GENERATING); + logout -> logout.logoutSuccessHandler(new LogoutSuccessHandler(serverLogoutHandlers)) + ); } private class LogoutSuccessHandler implements ServerLogoutSuccessHandler { @@ -42,7 +52,7 @@ private class LogoutSuccessHandler implements ServerLogoutSuccessHandler { public LogoutSuccessHandler(ServerLogoutHandler... logoutHandler) { var defaultHandler = new RedirectServerLogoutSuccessHandler(); - defaultHandler.setLogoutSuccessUrl(URI.create("/console/?logout")); + defaultHandler.setLogoutSuccessUrl(URI.create("/login?logout")); this.defaultHandler = defaultHandler; if (logoutHandler.length == 1) { this.logoutHandler = logoutHandler[0]; @@ -51,6 +61,32 @@ public LogoutSuccessHandler(ServerLogoutHandler... logoutHandler) { } } + @Bean + RouterFunction logoutPage( + UserService userService, + GlobalInfoService globalInfoService + ) { + return RouterFunctions.route() + .GET("/logout", request -> { + var user = ReactiveSecurityContextHolder.getContext() + .map(SecurityContext::getAuthentication) + .map(Authentication::getName) + .flatMap(userService::getUser); + var exchange = request.exchange(); + var contextPath = exchange.getRequest().getPath().contextPath().value(); + return ServerResponse.ok().render("logout", Map.of( + "globalInfo", globalInfoService.getGlobalInfo(), + "action", contextPath + "/logout", + "user", user + )); + }) + .before(request -> { + request.exchange().getAttributes().put(ModelConst.NO_CACHE, true); + return request; + }) + .build(); + } + @Override public Mono onLogoutSuccess(WebFilterExchange exchange, Authentication authentication) { diff --git a/application/src/main/java/run/halo/app/security/RedirectAccessDeniedHandler.java b/application/src/main/java/run/halo/app/security/RedirectAccessDeniedHandler.java new file mode 100644 index 0000000000..c75d37674d --- /dev/null +++ b/application/src/main/java/run/halo/app/security/RedirectAccessDeniedHandler.java @@ -0,0 +1,31 @@ +package run.halo.app.security; + +import java.net.URI; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.server.DefaultServerRedirectStrategy; +import org.springframework.security.web.server.ServerRedirectStrategy; +import org.springframework.security.web.server.authorization.ServerAccessDeniedHandler; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +/** + * Redirect access denied handler. + * + * @author johnniang + * @since 2.20.0 + */ +public class RedirectAccessDeniedHandler implements ServerAccessDeniedHandler { + + private final ServerRedirectStrategy redirectStrategy = new DefaultServerRedirectStrategy(); + + private final URI redirectUri; + + public RedirectAccessDeniedHandler(String redirectUri) { + this.redirectUri = URI.create(redirectUri); + } + + @Override + public Mono handle(ServerWebExchange exchange, AccessDeniedException denied) { + return redirectStrategy.sendRedirect(exchange, redirectUri); + } +} diff --git a/application/src/main/java/run/halo/app/security/SecurityWebFiltersConfigurer.java b/application/src/main/java/run/halo/app/security/SecurityWebFiltersConfigurer.java index daef125f6a..bb134c9f36 100644 --- a/application/src/main/java/run/halo/app/security/SecurityWebFiltersConfigurer.java +++ b/application/src/main/java/run/halo/app/security/SecurityWebFiltersConfigurer.java @@ -4,11 +4,14 @@ import static org.springframework.security.config.web.server.SecurityWebFiltersOrder.AUTHENTICATION; import static org.springframework.security.config.web.server.SecurityWebFiltersOrder.FIRST; import static org.springframework.security.config.web.server.SecurityWebFiltersOrder.FORM_LOGIN; +import static org.springframework.security.config.web.server.SecurityWebFiltersOrder.HTTP_BASIC; import static org.springframework.security.config.web.server.SecurityWebFiltersOrder.LAST; +import static org.springframework.security.config.web.server.SecurityWebFiltersOrder.OAUTH2_AUTHORIZATION_CODE; import lombok.Setter; import org.pf4j.ExtensionPoint; import org.springframework.core.annotation.AnnotationAwareOrderComparator; +import org.springframework.core.annotation.Order; import org.springframework.security.config.web.server.ServerHttpSecurity; import org.springframework.security.web.server.WebFilterChainProxy; import org.springframework.stereotype.Component; @@ -20,6 +23,8 @@ import run.halo.app.security.authentication.SecurityConfigurer; @Component +// Specific an order here to control the order or security configurer initialization +@Order(100) public class SecurityWebFiltersConfigurer implements SecurityConfigurer { private final ExtensionGetter extensionGetter; @@ -35,6 +40,10 @@ public void configure(ServerHttpSecurity http) { new SecurityWebFilterChainProxy(BeforeSecurityWebFilter.class), FIRST ) + .addFilterAt( + new SecurityWebFilterChainProxy(HttpBasicSecurityWebFilter.class), + HTTP_BASIC + ) .addFilterAt( new SecurityWebFilterChainProxy(FormLoginSecurityWebFilter.class), FORM_LOGIN @@ -47,6 +56,10 @@ public void configure(ServerHttpSecurity http) { new SecurityWebFilterChainProxy(AnonymousAuthenticationSecurityWebFilter.class), ANONYMOUS_AUTHENTICATION ) + .addFilterAt( + new SecurityWebFilterChainProxy(OAuth2AuthorizationCodeSecurityWebFilter.class), + OAUTH2_AUTHORIZATION_CODE + ) .addFilterAt( new SecurityWebFilterChainProxy(AfterSecurityWebFilter.class), LAST diff --git a/application/src/main/java/run/halo/app/security/authentication/exception/TooManyRequestsException.java b/application/src/main/java/run/halo/app/security/authentication/exception/TooManyRequestsException.java new file mode 100644 index 0000000000..9e0f4382e1 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/exception/TooManyRequestsException.java @@ -0,0 +1,21 @@ +package run.halo.app.security.authentication.exception; + +import org.springframework.lang.Nullable; +import org.springframework.security.core.AuthenticationException; +import run.halo.app.infra.exception.RateLimitExceededException; + +/** + * Too many requests exception while authenticating. Because + * {@link RateLimitExceededException} is not a subclass of + * {@link AuthenticationException}, we need to create a new exception class to map it. + * + * @author johnniang + * @since 2.20.0 + */ +public class TooManyRequestsException extends AuthenticationException { + + public TooManyRequestsException(@Nullable Throwable throwable) { + super("Too many requests.", throwable); + } + +} diff --git a/application/src/main/java/run/halo/app/security/authentication/exception/TwoFactorAuthException.java b/application/src/main/java/run/halo/app/security/authentication/exception/TwoFactorAuthException.java new file mode 100644 index 0000000000..fb37664b1f --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/exception/TwoFactorAuthException.java @@ -0,0 +1,15 @@ +package run.halo.app.security.authentication.exception; + +import org.springframework.security.core.AuthenticationException; + +public class TwoFactorAuthException extends AuthenticationException { + + public TwoFactorAuthException(String msg, Throwable cause) { + super(msg, cause); + } + + public TwoFactorAuthException(String msg) { + super(msg); + } + +} diff --git a/application/src/main/java/run/halo/app/security/authentication/jwt/JwtAuthenticationConfigurer.java b/application/src/main/java/run/halo/app/security/authentication/jwt/JwtAuthenticationConfigurer.java deleted file mode 100644 index 26c18b53d4..0000000000 --- a/application/src/main/java/run/halo/app/security/authentication/jwt/JwtAuthenticationConfigurer.java +++ /dev/null @@ -1,70 +0,0 @@ -package run.halo.app.security.authentication.jwt; - -import static org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers.pathMatchers; - -import org.springframework.http.HttpMethod; -import org.springframework.http.MediaType; -import org.springframework.http.codec.ServerCodecConfigurer; -import org.springframework.security.config.web.server.SecurityWebFiltersOrder; -import org.springframework.security.config.web.server.ServerHttpSecurity; -import org.springframework.security.core.userdetails.ReactiveUserDetailsService; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.security.oauth2.jwt.JwtEncoder; -import org.springframework.security.web.server.authentication.AuthenticationWebFilter; -import org.springframework.security.web.server.util.matcher.AndServerWebExchangeMatcher; -import org.springframework.security.web.server.util.matcher.MediaTypeServerWebExchangeMatcher; -import org.springframework.web.reactive.function.server.ServerResponse; -import run.halo.app.infra.properties.JwtProperties; -import run.halo.app.security.authentication.SecurityConfigurer; - -/** - * TODO: Use It after 2.0.0. - */ -public class JwtAuthenticationConfigurer implements SecurityConfigurer { - - private final ReactiveUserDetailsService userDetailsService; - - private final PasswordEncoder passwordEncoder; - - private final ServerCodecConfigurer codec; - - private final JwtEncoder jwtEncoder; - - private final ServerResponse.Context context; - - private final JwtProperties jwtProp; - - public JwtAuthenticationConfigurer(ReactiveUserDetailsService userDetailsService, - PasswordEncoder passwordEncoder, - ServerCodecConfigurer codec, - JwtEncoder jwtEncoder, - ServerResponse.Context context, - JwtProperties jwtProp) { - this.userDetailsService = userDetailsService; - this.passwordEncoder = passwordEncoder; - this.codec = codec; - this.jwtEncoder = jwtEncoder; - this.context = context; - this.jwtProp = jwtProp; - } - - @Override - public void configure(ServerHttpSecurity http) { - var loginManager = new LoginAuthenticationManager(userDetailsService, passwordEncoder); - - var filter = new AuthenticationWebFilter(loginManager); - var loginMatcher = new AndServerWebExchangeMatcher( - pathMatchers(HttpMethod.POST, "/api/auth/token"), - new MediaTypeServerWebExchangeMatcher(MediaType.APPLICATION_JSON) - ); - - filter.setRequiresAuthenticationMatcher(loginMatcher); - filter.setServerAuthenticationConverter( - new LoginAuthenticationConverter(codec.getReaders())); - filter.setAuthenticationSuccessHandler( - new LoginAuthenticationSuccessHandler(jwtEncoder, jwtProp, context)); - filter.setAuthenticationFailureHandler(new LoginAuthenticationFailureHandler(context)); - - http.addFilterAt(filter, SecurityWebFiltersOrder.FORM_LOGIN); - } -} diff --git a/application/src/main/java/run/halo/app/security/authentication/jwt/LoginAuthenticationConverter.java b/application/src/main/java/run/halo/app/security/authentication/jwt/LoginAuthenticationConverter.java deleted file mode 100644 index 05d1d00314..0000000000 --- a/application/src/main/java/run/halo/app/security/authentication/jwt/LoginAuthenticationConverter.java +++ /dev/null @@ -1,37 +0,0 @@ -package run.halo.app.security.authentication.jwt; - -import static org.springframework.security.authentication.UsernamePasswordAuthenticationToken.unauthenticated; - -import java.util.List; -import lombok.Data; -import org.springframework.http.codec.HttpMessageReader; -import org.springframework.security.core.Authentication; -import org.springframework.security.web.server.authentication.ServerAuthenticationConverter; -import org.springframework.web.reactive.function.server.ServerRequest; -import org.springframework.web.server.ServerWebExchange; -import reactor.core.publisher.Mono; - -public class LoginAuthenticationConverter implements ServerAuthenticationConverter { - - private final List> reader; - - public LoginAuthenticationConverter(List> reader) { - this.reader = reader; - } - - @Override - public Mono convert(ServerWebExchange exchange) { - return ServerRequest.create(exchange, this.reader) - .bodyToMono(UsernamePasswordRequest.class) - .map(request -> unauthenticated(request.getUsername(), request.getPassword())); - } - - @Data - public static class UsernamePasswordRequest { - - private String username; - - private String password; - - } -} diff --git a/application/src/main/java/run/halo/app/security/authentication/jwt/LoginAuthenticationFailureHandler.java b/application/src/main/java/run/halo/app/security/authentication/jwt/LoginAuthenticationFailureHandler.java deleted file mode 100644 index 8078a2ea5d..0000000000 --- a/application/src/main/java/run/halo/app/security/authentication/jwt/LoginAuthenticationFailureHandler.java +++ /dev/null @@ -1,31 +0,0 @@ -package run.halo.app.security.authentication.jwt; - -import java.util.Map; -import org.springframework.http.MediaType; -import org.springframework.security.core.AuthenticationException; -import org.springframework.security.web.server.WebFilterExchange; -import org.springframework.security.web.server.authentication.ServerAuthenticationFailureHandler; -import org.springframework.web.reactive.function.server.ServerResponse; -import reactor.core.publisher.Mono; - -public class LoginAuthenticationFailureHandler implements ServerAuthenticationFailureHandler { - - private final ServerResponse.Context context; - - public LoginAuthenticationFailureHandler(ServerResponse.Context context) { - this.context = context; - } - - @Override - public Mono onAuthenticationFailure(WebFilterExchange webFilterExchange, - AuthenticationException exception) { - return ServerResponse.badRequest() - .contentType(MediaType.APPLICATION_JSON) - .bodyValue( - Map.of("error", exception.getLocalizedMessage()) - ) - .flatMap(serverResponse -> - serverResponse.writeTo(webFilterExchange.getExchange(), context)); - } - -} diff --git a/application/src/main/java/run/halo/app/security/authentication/jwt/LoginAuthenticationManager.java b/application/src/main/java/run/halo/app/security/authentication/jwt/LoginAuthenticationManager.java deleted file mode 100644 index a5b3ce3156..0000000000 --- a/application/src/main/java/run/halo/app/security/authentication/jwt/LoginAuthenticationManager.java +++ /dev/null @@ -1,19 +0,0 @@ -package run.halo.app.security.authentication.jwt; - -import org.springframework.security.authentication.UserDetailsRepositoryReactiveAuthenticationManager; -import org.springframework.security.core.userdetails.ReactiveUserDetailsPasswordService; -import org.springframework.security.core.userdetails.ReactiveUserDetailsService; -import org.springframework.security.crypto.password.PasswordEncoder; - -public final class LoginAuthenticationManager - extends UserDetailsRepositoryReactiveAuthenticationManager { - - public LoginAuthenticationManager(ReactiveUserDetailsService userDetailsService, - PasswordEncoder passwordEncoder) { - super(userDetailsService); - super.setPasswordEncoder(passwordEncoder); - if (userDetailsService instanceof ReactiveUserDetailsPasswordService passwordService) { - super.setUserDetailsPasswordService(passwordService); - } - } -} diff --git a/application/src/main/java/run/halo/app/security/authentication/jwt/LoginAuthenticationSuccessHandler.java b/application/src/main/java/run/halo/app/security/authentication/jwt/LoginAuthenticationSuccessHandler.java deleted file mode 100644 index 6ec9161a1b..0000000000 --- a/application/src/main/java/run/halo/app/security/authentication/jwt/LoginAuthenticationSuccessHandler.java +++ /dev/null @@ -1,62 +0,0 @@ -package run.halo.app.security.authentication.jwt; - -import java.time.Instant; -import java.time.temporal.ChronoUnit; -import java.util.Map; -import java.util.stream.Collectors; -import org.springframework.http.MediaType; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.oauth2.jwt.JwsHeader; -import org.springframework.security.oauth2.jwt.JwtClaimsSet; -import org.springframework.security.oauth2.jwt.JwtEncoder; -import org.springframework.security.oauth2.jwt.JwtEncoderParameters; -import org.springframework.security.web.server.WebFilterExchange; -import org.springframework.security.web.server.authentication.ServerAuthenticationSuccessHandler; -import org.springframework.web.reactive.function.server.ServerResponse; -import reactor.core.publisher.Mono; -import run.halo.app.infra.properties.JwtProperties; - -public class LoginAuthenticationSuccessHandler implements ServerAuthenticationSuccessHandler { - - private final JwtEncoder jwtEncoder; - - private final JwtProperties jwtProp; - - private final ServerResponse.Context context; - - public LoginAuthenticationSuccessHandler(JwtEncoder jwtEncoder, JwtProperties jwtProp, - ServerResponse.Context context) { - this.jwtEncoder = jwtEncoder; - this.jwtProp = jwtProp; - this.context = context; - } - - @Override - public Mono onAuthenticationSuccess(WebFilterExchange webFilterExchange, - Authentication authentication) { - var issuedAt = Instant.now(); - // TODO Make the expiresAt configurable - var expiresAt = issuedAt.plus(24, ChronoUnit.HOURS); - var headers = JwsHeader.with(jwtProp.getJwsAlgorithm()).build(); - var claims = JwtClaimsSet.builder() - .issuer("Halo Owner") - .issuedAt(issuedAt) - .expiresAt(expiresAt) - // the principal is the username - .subject(authentication.getName()) - .claim("scope", authentication.getAuthorities().stream() - .map(GrantedAuthority::getAuthority) - .collect(Collectors.toList())) - .build(); - - var jwt = jwtEncoder.encode(JwtEncoderParameters.from(headers, claims)); - - return ServerResponse.ok() - .contentType(MediaType.APPLICATION_JSON) - .bodyValue(Map.of("token", jwt.getTokenValue())) - .flatMap(serverResponse -> serverResponse.writeTo(webFilterExchange.getExchange(), - this.context)); - } - -} diff --git a/application/src/main/java/run/halo/app/security/authentication/login/LoginAuthenticationConverter.java b/application/src/main/java/run/halo/app/security/authentication/login/LoginAuthenticationConverter.java index 9a3bfb7e12..26df92cbf0 100644 --- a/application/src/main/java/run/halo/app/security/authentication/login/LoginAuthenticationConverter.java +++ b/application/src/main/java/run/halo/app/security/authentication/login/LoginAuthenticationConverter.java @@ -13,9 +13,9 @@ import org.springframework.security.web.server.authentication.ServerFormLoginAuthenticationConverter; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; -import run.halo.app.infra.exception.RateLimitExceededException; import run.halo.app.infra.utils.IpAddressUtils; import run.halo.app.security.authentication.CryptoService; +import run.halo.app.security.authentication.exception.TooManyRequestsException; @Slf4j public class LoginAuthenticationConverter extends ServerFormLoginAuthenticationConverter { @@ -35,6 +35,9 @@ public Mono convert(ServerWebExchange exchange) { return super.convert(exchange) // validate the password .flatMap(token -> { + if (token.getCredentials() == null) { + return Mono.error(new BadCredentialsException("Empty credentials.")); + } var credentials = (String) token.getCredentials(); byte[] credentialsBytes; try { @@ -51,7 +54,9 @@ public Mono convert(ServerWebExchange exchange) { new String(decryptedCredentials, UTF_8))); }) .transformDeferred(createIpBasedRateLimiter(exchange)) - .onErrorMap(RequestNotPermitted.class, RateLimitExceededException::new); + // We have to remap the exception to an AuthenticationException + // for using in failure handler + .onErrorMap(RequestNotPermitted.class, TooManyRequestsException::new); } private RateLimiterOperator createIpBasedRateLimiter(ServerWebExchange exchange) { diff --git a/application/src/main/java/run/halo/app/security/authentication/login/LoginSecurityConfigurer.java b/application/src/main/java/run/halo/app/security/authentication/login/LoginSecurityConfigurer.java index 8fff496451..43296e113e 100644 --- a/application/src/main/java/run/halo/app/security/authentication/login/LoginSecurityConfigurer.java +++ b/application/src/main/java/run/halo/app/security/authentication/login/LoginSecurityConfigurer.java @@ -3,26 +3,33 @@ import io.github.resilience4j.ratelimiter.RateLimiterRegistry; import io.micrometer.observation.ObservationRegistry; import org.springframework.context.MessageSource; +import org.springframework.core.annotation.Order; import org.springframework.http.HttpMethod; import org.springframework.security.authentication.ObservationReactiveAuthenticationManager; import org.springframework.security.authentication.ReactiveAuthenticationManager; import org.springframework.security.authentication.UserDetailsRepositoryReactiveAuthenticationManager; import org.springframework.security.config.web.server.SecurityWebFiltersOrder; import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.core.Authentication; import org.springframework.security.core.userdetails.ReactiveUserDetailsPasswordService; import org.springframework.security.core.userdetails.ReactiveUserDetailsService; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.server.WebFilterExchange; import org.springframework.security.web.server.authentication.AuthenticationWebFilter; import org.springframework.security.web.server.context.ServerSecurityContextRepository; import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.server.ServerResponse; +import reactor.core.publisher.Mono; import run.halo.app.plugin.extensionpoint.ExtensionGetter; +import run.halo.app.security.HaloUserDetails; import run.halo.app.security.LoginHandlerEnhancer; import run.halo.app.security.authentication.CryptoService; import run.halo.app.security.authentication.SecurityConfigurer; +import run.halo.app.security.authentication.twofactor.TwoFactorAuthentication; @Component +@Order(0) public class LoginSecurityConfigurer implements SecurityConfigurer { private final ObservationRegistry observationRegistry; @@ -66,10 +73,20 @@ public LoginSecurityConfigurer(ObservationRegistry observationRegistry, @Override public void configure(ServerHttpSecurity http) { - var filter = new AuthenticationWebFilter(authenticationManager()); + var filter = new AuthenticationWebFilter(authenticationManager()) { + @Override + protected Mono onAuthenticationSuccess(Authentication authentication, + WebFilterExchange webFilterExchange) { + // check if 2FA is enabled after authenticating successfully. + if (authentication.getPrincipal() instanceof HaloUserDetails userDetails + && userDetails.isTwoFactorAuthEnabled()) { + authentication = new TwoFactorAuthentication(authentication); + } + return super.onAuthenticationSuccess(authentication, webFilterExchange); + } + }; var requiresMatcher = ServerWebExchangeMatchers.pathMatchers(HttpMethod.POST, "/login"); - var handler = - new UsernamePasswordHandler(context, messageSource, loginHandlerEnhancer); + var handler = new UsernamePasswordHandler(context, messageSource, loginHandlerEnhancer); var authConverter = new LoginAuthenticationConverter(cryptoService, rateLimiterRegistry); filter.setRequiresAuthenticationMatcher(requiresMatcher); filter.setAuthenticationFailureHandler(handler); diff --git a/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordDelegatingAuthenticationManager.java b/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordDelegatingAuthenticationManager.java index 912d9a5267..5ddf5e78c5 100644 --- a/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordDelegatingAuthenticationManager.java +++ b/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordDelegatingAuthenticationManager.java @@ -6,8 +6,6 @@ import org.springframework.security.core.AuthenticationException; import reactor.core.publisher.Mono; import run.halo.app.plugin.extensionpoint.ExtensionGetter; -import run.halo.app.security.HaloUserDetails; -import run.halo.app.security.authentication.twofactor.TwoFactorAuthentication; @Slf4j public class UsernamePasswordDelegatingAuthenticationManager @@ -40,14 +38,6 @@ public Mono authenticate(Authentication authentication) { ) .switchIfEmpty( Mono.defer(() -> defaultAuthenticationManager.authenticate(authentication)) - ) - // check if MFA is enabled after authenticated - .map(a -> { - if (a.getPrincipal() instanceof HaloUserDetails user - && user.isTwoFactorAuthEnabled()) { - a = new TwoFactorAuthentication(a); - } - return a; - }); + ); } } diff --git a/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordHandler.java b/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordHandler.java index 33ce5b04db..dd20aa7017 100644 --- a/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordHandler.java +++ b/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordHandler.java @@ -5,13 +5,17 @@ import static run.halo.app.infra.exception.Exceptions.createErrorResponse; import static run.halo.app.security.authentication.WebExchangeMatchers.ignoringMediaTypeAll; +import java.net.URI; +import lombok.Setter; import lombok.extern.slf4j.Slf4j; import org.springframework.context.MessageSource; +import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.CredentialsContainer; +import org.springframework.security.web.server.DefaultServerRedirectStrategy; +import org.springframework.security.web.server.ServerRedirectStrategy; import org.springframework.security.web.server.WebFilterExchange; -import org.springframework.security.web.server.authentication.RedirectServerAuthenticationFailureHandler; import org.springframework.security.web.server.authentication.RedirectServerAuthenticationSuccessHandler; import org.springframework.security.web.server.authentication.ServerAuthenticationFailureHandler; import org.springframework.security.web.server.authentication.ServerAuthenticationSuccessHandler; @@ -21,6 +25,9 @@ import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; import run.halo.app.security.LoginHandlerEnhancer; +import run.halo.app.security.authentication.exception.TooManyRequestsException; +import run.halo.app.security.authentication.rememberme.RememberMeRequestCache; +import run.halo.app.security.authentication.rememberme.WebSessionRememberMeRequestCache; import run.halo.app.security.authentication.twofactor.TwoFactorAuthentication; @Slf4j @@ -33,11 +40,13 @@ public class UsernamePasswordHandler implements ServerAuthenticationSuccessHandl private final LoginHandlerEnhancer loginHandlerEnhancer; - private final ServerAuthenticationFailureHandler defaultFailureHandler = - new RedirectServerAuthenticationFailureHandler("/console?error#/login"); + private final ServerRedirectStrategy redirectStrategy = new DefaultServerRedirectStrategy(); + + @Setter + private RememberMeRequestCache rememberMeRequestCache = new WebSessionRememberMeRequestCache(); private final ServerAuthenticationSuccessHandler defaultSuccessHandler = - new RedirectServerAuthenticationSuccessHandler("/console/"); + new RedirectServerAuthenticationSuccessHandler("/uc"); public UsernamePasswordHandler(ServerResponse.Context context, MessageSource messageSource, LoginHandlerEnhancer loginHandlerEnhancer) { @@ -54,10 +63,17 @@ public Mono onAuthenticationFailure(WebFilterExchange webFilterExchange, .then(ignoringMediaTypeAll(APPLICATION_JSON) .matches(exchange) .filter(ServerWebExchangeMatcher.MatchResult::isMatch) - .switchIfEmpty( - defaultFailureHandler.onAuthenticationFailure(webFilterExchange, exception) - // Skip the handleAuthenticationException. - .then(Mono.empty()) + .switchIfEmpty(Mono.defer( + () -> { + URI location = URI.create("/login?error&method=local"); + if (exception instanceof BadCredentialsException) { + location = URI.create("/login?error=invalid-credential&method=local"); + } + if (exception instanceof TooManyRequestsException) { + location = URI.create("/login?error=rate-limit-exceeded&method=local"); + } + return redirectStrategy.sendRedirect(exchange, location); + }).then(Mono.empty()) ) .flatMap(matchResult -> handleAuthenticationException(exception, exchange))); } @@ -66,10 +82,12 @@ public Mono onAuthenticationFailure(WebFilterExchange webFilterExchange, public Mono onAuthenticationSuccess(WebFilterExchange webFilterExchange, Authentication authentication) { if (authentication instanceof TwoFactorAuthentication) { - // continue filtering for authorization - return loginHandlerEnhancer.onLoginSuccess(webFilterExchange.getExchange(), - authentication) - .then(webFilterExchange.getChain().filter(webFilterExchange.getExchange())); + return rememberMeRequestCache.saveRememberMe(webFilterExchange.getExchange()) + // Do not use RedirectServerAuthenticationSuccessHandler to redirect + // because it will use request cache to redirect + .then(redirectStrategy.sendRedirect(webFilterExchange.getExchange(), + URI.create("/challenges/two-factor/totp")) + ); } if (authentication instanceof CredentialsContainer container) { diff --git a/application/src/main/java/run/halo/app/security/authentication/oauth2/DefaultOAuth2LoginHandlerEnhancer.java b/application/src/main/java/run/halo/app/security/authentication/oauth2/DefaultOAuth2LoginHandlerEnhancer.java new file mode 100644 index 0000000000..40ca4bdbfa --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/oauth2/DefaultOAuth2LoginHandlerEnhancer.java @@ -0,0 +1,66 @@ +package run.halo.app.security.authentication.oauth2; + +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.AuthenticationTrustResolver; +import org.springframework.security.authentication.AuthenticationTrustResolverImpl; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; +import run.halo.app.core.user.service.UserConnectionService; + +/** + * Default implementation of {@link OAuth2LoginHandlerEnhancer}. + * + * @author johnniang + * @since 2.20.0 + */ +@Slf4j +@Component +public class DefaultOAuth2LoginHandlerEnhancer implements OAuth2LoginHandlerEnhancer { + + private final UserConnectionService connectionService; + + @Setter + private OAuth2AuthenticationTokenCache oauth2TokenCache = + new WebSessionOAuth2AuthenticationTokenCache(); + + private final AuthenticationTrustResolver authenticationTrustResolver = + new AuthenticationTrustResolverImpl(); + + public DefaultOAuth2LoginHandlerEnhancer(UserConnectionService connectionService) { + this.connectionService = connectionService; + } + + @Override + public Mono loginSuccess(ServerWebExchange exchange, Authentication authentication) { + if (!authenticationTrustResolver.isFullyAuthenticated(authentication)) { + // Should never happen + // Remove token directly if not fully authenticated + return oauth2TokenCache.removeToken(exchange).then(); + } + return oauth2TokenCache.getToken(exchange) + .flatMap(oauth2Token -> { + var oauth2User = oauth2Token.getPrincipal(); + var username = authentication.getName(); + var registrationId = oauth2Token.getAuthorizedClientRegistrationId(); + return connectionService.updateUserConnectionIfPresent(registrationId, oauth2User) + .doOnNext(connection -> { + if (log.isDebugEnabled()) { + log.debug( + "User connection already exists, skip creating. connection: [{}]", + connection + ); + } + }) + .switchIfEmpty(Mono.defer(() -> connectionService.createUserConnection( + username, + registrationId, + oauth2User + ))) + .then(oauth2TokenCache.removeToken(exchange)); + }); + } + +} diff --git a/application/src/main/java/run/halo/app/security/authentication/oauth2/MapOAuth2AuthenticationFilter.java b/application/src/main/java/run/halo/app/security/authentication/oauth2/MapOAuth2AuthenticationFilter.java new file mode 100644 index 0000000000..e4e5ba64c0 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/oauth2/MapOAuth2AuthenticationFilter.java @@ -0,0 +1,128 @@ +package run.halo.app.security.authentication.oauth2; + +import static run.halo.app.security.authentication.oauth2.HaloOAuth2AuthenticationToken.authenticated; + +import java.net.URI; +import lombok.Setter; +import org.springframework.security.authentication.AuthenticationTrustResolver; +import org.springframework.security.authentication.AuthenticationTrustResolverImpl; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.ReactiveSecurityContextHolder; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextImpl; +import org.springframework.security.core.userdetails.ReactiveUserDetailsService; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.web.server.DefaultServerRedirectStrategy; +import org.springframework.security.web.server.ServerRedirectStrategy; +import org.springframework.security.web.server.WebFilterExchange; +import org.springframework.security.web.server.authentication.logout.SecurityContextServerLogoutHandler; +import org.springframework.security.web.server.authentication.logout.ServerLogoutHandler; +import org.springframework.security.web.server.context.ServerSecurityContextRepository; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilter; +import org.springframework.web.server.WebFilterChain; +import reactor.core.publisher.Mono; +import run.halo.app.core.user.service.UserConnectionService; + +/** + * A filter to map OAuth2 authentication to authenticated user. + * + * @author johnniang + * @since 2.20.0 + */ +class MapOAuth2AuthenticationFilter implements WebFilter { + + private static final String PRE_AUTHENTICATION = + MapOAuth2AuthenticationFilter.class.getName() + ".PRE_AUTHENTICATION"; + + private final UserConnectionService connectionService; + + private final ServerSecurityContextRepository securityContextRepository; + + @Setter + private OAuth2AuthenticationTokenCache authenticationCache = + new WebSessionOAuth2AuthenticationTokenCache(); + + private final ReactiveUserDetailsService userDetailsService; + + private final ServerLogoutHandler logoutHandler; + + private final ServerRedirectStrategy redirectStrategy = new DefaultServerRedirectStrategy(); + + @Setter + private AuthenticationTrustResolver authenticationTrustResolver + = new AuthenticationTrustResolverImpl(); + + public MapOAuth2AuthenticationFilter( + ServerSecurityContextRepository securityContextRepository, + UserConnectionService connectionService, + ReactiveUserDetailsService userDetailsService) { + this.connectionService = connectionService; + this.securityContextRepository = securityContextRepository; + this.userDetailsService = userDetailsService; + var logoutHandler = new SecurityContextServerLogoutHandler(); + logoutHandler.setSecurityContextRepository(securityContextRepository); + this.logoutHandler = logoutHandler; + } + + @Override + public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { + return ReactiveSecurityContextHolder.getContext() + .map(SecurityContext::getAuthentication) + .filter(authenticationTrustResolver::isAuthenticated) + .doOnNext( + // cache the pre-authentication + authentication -> exchange.getAttributes().put(PRE_AUTHENTICATION, authentication) + ) + .then(chain.filter(exchange)) + .then(Mono.defer(() -> ReactiveSecurityContextHolder.getContext() + .map(SecurityContext::getAuthentication) + .filter(OAuth2AuthenticationToken.class::isInstance) + .cast(OAuth2AuthenticationToken.class) + .flatMap(oauth2Token -> { + var registrationId = oauth2Token.getAuthorizedClientRegistrationId(); + var oauth2User = oauth2Token.getPrincipal(); + // check the connection + return connectionService.updateUserConnectionIfPresent( + registrationId, oauth2User + ) + .switchIfEmpty(Mono.defer(() -> { + var preAuthenticationObject = exchange.getAttribute(PRE_AUTHENTICATION); + if (preAuthenticationObject instanceof Authentication preAuth + && authenticationTrustResolver.isAuthenticated(preAuth)) { + // check the authentication again + // try to bind the user automatically + return connectionService.createUserConnection( + preAuth.getName(), registrationId, oauth2User + ); + } + // save the OAuth2Authentication into session + return authenticationCache.saveToken(exchange, oauth2Token) + .then(Mono.defer(() -> { + var webFilterExchange = new WebFilterExchange(exchange, chain); + // clear the security context + return logoutHandler.logout(webFilterExchange, oauth2Token); + })) + .then(Mono.defer(() -> redirectStrategy.sendRedirect(exchange, + URI.create("/login?oauth2_bind") + ))) + // skip handling + .then(Mono.empty()); + })) + // user bound and remap the authentication + .flatMap(connection -> + userDetailsService.findByUsername(connection.getSpec().getUsername()) + ) + .map(userDetails -> authenticated(userDetails, oauth2Token)) + .flatMap(haloOAuth2Token -> { + var securityContext = new SecurityContextImpl(haloOAuth2Token); + return securityContextRepository.save(exchange, securityContext); + // because this happens after the filter, there is no need to + // write SecurityContext to the context + }); + }) + .then()) + ); + } + +} diff --git a/application/src/main/java/run/halo/app/security/authentication/oauth2/OAuth2AuthenticationTokenCache.java b/application/src/main/java/run/halo/app/security/authentication/oauth2/OAuth2AuthenticationTokenCache.java new file mode 100644 index 0000000000..9c2bf964bd --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/oauth2/OAuth2AuthenticationTokenCache.java @@ -0,0 +1,41 @@ +package run.halo.app.security.authentication.oauth2; + +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +/** + * OAuth2 authentication token cache. Saving OAuth2AuthenticationToken is mainly for further binding + * to Halo user. + * + * @author johnniang + * @since 2.20.0 + */ +public interface OAuth2AuthenticationTokenCache { + + /** + * Save OAuth2AuthenticationToken into cache. + * + * @param exchange Server web exchange + * @param oauth2Token OAuth2AuthenticationToken + * @return empty + */ + Mono saveToken(ServerWebExchange exchange, OAuth2AuthenticationToken oauth2Token); + + /** + * Get OAuth2AuthenticationToken from cache. + * + * @param exchange Server web exchange + * @return an {@link OAuth2AuthenticationToken} if present, empty otherwise + */ + Mono getToken(ServerWebExchange exchange); + + /** + * Remove OAuth2AuthenticationToken from cache. + * + * @param exchange Server web exchange + * @return empty + */ + Mono removeToken(ServerWebExchange exchange); + +} diff --git a/application/src/main/java/run/halo/app/security/authentication/oauth2/OAuth2LoginHandlerEnhancer.java b/application/src/main/java/run/halo/app/security/authentication/oauth2/OAuth2LoginHandlerEnhancer.java new file mode 100644 index 0000000000..dbf9f8ec70 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/oauth2/OAuth2LoginHandlerEnhancer.java @@ -0,0 +1,17 @@ +package run.halo.app.security.authentication.oauth2; + +import org.springframework.security.core.Authentication; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +/** + * OAuth2 login handler enhancer. + * + * @author johnniang + * @since 2.20.0 + */ +public interface OAuth2LoginHandlerEnhancer { + + Mono loginSuccess(ServerWebExchange exchange, Authentication authentication); + +} diff --git a/application/src/main/java/run/halo/app/security/authentication/oauth2/OAuth2SecurityConfigurer.java b/application/src/main/java/run/halo/app/security/authentication/oauth2/OAuth2SecurityConfigurer.java new file mode 100644 index 0000000000..0f25d6883f --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/oauth2/OAuth2SecurityConfigurer.java @@ -0,0 +1,42 @@ +package run.halo.app.security.authentication.oauth2; + +import org.springframework.core.annotation.Order; +import org.springframework.security.config.web.server.SecurityWebFiltersOrder; +import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.core.userdetails.ReactiveUserDetailsService; +import org.springframework.security.web.server.context.ServerSecurityContextRepository; +import org.springframework.stereotype.Component; +import run.halo.app.core.user.service.UserConnectionService; +import run.halo.app.security.authentication.SecurityConfigurer; + +/** + * OAuth2 security configurer. + * + * @author johnniang + * @since 2.20.0 + */ +@Component +@Order(0) +class OAuth2SecurityConfigurer implements SecurityConfigurer { + + private final ServerSecurityContextRepository securityContextRepository; + + private final UserConnectionService connectionService; + + private final ReactiveUserDetailsService userDetailsService; + + public OAuth2SecurityConfigurer(ServerSecurityContextRepository securityContextRepository, + UserConnectionService connectionService, ReactiveUserDetailsService userDetailsService) { + this.securityContextRepository = securityContextRepository; + this.connectionService = connectionService; + this.userDetailsService = userDetailsService; + } + + @Override + public void configure(ServerHttpSecurity http) { + var mapOAuth2Filter = new MapOAuth2AuthenticationFilter( + securityContextRepository, connectionService, userDetailsService + ); + http.addFilterBefore(mapOAuth2Filter, SecurityWebFiltersOrder.AUTHENTICATION); + } +} diff --git a/application/src/main/java/run/halo/app/security/authentication/oauth2/WebSessionOAuth2AuthenticationTokenCache.java b/application/src/main/java/run/halo/app/security/authentication/oauth2/WebSessionOAuth2AuthenticationTokenCache.java new file mode 100644 index 0000000000..44995a8f93 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/oauth2/WebSessionOAuth2AuthenticationTokenCache.java @@ -0,0 +1,42 @@ +package run.halo.app.security.authentication.oauth2; + +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +/** + * WebSession cache implementation of {@link OAuth2AuthenticationTokenCache}. + * + * @author johnniang + * @since 2.20.0 + */ +public class WebSessionOAuth2AuthenticationTokenCache implements OAuth2AuthenticationTokenCache { + + private static final String SESSION_ATTRIBUTE_KEY = + OAuth2AuthenticationTokenCache.class + ".OAUTH2_TOKEN"; + + @Override + public Mono saveToken(ServerWebExchange exchange, OAuth2AuthenticationToken oauth2Token) { + return exchange.getSession() + .doOnNext(session -> { + session.getAttributes().put(SESSION_ATTRIBUTE_KEY, oauth2Token); + }) + .then(); + } + + @Override + public Mono getToken(ServerWebExchange exchange) { + return exchange.getSession() + .mapNotNull(session -> session.getAttribute(SESSION_ATTRIBUTE_KEY)) + .filter(OAuth2AuthenticationToken.class::isInstance) + .cast(OAuth2AuthenticationToken.class); + } + + @Override + public Mono removeToken(ServerWebExchange exchange) { + return exchange.getSession() + .doOnNext(session -> session.getAttributes().remove(SESSION_ATTRIBUTE_KEY)) + .then(); + } + +} diff --git a/application/src/main/java/run/halo/app/security/authentication/pat/PatAuthenticationConverter.java b/application/src/main/java/run/halo/app/security/authentication/pat/PatAuthenticationConverter.java new file mode 100644 index 0000000000..ab5ec828ac --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/pat/PatAuthenticationConverter.java @@ -0,0 +1,29 @@ +package run.halo.app.security.authentication.pat; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthenticationToken; +import org.springframework.security.oauth2.server.resource.web.server.authentication.ServerBearerTokenAuthenticationConverter; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +/** + * PAT authentication converter. + * + * @author johnniang + * @since 2.20.4 + */ +class PatAuthenticationConverter extends ServerBearerTokenAuthenticationConverter { + + public static final String PAT_TOKEN_PREFIX = "pat_"; + + @Override + public Mono convert(ServerWebExchange exchange) { + return super.convert(exchange) + .cast(BearerTokenAuthenticationToken.class) + .map(BearerTokenAuthenticationToken::getToken) + .filter(token -> StringUtils.startsWith(token, PAT_TOKEN_PREFIX)) + .map(token -> StringUtils.removeStart(token, PAT_TOKEN_PREFIX)) + .map(BearerTokenAuthenticationToken::new); + } +} diff --git a/application/src/main/java/run/halo/app/security/authentication/pat/PatAuthenticationManager.java b/application/src/main/java/run/halo/app/security/authentication/pat/PatAuthenticationManager.java index 9cd8860038..5f4c9c66ce 100644 --- a/application/src/main/java/run/halo/app/security/authentication/pat/PatAuthenticationManager.java +++ b/application/src/main/java/run/halo/app/security/authentication/pat/PatAuthenticationManager.java @@ -1,8 +1,6 @@ package run.halo.app.security.authentication.pat; -import static org.apache.commons.lang3.StringUtils.removeStart; import static org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder.withJwkSource; -import static run.halo.app.security.authentication.pat.PatServerWebExchangeMatcher.PAT_TOKEN_PREFIX; import static run.halo.app.security.authorization.AuthorityUtils.ANONYMOUS_ROLE_NAME; import static run.halo.app.security.authorization.AuthorityUtils.AUTHENTICATED_ROLE_NAME; import static run.halo.app.security.authorization.AuthorityUtils.ROLE_PREFIX; @@ -18,7 +16,6 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException; -import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthenticationToken; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; import org.springframework.security.oauth2.server.resource.authentication.JwtReactiveAuthenticationManager; import reactor.core.publisher.Flux; @@ -30,14 +27,14 @@ import run.halo.app.security.authentication.CryptoService; import run.halo.app.security.authorization.AuthorityUtils; -public class PatAuthenticationManager implements ReactiveAuthenticationManager { +class PatAuthenticationManager implements ReactiveAuthenticationManager { /** * Minimal duration gap of personal access token update. */ private static final Duration MIN_UPDATE_GAP = Duration.ofMinutes(1); - private final ReactiveAuthenticationManager delegate; + private final JwtReactiveAuthenticationManager delegate; private final ReactiveExtensionClient client; @@ -52,33 +49,28 @@ public PatAuthenticationManager(ReactiveExtensionClient client, CryptoService cr this.clock = Clock.systemDefaultZone(); } - private ReactiveAuthenticationManager getDelegate() { + private JwtReactiveAuthenticationManager getDelegate() { var jwtDecoder = withJwkSource(signedJWT -> Flux.just(cryptoService.getJwk())) .build(); return new JwtReactiveAuthenticationManager(jwtDecoder); } - public void setClock(Clock clock) { + /** + * Set new clock. Only for testing. + * + * @param clock new clock + */ + void setClock(Clock clock) { this.clock = clock; } @Override public Mono authenticate(Authentication authentication) { - return Mono.just(authentication) - .map(this::clearPrefix) - .flatMap(delegate::authenticate) + return delegate.authenticate(authentication) .cast(JwtAuthenticationToken.class) .flatMap(this::checkAndRebuild); } - private Authentication clearPrefix(Authentication authentication) { - if (authentication instanceof BearerTokenAuthenticationToken bearerToken) { - var newToken = removeStart(bearerToken.getToken(), PAT_TOKEN_PREFIX); - return new BearerTokenAuthenticationToken(newToken); - } - return authentication; - } - private Mono checkAndRebuild(JwtAuthenticationToken jat) { var jwt = jat.getToken(); var patName = jwt.getClaimAsString("pat_name"); diff --git a/application/src/main/java/run/halo/app/security/authentication/pat/PatEndpoint.java b/application/src/main/java/run/halo/app/security/authentication/pat/PatEndpoint.java index 62d1c0a27c..0a1578b3aa 100644 --- a/application/src/main/java/run/halo/app/security/authentication/pat/PatEndpoint.java +++ b/application/src/main/java/run/halo/app/security/authentication/pat/PatEndpoint.java @@ -15,7 +15,7 @@ import run.halo.app.security.PersonalAccessToken; @Component -public class PatEndpoint implements CustomEndpoint { +class PatEndpoint implements CustomEndpoint { private final UserScopedPatHandler patHandler; diff --git a/application/src/main/java/run/halo/app/security/authentication/pat/PatSecurityConfigurer.java b/application/src/main/java/run/halo/app/security/authentication/pat/PatSecurityConfigurer.java new file mode 100644 index 0000000000..275f2ae92f --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/pat/PatSecurityConfigurer.java @@ -0,0 +1,45 @@ +package run.halo.app.security.authentication.pat; + +import org.springframework.core.annotation.Order; +import org.springframework.security.config.web.server.SecurityWebFiltersOrder; +import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.oauth2.server.resource.web.server.BearerTokenServerAuthenticationEntryPoint; +import org.springframework.security.web.server.authentication.AuthenticationWebFilter; +import org.springframework.security.web.server.authentication.ServerAuthenticationEntryPointFailureHandler; +import org.springframework.stereotype.Component; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.security.authentication.CryptoService; +import run.halo.app.security.authentication.SecurityConfigurer; + +/** + * PAT security configurer. + * + * @author johnniang + * @since 2.20.4 + */ +@Component +// Specific an order here to control the order or security configurer initialization +@Order(0) +class PatSecurityConfigurer implements SecurityConfigurer { + + private final ReactiveExtensionClient client; + + private final CryptoService cryptoService; + + public PatSecurityConfigurer(ReactiveExtensionClient client, CryptoService cryptoService) { + this.client = client; + this.cryptoService = cryptoService; + } + + @Override + public void configure(ServerHttpSecurity http) { + var filter = + new AuthenticationWebFilter(new PatAuthenticationManager(client, cryptoService)); + filter.setServerAuthenticationConverter(new PatAuthenticationConverter()); + var entryPoint = new BearerTokenServerAuthenticationEntryPoint(); + var failureHandler = new ServerAuthenticationEntryPointFailureHandler(entryPoint); + filter.setAuthenticationFailureHandler(failureHandler); + http.addFilterAt(filter, SecurityWebFiltersOrder.AUTHENTICATION); + } + +} diff --git a/application/src/main/java/run/halo/app/security/authentication/pat/PatServerWebExchangeMatcher.java b/application/src/main/java/run/halo/app/security/authentication/pat/PatServerWebExchangeMatcher.java deleted file mode 100644 index 4d8a7e1772..0000000000 --- a/application/src/main/java/run/halo/app/security/authentication/pat/PatServerWebExchangeMatcher.java +++ /dev/null @@ -1,30 +0,0 @@ -package run.halo.app.security.authentication.pat; - -import org.apache.commons.lang3.StringUtils; -import org.springframework.security.core.AuthenticationException; -import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthenticationToken; -import org.springframework.security.oauth2.server.resource.web.server.authentication.ServerBearerTokenAuthenticationConverter; -import org.springframework.security.web.server.authentication.ServerAuthenticationConverter; -import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; -import org.springframework.web.server.ServerWebExchange; -import reactor.core.publisher.Mono; - -public class PatServerWebExchangeMatcher implements ServerWebExchangeMatcher { - - public static final String PAT_TOKEN_PREFIX = "pat_"; - - private final ServerAuthenticationConverter authConverter = - new ServerBearerTokenAuthenticationConverter(); - - @Override - public Mono matches(ServerWebExchange exchange) { - return authConverter.convert(exchange) - .filter(a -> a instanceof BearerTokenAuthenticationToken) - .cast(BearerTokenAuthenticationToken.class) - .map(BearerTokenAuthenticationToken::getToken) - .filter(tokenString -> StringUtils.startsWith(tokenString, PAT_TOKEN_PREFIX)) - .flatMap(t -> MatchResult.match()) - .onErrorResume(AuthenticationException.class, t -> MatchResult.notMatch()) - .switchIfEmpty(Mono.defer(MatchResult::notMatch)); - } -} diff --git a/application/src/main/java/run/halo/app/security/authentication/pat/impl/UserScopedPatHandlerImpl.java b/application/src/main/java/run/halo/app/security/authentication/pat/UserScopedPatHandlerImpl.java similarity index 93% rename from application/src/main/java/run/halo/app/security/authentication/pat/impl/UserScopedPatHandlerImpl.java rename to application/src/main/java/run/halo/app/security/authentication/pat/UserScopedPatHandlerImpl.java index a7dfd8b010..ce71890ab0 100644 --- a/application/src/main/java/run/halo/app/security/authentication/pat/impl/UserScopedPatHandlerImpl.java +++ b/application/src/main/java/run/halo/app/security/authentication/pat/UserScopedPatHandlerImpl.java @@ -1,7 +1,7 @@ -package run.halo.app.security.authentication.pat.impl; +package run.halo.app.security.authentication.pat; import static run.halo.app.extension.Comparators.compareCreationTimestamp; -import static run.halo.app.security.authentication.pat.PatServerWebExchangeMatcher.PAT_TOKEN_PREFIX; +import static run.halo.app.security.authentication.pat.PatAuthenticationConverter.PAT_TOKEN_PREFIX; import com.nimbusds.jose.jwk.JWKSet; import com.nimbusds.jose.jwk.source.ImmutableJWKSet; @@ -11,6 +11,8 @@ import java.util.List; import java.util.Objects; import java.util.function.Predicate; +import org.springframework.security.authentication.AuthenticationTrustResolver; +import org.springframework.security.authentication.AuthenticationTrustResolverImpl; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.context.ReactiveSecurityContextHolder; @@ -29,7 +31,7 @@ import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.server.ServerWebInputException; import reactor.core.publisher.Mono; -import run.halo.app.core.extension.service.RoleService; +import run.halo.app.core.user.service.RoleService; import run.halo.app.extension.ExtensionUtil; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; @@ -39,11 +41,10 @@ import run.halo.app.infra.exception.NotFoundException; import run.halo.app.security.PersonalAccessToken; import run.halo.app.security.authentication.CryptoService; -import run.halo.app.security.authentication.pat.UserScopedPatHandler; import run.halo.app.security.authorization.AuthorityUtils; @Service -public class UserScopedPatHandlerImpl implements UserScopedPatHandler { +class UserScopedPatHandlerImpl implements UserScopedPatHandler { private static final String ACCESS_TOKEN_ANNO_NAME = "security.halo.run/access-token"; @@ -64,6 +65,9 @@ public class UserScopedPatHandlerImpl implements UserScopedPatHandler { private Clock clock; + private final AuthenticationTrustResolver authTrustResolver = + new AuthenticationTrustResolverImpl(); + public UserScopedPatHandlerImpl(ReactiveExtensionClient client, CryptoService cryptoService, ExternalUrlSupplier externalUrl, @@ -84,8 +88,8 @@ public void setClock(Clock clock) { this.clock = clock; } - private static Mono mustBeRealUser(Mono authentication) { - return authentication.filter(AuthorityUtils::isRealUser) + private Mono mustBeAuthenticated(Mono authentication) { + return authentication.filter(authTrustResolver::isAuthenticated) // Non-username-password authentication could not access the API at any time. .switchIfEmpty(Mono.error(AccessDeniedException::new)); } @@ -94,7 +98,7 @@ private static Mono mustBeRealUser(Mono authenti public Mono create(ServerRequest request) { return ReactiveSecurityContextHolder.getContext() .map(SecurityContext::getAuthentication) - .transform(UserScopedPatHandlerImpl::mustBeRealUser) + .transform(this::mustBeAuthenticated) .flatMap(auth -> request.bodyToMono(PersonalAccessToken.class) .switchIfEmpty( Mono.error(() -> new ServerWebInputException("Missing request body."))) @@ -222,7 +226,7 @@ public Mono delete(ServerRequest request) { public Mono restore(ServerRequest request) { var restoredPat = ReactiveSecurityContextHolder.getContext() .map(SecurityContext::getAuthentication) - .transform(UserScopedPatHandlerImpl::mustBeRealUser) + .transform(this::mustBeAuthenticated) .flatMap(auth -> { var name = request.pathVariable("name"); return getPat(name, auth.getName()); diff --git a/application/src/main/java/run/halo/app/security/authentication/rememberme/RememberMeConfigurer.java b/application/src/main/java/run/halo/app/security/authentication/rememberme/RememberMeConfigurer.java index 97cbc683c1..7e2dbf0583 100644 --- a/application/src/main/java/run/halo/app/security/authentication/rememberme/RememberMeConfigurer.java +++ b/application/src/main/java/run/halo/app/security/authentication/rememberme/RememberMeConfigurer.java @@ -3,6 +3,7 @@ import static org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher.MatchResult; import lombok.RequiredArgsConstructor; +import org.springframework.core.annotation.Order; import org.springframework.security.config.web.server.SecurityWebFiltersOrder; import org.springframework.security.config.web.server.ServerHttpSecurity; import org.springframework.security.core.context.ReactiveSecurityContextHolder; @@ -13,6 +14,7 @@ @Component @RequiredArgsConstructor +@Order(0) public class RememberMeConfigurer implements SecurityConfigurer { private final RememberMeServices rememberMeServices; diff --git a/application/src/main/java/run/halo/app/security/authentication/rememberme/RememberMeRequestCache.java b/application/src/main/java/run/halo/app/security/authentication/rememberme/RememberMeRequestCache.java new file mode 100644 index 0000000000..846afb8215 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/rememberme/RememberMeRequestCache.java @@ -0,0 +1,39 @@ +package run.halo.app.security.authentication.rememberme; + +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +/** + * An interface for caching remember-me parameter in request for further handling. Especially + * useful for two-factor authentication. + * + * @author johnniang + * @since 2.20.0 + */ +public interface RememberMeRequestCache { + + /** + * Save remember-me parameter or form into cache. + * + * @param exchange exchange + * @return empty to return + */ + Mono saveRememberMe(ServerWebExchange exchange); + + /** + * Check if remember-me parameter exists in cache. + * + * @param exchange exchange + * @return true if remember-me exists, false otherwise + */ + Mono isRememberMe(ServerWebExchange exchange); + + /** + * Remove remember-me parameter from cache. + * + * @param exchange exchange + * @return empty to return + */ + Mono removeRememberMe(ServerWebExchange exchange); + +} diff --git a/application/src/main/java/run/halo/app/security/authentication/rememberme/TokenBasedRememberMeServices.java b/application/src/main/java/run/halo/app/security/authentication/rememberme/TokenBasedRememberMeServices.java index 17e67308f0..6de95ecf44 100644 --- a/application/src/main/java/run/halo/app/security/authentication/rememberme/TokenBasedRememberMeServices.java +++ b/application/src/main/java/run/halo/app/security/authentication/rememberme/TokenBasedRememberMeServices.java @@ -1,8 +1,5 @@ package run.halo.app.security.authentication.rememberme; -import static org.apache.commons.lang3.BooleanUtils.isTrue; -import static org.apache.commons.lang3.BooleanUtils.toBoolean; - import java.net.URLDecoder; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; @@ -58,17 +55,13 @@ @RequiredArgsConstructor public class TokenBasedRememberMeServices implements ServerLogoutHandler, RememberMeServices { - public static final int TWO_WEEKS_S = 1209600; - - public static final String DEFAULT_PARAMETER = "remember-me"; - public static final String DEFAULT_ALGORITHM = "SHA-256"; private static final String DELIMITER = ":"; protected final CookieSignatureKeyResolver cookieSignatureKeyResolver; - protected final ReactiveUserDetailsService userDetailsService; + private final ReactiveUserDetailsService userDetailsService; protected final RememberMeCookieResolver rememberMeCookieResolver; @@ -76,6 +69,8 @@ public class TokenBasedRememberMeServices implements ServerLogoutHandler, Rememb private GrantedAuthoritiesMapper authoritiesMapper = new NullAuthoritiesMapper(); + private RememberMeRequestCache rememberMeRequestCache = new WebSessionRememberMeRequestCache(); + private static boolean equals(String expected, String actual) { byte[] expectedBytes = bytesUtf8(expected); byte[] actualBytes = bytesUtf8(actual); @@ -208,17 +203,18 @@ private boolean isValidCookieTokensLength(String[] cookieTokens) { public Mono loginFail(ServerWebExchange exchange) { log.debug("Interactive login attempt was unsuccessful."); cancelCookie(exchange); - return Mono.empty(); + return rememberMeRequestCache.saveRememberMe(exchange); } @Override public Mono loginSuccess(ServerWebExchange exchange, Authentication successfulAuthentication) { - if (!rememberMeRequested(exchange)) { - log.debug("Remember-me login not requested."); - return Mono.empty(); - } - return onLoginSuccess(exchange, successfulAuthentication); + return rememberMeRequestCache.isRememberMe(exchange) + .filter(Boolean::booleanValue) + .switchIfEmpty(Mono.fromRunnable(() -> { + log.debug("Remember-me login not requested."); + })) + .flatMap(rememberMe -> onLoginSuccess(exchange, successfulAuthentication)); } protected Mono onLoginSuccess(ServerWebExchange exchange, @@ -282,18 +278,6 @@ protected long calculateExpireTime(ServerWebExchange exchange, return Instant.now().plusSeconds(tokenLifetime).toEpochMilli(); } - protected boolean rememberMeRequested(ServerWebExchange exchange) { - String rememberMe = exchange.getRequest().getQueryParams().getFirst(DEFAULT_PARAMETER); - if (isTrue(toBoolean(rememberMe))) { - return true; - } - if (log.isDebugEnabled()) { - log.debug("Did not send remember-me cookie (principal did not set parameter '{}')", - DEFAULT_PARAMETER); - } - return false; - } - protected String[] decodeCookie(String cookieValue) throws InvalidCookieException { int paddingCount = 4 - (cookieValue.length() % 4); if (paddingCount < 4) { diff --git a/application/src/main/java/run/halo/app/security/authentication/rememberme/WebSessionRememberMeRequestCache.java b/application/src/main/java/run/halo/app/security/authentication/rememberme/WebSessionRememberMeRequestCache.java new file mode 100644 index 0000000000..0152ad6901 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/rememberme/WebSessionRememberMeRequestCache.java @@ -0,0 +1,63 @@ +package run.halo.app.security.authentication.rememberme; + +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebSession; +import reactor.core.publisher.Mono; + +/** + * An implementation of {@link RememberMeRequestCache} that stores remember-me parameter in + * {@link WebSession}. + * + * @author johnniang + * @since 2.20.0 + */ +public class WebSessionRememberMeRequestCache implements RememberMeRequestCache { + + private static final String SESSION_ATTRIBUTE_NAME = + RememberMeRequestCache.class + ".REMEMBER_ME"; + + private static final String DEFAULT_PARAMETER = "remember-me"; + + @Override + public Mono saveRememberMe(ServerWebExchange exchange) { + return resolveFromQuery(exchange) + .switchIfEmpty(resolveFromForm(exchange)) + .flatMap(rememberMe -> exchange.getSession().doOnNext( + session -> session.getAttributes().put(SESSION_ATTRIBUTE_NAME, rememberMe)) + ) + .then(); + } + + @Override + public Mono isRememberMe(ServerWebExchange exchange) { + return resolveFromQuery(exchange) + .switchIfEmpty(resolveFromForm(exchange)) + .switchIfEmpty(resolveFromSession(exchange)) + .defaultIfEmpty(false); + } + + @Override + public Mono removeRememberMe(ServerWebExchange exchange) { + return exchange.getSession() + .doOnNext(session -> session.getAttributes().remove(SESSION_ATTRIBUTE_NAME)) + .then(); + } + + private Mono resolveFromQuery(ServerWebExchange exchange) { + return Mono.justOrEmpty(exchange.getRequest().getQueryParams().getFirst(DEFAULT_PARAMETER)) + .map(Boolean::parseBoolean); + } + + private Mono resolveFromForm(ServerWebExchange exchange) { + return exchange.getFormData() + .mapNotNull(form -> form.getFirst(DEFAULT_PARAMETER)) + .map(Boolean::parseBoolean); + } + + private Mono resolveFromSession(ServerWebExchange exchange) { + return exchange.getSession() + .mapNotNull(session -> session.getAttribute(SESSION_ATTRIBUTE_NAME)) + .filter(Boolean.class::isInstance) + .cast(Boolean.class); + } +} diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/TotpAuthenticationSuccessHandler.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/TotpAuthenticationSuccessHandler.java new file mode 100644 index 0000000000..8e67f7e8f7 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/twofactor/TotpAuthenticationSuccessHandler.java @@ -0,0 +1,33 @@ +package run.halo.app.security.authentication.twofactor; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.server.WebFilterExchange; +import org.springframework.security.web.server.authentication.RedirectServerAuthenticationSuccessHandler; +import org.springframework.security.web.server.authentication.ServerAuthenticationSuccessHandler; +import org.springframework.security.web.server.savedrequest.ServerRequestCache; +import reactor.core.publisher.Mono; +import run.halo.app.security.LoginHandlerEnhancer; + +@Slf4j +public class TotpAuthenticationSuccessHandler implements ServerAuthenticationSuccessHandler { + + private final LoginHandlerEnhancer loginEnhancer; + + private final ServerAuthenticationSuccessHandler successHandler; + + public TotpAuthenticationSuccessHandler(LoginHandlerEnhancer loginEnhancer, + ServerRequestCache serverRequestCache) { + this.loginEnhancer = loginEnhancer; + var successHandler = new RedirectServerAuthenticationSuccessHandler("/uc"); + successHandler.setRequestCache(serverRequestCache); + this.successHandler = successHandler; + } + + @Override + public Mono onAuthenticationSuccess(WebFilterExchange webFilterExchange, + Authentication authentication) { + return loginEnhancer.onLoginSuccess(webFilterExchange.getExchange(), authentication) + .then(successHandler.onAuthenticationSuccess(webFilterExchange, authentication)); + } +} diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthEndpoint.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthEndpoint.java index 991faadffa..31e9198781 100644 --- a/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthEndpoint.java +++ b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthEndpoint.java @@ -15,7 +15,6 @@ import org.springframework.security.core.context.SecurityContext; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Component; -import org.springframework.validation.BeanPropertyBindingResult; import org.springframework.validation.Validator; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerRequest; @@ -25,10 +24,11 @@ import reactor.core.publisher.Mono; import run.halo.app.core.extension.User; import run.halo.app.core.extension.endpoint.CustomEndpoint; -import run.halo.app.core.extension.service.UserService; +import run.halo.app.core.user.service.UserService; import run.halo.app.extension.GroupVersion; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.ExternalUrlSupplier; +import run.halo.app.infra.ValidationUtils; import run.halo.app.infra.exception.AccessDeniedException; import run.halo.app.infra.exception.RequestBodyValidationException; import run.halo.app.security.authentication.twofactor.totp.TotpAuthService; @@ -108,8 +108,9 @@ public RouterFunction endpoint() { private Mono deleteTotp(ServerRequest request) { var totpDeleteRequestMono = request.bodyToMono(PasswordRequest.class) .switchIfEmpty(Mono.error(() -> new ServerWebInputException("Request body required"))) - .doOnNext( - passwordRequest -> this.validateRequest(passwordRequest, "passwordRequest")); + .doOnNext(passwordRequest -> this.validateRequest(passwordRequest, "passwordRequest", + request) + ); var twoFactorAuthSettings = totpDeleteRequestMono.flatMap(passwordRequest -> getCurrentUser() @@ -148,7 +149,8 @@ private Mono enableTwoFactor(ServerRequest request) { private Mono toggleTwoFactor(ServerRequest request, boolean enabled) { return request.bodyToMono(PasswordRequest.class) .switchIfEmpty(Mono.error(() -> new ServerWebInputException("Request body required"))) - .doOnNext(passwordRequest -> this.validateRequest(passwordRequest, "passwordRequest")) + .doOnNext(passwordRequest -> this.validateRequest(passwordRequest, + "passwordRequest", request)) .flatMap(passwordRequest -> getCurrentUser() .filter(user -> { var encodedPassword = user.getSpec().getPassword(); @@ -199,7 +201,7 @@ public static class TotpAuthLinkResponse { private Mono configureTotp(ServerRequest request) { var totpRequestMono = request.bodyToMono(TotpRequest.class) .switchIfEmpty(Mono.error(() -> new ServerWebInputException("Request body required."))) - .doOnNext(totpRequest -> this.validateRequest(totpRequest, "totp")); + .doOnNext(totpRequest -> this.validateRequest(totpRequest, "totp", request)); var configuredUser = totpRequestMono.flatMap(totpRequest -> { // validate password @@ -235,11 +237,11 @@ private Mono configureTotp(ServerRequest request) { return ServerResponse.ok().body(twoFactorAuthSettings, TwoFactorAuthSettings.class); } - private void validateRequest(Object target, String name) { - var errors = new BeanPropertyBindingResult(target, name); - validator.validate(target, errors); - if (errors.hasErrors()) { - throw new RequestBodyValidationException(errors); + private void validateRequest(Object target, String name, ServerRequest request) { + var bindingResult = + ValidationUtils.validate(target, name, validator, request.exchange()); + if (bindingResult.hasErrors()) { + throw new RequestBodyValidationException(bindingResult); } } diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthResponseHandler.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthResponseHandler.java deleted file mode 100644 index a4216a4831..0000000000 --- a/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthResponseHandler.java +++ /dev/null @@ -1,10 +0,0 @@ -package run.halo.app.security.authentication.twofactor; - -import org.springframework.web.server.ServerWebExchange; -import reactor.core.publisher.Mono; - -public interface TwoFactorAuthResponseHandler { - - Mono handle(ServerWebExchange exchange); - -} diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthSecurityConfigurer.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthSecurityConfigurer.java index d8dd6a770a..571d49043f 100644 --- a/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthSecurityConfigurer.java +++ b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthSecurityConfigurer.java @@ -1,47 +1,61 @@ package run.halo.app.security.authentication.twofactor; -import org.springframework.context.MessageSource; +import static org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers.pathMatchers; + +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpMethod; import org.springframework.security.config.web.server.SecurityWebFiltersOrder; import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.web.server.authentication.AuthenticationWebFilter; +import org.springframework.security.web.server.authentication.RedirectServerAuthenticationFailureHandler; import org.springframework.security.web.server.context.ServerSecurityContextRepository; +import org.springframework.security.web.server.savedrequest.ServerRequestCache; import org.springframework.stereotype.Component; -import org.springframework.web.reactive.function.server.ServerResponse; import run.halo.app.security.LoginHandlerEnhancer; import run.halo.app.security.authentication.SecurityConfigurer; import run.halo.app.security.authentication.twofactor.totp.TotpAuthService; -import run.halo.app.security.authentication.twofactor.totp.TotpAuthenticationFilter; +import run.halo.app.security.authentication.twofactor.totp.TotpAuthenticationManager; +import run.halo.app.security.authentication.twofactor.totp.TotpCodeAuthenticationConverter; @Component +@Order(0) public class TwoFactorAuthSecurityConfigurer implements SecurityConfigurer { private final ServerSecurityContextRepository securityContextRepository; private final TotpAuthService totpAuthService; - private final ServerResponse.Context context; - - private final MessageSource messageSource; - private final LoginHandlerEnhancer loginHandlerEnhancer; + private final ServerRequestCache serverRequestCache; + public TwoFactorAuthSecurityConfigurer( ServerSecurityContextRepository securityContextRepository, - TotpAuthService totpAuthService, - ServerResponse.Context context, - MessageSource messageSource, - LoginHandlerEnhancer loginHandlerEnhancer + TotpAuthService totpAuthService, LoginHandlerEnhancer loginHandlerEnhancer, + ServerRequestCache serverRequestCache ) { this.securityContextRepository = securityContextRepository; this.totpAuthService = totpAuthService; - this.context = context; - this.messageSource = messageSource; this.loginHandlerEnhancer = loginHandlerEnhancer; + this.serverRequestCache = serverRequestCache; } @Override public void configure(ServerHttpSecurity http) { - var filter = new TotpAuthenticationFilter(securityContextRepository, totpAuthService, - context, messageSource, loginHandlerEnhancer); - http.addFilterAfter(filter, SecurityWebFiltersOrder.AUTHENTICATION); + var authManager = new TotpAuthenticationManager(totpAuthService); + var filter = new AuthenticationWebFilter(authManager); + filter.setRequiresAuthenticationMatcher( + pathMatchers(HttpMethod.POST, "/challenges/two-factor/totp") + ); + filter.setSecurityContextRepository(securityContextRepository); + filter.setServerAuthenticationConverter(new TotpCodeAuthenticationConverter()); + filter.setAuthenticationSuccessHandler( + new TotpAuthenticationSuccessHandler(loginHandlerEnhancer, serverRequestCache) + ); + filter.setAuthenticationFailureHandler( + new RedirectServerAuthenticationFailureHandler("/challenges/two-factor/totp?error") + ); + http.addFilterAt(filter, SecurityWebFiltersOrder.AUTHENTICATION); } + } diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthentication.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthentication.java index 45a27e66b0..b9da3183ea 100644 --- a/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthentication.java +++ b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthentication.java @@ -38,8 +38,8 @@ public Object getPrincipal() { @Override public boolean isAuthenticated() { - // return true for accessing anonymous resources - return true; + // for further authentication + return false; } public Authentication getPrevious() { diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/DefaultTwoFactorAuthResponseHandler.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthenticationEntryPoint.java similarity index 60% rename from application/src/main/java/run/halo/app/security/authentication/twofactor/DefaultTwoFactorAuthResponseHandler.java rename to application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthenticationEntryPoint.java index 9de2be00fe..08b44887e4 100644 --- a/application/src/main/java/run/halo/app/security/authentication/twofactor/DefaultTwoFactorAuthResponseHandler.java +++ b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthenticationEntryPoint.java @@ -2,21 +2,30 @@ import java.net.URI; import org.springframework.context.MessageSource; +import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.server.DefaultServerRedirectStrategy; +import org.springframework.security.web.server.ServerAuthenticationEntryPoint; import org.springframework.security.web.server.ServerRedirectStrategy; import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; -import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; import run.halo.app.infra.exception.Exceptions; -@Component -public class DefaultTwoFactorAuthResponseHandler implements TwoFactorAuthResponseHandler { +public class TwoFactorAuthenticationEntryPoint implements ServerAuthenticationEntryPoint { - private final ServerRedirectStrategy redirectStrategy = new DefaultServerRedirectStrategy(); + public static ServerWebExchangeMatcher MATCHER = exchange -> exchange.getPrincipal() + .filter(TwoFactorAuthentication.class::isInstance) + .flatMap(a -> ServerWebExchangeMatcher.MatchResult.match()) + .switchIfEmpty(ServerWebExchangeMatcher.MatchResult.notMatch()); + + private static final URI REDIRECT_LOCATION = URI.create("/challenges/two-factor/totp"); - private static final String REDIRECT_LOCATION = "/console/login/mfa"; + /** + * Because we don't want to cache the request before redirecting to the 2FA page, + * ServerRedirectStrategy is used to redirect the request. + */ + private final ServerRedirectStrategy redirectStrategy = new DefaultServerRedirectStrategy(); private final MessageSource messageSource; @@ -30,27 +39,26 @@ public class DefaultTwoFactorAuthResponseHandler implements TwoFactorAuthRespons return ServerWebExchangeMatcher.MatchResult.notMatch(); }; - public DefaultTwoFactorAuthResponseHandler(MessageSource messageSource, + public TwoFactorAuthenticationEntryPoint(MessageSource messageSource, ServerResponse.Context context) { this.messageSource = messageSource; this.context = context; } @Override - public Mono handle(ServerWebExchange exchange) { + public Mono commence(ServerWebExchange exchange, AuthenticationException ex) { return XHR_MATCHER.matches(exchange) .filter(ServerWebExchangeMatcher.MatchResult::isMatch) - .switchIfEmpty(Mono.defer( - () -> redirectStrategy.sendRedirect(exchange, URI.create(REDIRECT_LOCATION)) - .then(Mono.empty()))) + .switchIfEmpty( + redirectStrategy.sendRedirect(exchange, REDIRECT_LOCATION).then(Mono.empty()) + ) .flatMap(isXhr -> { var errorResponse = Exceptions.createErrorResponse( - new TwoFactorAuthRequiredException(URI.create(REDIRECT_LOCATION)), + new TwoFactorAuthRequiredException(REDIRECT_LOCATION), null, exchange, messageSource); return ServerResponse.status(errorResponse.getStatusCode()) .bodyValue(errorResponse.getBody()) .flatMap(response -> response.writeTo(exchange, context)); }); } - } diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthorizationManager.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthorizationManager.java deleted file mode 100644 index f716717a46..0000000000 --- a/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthorizationManager.java +++ /dev/null @@ -1,36 +0,0 @@ -package run.halo.app.security.authentication.twofactor; - -import java.net.URI; -import org.springframework.security.authorization.AuthorizationDecision; -import org.springframework.security.authorization.ReactiveAuthorizationManager; -import org.springframework.security.core.Authentication; -import org.springframework.security.web.server.authorization.AuthorizationContext; -import reactor.core.publisher.Mono; - -public class TwoFactorAuthorizationManager - implements ReactiveAuthorizationManager { - - private final ReactiveAuthorizationManager delegate; - - private static final URI REDIRECT_LOCATION = URI.create("/console/login?2fa=totp"); - - public TwoFactorAuthorizationManager( - ReactiveAuthorizationManager delegate) { - this.delegate = delegate; - } - - @Override - public Mono check(Mono authentication, - AuthorizationContext context) { - return authentication.flatMap(a -> { - Mono checked = delegate.check(Mono.just(a), context); - if (a instanceof TwoFactorAuthentication) { - checked = checked.filter(AuthorizationDecision::isGranted) - .switchIfEmpty( - Mono.error(() -> new TwoFactorAuthRequiredException(REDIRECT_LOCATION))); - } - return checked; - }); - } - -} diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpAuthenticationFilter.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpAuthenticationFilter.java deleted file mode 100644 index b2140007dd..0000000000 --- a/application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpAuthenticationFilter.java +++ /dev/null @@ -1,137 +0,0 @@ -package run.halo.app.security.authentication.twofactor.totp; - -import static org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers.pathMatchers; - -import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.StringUtils; -import org.springframework.context.MessageSource; -import org.springframework.http.HttpMethod; -import org.springframework.security.authentication.ReactiveAuthenticationManager; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.AuthenticationException; -import org.springframework.security.core.CredentialsContainer; -import org.springframework.security.core.context.ReactiveSecurityContextHolder; -import org.springframework.security.core.context.SecurityContext; -import org.springframework.security.web.server.authentication.AuthenticationWebFilter; -import org.springframework.security.web.server.authentication.ServerAuthenticationConverter; -import org.springframework.security.web.server.context.ServerSecurityContextRepository; -import org.springframework.web.reactive.function.server.ServerResponse; -import org.springframework.web.server.ServerWebExchange; -import reactor.core.publisher.Mono; -import run.halo.app.security.HaloUserDetails; -import run.halo.app.security.LoginHandlerEnhancer; -import run.halo.app.security.authentication.login.UsernamePasswordHandler; -import run.halo.app.security.authentication.twofactor.TwoFactorAuthentication; - -@Slf4j -public class TotpAuthenticationFilter extends AuthenticationWebFilter { - - public TotpAuthenticationFilter( - ServerSecurityContextRepository securityContextRepository, - TotpAuthService totpAuthService, - ServerResponse.Context context, - MessageSource messageSource, - LoginHandlerEnhancer loginHandlerEnhancer - ) { - super(new TwoFactorAuthManager(totpAuthService)); - - setSecurityContextRepository(securityContextRepository); - setRequiresAuthenticationMatcher(pathMatchers(HttpMethod.POST, "/login/2fa/totp")); - setServerAuthenticationConverter(new TotpCodeAuthenticationConverter()); - - var handler = new UsernamePasswordHandler(context, messageSource, loginHandlerEnhancer); - setAuthenticationSuccessHandler(handler); - setAuthenticationFailureHandler(handler); - } - - private static class TotpCodeAuthenticationConverter implements ServerAuthenticationConverter { - - private final String codeParameter = "code"; - - @Override - public Mono convert(ServerWebExchange exchange) { - // Check the request is authenticated before. - return ReactiveSecurityContextHolder.getContext() - .map(SecurityContext::getAuthentication) - .filter(TwoFactorAuthentication.class::isInstance) - .switchIfEmpty(Mono.error( - () -> new TwoFactorAuthException("MFA Authentication required."))) - .flatMap(authentication -> exchange.getFormData()) - .handle((formData, sink) -> { - var codeStr = formData.getFirst(codeParameter); - if (StringUtils.isBlank(codeStr)) { - sink.error(new TwoFactorAuthException("Empty code parameter.")); - return; - } - try { - var code = Integer.parseInt(codeStr); - sink.next(new TotpAuthenticationToken(code)); - } catch (NumberFormatException e) { - sink.error( - new TwoFactorAuthException("Invalid code parameter " + codeStr + '.')); - } - }); - } - } - - private static class TwoFactorAuthException extends AuthenticationException { - - public TwoFactorAuthException(String msg, Throwable cause) { - super(msg, cause); - } - - public TwoFactorAuthException(String msg) { - super(msg); - } - - } - - private static class TwoFactorAuthManager implements ReactiveAuthenticationManager { - - private final TotpAuthService totpAuthService; - - private TwoFactorAuthManager(TotpAuthService totpAuthService) { - this.totpAuthService = totpAuthService; - } - - @Override - public Mono authenticate(Authentication authentication) { - // it should be TotpAuthenticationToken - var code = (Integer) authentication.getCredentials(); - log.debug("Got TOTP code {}", code); - - // get user details - return ReactiveSecurityContextHolder.getContext() - .map(SecurityContext::getAuthentication) - .cast(TwoFactorAuthentication.class) - .map(TwoFactorAuthentication::getPrevious) - .flatMap(previousAuth -> { - var principal = previousAuth.getPrincipal(); - if (!(principal instanceof HaloUserDetails user)) { - return Mono.error( - new TwoFactorAuthException("Invalid authentication principal.") - ); - } - var totpEncryptedSecret = user.getTotpEncryptedSecret(); - if (StringUtils.isBlank(totpEncryptedSecret)) { - return Mono.error( - new TwoFactorAuthException("TOTP secret not configured.") - ); - } - var rawSecret = totpAuthService.decryptSecret(totpEncryptedSecret); - var validated = totpAuthService.validateTotp(rawSecret, code); - if (!validated) { - return Mono.error(new TwoFactorAuthException("Invalid TOTP code " + code)); - } - if (log.isDebugEnabled()) { - log.debug("TOTP authentication for {} with code {} successfully.", - previousAuth.getName(), code); - } - if (previousAuth instanceof CredentialsContainer container) { - container.eraseCredentials(); - } - return Mono.just(previousAuth); - }); - } - } -} diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpAuthenticationManager.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpAuthenticationManager.java new file mode 100644 index 0000000000..e9fffb96f8 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpAuthenticationManager.java @@ -0,0 +1,69 @@ +package run.halo.app.security.authentication.twofactor.totp; + +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.security.authentication.ReactiveAuthenticationManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.CredentialsContainer; +import org.springframework.security.core.context.ReactiveSecurityContextHolder; +import org.springframework.security.core.context.SecurityContext; +import reactor.core.publisher.Mono; +import run.halo.app.security.HaloUserDetails; +import run.halo.app.security.authentication.exception.TwoFactorAuthException; +import run.halo.app.security.authentication.twofactor.TwoFactorAuthentication; + +/** + * TOTP authentication manager. + * + * @author johnniang + */ +@Slf4j +public class TotpAuthenticationManager implements ReactiveAuthenticationManager { + + private final TotpAuthService totpAuthService; + + public TotpAuthenticationManager(TotpAuthService totpAuthService) { + this.totpAuthService = totpAuthService; + } + + @Override + public Mono authenticate(Authentication authentication) { + // it should be TotpAuthenticationToken + var code = (Integer) authentication.getCredentials(); + log.debug("Got TOTP code {}", code); + + // get user details + return ReactiveSecurityContextHolder.getContext() + .map(SecurityContext::getAuthentication) + .cast(TwoFactorAuthentication.class) + .map(TwoFactorAuthentication::getPrevious) + .flatMap(previousAuth -> { + var principal = previousAuth.getPrincipal(); + if (!(principal instanceof HaloUserDetails user)) { + return Mono.error( + new TwoFactorAuthException("Invalid authentication principal.") + ); + } + var totpEncryptedSecret = user.getTotpEncryptedSecret(); + if (StringUtils.isBlank(totpEncryptedSecret)) { + return Mono.error( + new TwoFactorAuthException("TOTP secret not configured.") + ); + } + var rawSecret = totpAuthService.decryptSecret(totpEncryptedSecret); + var validated = totpAuthService.validateTotp(rawSecret, code); + if (!validated) { + return Mono.error(new TwoFactorAuthException("Invalid TOTP code " + code)); + } + if (log.isDebugEnabled()) { + log.debug( + "TOTP authentication for {} with code {} successfully.", + previousAuth.getName(), code); + } + if (previousAuth instanceof CredentialsContainer container) { + container.eraseCredentials(); + } + return Mono.just(previousAuth); + }); + } +} diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpCodeAuthenticationConverter.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpCodeAuthenticationConverter.java new file mode 100644 index 0000000000..9adc9061f7 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpCodeAuthenticationConverter.java @@ -0,0 +1,52 @@ +package run.halo.app.security.authentication.twofactor.totp; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.ReactiveSecurityContextHolder; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.web.server.authentication.ServerAuthenticationConverter; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; +import run.halo.app.security.authentication.exception.TwoFactorAuthException; +import run.halo.app.security.authentication.twofactor.TwoFactorAuthentication; + +/** + * TOTP code authentication converter. + * + * @author johnniang + */ +public class TotpCodeAuthenticationConverter implements ServerAuthenticationConverter { + + private final String codeParameter = "code"; + + @Override + public Mono convert(ServerWebExchange exchange) { + // Check the request is authenticated before. + return ReactiveSecurityContextHolder.getContext() + .map(SecurityContext::getAuthentication) + .filter(TwoFactorAuthentication.class::isInstance) + .switchIfEmpty(Mono.error( + () -> new TwoFactorAuthException( + "MFA Authentication required." + )) + ) + .flatMap(authentication -> exchange.getFormData()) + .handle((formData, sink) -> { + var codeStr = formData.getFirst(codeParameter); + if (StringUtils.isBlank(codeStr)) { + sink.error(new TwoFactorAuthException( + "Empty code parameter." + )); + return; + } + try { + var code = Integer.parseInt(codeStr); + sink.next(new TotpAuthenticationToken(code)); + } catch (NumberFormatException e) { + sink.error(new TwoFactorAuthException( + "Invalid code parameter " + codeStr + '.') + ); + } + }); + } +} diff --git a/application/src/main/java/run/halo/app/security/authorization/Attributes.java b/application/src/main/java/run/halo/app/security/authorization/Attributes.java index a68317ba76..55cf3f3976 100644 --- a/application/src/main/java/run/halo/app/security/authorization/Attributes.java +++ b/application/src/main/java/run/halo/app/security/authorization/Attributes.java @@ -1,7 +1,5 @@ package run.halo.app.security.authorization; -import java.security.Principal; - /** * Attributes is used by an Authorizer to get information about a request * that is used to make an authorization decision. @@ -10,10 +8,6 @@ * @since 2.0.0 */ public interface Attributes { - /** - * @return the UserDetails object to authorize - */ - Principal getPrincipal(); /** * @return the verb associated with API requests(this includes get, list, diff --git a/application/src/main/java/run/halo/app/security/authorization/AttributesRecord.java b/application/src/main/java/run/halo/app/security/authorization/AttributesRecord.java index 3fb6833671..af7eaf0637 100644 --- a/application/src/main/java/run/halo/app/security/authorization/AttributesRecord.java +++ b/application/src/main/java/run/halo/app/security/authorization/AttributesRecord.java @@ -1,23 +1,14 @@ package run.halo.app.security.authorization; -import java.security.Principal; - /** * @author guqing * @since 2.0.0 */ public class AttributesRecord implements Attributes { private final RequestInfo requestInfo; - private final Principal principal; - public AttributesRecord(Principal principal, RequestInfo requestInfo) { + public AttributesRecord(RequestInfo requestInfo) { this.requestInfo = requestInfo; - this.principal = principal; - } - - @Override - public Principal getPrincipal() { - return this.principal; } @Override diff --git a/application/src/main/java/run/halo/app/security/authorization/AuthorityUtils.java b/application/src/main/java/run/halo/app/security/authorization/AuthorityUtils.java index 64460ea9c2..846830aa0d 100644 --- a/application/src/main/java/run/halo/app/security/authorization/AuthorityUtils.java +++ b/application/src/main/java/run/halo/app/security/authorization/AuthorityUtils.java @@ -4,9 +4,6 @@ import java.util.Set; import java.util.stream.Collectors; import org.apache.commons.lang3.StringUtils; -import org.springframework.security.authentication.RememberMeAuthenticationToken; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; /** @@ -39,8 +36,8 @@ public static Set authoritiesToRoles( Collection authorities) { return authorities.stream() .map(GrantedAuthority::getAuthority) + .filter(authority -> StringUtils.startsWith(authority, ROLE_PREFIX)) .map(authority -> { - authority = StringUtils.removeStart(authority, SCOPE_PREFIX); authority = StringUtils.removeStart(authority, ROLE_PREFIX); return authority; }) @@ -51,14 +48,4 @@ public static boolean containsSuperRole(Collection roles) { return roles.contains(SUPER_ROLE_NAME); } - /** - * Check if the authentication is a real user. - * - * @param authentication current authentication - * @return true if the authentication is a real user; false otherwise - */ - public static boolean isRealUser(Authentication authentication) { - return authentication instanceof UsernamePasswordAuthenticationToken - || authentication instanceof RememberMeAuthenticationToken; - } } diff --git a/application/src/main/java/run/halo/app/security/authorization/AuthorizationExchangeConfigurers.java b/application/src/main/java/run/halo/app/security/authorization/AuthorizationExchangeConfigurers.java new file mode 100644 index 0000000000..d89391b02f --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authorization/AuthorizationExchangeConfigurers.java @@ -0,0 +1,110 @@ +package run.halo.app.security.authorization; + +import java.util.Collections; +import org.springframework.context.annotation.Bean; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.AuthenticationTrustResolver; +import org.springframework.security.authentication.AuthenticationTrustResolverImpl; +import org.springframework.security.authorization.AuthorizationDecision; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.server.util.matcher.AndServerWebExchangeMatcher; +import org.springframework.security.web.server.util.matcher.MediaTypeServerWebExchangeMatcher; +import org.springframework.security.web.server.util.matcher.NegatedServerWebExchangeMatcher; +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; +import run.halo.app.core.user.service.RoleService; +import run.halo.app.security.authentication.SecurityConfigurer; + +/** + * Authorization exchange configurers. + * + * @author johnniang + * @since 2.20.0 + */ +@Component +class AuthorizationExchangeConfigurers { + + private final AuthenticationTrustResolver authenticationTrustResolver = + new AuthenticationTrustResolverImpl(); + + @Bean + @Order(0) + SecurityConfigurer apiAuthorizationConfigurer(RoleService roleService) { + return http -> http.authorizeExchange( + spec -> spec.pathMatchers("/api/**", "/apis/**", "/actuator/**") + .access(new RequestInfoAuthorizationManager(roleService))); + } + + @Bean + @Order(100) + SecurityConfigurer unauthenticatedAuthorizationConfigurer() { + return http -> http.authorizeExchange(spec -> { + spec.pathMatchers(HttpMethod.GET, "/login", "/signup") + .access((authentication, context) -> authentication.map( + a -> !authenticationTrustResolver.isAuthenticated(a) + ) + .defaultIfEmpty(true) + .map(AuthorizationDecision::new)); + }); + } + + @Bean + @Order(200) + SecurityConfigurer preAuthenticationAuthorizationConfigurer() { + return http -> http.authorizeExchange(spec -> spec.pathMatchers( + "/login/**", + "/challenges/**", + "/password-reset/**", + "/signup" + ).permitAll()); + } + + @Bean + @Order(300) + SecurityConfigurer authenticatedAuthorizationConfigurer() { + // Anonymous user is not allowed + return http -> http.authorizeExchange( + spec -> spec.pathMatchers( + "/console/**", + "/uc/**", + "/logout" + ).authenticated() + ); + } + + @Bean + @Order(400) + SecurityConfigurer anonymousOrAuthenticatedAuthorizationConfigurer() { + return http -> http.authorizeExchange( + spec -> spec.matchers(createHtmlMatcher()).access((authentication, context) -> + // we only need to check the authentication is authenticated + // because we treat anonymous user as authenticated + authentication.map(Authentication::isAuthenticated) + .map(AuthorizationDecision::new) + .switchIfEmpty(Mono.fromSupplier(() -> new AuthorizationDecision(false))) + ) + ); + } + + @Bean + @Order + SecurityConfigurer permitAllAuthorizationConfigurer() { + return http -> http.authorizeExchange(spec -> spec.anyExchange().permitAll()); + } + + private static ServerWebExchangeMatcher createHtmlMatcher() { + ServerWebExchangeMatcher get = + ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, "/**"); + ServerWebExchangeMatcher notFavicon = new NegatedServerWebExchangeMatcher( + ServerWebExchangeMatchers.pathMatchers("/favicon.*")); + MediaTypeServerWebExchangeMatcher html = + new MediaTypeServerWebExchangeMatcher(MediaType.TEXT_HTML); + html.setIgnoredMediaTypes(Collections.singleton(MediaType.ALL)); + return new AndServerWebExchangeMatcher(get, notFavicon, html); + } + +} diff --git a/application/src/main/java/run/halo/app/security/authorization/DefaultRuleResolver.java b/application/src/main/java/run/halo/app/security/authorization/DefaultRuleResolver.java index 00cfdc14a2..d13b774dc2 100644 --- a/application/src/main/java/run/halo/app/security/authorization/DefaultRuleResolver.java +++ b/application/src/main/java/run/halo/app/security/authorization/DefaultRuleResolver.java @@ -7,7 +7,7 @@ import org.springframework.security.core.Authentication; import org.springframework.util.CollectionUtils; import reactor.core.publisher.Mono; -import run.halo.app.core.extension.service.RoleService; +import run.halo.app.core.user.service.RoleService; /** * @author guqing @@ -27,7 +27,7 @@ public DefaultRuleResolver(RoleService roleService) { public Mono visitRules(Authentication authentication, RequestInfo requestInfo) { var roleNames = AuthorityUtils.authoritiesToRoles(authentication.getAuthorities()); - var record = new AttributesRecord(authentication, requestInfo); + var record = new AttributesRecord(requestInfo); var visitor = new AuthorizingVisitor(record); // If the request is an userspace scoped request, diff --git a/application/src/main/java/run/halo/app/security/authorization/RequestInfoAuthorizationManager.java b/application/src/main/java/run/halo/app/security/authorization/RequestInfoAuthorizationManager.java index f450af7c47..d55fafe445 100644 --- a/application/src/main/java/run/halo/app/security/authorization/RequestInfoAuthorizationManager.java +++ b/application/src/main/java/run/halo/app/security/authorization/RequestInfoAuthorizationManager.java @@ -2,17 +2,16 @@ import java.util.List; import lombok.extern.slf4j.Slf4j; -import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.security.authorization.AuthorizationDecision; import org.springframework.security.authorization.ReactiveAuthorizationManager; import org.springframework.security.core.Authentication; import org.springframework.security.web.server.authorization.AuthorizationContext; import reactor.core.publisher.Mono; -import run.halo.app.core.extension.service.RoleService; +import run.halo.app.core.user.service.RoleService; @Slf4j public class RequestInfoAuthorizationManager - implements ReactiveAuthorizationManager { + implements ReactiveAuthorizationManager { private final AuthorizationRuleResolver ruleResolver; @@ -22,19 +21,19 @@ public RequestInfoAuthorizationManager(RoleService roleService) { @Override public Mono check(Mono authentication, - AuthorizationContext context) { - ServerHttpRequest request = context.getExchange().getRequest(); - RequestInfo requestInfo = RequestInfoFactory.INSTANCE.newRequestInfo(request); - - return authentication.flatMap(auth -> this.ruleResolver.visitRules(auth, requestInfo) - .doOnNext(visitor -> showErrorMessage(visitor.getErrors())) - .filter(AuthorizingVisitor::isAllowed) - .map(visitor -> new AuthorizationDecision(isGranted(auth))) - .switchIfEmpty(Mono.fromSupplier(() -> new AuthorizationDecision(false)))); - } - - private boolean isGranted(Authentication authentication) { - return authentication != null && authentication.isAuthenticated(); + AuthorizationContext context) { + var request = context.getExchange().getRequest(); + var requestInfo = RequestInfoFactory.INSTANCE.newRequestInfo(request); + + // We allow anonymous user to access some resources + // so we don't invoke AuthenticationTrustResolver.isAuthenticated + // to check if the user is authenticated + return authentication.filter(Authentication::isAuthenticated) + .flatMap(auth -> ruleResolver.visitRules(auth, requestInfo)) + .doOnNext(visitor -> showErrorMessage(visitor.getErrors())) + .map(AuthorizingVisitor::isAllowed) + .defaultIfEmpty(false) + .map(AuthorizationDecision::new); } private void showErrorMessage(List errors) { diff --git a/application/src/main/java/run/halo/app/security/authorization/RequestInfoFactory.java b/application/src/main/java/run/halo/app/security/authorization/RequestInfoFactory.java index 4f629f6a7c..b908c9e8e9 100644 --- a/application/src/main/java/run/halo/app/security/authorization/RequestInfoFactory.java +++ b/application/src/main/java/run/halo/app/security/authorization/RequestInfoFactory.java @@ -6,7 +6,7 @@ import org.apache.commons.lang3.StringUtils; import org.springframework.http.server.PathContainer; import org.springframework.http.server.reactive.ServerHttpRequest; -import run.halo.app.console.WebSocketUtils; +import run.halo.app.infra.console.WebSocketUtils; /** * Creates {@link RequestInfo} from {@link ServerHttpRequest}. diff --git a/application/src/main/java/run/halo/app/security/jackson2/HaloOAuth2AuthenticationTokenMixin.java b/application/src/main/java/run/halo/app/security/jackson2/HaloOAuth2AuthenticationTokenMixin.java new file mode 100644 index 0000000000..eb6e807844 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/jackson2/HaloOAuth2AuthenticationTokenMixin.java @@ -0,0 +1,31 @@ +package run.halo.app.security.jackson2; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import run.halo.app.security.authentication.oauth2.HaloOAuth2AuthenticationToken; + +/** + * Mixin for {@link HaloOAuth2AuthenticationToken}. + * + * @author johnniang + * @since 2.20.0 + */ +@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS) +@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY, + getterVisibility = JsonAutoDetect.Visibility.NONE, + isGetterVisibility = JsonAutoDetect.Visibility.NONE) +@JsonIgnoreProperties(ignoreUnknown = true) +abstract class HaloOAuth2AuthenticationTokenMixin { + + @JsonCreator + HaloOAuth2AuthenticationTokenMixin( + @JsonProperty("userDetails") UserDetails userDetails, + @JsonProperty("original") OAuth2AuthenticationToken original + ) { + } +} diff --git a/application/src/main/java/run/halo/app/security/jackson2/HaloSecurityJackson2Module.java b/application/src/main/java/run/halo/app/security/jackson2/HaloSecurityJackson2Module.java index ff3687f77f..5114386942 100644 --- a/application/src/main/java/run/halo/app/security/jackson2/HaloSecurityJackson2Module.java +++ b/application/src/main/java/run/halo/app/security/jackson2/HaloSecurityJackson2Module.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.module.SimpleModule; import org.springframework.security.jackson2.SecurityJackson2Modules; import run.halo.app.security.authentication.login.HaloUser; +import run.halo.app.security.authentication.oauth2.HaloOAuth2AuthenticationToken; import run.halo.app.security.authentication.twofactor.TwoFactorAuthentication; /** @@ -21,8 +22,12 @@ public HaloSecurityJackson2Module() { public void setupModule(SetupContext context) { SecurityJackson2Modules.enableDefaultTyping(context.getOwner()); context.setMixInAnnotations(HaloUser.class, HaloUserMixin.class); - context.setMixInAnnotations(TwoFactorAuthentication.class, - TwoFactorAuthenticationMixin.class); + context.setMixInAnnotations( + TwoFactorAuthentication.class, TwoFactorAuthenticationMixin.class + ); + context.setMixInAnnotations( + HaloOAuth2AuthenticationToken.class, HaloOAuth2AuthenticationTokenMixin.class + ); } } diff --git a/application/src/main/java/run/halo/app/security/jackson2/TwoFactorAuthenticationMixin.java b/application/src/main/java/run/halo/app/security/jackson2/TwoFactorAuthenticationMixin.java index 71c1f737ea..d38ae936b9 100644 --- a/application/src/main/java/run/halo/app/security/jackson2/TwoFactorAuthenticationMixin.java +++ b/application/src/main/java/run/halo/app/security/jackson2/TwoFactorAuthenticationMixin.java @@ -20,6 +20,8 @@ abstract class TwoFactorAuthenticationMixin { @JsonCreator - TwoFactorAuthenticationMixin(@JsonProperty("previous") Authentication previous) { + TwoFactorAuthenticationMixin( + @JsonProperty("previous") Authentication previous + ) { } } diff --git a/application/src/main/java/run/halo/app/security/preauth/DefaultPasswordResetAvailabilityProviders.java b/application/src/main/java/run/halo/app/security/preauth/DefaultPasswordResetAvailabilityProviders.java new file mode 100644 index 0000000000..e311eddbee --- /dev/null +++ b/application/src/main/java/run/halo/app/security/preauth/DefaultPasswordResetAvailabilityProviders.java @@ -0,0 +1,42 @@ +package run.halo.app.security.preauth; + +import java.util.List; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import run.halo.app.infra.properties.HaloProperties; +import run.halo.app.infra.properties.SecurityProperties; +import run.halo.app.infra.properties.SecurityProperties.PasswordResetMethod; + +/** + * Default password reset availability providers. + * + * @author johnniang + * @since 2.20.0 + */ +@Component +public class DefaultPasswordResetAvailabilityProviders + implements PasswordResetAvailabilityProviders { + + private final SecurityProperties securityProperties; + + private final List providers; + + public DefaultPasswordResetAvailabilityProviders(HaloProperties haloProperties, + ObjectProvider providers) { + this.securityProperties = haloProperties.getSecurity(); + this.providers = providers.orderedStream().toList(); + } + + @Override + public Flux getAvailableMethods() { + return Flux.fromIterable(securityProperties.getPasswordResetMethods()) + .filterWhen(method -> providers.stream() + .filter(provider -> provider.support(method.getName())) + .findFirst() + .map(provider -> provider.isAvailable(method)) + .orElseGet(() -> Mono.just(false)) + ); + } +} diff --git a/application/src/main/java/run/halo/app/security/preauth/EmailPasswordResetAvailabilityProvider.java b/application/src/main/java/run/halo/app/security/preauth/EmailPasswordResetAvailabilityProvider.java new file mode 100644 index 0000000000..7509ac793d --- /dev/null +++ b/application/src/main/java/run/halo/app/security/preauth/EmailPasswordResetAvailabilityProvider.java @@ -0,0 +1,26 @@ +package run.halo.app.security.preauth; + +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; +import run.halo.app.infra.properties.SecurityProperties; + +/** + * Email password reset availability provider. + * + * @author johnniang + * @since 2.20.0 + */ +@Component +public class EmailPasswordResetAvailabilityProvider implements PasswordResetAvailabilityProvider { + + @Override + public Mono isAvailable(SecurityProperties.PasswordResetMethod method) { + // TODO Check the email notifier is available in the future + return Mono.just(true); + } + + @Override + public boolean support(String name) { + return "email".equals(name); + } +} diff --git a/application/src/main/java/run/halo/app/security/preauth/PasswordResetAvailabilityProvider.java b/application/src/main/java/run/halo/app/security/preauth/PasswordResetAvailabilityProvider.java new file mode 100644 index 0000000000..5f097149bb --- /dev/null +++ b/application/src/main/java/run/halo/app/security/preauth/PasswordResetAvailabilityProvider.java @@ -0,0 +1,30 @@ +package run.halo.app.security.preauth; + +import reactor.core.publisher.Mono; +import run.halo.app.infra.properties.SecurityProperties; + +/** + * Password reset availability provider. + * + * @author johnniang + * @since 2.20.0 + */ +public interface PasswordResetAvailabilityProvider { + + /** + * Check if the password reset method is available. + * + * @param method password reset method + * @return true if available, false otherwise + */ + Mono isAvailable(SecurityProperties.PasswordResetMethod method); + + /** + * Check if the provider supports the name. + * + * @param name password reset method name + * @return true if supports, false otherwise + */ + boolean support(String name); + +} diff --git a/application/src/main/java/run/halo/app/security/preauth/PasswordResetAvailabilityProviders.java b/application/src/main/java/run/halo/app/security/preauth/PasswordResetAvailabilityProviders.java new file mode 100644 index 0000000000..a88f976a42 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/preauth/PasswordResetAvailabilityProviders.java @@ -0,0 +1,31 @@ +package run.halo.app.security.preauth; + +import reactor.core.publisher.Flux; +import run.halo.app.infra.properties.SecurityProperties.PasswordResetMethod; + +/** + * Password reset availability providers. + * + * @author johnniang + * @since 2.20.0 + */ +public interface PasswordResetAvailabilityProviders { + + /** + * Get available password reset methods. + * + * @return available password reset methods + */ + Flux getAvailableMethods(); + + /** + * Get other available password reset methods. + * + * @param methodName method name + * @return other available password reset methods + */ + default Flux getOtherAvailableMethods(String methodName) { + return getAvailableMethods().filter(method -> !method.getName().equals(methodName)); + } + +} diff --git a/application/src/main/java/run/halo/app/security/preauth/PreAuthEmailPasswordResetEndpoint.java b/application/src/main/java/run/halo/app/security/preauth/PreAuthEmailPasswordResetEndpoint.java new file mode 100644 index 0000000000..afa9fed61a --- /dev/null +++ b/application/src/main/java/run/halo/app/security/preauth/PreAuthEmailPasswordResetEndpoint.java @@ -0,0 +1,221 @@ +package run.halo.app.security.preauth; + +import static org.springframework.web.reactive.function.server.RequestPredicates.contentType; +import static org.springframework.web.reactive.function.server.RequestPredicates.path; +import static run.halo.app.infra.ValidationUtils.validate; + +import io.github.resilience4j.ratelimiter.RateLimiterRegistry; +import io.github.resilience4j.ratelimiter.RequestNotPermitted; +import io.github.resilience4j.reactor.ratelimiter.operator.RateLimiterOperator; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import java.net.URI; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import lombok.Data; +import org.springframework.context.MessageSource; +import org.springframework.context.annotation.Bean; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.stereotype.Component; +import org.springframework.validation.Validator; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.RouterFunctions; +import org.springframework.web.reactive.function.server.ServerResponse; +import reactor.core.publisher.Mono; +import run.halo.app.core.user.service.EmailPasswordRecoveryService; +import run.halo.app.core.user.service.InvalidResetTokenException; +import run.halo.app.infra.ValidationUtils; +import run.halo.app.infra.actuator.GlobalInfoService; +import run.halo.app.infra.utils.HaloUtils; +import run.halo.app.infra.utils.IpAddressUtils; + +/** + * Pre-auth password reset endpoint. + * + * @author johnniang + * @since 2.20.0 + */ +@Component +class PreAuthEmailPasswordResetEndpoint { + + private static final String SEND_TEMPLATE = "password-reset/email/send"; + private static final String RESET_TEMPLATE = "password-reset/email/reset"; + + private final RateLimiterRegistry rateLimiterRegistry; + + public PreAuthEmailPasswordResetEndpoint( + RateLimiterRegistry rateLimiterRegistry + ) { + this.rateLimiterRegistry = rateLimiterRegistry; + } + + @Bean + @Order(Ordered.HIGHEST_PRECEDENCE + 100) + RouterFunction preAuthPasswordResetEndpoints( + GlobalInfoService globalInfoService, + PasswordResetAvailabilityProviders availabilityProviders, + MessageSource messageSource, + EmailPasswordRecoveryService emailService, + Validator validator + ) { + return RouterFunctions.nest(path("/password-reset/email"), RouterFunctions.route() + .GET("", request -> request.bind(SendForm.class) + .flatMap(sendForm -> ServerResponse.ok().render(SEND_TEMPLATE, Map.of( + "otherMethods", availabilityProviders.getOtherAvailableMethods("email"), + "globalInfo", globalInfoService.getGlobalInfo(), + "form", sendForm + ))) + ) + .GET("/{resetToken}", + request -> { + var token = request.pathVariable("resetToken"); + return request.bind(ResetForm.class) + .flatMap(resetForm -> { + var model = new HashMap(); + model.put("form", resetForm); + model.put("globalInfo", globalInfoService.getGlobalInfo()); + return emailService.getValidResetToken(token) + .flatMap(resetToken -> { + // TODO Check the 2FA of the user + model.put("username", resetToken.username()); + return ServerResponse.ok().render(RESET_TEMPLATE, model); + }) + .transformDeferred(rateLimiterForPasswordResetVerification( + request.exchange().getRequest() + )) + .onErrorResume(InvalidResetTokenException.class, e -> + ServerResponse.status(HttpStatus.FOUND) + .location(URI.create( + "/password-reset/email?error=invalid_reset_token") + ) + .build() + ) + .onErrorResume(RequestNotPermitted.class, e -> { + model.put("error", "rate_limit_exceeded"); + return ServerResponse.status(HttpStatus.TOO_MANY_REQUESTS) + .render(RESET_TEMPLATE, model); + }); + }); + } + ) + .POST("/{resetToken}", request -> { + var token = request.pathVariable("resetToken"); + return request.bind(ResetForm.class) + .flatMap(resetForm -> emailService.getValidResetToken(token) + .flatMap(resetToken -> { + var bindingResult = validate(resetForm, validator, request.exchange()); + var model = bindingResult.getModel(); + model.put("globalInfo", globalInfoService.getGlobalInfo()); + model.put("username", resetToken.username()); + if (!Objects.equals( + resetForm.getPassword(), resetForm.getConfirmPassword() + )) { + bindingResult.rejectValue( + "confirmPassword", + "validation.error.password.confirmPassword.mismatch", + "Password and confirm password mismatch" + ); + } + if (bindingResult.hasErrors()) { + return ServerResponse.badRequest().render(RESET_TEMPLATE, model); + } + return emailService.changePassword(resetForm.getPassword(), token) + .then(ServerResponse.status(HttpStatus.FOUND) + .location(URI.create("/login?password_reset")) + .build() + ) + .transformDeferred(rateLimiterForPasswordResetVerification( + request.exchange().getRequest() + )) + .onErrorResume(RequestNotPermitted.class, e -> { + model.put("error", "rate_limit_exceeded"); + return ServerResponse.status(HttpStatus.TOO_MANY_REQUESTS) + .render(RESET_TEMPLATE, model); + }); + }) + .onErrorResume(InvalidResetTokenException.class, + e -> ServerResponse.status(HttpStatus.FOUND) + .location(URI.create( + "/password-reset/email?error=invalid_reset_token" + )) + .build() + ) + ); + }) + .POST("", contentType(MediaType.APPLICATION_FORM_URLENCODED), + request -> request.bind(SendForm.class) + .flatMap(sendForm -> { + // validate the send form + var bindingResult = validate(sendForm, validator, request.exchange()); + var model = bindingResult.getModel(); + model.put("globalInfo", globalInfoService.getGlobalInfo()); + if (bindingResult.hasErrors()) { + return ServerResponse.badRequest().render(SEND_TEMPLATE, model); + } + return emailService.sendPasswordResetEmail(sendForm.getEmail()) + .then(Mono.defer(() -> { + model.put("sent", true); + return ServerResponse.ok().render(SEND_TEMPLATE, model); + })) + .transformDeferred(rateLimiterForSendPasswordResetEmail( + request.exchange().getRequest() + )) + .onErrorResume(RequestNotPermitted.class, e -> { + model.put("error", "rate_limit_exceeded"); + return ServerResponse.status(HttpStatus.TOO_MANY_REQUESTS) + .render(SEND_TEMPLATE, model); + }); + }) + ) + .before(HaloUtils.noCache()) + .build()); + } + + RateLimiterOperator rateLimiterForSendPasswordResetEmail(ServerHttpRequest request) { + var clientIp = IpAddressUtils.getClientIp(request); + var rateLimiterKey = "send-password-reset-email-from-" + clientIp; + var rateLimiter = + rateLimiterRegistry.rateLimiter(rateLimiterKey, "send-password-reset-email"); + return RateLimiterOperator.of(rateLimiter); + } + + RateLimiterOperator rateLimiterForPasswordResetVerification(ServerHttpRequest request) { + var clientIp = IpAddressUtils.getClientIp(request); + var rateLimiterKey = "password-reset-email-verify-from-" + clientIp; + var rateLimiter = + rateLimiterRegistry.rateLimiter(rateLimiterKey, "password-reset-verification"); + return RateLimiterOperator.of(rateLimiter); + } + + @Data + static class ResetForm { + + @NotBlank + @Pattern( + regexp = ValidationUtils.PASSWORD_REGEX, + message = "{validation.error.password.pattern}" + ) + @Size(min = 5, max = 257) + private String password; + + @NotBlank + private String confirmPassword; + + } + + @Data + static class SendForm { + + @NotBlank + @Email + private String email; + + } +} diff --git a/application/src/main/java/run/halo/app/security/preauth/PreAuthLoginEndpoint.java b/application/src/main/java/run/halo/app/security/preauth/PreAuthLoginEndpoint.java new file mode 100644 index 0000000000..4e7fc1954b --- /dev/null +++ b/application/src/main/java/run/halo/app/security/preauth/PreAuthLoginEndpoint.java @@ -0,0 +1,121 @@ +package run.halo.app.security.preauth; + +import static org.springframework.web.reactive.function.server.RequestPredicates.path; + +import java.util.Base64; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import org.apache.commons.lang3.StringUtils; +import org.springframework.context.annotation.Bean; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.security.web.server.savedrequest.ServerRequestCache; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.RouterFunctions; +import org.springframework.web.reactive.function.server.ServerResponse; +import org.springframework.web.server.ServerWebInputException; +import reactor.core.publisher.Mono; +import run.halo.app.core.extension.AuthProvider; +import run.halo.app.infra.actuator.GlobalInfoService; +import run.halo.app.infra.utils.HaloUtils; +import run.halo.app.plugin.PluginConst; +import run.halo.app.security.AuthProviderService; +import run.halo.app.security.HaloServerRequestCache; +import run.halo.app.security.authentication.CryptoService; +import run.halo.app.security.authentication.rememberme.RememberMeRequestCache; +import run.halo.app.security.authentication.rememberme.WebSessionRememberMeRequestCache; + +/** + * Pre-auth login endpoints. + * + * @author johnniang + * @since 2.20.0 + */ +@Component +class PreAuthLoginEndpoint { + + private final CryptoService cryptoService; + + private final GlobalInfoService globalInfoService; + + private final AuthProviderService authProviderService; + + private final ServerRequestCache serverRequestCache = new HaloServerRequestCache(); + + private final RememberMeRequestCache rememberMeRequestCache = + new WebSessionRememberMeRequestCache(); + + PreAuthLoginEndpoint(CryptoService cryptoService, GlobalInfoService globalInfoService, + AuthProviderService authProviderService) { + this.cryptoService = cryptoService; + this.globalInfoService = globalInfoService; + this.authProviderService = authProviderService; + } + + @Bean + @Order(Ordered.HIGHEST_PRECEDENCE + 100) + RouterFunction preAuthLoginEndpoints() { + return RouterFunctions.nest(path("/login"), RouterFunctions.route() + .GET("", request -> { + var exchange = request.exchange(); + var contextPath = exchange.getRequest().getPath().contextPath().value(); + var publicKey = cryptoService.readPublicKey() + .map(key -> Base64.getEncoder().encodeToString(key)); + var globalInfo = globalInfoService.getGlobalInfo().cache(); + var authProviders = authProviderService.getEnabledProviders().cache(); + + var allFormProviders = authProviders + .filter(ap -> AuthProvider.AuthType.FORM.equals(ap.getSpec().getAuthType())) + .cache(); + + var authProvider = Mono.justOrEmpty(request.queryParam("method")) + .flatMap(method -> allFormProviders + .filter(ap -> Objects.equals(method, ap.getMetadata().getName())) + .next() + .switchIfEmpty(Mono.error( + () -> new ServerWebInputException("Invalid login method " + method)) + ) + ) + .switchIfEmpty(allFormProviders.next()) + .cache(); + + var fragmentTemplateName = authProvider.map(ap -> { + var templateName = "login_" + ap.getMetadata().getName(); + return Optional.ofNullable(ap.getMetadata().getLabels()) + .map(labels -> labels.get(PluginConst.PLUGIN_NAME_LABEL_NAME)) + .filter(StringUtils::isNotBlank) + .map(pluginName -> String.join(":", "plugin", pluginName, templateName)) + .orElse(templateName); + }); + + var socialAuthProviders = authProviders + .filter(ap -> !AuthProvider.AuthType.FORM.equals(ap.getSpec().getAuthType())) + .cache(); + var formAuthProviders = allFormProviders + .filterWhen(ap -> authProvider + .map(provider -> !Objects.equals(provider.getMetadata().getName(), + ap.getMetadata().getName()) + ) + ) + .cache(); + + return serverRequestCache.saveRequest(exchange).then(Mono.defer(() -> + ServerResponse.ok().render("login", Map.of( + "action", contextPath + "/login", + "publicKey", publicKey, + "globalInfo", globalInfo, + "authProvider", authProvider, + "fragmentTemplateName", fragmentTemplateName, + "socialAuthProviders", socialAuthProviders, + "formAuthProviders", formAuthProviders, + "rememberMe", rememberMeRequestCache.isRememberMe(exchange) + // TODO Add more models here + )) + )); + }) + .before(HaloUtils.noCache()) + .build()); + } +} diff --git a/application/src/main/java/run/halo/app/security/preauth/PreAuthSignUpEndpoint.java b/application/src/main/java/run/halo/app/security/preauth/PreAuthSignUpEndpoint.java new file mode 100644 index 0000000000..62ab62ed03 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/preauth/PreAuthSignUpEndpoint.java @@ -0,0 +1,160 @@ +package run.halo.app.security.preauth; + +import static org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.web.reactive.function.server.RequestPredicates.contentType; +import static org.springframework.web.reactive.function.server.RequestPredicates.path; +import static run.halo.app.infra.ValidationUtils.validate; + +import io.github.resilience4j.ratelimiter.RateLimiterRegistry; +import io.github.resilience4j.ratelimiter.RequestNotPermitted; +import io.github.resilience4j.reactor.ratelimiter.operator.RateLimiterOperator; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import java.net.URI; +import lombok.Data; +import org.springframework.context.annotation.Bean; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpStatus; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.stereotype.Component; +import org.springframework.validation.BeanPropertyBindingResult; +import org.springframework.validation.FieldError; +import org.springframework.validation.Validator; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.RouterFunctions; +import org.springframework.web.reactive.function.server.ServerResponse; +import reactor.core.publisher.Mono; +import run.halo.app.core.user.service.EmailVerificationService; +import run.halo.app.core.user.service.SignUpData; +import run.halo.app.core.user.service.UserService; +import run.halo.app.infra.actuator.GlobalInfoService; +import run.halo.app.infra.exception.DuplicateNameException; +import run.halo.app.infra.exception.EmailVerificationFailed; +import run.halo.app.infra.exception.RateLimitExceededException; +import run.halo.app.infra.exception.RequestBodyValidationException; +import run.halo.app.infra.utils.HaloUtils; +import run.halo.app.infra.utils.IpAddressUtils; + +/** + * Pre-auth sign up endpoint. + * + * @author johnniang + * @since 2.20.0 + */ +@Component +class PreAuthSignUpEndpoint { + + private final GlobalInfoService globalInfoService; + + private final Validator validator; + + private final UserService userService; + + private final EmailVerificationService emailVerificationService; + + private final RateLimiterRegistry rateLimiterRegistry; + + PreAuthSignUpEndpoint(GlobalInfoService globalInfoService, + Validator validator, + UserService userService, + EmailVerificationService emailVerificationService, + RateLimiterRegistry rateLimiterRegistry) { + this.globalInfoService = globalInfoService; + this.validator = validator; + this.userService = userService; + this.emailVerificationService = emailVerificationService; + this.rateLimiterRegistry = rateLimiterRegistry; + } + + @Bean + @Order(Ordered.HIGHEST_PRECEDENCE + 100) + RouterFunction preAuthSignUpEndpoints() { + return RouterFunctions.nest(path("/signup"), RouterFunctions.route() + .GET("", request -> { + var signUpData = new SignUpData(); + var bindingResult = new BeanPropertyBindingResult(signUpData, "form"); + var model = bindingResult.getModel(); + model.put("globalInfo", globalInfoService.getGlobalInfo()); + return ServerResponse.ok().render("signup", model); + }) + .POST( + "", + contentType(APPLICATION_FORM_URLENCODED), + request -> request.bind(SignUpData.class) + .flatMap(signUpData -> { + // sign up + var bindingResult = validate(signUpData, validator, request.exchange()); + var model = bindingResult.getModel(); + model.put("globalInfo", globalInfoService.getGlobalInfo()); + if (bindingResult.hasErrors()) { + return ServerResponse.ok().render("signup", model); + } + return userService.signUp(signUpData) + .flatMap(user -> ServerResponse.status(HttpStatus.FOUND) + .location(URI.create("/login?signup")) + .build() + ) + .doOnError(t -> { + model.put("error", "unknown"); + model.put("errorMessage", t.getMessage()); + }) + .doOnError(EmailVerificationFailed.class, + e -> { + bindingResult.addError(new FieldError("form", + "emailCode", + signUpData.getEmailCode(), + true, + new String[] {"signup.error.email-code.invalid"}, + null, + "Invalid Email Code")); + } + ) + .doOnError(RateLimitExceededException.class, + e -> model.put("error", "rate-limit-exceeded") + ) + .doOnError(DuplicateNameException.class, + e -> model.put("error", "duplicate-username") + ) + .onErrorResume(e -> ServerResponse.ok().render("signup", model)); + }) + ) + .POST("/send-email-code", contentType(APPLICATION_JSON), + request -> request.bodyToMono(SendEmailCodeBody.class) + .flatMap(body -> { + var bindingResult = validate(body, "body", validator, request.exchange()); + if (bindingResult.hasErrors()) { + return Mono.error(new RequestBodyValidationException(bindingResult)); + } + var email = body.getEmail(); + return emailVerificationService.sendRegisterVerificationCode(email) + .transformDeferred( + rateLimiterForSendingEmailCode(request.exchange().getRequest()) + ) + .onErrorMap(RequestNotPermitted.class, RateLimitExceededException::new); + }) + .then(ServerResponse.accepted().build()) + ) + .before(HaloUtils.noCache()) + .build()); + } + + private RateLimiterOperator rateLimiterForSendingEmailCode(ServerHttpRequest request) { + var clientIp = IpAddressUtils.getClientIp(request); + var rateLimiterKey = "send-email-code-for-signing-up-from-" + clientIp; + var rateLimiter = + rateLimiterRegistry.rateLimiter(rateLimiterKey, "send-email-verification-code"); + return RateLimiterOperator.of(rateLimiter); + } + + + @Data + public static class SendEmailCodeBody { + + @Email + @NotBlank + String email; + + } +} diff --git a/application/src/main/java/run/halo/app/security/preauth/PreAuthTwoFactorEndpoint.java b/application/src/main/java/run/halo/app/security/preauth/PreAuthTwoFactorEndpoint.java new file mode 100644 index 0000000000..ebf3d551bc --- /dev/null +++ b/application/src/main/java/run/halo/app/security/preauth/PreAuthTwoFactorEndpoint.java @@ -0,0 +1,36 @@ +package run.halo.app.security.preauth; + +import java.util.Map; +import org.springframework.context.annotation.Bean; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.RouterFunctions; +import org.springframework.web.reactive.function.server.ServerResponse; +import run.halo.app.infra.actuator.GlobalInfoService; +import run.halo.app.infra.utils.HaloUtils; + +/** + * Pre-auth two-factor endpoints. + * + * @author johnniang + * @since 2.20.0 + */ +@Component +class PreAuthTwoFactorEndpoint { + + @Bean + @Order(Ordered.HIGHEST_PRECEDENCE + 100) + RouterFunction preAuthTwoFactorEndpoints(GlobalInfoService globalInfoService) { + return RouterFunctions.route() + .GET("/challenges/two-factor/totp", + request -> ServerResponse.ok().render("challenges/two-factor/totp", Map.of( + "globalInfo", globalInfoService.getGlobalInfo() + )) + ) + .before(HaloUtils.noCache()) + .build(); + } + +} diff --git a/application/src/main/java/run/halo/app/security/preauth/SystemSetupEndpoint.java b/application/src/main/java/run/halo/app/security/preauth/SystemSetupEndpoint.java new file mode 100644 index 0000000000..ab4d7718f4 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/preauth/SystemSetupEndpoint.java @@ -0,0 +1,299 @@ +package run.halo.app.security.preauth; + +import static io.r2dbc.spi.ConnectionFactoryOptions.DRIVER; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; +import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; +import static org.springdoc.core.fn.builders.content.Builder.contentBuilder; +import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder; +import static org.springdoc.core.fn.builders.schema.Builder.schemaBuilder; +import static org.springframework.web.reactive.function.server.RequestPredicates.accept; +import static org.springframework.web.reactive.function.server.RequestPredicates.contentType; +import static org.springframework.web.reactive.function.server.RequestPredicates.path; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Properties; +import lombok.RequiredArgsConstructor; +import org.springdoc.core.fn.builders.content.Builder; +import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.config.PlaceholderConfigurerSupport; +import org.springframework.boot.autoconfigure.r2dbc.R2dbcConnectionDetails; +import org.springframework.context.annotation.Bean; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.core.io.ClassPathResource; +import org.springframework.dao.OptimisticLockingFailureException; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.util.InMemoryResource; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.util.PropertyPlaceholderHelper; +import org.springframework.util.StreamUtils; +import org.springframework.validation.BeanPropertyBindingResult; +import org.springframework.validation.BindingResult; +import org.springframework.validation.Validator; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.ServerRequest; +import org.springframework.web.reactive.function.server.ServerResponse; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; +import reactor.util.retry.Retry; +import run.halo.app.extension.ConfigMap; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.extension.Unstructured; +import run.halo.app.infra.InitializationStateGetter; +import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; +import run.halo.app.infra.SystemSetting; +import run.halo.app.infra.SystemState; +import run.halo.app.infra.ValidationUtils; +import run.halo.app.infra.exception.RequestBodyValidationException; +import run.halo.app.infra.utils.HaloUtils; +import run.halo.app.infra.utils.JsonUtils; +import run.halo.app.infra.utils.YamlUnstructuredLoader; +import run.halo.app.plugin.PluginService; +import run.halo.app.security.SuperAdminInitializer; +import run.halo.app.theme.service.ThemeService; + +@Component +@RequiredArgsConstructor +public class SystemSetupEndpoint { + static final String SETUP_TEMPLATE = "setup"; + static final PropertyPlaceholderHelper PROPERTY_PLACEHOLDER_HELPER = + new PropertyPlaceholderHelper( + PlaceholderConfigurerSupport.DEFAULT_PLACEHOLDER_PREFIX, + PlaceholderConfigurerSupport.DEFAULT_PLACEHOLDER_SUFFIX + ); + + private final InitializationStateGetter initializationStateGetter; + private final SystemConfigurableEnvironmentFetcher systemConfigFetcher; + private final SuperAdminInitializer superAdminInitializer; + private final ReactiveExtensionClient client; + private final PluginService pluginService; + private final ThemeService themeService; + private final Validator validator; + private final ObjectProvider connectionDetails; + + @Bean + @Order(Ordered.HIGHEST_PRECEDENCE + 100) + RouterFunction setupPageRouter() { + final var tag = "SystemV1alpha1Public"; + return SpringdocRouteBuilder.route() + .GET(path("/system/setup").and(accept(MediaType.TEXT_HTML)), this::setupPage, + builder -> builder.operationId("JumpToSetupPage") + .description("Jump to setup page") + .tag(tag) + .response(responseBuilder() + .content(Builder.contentBuilder() + .mediaType(MediaType.TEXT_HTML_VALUE)) + .implementation(String.class) + ) + ) + .POST("/system/setup", contentType(MediaType.APPLICATION_FORM_URLENCODED), this::setup, + builder -> builder + .operationId("SetupSystem") + .description("Setup system") + .tag(tag) + .requestBody(requestBodyBuilder() + .required(true) + .content(contentBuilder() + .mediaType(MediaType.APPLICATION_JSON_VALUE) + .schema(schemaBuilder() + .implementation(SetupRequest.class)) + ) + ) + .response(responseBuilder() + .responseCode(String.valueOf(HttpStatus.NO_CONTENT.value())) + .implementation(Void.class) + ) + ) + .before(HaloUtils.noCache(), builder -> builder.operationId("SetNoCacheForSetUpPage")) + .build(); + } + + private Mono setup(ServerRequest request) { + return request.formData() + .map(SetupRequest::new) + .filterWhen(body -> initializationStateGetter.userInitialized() + .map(initialized -> !initialized) + ) + .flatMap(body -> { + var bindingResult = ValidationUtils.validate(body, validator, request.exchange()); + if (bindingResult.hasErrors()) { + return handleValidationErrors(bindingResult, request); + } + return doInitialization(body) + .then(Mono.defer(() -> handleSetupSuccessfully(request))); + }); + } + + private static Mono handleSetupSuccessfully(ServerRequest request) { + if (isHtmlRequest(request)) { + return redirectToConsole(); + } + return ServerResponse.noContent().build(); + } + + private Mono handleValidationErrors(BindingResult bindingResult, + ServerRequest request) { + if (isHtmlRequest(request)) { + var model = bindingResult.getModel(); + model.put("usingH2database", usingH2database()); + return ServerResponse.status(HttpStatus.BAD_REQUEST) + .render(SETUP_TEMPLATE, model); + } + return Mono.error(new RequestBodyValidationException(bindingResult)); + } + + private static boolean isHtmlRequest(ServerRequest request) { + return request.headers().accept().contains(MediaType.TEXT_HTML) + && !HaloUtils.isXhr(request.headers().asHttpHeaders()); + } + + private static Mono redirectToConsole() { + return ServerResponse.status(HttpStatus.FOUND).location(URI.create("/console")).build(); + } + + private Mono doInitialization(SetupRequest body) { + var superUserMono = superAdminInitializer.initialize( + SuperAdminInitializer.InitializationParam.builder() + .username(body.getUsername()) + .password(body.getPassword()) + .email(body.getEmail()) + .build() + ) + .subscribeOn(Schedulers.boundedElastic()); + + var basicConfigMono = Mono.defer(() -> systemConfigFetcher.getConfigMap() + .flatMap(configMap -> { + mergeToBasicConfig(body, configMap); + return client.update(configMap); + }) + ) + .retryWhen(Retry.backoff(5, Duration.ofMillis(100)) + .filter(t -> t instanceof OptimisticLockingFailureException) + ) + .subscribeOn(Schedulers.boundedElastic()) + .then(); + return Mono.when(superUserMono, basicConfigMono, + initializeNecessaryData(body.getUsername()), + pluginService.installPresetPlugins(), + themeService.installPresetTheme() + ) + .then(SystemState.upsetSystemState(client, state -> state.setIsSetup(true))); + } + + private Mono initializeNecessaryData(String username) { + return loadPresetExtensions(username) + .concatMap(client::create) + .subscribeOn(Schedulers.boundedElastic()) + .then(); + } + + private static void mergeToBasicConfig(SetupRequest body, ConfigMap configMap) { + Map data = configMap.getData(); + if (data == null) { + data = new LinkedHashMap<>(); + configMap.setData(data); + } + String basic = data.getOrDefault(SystemSetting.Basic.GROUP, "{}"); + var basicSetting = JsonUtils.jsonToObject(basic, SystemSetting.Basic.class); + basicSetting.setTitle(body.getSiteTitle()); + data.put(SystemSetting.Basic.GROUP, JsonUtils.objectToJson(basicSetting)); + } + + private Mono setupPage(ServerRequest request) { + return initializationStateGetter.userInitialized() + .flatMap(initialized -> { + if (initialized) { + return redirectToConsole(); + } + var body = new SetupRequest(new LinkedMultiValueMap<>()); + var bindingResult = new BeanPropertyBindingResult(body, "form"); + var model = bindingResult.getModel(); + model.put("usingH2database", usingH2database()); + return ServerResponse.ok().render(SETUP_TEMPLATE, model); + }); + } + + private boolean usingH2database() { + var rcd = connectionDetails.getIfUnique(); + if (rcd == null) { + // If no R2dbcConnectionDetails is available, we assume H2(mem) is used. + return true; + } + var options = rcd.getConnectionFactoryOptions(); + return Optional.ofNullable(options.getValue(DRIVER)) + .map(Object::toString) + .map("h2"::equalsIgnoreCase) + .orElse(false); + } + + record SetupRequest(MultiValueMap formData) { + + @Schema(requiredMode = REQUIRED, minLength = 4, maxLength = 63) + @NotBlank + @Size(min = 4, max = 63) + @Pattern(regexp = ValidationUtils.NAME_REGEX, + message = "{validation.error.username.pattern}") + public String getUsername() { + return formData.getFirst("username"); + } + + @Schema(requiredMode = REQUIRED, minLength = 5, maxLength = 257) + @NotBlank + @Pattern(regexp = ValidationUtils.PASSWORD_REGEX, + message = "{validation.error.password.pattern}") + @Size(min = 5, max = 257) + public String getPassword() { + return formData.getFirst("password"); + } + + @Email + public String getEmail() { + return formData.getFirst("email"); + } + + @NotBlank + @Size(max = 80) + public String getSiteTitle() { + return formData.getFirst("siteTitle"); + } + } + + Flux loadPresetExtensions(String username) { + return Mono.fromCallable( + () -> { + // read initial-data.yaml to string + var classPathResource = new ClassPathResource("initial-data.yaml"); + String rawContent = StreamUtils.copyToString(classPathResource.getInputStream(), + StandardCharsets.UTF_8); + // build properties + var properties = new Properties(); + properties.setProperty("username", username); + properties.setProperty("timestamp", Instant.now().toString()); + // replace placeholders + var processedContent = + PROPERTY_PLACEHOLDER_HELPER.replacePlaceholders(rawContent, properties); + // load yaml to unstructured + var stringResource = new InMemoryResource(processedContent); + var loader = new YamlUnstructuredLoader(stringResource); + return loader.load(); + }) + .flatMapMany(Flux::fromIterable) + .subscribeOn(Schedulers.boundedElastic()); + } +} \ No newline at end of file diff --git a/application/src/main/java/run/halo/app/theme/HaloViewResolver.java b/application/src/main/java/run/halo/app/theme/HaloViewResolver.java index 540e54512d..1719cc920d 100644 --- a/application/src/main/java/run/halo/app/theme/HaloViewResolver.java +++ b/application/src/main/java/run/halo/app/theme/HaloViewResolver.java @@ -6,11 +6,16 @@ import java.util.Map; import java.util.Optional; import org.attoparser.ParseException; +import org.springframework.beans.factory.InitializingBean; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.thymeleaf.ThymeleafProperties; +import org.springframework.boot.context.properties.PropertyMapper; import org.springframework.context.ApplicationContext; +import org.springframework.core.Ordered; import org.springframework.http.MediaType; import org.springframework.lang.NonNull; import org.springframework.stereotype.Component; +import org.springframework.util.unit.DataSize; import org.springframework.web.ErrorResponse; import org.springframework.web.reactive.result.view.View; import org.springframework.web.server.ServerWebExchange; @@ -24,13 +29,16 @@ import run.halo.app.theme.router.ModelConst; @Component("thymeleafReactiveViewResolver") -public class HaloViewResolver extends ThymeleafReactiveViewResolver { +public class HaloViewResolver extends ThymeleafReactiveViewResolver implements InitializingBean { private final FinderRegistry finderRegistry; - public HaloViewResolver(FinderRegistry finderRegistry) { - setViewClass(HaloView.class); + private final ThymeleafProperties thymeleafProperties; + + public HaloViewResolver(FinderRegistry finderRegistry, + ThymeleafProperties thymeleafProperties) { this.finderRegistry = finderRegistry; + this.thymeleafProperties = thymeleafProperties; } @Override @@ -44,6 +52,37 @@ protected Mono loadView(String viewName, Locale locale) { }); } + @Override + public void afterPropertiesSet() throws Exception { + setViewClass(HaloView.class); + var map = PropertyMapper.get(); + map.from(thymeleafProperties::getEncoding) + .whenNonNull() + .to(this::setDefaultCharset); + map.from(thymeleafProperties::getExcludedViewNames) + .whenNonNull() + .to(this::setExcludedViewNames); + map.from(thymeleafProperties::getViewNames) + .whenNonNull() + .to(this::setViewNames); + + var reactive = thymeleafProperties.getReactive(); + map.from(reactive::getMediaTypes) + .whenNonNull() + .to(this::setSupportedMediaTypes); + map.from(reactive::getFullModeViewNames) + .whenNonNull() + .to(this::setFullModeViewNames); + map.from(reactive::getChunkedModeViewNames) + .whenNonNull() + .to(this::setChunkedModeViewNames); + map.from(reactive::getMaxChunkSize) + .asInt(DataSize::toBytes) + .when(size -> size > 0) + .to(this::setResponseMaxChunkSizeBytes); + setOrder(Ordered.LOWEST_PRECEDENCE - 5); + } + public static class HaloView extends ThymeleafReactiveView { @Autowired @@ -58,7 +97,10 @@ public Mono render(Map model, MediaType contentType, return themeResolver.getTheme(exchange).flatMap(theme -> { // calculate the engine before rendering setTemplateEngine(engineManager.getTemplateEngine(theme)); - exchange.getAttributes().put(ModelConst.POWERED_BY_HALO_TEMPLATE_ENGINE, true); + var noCache = (Boolean) exchange.getAttributes() + .getOrDefault(ModelConst.NO_CACHE, false); + exchange.getAttributes() + .put(ModelConst.POWERED_BY_HALO_TEMPLATE_ENGINE, !noCache); return super.render(model, contentType, exchange) .onErrorMap(TemplateProcessingException.class::isInstance, tee -> { if (tee instanceof TemplateInputException) { diff --git a/application/src/main/java/run/halo/app/theme/ReactivePropertyAccessor.java b/application/src/main/java/run/halo/app/theme/ReactivePropertyAccessor.java deleted file mode 100644 index db847d24b7..0000000000 --- a/application/src/main/java/run/halo/app/theme/ReactivePropertyAccessor.java +++ /dev/null @@ -1,118 +0,0 @@ -package run.halo.app.theme; - -import java.util.List; -import org.springframework.expression.AccessException; -import org.springframework.expression.EvaluationContext; -import org.springframework.expression.PropertyAccessor; -import org.springframework.expression.TypedValue; -import org.springframework.expression.spel.ast.AstUtils; -import org.springframework.integration.json.JsonPropertyAccessor; -import org.springframework.lang.NonNull; -import org.springframework.lang.Nullable; -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; - -/** - * A SpEL PropertyAccessor that knows how to read properties from {@link Mono} or {@link Flux} - * object. It first converts the target to the actual value and then calls other - * {@link PropertyAccessor}s to parse the result, If it still cannot be resolved, - * {@link JsonPropertyAccessor} will be used to resolve finally. - * - * @author guqing - * @since 2.0.0 - */ -public class ReactivePropertyAccessor implements PropertyAccessor { - - @Override - public Class[] getSpecificTargetClasses() { - return null; - } - - @Override - public boolean canRead(@NonNull EvaluationContext context, Object target, @NonNull String name) - throws AccessException { - if (isReactiveType(target)) { - return true; - } - var propertyAccessors = - getPropertyAccessorsToTry(target.getClass(), context.getPropertyAccessors()); - for (PropertyAccessor propertyAccessor : propertyAccessors) { - if (propertyAccessor.canRead(context, target, name)) { - return true; - } - } - return false; - } - - @Override - @NonNull - public TypedValue read(@NonNull EvaluationContext context, Object target, @NonNull String name) - throws AccessException { - if (target == null) { - return TypedValue.NULL; - } - Object value = blockingGetForReactive(target); - - List propertyAccessorsToTry = - getPropertyAccessorsToTry(value, context.getPropertyAccessors()); - for (PropertyAccessor propertyAccessor : propertyAccessorsToTry) { - try { - TypedValue result = propertyAccessor.read(context, value, name); - return new TypedValue(blockingGetForReactive(result.getValue())); - } catch (AccessException e) { - // ignore this - } - } - - throw new AccessException("Cannot read property '" + name + "' from [" + value + "]"); - } - - @Nullable - private static Object blockingGetForReactive(@Nullable Object target) { - if (target == null) { - return null; - } - Class clazz = target.getClass(); - Object value = target; - if (Mono.class.isAssignableFrom(clazz)) { - value = ((Mono) target).block(); - } else if (Flux.class.isAssignableFrom(clazz)) { - value = ((Flux) target).collectList().block(); - } - return value; - } - - private boolean isReactiveType(Object target) { - if (target == null) { - return true; - } - Class clazz = target.getClass(); - return Mono.class.isAssignableFrom(clazz) - || Flux.class.isAssignableFrom(clazz); - } - - private List getPropertyAccessorsToTry( - @Nullable Object contextObject, List propertyAccessors) { - - Class targetType = (contextObject != null ? contextObject.getClass() : null); - - List resolvers = - AstUtils.getPropertyAccessorsToTry(targetType, propertyAccessors); - // remove this resolver to avoid infinite loop - resolvers.remove(this); - return resolvers; - } - - @Override - public boolean canWrite(@NonNull EvaluationContext context, Object target, @NonNull String name) - throws AccessException { - return false; - } - - @Override - public void write(@NonNull EvaluationContext context, Object target, @NonNull String name, - Object newValue) - throws AccessException { - throw new UnsupportedOperationException("Write is not supported"); - } -} diff --git a/application/src/main/java/run/halo/app/theme/ReactiveSpelVariableExpressionEvaluator.java b/application/src/main/java/run/halo/app/theme/ReactiveSpelVariableExpressionEvaluator.java index 1794497789..b961f0eea1 100644 --- a/application/src/main/java/run/halo/app/theme/ReactiveSpelVariableExpressionEvaluator.java +++ b/application/src/main/java/run/halo/app/theme/ReactiveSpelVariableExpressionEvaluator.java @@ -1,12 +1,12 @@ package run.halo.app.theme; +import java.util.Optional; import org.thymeleaf.context.IExpressionContext; import org.thymeleaf.spring6.expression.SPELVariableExpressionEvaluator; import org.thymeleaf.standard.expression.IStandardVariableExpression; import org.thymeleaf.standard.expression.IStandardVariableExpressionEvaluator; import org.thymeleaf.standard.expression.StandardExpressionExecutionContext; -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; +import run.halo.app.infra.utils.ReactiveUtils; /** * Reactive SPEL variable expression evaluator. @@ -17,28 +17,25 @@ public class ReactiveSpelVariableExpressionEvaluator implements IStandardVariableExpressionEvaluator { - private final SPELVariableExpressionEvaluator delegate = - SPELVariableExpressionEvaluator.INSTANCE; + private final IStandardVariableExpressionEvaluator delegate; public static final ReactiveSpelVariableExpressionEvaluator INSTANCE = new ReactiveSpelVariableExpressionEvaluator(); + public ReactiveSpelVariableExpressionEvaluator(IStandardVariableExpressionEvaluator delegate) { + this.delegate = delegate; + } + + public ReactiveSpelVariableExpressionEvaluator() { + this(SPELVariableExpressionEvaluator.INSTANCE); + } + @Override public Object evaluate(IExpressionContext context, IStandardVariableExpression expression, StandardExpressionExecutionContext expContext) { - Object returnValue = delegate.evaluate(context, expression, expContext); - if (returnValue == null) { - return null; - } - - Class clazz = returnValue.getClass(); - // Note that: 3 instanceof Foo -> syntax error - if (Mono.class.isAssignableFrom(clazz)) { - return ((Mono) returnValue).block(); - } - if (Flux.class.isAssignableFrom(clazz)) { - return ((Flux) returnValue).collectList().block(); - } - return returnValue; + var returnValue = delegate.evaluate(context, expression, expContext); + return Optional.ofNullable(returnValue) + .map(ReactiveUtils::blockReactiveValue) + .orElse(null); } } diff --git a/application/src/main/java/run/halo/app/theme/SiteSettingVariablesAcquirer.java b/application/src/main/java/run/halo/app/theme/SiteSettingVariablesAcquirer.java index d5145ae8e2..72271a42e4 100644 --- a/application/src/main/java/run/halo/app/theme/SiteSettingVariablesAcquirer.java +++ b/application/src/main/java/run/halo/app/theme/SiteSettingVariablesAcquirer.java @@ -7,6 +7,7 @@ import reactor.core.publisher.Mono; import run.halo.app.infra.ExternalUrlSupplier; import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; +import run.halo.app.infra.SystemVersionSupplier; import run.halo.app.theme.finders.vo.SiteSettingVo; /** @@ -21,6 +22,7 @@ public class SiteSettingVariablesAcquirer implements ViewContextBasedVariablesAc private final SystemConfigurableEnvironmentFetcher environmentFetcher; private final ExternalUrlSupplier externalUrlSupplier; + private final SystemVersionSupplier systemVersionSupplier; @Override public Mono> acquire(ServerWebExchange exchange) { @@ -28,7 +30,8 @@ public Mono> acquire(ServerWebExchange exchange) { .filter(configMap -> configMap.getData() != null) .map(configMap -> { SiteSettingVo siteSettingVo = SiteSettingVo.from(configMap) - .withUrl(externalUrlSupplier.getURL(exchange.getRequest())); + .withUrl(externalUrlSupplier.getURL(exchange.getRequest())) + .withVersion(systemVersionSupplier.get().toString()); return Map.of("site", siteSettingVo); }); } diff --git a/application/src/main/java/run/halo/app/theme/TemplateEngineManager.java b/application/src/main/java/run/halo/app/theme/TemplateEngineManager.java index 36ac57da3b..9b9487d726 100644 --- a/application/src/main/java/run/halo/app/theme/TemplateEngineManager.java +++ b/application/src/main/java/run/halo/app/theme/TemplateEngineManager.java @@ -1,13 +1,10 @@ package run.halo.app.theme; -import java.io.FileNotFoundException; -import java.nio.file.Path; import lombok.NonNull; import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.thymeleaf.ThymeleafProperties; import org.springframework.stereotype.Component; import org.springframework.util.ConcurrentLruCache; -import org.springframework.util.ResourceUtils; import org.thymeleaf.TemplateEngine; import org.thymeleaf.dialect.IDialect; import org.thymeleaf.spring6.ISpringWebFluxTemplateEngine; @@ -17,7 +14,6 @@ import org.thymeleaf.templateresolver.ITemplateResolver; import reactor.core.publisher.Mono; import run.halo.app.infra.ExternalUrlSupplier; -import run.halo.app.infra.exception.NotFoundException; import run.halo.app.plugin.HaloPluginManager; import run.halo.app.theme.dialect.HaloProcessorDialect; import run.halo.app.theme.engine.HaloTemplateEngine; @@ -71,24 +67,9 @@ public TemplateEngineManager(ThymeleafProperties thymeleafProperties, public ISpringWebFluxTemplateEngine getTemplateEngine(ThemeContext theme) { CacheKey cacheKey = buildCacheKey(theme); - // cache not exists, will create new engine - if (!engineCache.contains(cacheKey)) { - // before this, check if theme exists - if (!fileExists(theme.getPath())) { - throw new NotFoundException("Theme not found."); - } - } return engineCache.get(cacheKey); } - private boolean fileExists(Path path) { - try { - return ResourceUtils.getFile(path.toUri()).exists(); - } catch (FileNotFoundException e) { - return false; - } - } - public Mono clearCache(String themeName) { return themeResolver.getThemeContext(themeName) .doOnNext(themeContext -> { diff --git a/application/src/main/java/run/halo/app/theme/ThemeLocaleContextResolver.java b/application/src/main/java/run/halo/app/theme/ThemeLocaleContextResolver.java index cf82601a5f..9220690a3c 100644 --- a/application/src/main/java/run/halo/app/theme/ThemeLocaleContextResolver.java +++ b/application/src/main/java/run/halo/app/theme/ThemeLocaleContextResolver.java @@ -1,15 +1,15 @@ package run.halo.app.theme; import java.util.Locale; +import java.util.Optional; import java.util.TimeZone; -import java.util.function.Function; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.context.i18n.LocaleContext; import org.springframework.context.i18n.SimpleTimeZoneAwareLocaleContext; import org.springframework.http.HttpCookie; +import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.lang.NonNull; -import org.springframework.lang.Nullable; import org.springframework.stereotype.Component; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.adapter.WebHttpHandlerBuilder; @@ -22,71 +22,45 @@ @Slf4j @Component(WebHttpHandlerBuilder.LOCALE_CONTEXT_RESOLVER_BEAN_NAME) public class ThemeLocaleContextResolver extends AcceptHeaderLocaleContextResolver { - public static final String TIME_ZONE_REQUEST_ATTRIBUTE_NAME = - ThemeLocaleContextResolver.class.getName() + ".TIME_ZONE"; - public static final String LOCALE_REQUEST_ATTRIBUTE_NAME = - ThemeLocaleContextResolver.class.getName() + ".LOCALE"; - public static final String DEFAULT_PARAMETER_NAME = "language"; - public static final String TIME_ZONE_COOKIE_NAME = "time_zone"; + public static final String LANGUAGE_PARAMETER_NAME = "language"; + + public static final String LANGUAGE_COOKIE_NAME = LANGUAGE_PARAMETER_NAME; - private final Function defaultTimeZoneFunction = - exchange -> getDefaultTimeZone(); + public static final String TIME_ZONE_COOKIE_NAME = "time_zone"; @Override @NonNull public LocaleContext resolveLocaleContext(@NonNull ServerWebExchange exchange) { - parseLocaleCookieIfNecessary(exchange); + var request = exchange.getRequest(); + var locale = getLocaleFromQueryParameter(request) + .or(() -> getLocaleFromCookie(request)) + .orElseGet(() -> super.resolveLocaleContext(exchange).getLocale()); - Locale locale = getLocale(exchange); + var timeZone = getTimeZoneFromCookie(request) + .orElseGet(TimeZone::getDefault); - return new SimpleTimeZoneAwareLocaleContext(locale, - exchange.getAttribute(TIME_ZONE_REQUEST_ATTRIBUTE_NAME)); + return new SimpleTimeZoneAwareLocaleContext(locale, timeZone); } - @Nullable - private Locale getLocale(ServerWebExchange exchange) { - String language = exchange.getRequest().getQueryParams() - .getFirst(DEFAULT_PARAMETER_NAME); - - Locale locale; - if (StringUtils.isNotBlank(language)) { - locale = new Locale(language); - } else if (exchange.getAttribute(LOCALE_REQUEST_ATTRIBUTE_NAME) != null) { - locale = exchange.getAttribute(LOCALE_REQUEST_ATTRIBUTE_NAME); - } else { - locale = super.resolveLocaleContext(exchange).getLocale(); - } - return locale; + private Optional getLocaleFromCookie(ServerHttpRequest request) { + return Optional.ofNullable(request.getCookies().getFirst(LANGUAGE_COOKIE_NAME)) + .map(HttpCookie::getValue) + .filter(StringUtils::isNotBlank) + .map(Locale::forLanguageTag); } - private TimeZone getDefaultTimeZone() { - return TimeZone.getDefault(); + private Optional getLocaleFromQueryParameter(ServerHttpRequest request) { + return Optional.ofNullable(request.getQueryParams().getFirst(LANGUAGE_PARAMETER_NAME)) + .filter(StringUtils::isNotBlank) + .map(Locale::forLanguageTag); } - private void parseLocaleCookieIfNecessary(ServerWebExchange exchange) { - if (exchange.getAttribute(TIME_ZONE_REQUEST_ATTRIBUTE_NAME) == null) { - TimeZone timeZone = null; - HttpCookie cookie = exchange.getRequest() - .getCookies() - .getFirst(TIME_ZONE_COOKIE_NAME); - if (cookie != null) { - String value = cookie.getValue(); - timeZone = TimeZone.getTimeZone(value); - } - exchange.getAttributes().put(TIME_ZONE_REQUEST_ATTRIBUTE_NAME, - (timeZone != null ? timeZone : this.defaultTimeZoneFunction.apply(exchange))); - } - - if (exchange.getAttribute(LOCALE_REQUEST_ATTRIBUTE_NAME) == null) { - HttpCookie cookie = exchange.getRequest() - .getCookies() - .getFirst(DEFAULT_PARAMETER_NAME); - if (cookie != null) { - String value = cookie.getValue(); - exchange.getAttributes() - .put(LOCALE_REQUEST_ATTRIBUTE_NAME, new Locale(value)); - } - } + private Optional getTimeZoneFromCookie(ServerHttpRequest request) { + return Optional.ofNullable(request.getCookies().getFirst(TIME_ZONE_COOKIE_NAME)) + .map(HttpCookie::getValue) + .filter(StringUtils::isNotBlank) + .map(TimeZone::getTimeZone); } + } diff --git a/application/src/main/java/run/halo/app/theme/dialect/CommentElementTagProcessor.java b/application/src/main/java/run/halo/app/theme/dialect/CommentElementTagProcessor.java index 96b9935c1e..9e847194d7 100644 --- a/application/src/main/java/run/halo/app/theme/dialect/CommentElementTagProcessor.java +++ b/application/src/main/java/run/halo/app/theme/dialect/CommentElementTagProcessor.java @@ -45,6 +45,6 @@ protected void doProcess(ITemplateContext context, IProcessableElementTag tag, structureHandler.replaceWith("", false); return; } - commentWidget.render(context, tag, structureHandler); + commentWidget.render(SecureTemplateContextWrapper.wrap(context), tag, structureHandler); } } diff --git a/application/src/main/java/run/halo/app/theme/dialect/EvaluationContextEnhancer.java b/application/src/main/java/run/halo/app/theme/dialect/EvaluationContextEnhancer.java new file mode 100644 index 0000000000..36a390d379 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/dialect/EvaluationContextEnhancer.java @@ -0,0 +1,211 @@ +package run.halo.app.theme.dialect; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; +import org.springframework.core.convert.TypeDescriptor; +import org.springframework.expression.AccessException; +import org.springframework.expression.EvaluationContext; +import org.springframework.expression.MethodExecutor; +import org.springframework.expression.MethodResolver; +import org.springframework.expression.PropertyAccessor; +import org.springframework.expression.TypedValue; +import org.springframework.expression.spel.CompilablePropertyAccessor; +import org.springframework.expression.spel.support.ReflectivePropertyAccessor; +import org.springframework.integration.json.JsonPropertyAccessor; +import org.springframework.lang.Nullable; +import org.thymeleaf.context.ITemplateContext; +import org.thymeleaf.model.ITemplateEnd; +import org.thymeleaf.model.ITemplateStart; +import org.thymeleaf.processor.templateboundaries.AbstractTemplateBoundariesProcessor; +import org.thymeleaf.processor.templateboundaries.ITemplateBoundariesStructureHandler; +import org.thymeleaf.spring6.expression.ThymeleafEvaluationContext; +import org.thymeleaf.standard.StandardDialect; +import org.thymeleaf.templatemode.TemplateMode; +import run.halo.app.infra.utils.ReactiveUtils; + +/** + * Enhance the evaluation context to support reactive types. + * + * @author guqing + * @author johnniang + * @since 2.20.0 + */ +public class EvaluationContextEnhancer extends AbstractTemplateBoundariesProcessor { + + private static final int PRECEDENCE = StandardDialect.PROCESSOR_PRECEDENCE; + + private static final JsonPropertyAccessor JSON_PROPERTY_ACCESSOR = new JsonPropertyAccessor(); + + public EvaluationContextEnhancer() { + super(TemplateMode.HTML, PRECEDENCE); + } + + @Override + public void doProcessTemplateStart(ITemplateContext context, ITemplateStart templateStart, + ITemplateBoundariesStructureHandler structureHandler) { + var evluationContextObject = context.getVariable( + ThymeleafEvaluationContext.THYMELEAF_EVALUATION_CONTEXT_CONTEXT_VARIABLE_NAME + ); + if (evluationContextObject instanceof ThymeleafEvaluationContext evaluationContext) { + evaluationContext.addPropertyAccessor(JSON_PROPERTY_ACCESSOR); + ReactiveReflectivePropertyAccessor.wrap(evaluationContext); + ReactiveMethodResolver.wrap(evaluationContext); + } + } + + @Override + public void doProcessTemplateEnd(ITemplateContext context, ITemplateEnd templateEnd, + ITemplateBoundariesStructureHandler structureHandler) { + // nothing to do + } + + /** + * A {@link PropertyAccessor} that wraps the original {@link ReflectivePropertyAccessor} and + * blocks the reactive value. + */ + private static class ReactiveReflectivePropertyAccessor + extends ReflectivePropertyAccessor { + private final ReflectivePropertyAccessor delegate; + + private ReactiveReflectivePropertyAccessor(ReflectivePropertyAccessor delegate) { + this.delegate = delegate; + } + + @Override + public boolean canRead(EvaluationContext context, Object target, String name) + throws AccessException { + if (target == null) { + // For backward compatibility + return true; + } + return this.delegate.canRead(context, target, name); + } + + @Override + public TypedValue read(EvaluationContext context, Object target, String name) + throws AccessException { + if (target == null) { + // For backward compatibility + return TypedValue.NULL; + } + var typedValue = delegate.read(context, target, name); + return Optional.of(typedValue) + .filter(tv -> + Objects.nonNull(tv.getValue()) + && Objects.nonNull(tv.getTypeDescriptor()) + && ReactiveUtils.isReactiveType(tv.getTypeDescriptor().getType()) + ) + .map(tv -> new TypedValue(ReactiveUtils.blockReactiveValue(tv.getValue()))) + .orElse(typedValue); + } + + @Override + public boolean canWrite(EvaluationContext context, Object target, String name) + throws AccessException { + return delegate.canWrite(context, target, name); + } + + @Override + public void write(EvaluationContext context, Object target, String name, Object newValue) + throws AccessException { + delegate.write(context, target, name, newValue); + } + + @Override + public Class[] getSpecificTargetClasses() { + return delegate.getSpecificTargetClasses(); + } + + @Override + public PropertyAccessor createOptimalAccessor(EvaluationContext context, Object target, + String name) { + var optimalAccessor = delegate.createOptimalAccessor(context, target, name); + if (optimalAccessor instanceof CompilablePropertyAccessor optimalPropertyAccessor) { + if (ReactiveUtils.isReactiveType(optimalPropertyAccessor.getPropertyType())) { + return this; + } + return optimalPropertyAccessor; + } + return this; + } + + static void wrap(ThymeleafEvaluationContext evaluationContext) { + var wrappedPropertyAccessors = evaluationContext.getPropertyAccessors() + .stream() + .map(propertyAccessor -> { + if (propertyAccessor instanceof ReflectivePropertyAccessor reflectiveAccessor) { + return new ReactiveReflectivePropertyAccessor(reflectiveAccessor); + } + return propertyAccessor; + }) + // make the list mutable + .collect(Collectors.toCollection(ArrayList::new)); + evaluationContext.setPropertyAccessors(wrappedPropertyAccessors); + } + + @Override + public boolean equals(Object obj) { + return delegate.equals(obj); + } + + @Override + public int hashCode() { + return delegate.hashCode(); + } + + } + + /** + * A {@link MethodResolver} that wraps the original {@link MethodResolver} and blocks the + * reactive value. + * + * @param delegate the original {@link MethodResolver} + */ + private record ReactiveMethodResolver(MethodResolver delegate) implements MethodResolver { + + @Override + @Nullable + public MethodExecutor resolve(EvaluationContext context, Object targetObject, String name, + List argumentTypes) throws AccessException { + var executor = delegate.resolve(context, targetObject, name, argumentTypes); + return Optional.ofNullable(executor).map(ReactiveMethodExecutor::new).orElse(null); + } + + static void wrap(ThymeleafEvaluationContext evaluationContext) { + var wrappedMethodResolvers = evaluationContext.getMethodResolvers() + .stream() + .map(ReactiveMethodResolver::new) + // make the list mutable + .collect(Collectors.toCollection(ArrayList::new)); + evaluationContext.setMethodResolvers(wrappedMethodResolvers); + } + + } + + /** + * A {@link MethodExecutor} that wraps the original {@link MethodExecutor} and blocks the + * reactive value. + * + * @param delegate the original {@link MethodExecutor} + */ + private record ReactiveMethodExecutor(MethodExecutor delegate) implements MethodExecutor { + + @Override + public TypedValue execute(EvaluationContext context, Object target, Object... arguments) + throws AccessException { + var typedValue = delegate.execute(context, target, arguments); + return Optional.of(typedValue) + .filter(tv -> + Objects.nonNull(tv.getValue()) + && Objects.nonNull(tv.getTypeDescriptor()) + && ReactiveUtils.isReactiveType(tv.getTypeDescriptor().getType()) + ) + .map(tv -> new TypedValue(ReactiveUtils.blockReactiveValue(tv.getValue()))) + .orElse(typedValue); + } + + } +} diff --git a/application/src/main/java/run/halo/app/theme/dialect/GlobalHeadInjectionProcessor.java b/application/src/main/java/run/halo/app/theme/dialect/GlobalHeadInjectionProcessor.java index 6be5c52bbf..0604dc5910 100644 --- a/application/src/main/java/run/halo/app/theme/dialect/GlobalHeadInjectionProcessor.java +++ b/application/src/main/java/run/halo/app/theme/dialect/GlobalHeadInjectionProcessor.java @@ -42,6 +42,9 @@ public GlobalHeadInjectionProcessor(final String dialectPrefix) { @Override protected void doProcess(ITemplateContext context, IModel model, IElementModelStructureHandler structureHandler) { + if (context.containsVariable(InjectionExcluderProcessor.EXCLUDE_INJECTION_VARIABLE)) { + return; + } // note that this is important!! Object processedAlready = context.getVariable(PROCESS_FLAG); @@ -71,7 +74,9 @@ protected void doProcess(ITemplateContext context, IModel model, // apply processors to modelToInsert getTemplateHeadProcessors(context) - .concatMap(processor -> processor.process(context, modelToInsert, structureHandler)) + .concatMap(processor -> processor.process( + SecureTemplateContextWrapper.wrap(context), modelToInsert, structureHandler) + ) .then() .block(); diff --git a/application/src/main/java/run/halo/app/theme/dialect/HaloPostTemplateHandler.java b/application/src/main/java/run/halo/app/theme/dialect/HaloPostTemplateHandler.java new file mode 100644 index 0000000000..f4f20ba897 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/dialect/HaloPostTemplateHandler.java @@ -0,0 +1,71 @@ +package run.halo.app.theme.dialect; + +import static org.thymeleaf.spring6.context.SpringContextUtils.getApplicationContext; + +import java.time.Duration; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import org.springframework.lang.NonNull; +import org.springframework.util.CollectionUtils; +import org.thymeleaf.context.ITemplateContext; +import org.thymeleaf.engine.AbstractTemplateHandler; +import org.thymeleaf.model.IOpenElementTag; +import org.thymeleaf.model.IProcessableElementTag; +import org.thymeleaf.model.IStandaloneElementTag; +import reactor.core.publisher.Mono; +import run.halo.app.plugin.extensionpoint.ExtensionGetter; + +/** + * Template post-handler. + * + * @author johnniang + * @since 2.20.0 + */ +public class HaloPostTemplateHandler extends AbstractTemplateHandler { + + private List postProcessors = List.of(); + + @Override + public void setContext(ITemplateContext context) { + super.setContext(context); + this.postProcessors = Optional.ofNullable(getApplicationContext(context)) + .map(appContext -> appContext.getBeanProvider(ExtensionGetter.class).getIfUnique()) + .map(extensionGetter -> extensionGetter.getExtensionList(ElementTagPostProcessor.class)) + .orElseGet(List::of); + } + + @Override + public void handleStandaloneElement(IStandaloneElementTag standaloneElementTag) { + var processedTag = handleElementTag(standaloneElementTag); + super.handleStandaloneElement((IStandaloneElementTag) processedTag); + } + + @Override + public void handleOpenElement(IOpenElementTag openElementTag) { + var processedTag = handleElementTag(openElementTag); + super.handleOpenElement((IOpenElementTag) processedTag); + } + + @NonNull + private IProcessableElementTag handleElementTag( + @NonNull IProcessableElementTag processableElementTag + ) { + IProcessableElementTag processedTag = processableElementTag; + if (!CollectionUtils.isEmpty(postProcessors)) { + var tagProcessorChain = Mono.just(processableElementTag); + var context = getContext(); + for (ElementTagPostProcessor elementTagPostProcessor : postProcessors) { + tagProcessorChain = tagProcessorChain.flatMap( + tag -> elementTagPostProcessor.process( + SecureTemplateContextWrapper.wrap(context), tag) + .defaultIfEmpty(tag) + ); + } + processedTag = + Objects.requireNonNull(tagProcessorChain.defaultIfEmpty(processableElementTag) + .block(Duration.ofMinutes(1))); + } + return processedTag; + } +} diff --git a/application/src/main/java/run/halo/app/theme/dialect/HaloProcessorDialect.java b/application/src/main/java/run/halo/app/theme/dialect/HaloProcessorDialect.java index 6d61b3c48e..99105931d7 100644 --- a/application/src/main/java/run/halo/app/theme/dialect/HaloProcessorDialect.java +++ b/application/src/main/java/run/halo/app/theme/dialect/HaloProcessorDialect.java @@ -1,10 +1,15 @@ package run.halo.app.theme.dialect; +import static org.thymeleaf.templatemode.TemplateMode.HTML; + import java.util.HashSet; import java.util.Set; import org.thymeleaf.dialect.AbstractProcessorDialect; import org.thymeleaf.dialect.IExpressionObjectDialect; +import org.thymeleaf.dialect.IPostProcessorDialect; import org.thymeleaf.expression.IExpressionObjectFactory; +import org.thymeleaf.postprocessor.IPostProcessor; +import org.thymeleaf.postprocessor.PostProcessor; import org.thymeleaf.processor.IProcessor; import org.thymeleaf.standard.StandardDialect; @@ -14,8 +19,8 @@ * @author guqing * @since 2.0.0 */ -public class HaloProcessorDialect extends AbstractProcessorDialect implements - IExpressionObjectDialect { +public class HaloProcessorDialect extends AbstractProcessorDialect + implements IExpressionObjectDialect, IPostProcessorDialect { private static final String DIALECT_NAME = "haloThemeProcessorDialect"; private static final IExpressionObjectFactory HALO_EXPRESSION_OBJECTS_FACTORY = @@ -33,9 +38,10 @@ public Set getProcessors(String dialectPrefix) { // add more processors processors.add(new GlobalHeadInjectionProcessor(dialectPrefix)); processors.add(new TemplateFooterElementTagProcessor(dialectPrefix)); - processors.add(new JsonNodePropertyAccessorBoundariesProcessor()); + processors.add(new EvaluationContextEnhancer()); processors.add(new CommentElementTagProcessor(dialectPrefix)); processors.add(new CommentEnabledVariableProcessor()); + processors.add(new InjectionExcluderProcessor()); return processors; } @@ -43,4 +49,14 @@ public Set getProcessors(String dialectPrefix) { public IExpressionObjectFactory getExpressionObjectFactory() { return HALO_EXPRESSION_OBJECTS_FACTORY; } + + @Override + public int getDialectPostProcessorPrecedence() { + return Integer.MAX_VALUE; + } + + @Override + public Set getPostProcessors() { + return Set.of(new PostProcessor(HTML, HaloPostTemplateHandler.class, Integer.MAX_VALUE)); + } } diff --git a/application/src/main/java/run/halo/app/theme/dialect/HaloSpringSecurityDialect.java b/application/src/main/java/run/halo/app/theme/dialect/HaloSpringSecurityDialect.java index 31f2b623a1..7c7a44082b 100644 --- a/application/src/main/java/run/halo/app/theme/dialect/HaloSpringSecurityDialect.java +++ b/application/src/main/java/run/halo/app/theme/dialect/HaloSpringSecurityDialect.java @@ -13,6 +13,7 @@ import org.thymeleaf.extras.springsecurity6.dialect.SpringSecurityDialect; import org.thymeleaf.extras.springsecurity6.util.SpringSecurityContextUtils; import org.thymeleaf.extras.springsecurity6.util.SpringVersionUtils; +import run.halo.app.security.authorization.AuthorityUtils; /** * HaloSpringSecurityDialect overwrites value of thymeleafSpringSecurityContext. @@ -40,7 +41,9 @@ public void afterPropertiesSet() { // We have to build an anonymous authentication token here because the token won't be saved // into repository during anonymous authentication. var anonymousAuthentication = - new AnonymousAuthenticationToken("fallback", PRINCIPAL, createAuthorityList(Role)); + new AnonymousAuthenticationToken( + "fallback", PRINCIPAL, createAuthorityList(AuthorityUtils.ROLE_PREFIX + Role) + ); var anonymousSecurityContext = new SecurityContextImpl(anonymousAuthentication); final Function secCtxInitializer = diff --git a/application/src/main/java/run/halo/app/theme/dialect/InjectionExcluderProcessor.java b/application/src/main/java/run/halo/app/theme/dialect/InjectionExcluderProcessor.java new file mode 100644 index 0000000000..5fb6c1c7a2 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/dialect/InjectionExcluderProcessor.java @@ -0,0 +1,91 @@ +package run.halo.app.theme.dialect; + +import java.util.Set; +import java.util.regex.Pattern; +import org.springframework.util.Assert; +import org.thymeleaf.context.ITemplateContext; +import org.thymeleaf.model.ITemplateEnd; +import org.thymeleaf.model.ITemplateStart; +import org.thymeleaf.processor.templateboundaries.AbstractTemplateBoundariesProcessor; +import org.thymeleaf.processor.templateboundaries.ITemplateBoundariesProcessor; +import org.thymeleaf.processor.templateboundaries.ITemplateBoundariesStructureHandler; +import org.thymeleaf.standard.StandardDialect; +import org.thymeleaf.templatemode.TemplateMode; + +/** + *

Determine whether the current template being rendered needs to exclude the processor of + * code injection. If it needs to be excluded, set a local variable.

+ *

Why do you need to set a local variable here instead of directly judging in the processor?

+ *

Because the processor will process the fragment, and if you need to exclude the login + * .html template and the login.html is only a fragment, then the exclusion logic will + * fail, so here use {@link ITemplateBoundariesProcessor} events are only fired for the + * first-level template to solve this problem.

+ * + * @author guqing + * @since 2.20.0 + */ +public class InjectionExcluderProcessor extends AbstractTemplateBoundariesProcessor { + + public static final String EXCLUDE_INJECTION_VARIABLE = + InjectionExcluderProcessor.class.getName() + ".EXCLUDE_INJECTION"; + + private final PageInjectionExcluder injectionExcluder = new PageInjectionExcluder(); + + public InjectionExcluderProcessor() { + super(TemplateMode.HTML, StandardDialect.PROCESSOR_PRECEDENCE); + } + + @Override + public void doProcessTemplateStart(ITemplateContext context, ITemplateStart templateStart, + ITemplateBoundariesStructureHandler structureHandler) { + if (isExcluded(context)) { + structureHandler.setLocalVariable(EXCLUDE_INJECTION_VARIABLE, true); + } + } + + @Override + public void doProcessTemplateEnd(ITemplateContext context, ITemplateEnd templateEnd, + ITemplateBoundariesStructureHandler structureHandler) { + structureHandler.removeLocalVariable(EXCLUDE_INJECTION_VARIABLE); + } + + /** + * Check if the template will be rendered is excluded injection. + * + * @param context template context + * @return true if the template is excluded, otherwise false + */ + boolean isExcluded(ITemplateContext context) { + return injectionExcluder.isExcluded(context.getTemplateData().getTemplate()); + } + + static class PageInjectionExcluder { + + private final Set exactMatches = Set.of( + "login", + "signup", + "logout" + ); + + private final Set regexPatterns = Set.of( + Pattern.compile("error/.*"), + Pattern.compile("challenges/.*"), + Pattern.compile("password-reset/.*") + ); + + public boolean isExcluded(String templateName) { + Assert.notNull(templateName, "Template name must not be null"); + if (exactMatches.contains(templateName)) { + return true; + } + + for (Pattern pattern : regexPatterns) { + if (pattern.matcher(templateName).matches()) { + return true; + } + } + + return false; + } + } +} diff --git a/application/src/main/java/run/halo/app/theme/dialect/JsonNodePropertyAccessorBoundariesProcessor.java b/application/src/main/java/run/halo/app/theme/dialect/JsonNodePropertyAccessorBoundariesProcessor.java deleted file mode 100644 index 0ab86fffe4..0000000000 --- a/application/src/main/java/run/halo/app/theme/dialect/JsonNodePropertyAccessorBoundariesProcessor.java +++ /dev/null @@ -1,49 +0,0 @@ -package run.halo.app.theme.dialect; - -import org.springframework.integration.json.JsonPropertyAccessor; -import org.thymeleaf.context.ITemplateContext; -import org.thymeleaf.model.ITemplateEnd; -import org.thymeleaf.model.ITemplateStart; -import org.thymeleaf.processor.templateboundaries.AbstractTemplateBoundariesProcessor; -import org.thymeleaf.processor.templateboundaries.ITemplateBoundariesStructureHandler; -import org.thymeleaf.spring6.expression.ThymeleafEvaluationContext; -import org.thymeleaf.standard.StandardDialect; -import org.thymeleaf.templatemode.TemplateMode; -import run.halo.app.theme.ReactivePropertyAccessor; - -/** - * A template boundaries processor for add {@link JsonPropertyAccessor} to - * {@link ThymeleafEvaluationContext}. - * - * @author guqing - * @since 2.0.0 - */ -public class JsonNodePropertyAccessorBoundariesProcessor - extends AbstractTemplateBoundariesProcessor { - private static final int PRECEDENCE = StandardDialect.PROCESSOR_PRECEDENCE; - private static final JsonPropertyAccessor JSON_PROPERTY_ACCESSOR = new JsonPropertyAccessor(); - private static final ReactivePropertyAccessor REACTIVE_PROPERTY_ACCESSOR = - new ReactivePropertyAccessor(); - - public JsonNodePropertyAccessorBoundariesProcessor() { - super(TemplateMode.HTML, PRECEDENCE); - } - - @Override - public void doProcessTemplateStart(ITemplateContext context, ITemplateStart templateStart, - ITemplateBoundariesStructureHandler structureHandler) { - ThymeleafEvaluationContext evaluationContext = - (ThymeleafEvaluationContext) context.getVariable( - ThymeleafEvaluationContext.THYMELEAF_EVALUATION_CONTEXT_CONTEXT_VARIABLE_NAME); - if (evaluationContext != null) { - evaluationContext.addPropertyAccessor(JSON_PROPERTY_ACCESSOR); - evaluationContext.addPropertyAccessor(REACTIVE_PROPERTY_ACCESSOR); - } - } - - @Override - public void doProcessTemplateEnd(ITemplateContext context, ITemplateEnd templateEnd, - ITemplateBoundariesStructureHandler structureHandler) { - // nothing to do - } -} diff --git a/application/src/main/java/run/halo/app/theme/dialect/SecureTemplateContext.java b/application/src/main/java/run/halo/app/theme/dialect/SecureTemplateContext.java new file mode 100644 index 0000000000..3fe8d04036 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/dialect/SecureTemplateContext.java @@ -0,0 +1,159 @@ +package run.halo.app.theme.dialect; + +import static org.thymeleaf.spring6.expression.ThymeleafEvaluationContext.THYMELEAF_EVALUATION_CONTEXT_CONTEXT_VARIABLE_NAME; + +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; +import org.thymeleaf.IEngineConfiguration; +import org.thymeleaf.context.ITemplateContext; +import org.thymeleaf.context.IdentifierSequences; +import org.thymeleaf.engine.TemplateData; +import org.thymeleaf.expression.IExpressionObjects; +import org.thymeleaf.inline.IInliner; +import org.thymeleaf.model.IModelFactory; +import org.thymeleaf.model.IProcessableElementTag; +import org.thymeleaf.templatemode.TemplateMode; + +/** + * Secure template context. + * + * @author johnniang + * @since 2.20.0 + */ +class SecureTemplateContext implements ITemplateContext { + + private static final Set DANGEROUS_VARIABLES = + Set.of(THYMELEAF_EVALUATION_CONTEXT_CONTEXT_VARIABLE_NAME); + + private final ITemplateContext delegate; + + public SecureTemplateContext(ITemplateContext delegate) { + this.delegate = delegate; + } + + @Override + public TemplateData getTemplateData() { + return delegate.getTemplateData(); + } + + @Override + public TemplateMode getTemplateMode() { + return delegate.getTemplateMode(); + } + + @Override + public List getTemplateStack() { + return delegate.getTemplateStack(); + } + + @Override + public List getElementStack() { + return delegate.getElementStack(); + } + + @Override + public Map getTemplateResolutionAttributes() { + return delegate.getTemplateResolutionAttributes(); + } + + @Override + public IModelFactory getModelFactory() { + return delegate.getModelFactory(); + } + + @Override + public boolean hasSelectionTarget() { + return delegate.hasSelectionTarget(); + } + + @Override + public Object getSelectionTarget() { + return delegate.getSelectionTarget(); + } + + @Override + public IInliner getInliner() { + return delegate.getInliner(); + } + + @Override + public String getMessage( + Class origin, + String key, + Object[] messageParameters, + boolean useAbsentMessageRepresentation + ) { + return delegate.getMessage(origin, key, messageParameters, useAbsentMessageRepresentation); + } + + @Override + public String buildLink(String base, Map parameters) { + return delegate.buildLink(base, parameters); + } + + @Override + public IdentifierSequences getIdentifierSequences() { + return delegate.getIdentifierSequences(); + } + + @Override + public IEngineConfiguration getConfiguration() { + return delegate.getConfiguration(); + } + + @Override + public IExpressionObjects getExpressionObjects() { + return delegate.getExpressionObjects(); + } + + @Override + public Locale getLocale() { + return delegate.getLocale(); + } + + @Override + public boolean containsVariable(String name) { + if (DANGEROUS_VARIABLES.contains(name)) { + return false; + } + return delegate.containsVariable(name); + } + + @Override + public Set getVariableNames() { + return delegate.getVariableNames() + .stream() + .filter(name -> !DANGEROUS_VARIABLES.contains(name)) + .collect(Collectors.toSet()); + } + + @Override + public Object getVariable(String name) { + if (DANGEROUS_VARIABLES.contains(name)) { + return null; + } + return delegate.getVariable(name); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + SecureTemplateContext that = (SecureTemplateContext) o; + return Objects.equals(delegate, that.delegate); + } + + @Override + public int hashCode() { + return Objects.hashCode(delegate); + } +} diff --git a/application/src/main/java/run/halo/app/theme/dialect/SecureTemplateContextWrapper.java b/application/src/main/java/run/halo/app/theme/dialect/SecureTemplateContextWrapper.java new file mode 100644 index 0000000000..afafc81079 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/dialect/SecureTemplateContextWrapper.java @@ -0,0 +1,24 @@ +package run.halo.app.theme.dialect; + +import org.thymeleaf.context.Contexts; +import org.thymeleaf.context.ITemplateContext; + +/** + * Wrap the delegate template context to a secure template context according to whether it is a + * WebContext. + * + * @author guqing + * @since 2.20.4 + */ +public class SecureTemplateContextWrapper { + + /** + * Wrap the delegate template context to a secure template context. + */ + static SecureTemplateContext wrap(ITemplateContext delegate) { + if (Contexts.isWebContext(delegate)) { + return new SecureTemplateWebContext(delegate); + } + return new SecureTemplateContext(delegate); + } +} diff --git a/application/src/main/java/run/halo/app/theme/dialect/SecureTemplateWebContext.java b/application/src/main/java/run/halo/app/theme/dialect/SecureTemplateWebContext.java new file mode 100644 index 0000000000..79de141f00 --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/dialect/SecureTemplateWebContext.java @@ -0,0 +1,36 @@ +package run.halo.app.theme.dialect; + +import org.springframework.context.ApplicationContext; +import org.thymeleaf.context.ITemplateContext; +import org.thymeleaf.context.IWebContext; +import org.thymeleaf.web.IWebExchange; + +/** + * Secure template web context. + *

It's used to prevent some dangerous variables such as {@link ApplicationContext} from being + * accessed. + * + * @author guqing + * @see SecureTemplateContext + * @since 2.20.4 + */ +class SecureTemplateWebContext extends SecureTemplateContext implements IWebContext { + private final IWebContext delegate; + + /** + * The delegate must be an instance of IWebContext to create a SecureTemplateWebContext. + */ + public SecureTemplateWebContext(ITemplateContext delegate) { + super(delegate); + if (delegate instanceof IWebContext webContext) { + this.delegate = webContext; + } else { + throw new IllegalArgumentException("The delegate must be an instance of IWebContext"); + } + } + + @Override + public IWebExchange getExchange() { + return delegate.getExchange(); + } +} diff --git a/application/src/main/java/run/halo/app/theme/dialect/TemplateFooterElementTagProcessor.java b/application/src/main/java/run/halo/app/theme/dialect/TemplateFooterElementTagProcessor.java index 74239b1f2e..c41dd14549 100644 --- a/application/src/main/java/run/halo/app/theme/dialect/TemplateFooterElementTagProcessor.java +++ b/application/src/main/java/run/halo/app/theme/dialect/TemplateFooterElementTagProcessor.java @@ -48,6 +48,10 @@ public TemplateFooterElementTagProcessor(final String dialectPrefix) { protected void doProcess(ITemplateContext context, IProcessableElementTag tag, IElementTagStructureHandler structureHandler) { + if (context.containsVariable(InjectionExcluderProcessor.EXCLUDE_INJECTION_VARIABLE)) { + return; + } + IModel modelToInsert = context.getModelFactory().createModel(); /* * Obtain the Spring application context. @@ -58,8 +62,8 @@ protected void doProcess(ITemplateContext context, IProcessableElementTag tag, modelToInsert.add(context.getModelFactory().createText(globalFooterText)); getTemplateFooterProcessors(context) - .concatMap(processor -> processor.process(context, tag, - structureHandler, modelToInsert) + .concatMap(processor -> processor.process( + SecureTemplateContextWrapper.wrap(context), tag, structureHandler, modelToInsert) ) .then() .block(); diff --git a/application/src/main/java/run/halo/app/theme/endpoint/PublicUserEndpoint.java b/application/src/main/java/run/halo/app/theme/endpoint/PublicUserEndpoint.java deleted file mode 100644 index 9ebd7b22a8..0000000000 --- a/application/src/main/java/run/halo/app/theme/endpoint/PublicUserEndpoint.java +++ /dev/null @@ -1,285 +0,0 @@ -package run.halo.app.theme.endpoint; - -import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; -import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; -import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; -import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; -import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder; - -import io.github.resilience4j.ratelimiter.RateLimiterRegistry; -import io.github.resilience4j.ratelimiter.RequestNotPermitted; -import io.github.resilience4j.reactor.ratelimiter.operator.RateLimiterOperator; -import io.swagger.v3.oas.annotations.enums.ParameterIn; -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.RequiredArgsConstructor; -import org.apache.commons.lang3.BooleanUtils; -import org.apache.commons.lang3.StringUtils; -import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.context.SecurityContextImpl; -import org.springframework.security.core.userdetails.ReactiveUserDetailsService; -import org.springframework.security.web.server.context.ServerSecurityContextRepository; -import org.springframework.stereotype.Component; -import org.springframework.web.reactive.function.server.RouterFunction; -import org.springframework.web.reactive.function.server.ServerRequest; -import org.springframework.web.reactive.function.server.ServerResponse; -import org.springframework.web.server.ServerWebExchange; -import org.springframework.web.server.ServerWebInputException; -import reactor.core.publisher.Mono; -import run.halo.app.core.extension.User; -import run.halo.app.core.extension.endpoint.CustomEndpoint; -import run.halo.app.core.extension.service.EmailPasswordRecoveryService; -import run.halo.app.core.extension.service.EmailVerificationService; -import run.halo.app.core.extension.service.UserService; -import run.halo.app.extension.GroupVersion; -import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; -import run.halo.app.infra.SystemSetting; -import run.halo.app.infra.ValidationUtils; -import run.halo.app.infra.exception.AccessDeniedException; -import run.halo.app.infra.exception.EmailVerificationFailed; -import run.halo.app.infra.exception.RateLimitExceededException; -import run.halo.app.infra.utils.IpAddressUtils; - -/** - * User endpoint for unauthenticated user. - * - * @author guqing - * @since 2.4.0 - */ -@Component -@RequiredArgsConstructor -public class PublicUserEndpoint implements CustomEndpoint { - private final UserService userService; - private final ServerSecurityContextRepository securityContextRepository; - private final ReactiveUserDetailsService reactiveUserDetailsService; - private final EmailPasswordRecoveryService emailPasswordRecoveryService; - private final RateLimiterRegistry rateLimiterRegistry; - private final SystemConfigurableEnvironmentFetcher environmentFetcher; - private final EmailVerificationService emailVerificationService; - - @Override - public RouterFunction endpoint() { - var tag = "UserV1alpha1Public"; - return SpringdocRouteBuilder.route() - .POST("/users/-/signup", this::signUp, - builder -> builder.operationId("SignUp") - .description("Sign up a new user") - .tag(tag) - .requestBody(requestBodyBuilder().required(true) - .implementation(SignUpRequest.class) - ) - .response(responseBuilder().implementation(User.class)) - ) - .POST("/users/-/send-register-verify-email", this::sendRegisterVerifyEmail, - builder -> builder.operationId("SendRegisterVerifyEmail") - .description( - "Send registration verification email, which can be called when " - + "mustVerifyEmailOnRegistration in user settings is true" - ) - .tag(tag) - .requestBody(requestBodyBuilder() - .required(true) - .implementation(RegisterVerifyEmailRequest.class) - ) - .response(responseBuilder() - .responseCode(HttpStatus.NO_CONTENT.toString()) - .implementation(Void.class) - ) - ) - .POST("/users/-/send-password-reset-email", this::sendPasswordResetEmail, - builder -> builder.operationId("SendPasswordResetEmail") - .description("Send password reset email when forgot password") - .tag(tag) - .requestBody(requestBodyBuilder() - .required(true) - .implementation(PasswordResetEmailRequest.class) - ) - .response(responseBuilder() - .responseCode(HttpStatus.NO_CONTENT.toString()) - .implementation(Void.class)) - ) - .PUT("/users/{name}/reset-password", this::resetPasswordByToken, - builder -> builder.operationId("ResetPasswordByToken") - .description("Reset password by token") - .tag(tag) - .parameter(parameterBuilder() - .name("name") - .description("The name of the user") - .required(true) - .in(ParameterIn.PATH) - ) - .requestBody(requestBodyBuilder() - .required(true) - .implementation(ResetPasswordRequest.class) - ) - .response(responseBuilder() - .responseCode(HttpStatus.NO_CONTENT.toString()) - .implementation(Void.class) - ) - ) - .build(); - } - - private Mono resetPasswordByToken(ServerRequest request) { - var username = request.pathVariable("name"); - return request.bodyToMono(ResetPasswordRequest.class) - .doOnNext(resetReq -> { - if (StringUtils.isBlank(resetReq.token())) { - throw new ServerWebInputException("Token must not be blank"); - } - if (StringUtils.isBlank(resetReq.newPassword())) { - throw new ServerWebInputException("New password must not be blank"); - } - }) - .switchIfEmpty( - Mono.error(() -> new ServerWebInputException("Request body must not be empty")) - ) - .flatMap(resetReq -> { - var token = resetReq.token(); - var newPassword = resetReq.newPassword(); - return emailPasswordRecoveryService.changePassword(username, newPassword, token); - }) - .then(ServerResponse.noContent().build()); - } - - record PasswordResetEmailRequest(@Schema(requiredMode = REQUIRED) String username, - @Schema(requiredMode = REQUIRED) String email) { - } - - record ResetPasswordRequest(@Schema(requiredMode = REQUIRED, minLength = 6) String newPassword, - @Schema(requiredMode = REQUIRED) String token) { - } - - record RegisterVerifyEmailRequest(@Schema(requiredMode = REQUIRED) String email) { - } - - private Mono sendPasswordResetEmail(ServerRequest request) { - return request.bodyToMono(PasswordResetEmailRequest.class) - .flatMap(passwordResetRequest -> { - var username = passwordResetRequest.username(); - var email = passwordResetRequest.email(); - return Mono.just(passwordResetRequest) - .transformDeferred(sendResetPasswordEmailRateLimiter(username, email)) - .flatMap( - r -> emailPasswordRecoveryService.sendPasswordResetEmail(username, email)) - .onErrorMap(RequestNotPermitted.class, RateLimitExceededException::new); - }) - .then(ServerResponse.noContent().build()); - } - - RateLimiterOperator sendResetPasswordEmailRateLimiter(String username, String email) { - String rateLimiterKey = "send-reset-password-email-" + username + ":" + email; - var rateLimiter = - rateLimiterRegistry.rateLimiter(rateLimiterKey, "send-reset-password-email"); - return RateLimiterOperator.of(rateLimiter); - } - - @Override - public GroupVersion groupVersion() { - return GroupVersion.parseAPIVersion("api.halo.run/v1alpha1"); - } - - private Mono signUp(ServerRequest request) { - return request.bodyToMono(SignUpRequest.class) - .doOnNext(signUpRequest -> signUpRequest.user().getSpec().setEmailVerified(false)) - .flatMap(signUpRequest -> environmentFetcher.fetch(SystemSetting.User.GROUP, - SystemSetting.User.class) - .map(user -> BooleanUtils.isTrue(user.getMustVerifyEmailOnRegistration())) - .defaultIfEmpty(false) - .flatMap(mustVerifyEmailOnRegistration -> { - if (!mustVerifyEmailOnRegistration) { - return Mono.just(signUpRequest); - } - if (!StringUtils.isNumeric(signUpRequest.verifyCode)) { - return Mono.error(new EmailVerificationFailed()); - } - return emailVerificationService.verifyRegisterVerificationCode( - signUpRequest.user().getSpec().getEmail(), - signUpRequest.verifyCode) - .flatMap(verified -> { - if (BooleanUtils.isNotTrue(verified)) { - return Mono.error(new EmailVerificationFailed()); - } - signUpRequest.user().getSpec().setEmailVerified(true); - return Mono.just(signUpRequest); - }); - }) - ) - .flatMap(signUpRequest -> - userService.signUp(signUpRequest.user(), signUpRequest.password()) - ) - .flatMap(user -> authenticate(user.getMetadata().getName(), request.exchange()) - .thenReturn(user) - ) - .flatMap(user -> ServerResponse.ok() - .contentType(MediaType.APPLICATION_JSON) - .bodyValue(user) - ) - .transformDeferred(getRateLimiterForSignUp(request.exchange())) - .onErrorMap(RequestNotPermitted.class, RateLimitExceededException::new); - } - - private Mono sendRegisterVerifyEmail(ServerRequest request) { - return request.bodyToMono(RegisterVerifyEmailRequest.class) - .switchIfEmpty(Mono.error( - () -> new ServerWebInputException("Required request body is missing.")) - ) - .map(emailReq -> { - var email = emailReq.email(); - if (!ValidationUtils.isValidEmail(email)) { - throw new ServerWebInputException("Invalid email address."); - } - return email; - }) - .flatMap(email -> environmentFetcher.fetch(SystemSetting.User.GROUP, - SystemSetting.User.class) - .map(config -> BooleanUtils.isTrue(config.getMustVerifyEmailOnRegistration())) - .defaultIfEmpty(false) - .doOnNext(mustVerifyEmailOnRegistration -> { - if (!mustVerifyEmailOnRegistration) { - throw new AccessDeniedException("Email verification is not required."); - } - }) - .transformDeferred(sendRegisterEmailVerificationCodeRateLimiter(email)) - .flatMap(s -> emailVerificationService.sendRegisterVerificationCode(email) - .onErrorMap(RequestNotPermitted.class, RateLimitExceededException::new)) - .onErrorMap(RequestNotPermitted.class, RateLimitExceededException::new) - ) - .then(ServerResponse.ok().build()); - } - - private RateLimiterOperator getRateLimiterForSignUp(ServerWebExchange exchange) { - var clientIp = IpAddressUtils.getClientIp(exchange.getRequest()); - var rateLimiter = rateLimiterRegistry.rateLimiter("signup-from-ip-" + clientIp, - "signup"); - return RateLimiterOperator.of(rateLimiter); - } - - private Mono authenticate(String username, ServerWebExchange exchange) { - return reactiveUserDetailsService.findByUsername(username) - .flatMap(userDetails -> { - SecurityContextImpl securityContext = new SecurityContextImpl(); - UsernamePasswordAuthenticationToken authentication = - new UsernamePasswordAuthenticationToken(userDetails.getUsername(), - userDetails.getPassword(), userDetails.getAuthorities()); - securityContext.setAuthentication(authentication); - return securityContextRepository.save(exchange, securityContext); - }); - } - - private RateLimiterOperator sendRegisterEmailVerificationCodeRateLimiter(String email) { - String rateLimiterKey = "send-register-verify-email:" + email; - var rateLimiter = - rateLimiterRegistry.rateLimiter(rateLimiterKey, "send-email-verification-code"); - return RateLimiterOperator.of(rateLimiter); - } - - record SignUpRequest(@Schema(requiredMode = REQUIRED) User user, - @Schema(requiredMode = REQUIRED, minLength = 6) String password, - @Schema(requiredMode = NOT_REQUIRED, minLength = 6, maxLength = 6) - String verifyCode - ) { - } -} diff --git a/application/src/main/java/run/halo/app/core/extension/theme/ThemeEndpoint.java b/application/src/main/java/run/halo/app/theme/endpoint/ThemeEndpoint.java similarity index 86% rename from application/src/main/java/run/halo/app/core/extension/theme/ThemeEndpoint.java rename to application/src/main/java/run/halo/app/theme/endpoint/ThemeEndpoint.java index 9cc4210925..c5e0feb96b 100644 --- a/application/src/main/java/run/halo/app/core/extension/theme/ThemeEndpoint.java +++ b/application/src/main/java/run/halo/app/theme/endpoint/ThemeEndpoint.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.theme; +package run.halo.app.theme.endpoint; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; @@ -9,6 +9,7 @@ import static org.springframework.http.HttpStatus.NO_CONTENT; import static org.springframework.web.reactive.function.server.RequestPredicates.contentType; +import com.fasterxml.jackson.databind.node.ObjectNode; import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.media.Schema; import java.net.URI; @@ -38,6 +39,7 @@ import run.halo.app.core.extension.Setting; import run.halo.app.core.extension.Theme; import run.halo.app.core.extension.endpoint.CustomEndpoint; +import run.halo.app.core.user.service.SettingConfigService; import run.halo.app.extension.ConfigMap; import run.halo.app.extension.ListResult; import run.halo.app.extension.ReactiveExtensionClient; @@ -49,6 +51,8 @@ import run.halo.app.infra.exception.NotFoundException; import run.halo.app.infra.utils.JsonUtils; import run.halo.app.theme.TemplateEngineManager; +import run.halo.app.theme.service.ThemeService; +import run.halo.app.theme.service.ThemeUtils; /** * Endpoint for managing themes. @@ -73,6 +77,8 @@ public class ThemeEndpoint implements CustomEndpoint { private final ReactiveUrlDataBufferFetcher urlDataBufferFetcher; + private final SettingConfigService settingConfigService; + @Override public RouterFunction endpoint() { var tag = "ThemeV1alpha1Console"; @@ -161,8 +167,9 @@ public RouterFunction endpoint() { ) .PUT("themes/{name}/config", this::updateThemeConfig, builder -> builder.operationId("updateThemeConfig") - .description("Update the configMap of theme setting.") + .description("Update the configMap of theme setting. It is deprecated.") .tag(tag) + .deprecated(true) .parameter(parameterBuilder() .name("name") .in(ParameterIn.PATH) @@ -176,6 +183,24 @@ public RouterFunction endpoint() { .response(responseBuilder() .implementation(ConfigMap.class)) ) + .PUT("themes/{name}/json-config", this::updateThemeJsonConfig, + builder -> builder.operationId("updateThemeJsonConfig") + .description("Update the configMap of theme setting.") + .tag(tag) + .parameter(parameterBuilder() + .name("name") + .in(ParameterIn.PATH) + .required(true) + .implementation(String.class) + ) + .requestBody(requestBodyBuilder() + .required(true) + .content(contentBuilder().mediaType(MediaType.APPLICATION_JSON_VALUE) + .schema(schemaBuilder().implementation(ObjectNode.class)))) + .response(responseBuilder() + .responseCode(String.valueOf(NO_CONTENT.value())) + .implementation(Void.class)) + ) .PUT("themes/{name}/activation", this::activateTheme, builder -> builder.operationId("activateTheme") .description("Activate a theme by name.") @@ -235,8 +260,10 @@ public RouterFunction endpoint() { ) .GET("themes/{name}/config", this::fetchThemeConfig, builder -> builder.operationId("fetchThemeConfig") - .description("Fetch configMap of theme by configured configMapName.") + .description( + "Fetch configMap of theme by configured configMapName. It is deprecated.") .tag(tag) + .deprecated(true) .parameter(parameterBuilder() .name("name") .in(ParameterIn.PATH) @@ -246,9 +273,52 @@ public RouterFunction endpoint() { .response(responseBuilder() .implementation(ConfigMap.class)) ) + .GET("themes/{name}/json-config", this::fetchThemeJsonConfig, + builder -> builder.operationId("fetchThemeJsonConfig") + .description( + "Fetch converted json config of theme by configured configMapName.") + .tag(tag) + .parameter(parameterBuilder() + .name("name") + .in(ParameterIn.PATH) + .required(true) + .implementation(String.class) + ) + .response(responseBuilder() + .implementation(ObjectNode.class)) + ) .build(); } + private Mono fetchThemeJsonConfig(ServerRequest request) { + return themeNameInPathVariableOrActivated(request) + .flatMap(themeName -> client.fetch(Theme.class, themeName)) + .mapNotNull(theme -> theme.getSpec().getConfigMapName()) + .flatMap(settingConfigService::fetchConfig) + .flatMap(json -> ServerResponse.ok().bodyValue(json)); + } + + private Mono updateThemeJsonConfig(ServerRequest request) { + final var themeName = request.pathVariable("name"); + return client.fetch(Theme.class, themeName) + .doOnNext(theme -> { + String configMapName = theme.getSpec().getConfigMapName(); + if (StringUtils.isBlank(configMapName)) { + throw new ServerWebInputException( + "Unable to complete the request because the theme configMapName is blank."); + } + }) + .flatMap(theme -> { + final var configMapName = theme.getSpec().getConfigMapName(); + return request.bodyToMono(ObjectNode.class) + .switchIfEmpty( + Mono.error(new ServerWebInputException("Required request body is missing"))) + .flatMap(configJsonData -> + settingConfigService.upsertConfig(configMapName, configJsonData)); + }) + .then(ServerResponse.noContent().build()); + } + private Mono invalidateCache(ServerRequest request) { final var name = request.pathVariable("name"); return client.get(Theme.class, name) @@ -306,6 +376,7 @@ private Mono activateTheme(ServerRequest request) { .flatMap(activatedTheme -> ServerResponse.ok().bodyValue(activatedTheme)); } + @Deprecated(since = "2.20.0", forRemoval = true) private Mono updateThemeConfig(ServerRequest request) { final var themeName = request.pathVariable("name"); return client.fetch(Theme.class, themeName) @@ -343,6 +414,7 @@ private Mono updateThemeConfig(ServerRequest request) { .flatMap(configMap -> ServerResponse.ok().bodyValue(configMap)); } + @Deprecated(since = "2.20.0", forRemoval = true) private Mono fetchThemeConfig(ServerRequest request) { return themeNameInPathVariableOrActivated(request) .flatMap(themeName -> client.fetch(Theme.class, themeName)) diff --git a/application/src/main/java/run/halo/app/theme/engine/HaloTemplateEngine.java b/application/src/main/java/run/halo/app/theme/engine/HaloTemplateEngine.java index fcbd3c3e19..c890d57dfd 100644 --- a/application/src/main/java/run/halo/app/theme/engine/HaloTemplateEngine.java +++ b/application/src/main/java/run/halo/app/theme/engine/HaloTemplateEngine.java @@ -42,10 +42,19 @@ public Publisher processStream(String template, Set markupSe // We have to subscribe on blocking thread, because some blocking operations will be present // while processing. if (publisher instanceof Mono mono) { - return mono.subscribeOn(Schedulers.boundedElastic()); + return mono.subscribeOn(Schedulers.boundedElastic()) + // We should switch back to non-blocking thread. + // See https://github.com/spring-projects/spring-framework/issues/26958 + // for more details. + .publishOn(Schedulers.parallel()); } if (publisher instanceof Flux flux) { - return flux.subscribeOn(Schedulers.boundedElastic()); + return flux + .subscribeOn(Schedulers.boundedElastic()) + // We should switch back to non-blocking thread. + // See https://github.com/spring-projects/spring-framework/issues/26958 + // for more details. + .publishOn(Schedulers.parallel()); } return publisher; } diff --git a/application/src/main/java/run/halo/app/theme/finders/impl/CommentPublicQueryServiceImpl.java b/application/src/main/java/run/halo/app/theme/finders/impl/CommentPublicQueryServiceImpl.java index 3a39a80c29..8655f8be97 100644 --- a/application/src/main/java/run/halo/app/theme/finders/impl/CommentPublicQueryServiceImpl.java +++ b/application/src/main/java/run/halo/app/theme/finders/impl/CommentPublicQueryServiceImpl.java @@ -24,10 +24,12 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.content.comment.OwnerInfo; +import run.halo.app.core.counter.CounterService; +import run.halo.app.core.counter.MeterUtils; import run.halo.app.core.extension.User; import run.halo.app.core.extension.content.Comment; import run.halo.app.core.extension.content.Reply; -import run.halo.app.core.extension.service.UserService; +import run.halo.app.core.user.service.UserService; import run.halo.app.extension.AbstractExtension; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ListResult; @@ -38,8 +40,6 @@ import run.halo.app.extension.index.query.Query; import run.halo.app.extension.router.selector.FieldSelector; import run.halo.app.infra.AnonymousUserConst; -import run.halo.app.metrics.CounterService; -import run.halo.app.metrics.MeterUtils; import run.halo.app.theme.finders.CommentPublicQueryService; import run.halo.app.theme.finders.vo.CommentStatsVo; import run.halo.app.theme.finders.vo.CommentVo; @@ -86,7 +86,7 @@ public Mono> list(Ref ref, PageRequest pageParam) { return client.listBy(Comment.class, listOptions, pageRequest) .flatMap(listResult -> Flux.fromStream(listResult.get()) .map(this::toCommentVo) - .concatMap(Function.identity()) + .flatMapSequential(Function.identity()) .collectList() .map(commentVos -> new ListResult<>(listResult.getPage(), listResult.getSize(), @@ -102,7 +102,7 @@ public Mono> list(Ref ref, PageRequest pageParam) { public Mono> convertToWithReplyVo(ListResult comments, int replySize) { return Flux.fromIterable(comments.getItems()) - .concatMap(commentVo -> { + .flatMapSequential(commentVo -> { var commentName = commentVo.getMetadata().getName(); return listReply(commentName, 1, replySize) .map(replyList -> CommentWithReplyVo.from(commentVo) @@ -135,7 +135,7 @@ public Mono> listReply(String commentName, PageRequest pageP .orElse(PageRequestImpl.ofSize(0)); return client.listBy(Reply.class, listOptions, pageRequest) .flatMap(list -> Flux.fromStream(list.get().map(this::toReplyVo)) - .concatMap(Function.identity()) + .flatMapSequential(Function.identity()) .collectList() .map(replyVos -> new ListResult<>(list.getPage(), list.getSize(), list.getTotal(), diff --git a/application/src/main/java/run/halo/app/theme/finders/impl/ContributorFinderImpl.java b/application/src/main/java/run/halo/app/theme/finders/impl/ContributorFinderImpl.java index 3d05eef7f0..d01abc4b36 100644 --- a/application/src/main/java/run/halo/app/theme/finders/impl/ContributorFinderImpl.java +++ b/application/src/main/java/run/halo/app/theme/finders/impl/ContributorFinderImpl.java @@ -4,7 +4,7 @@ import lombok.RequiredArgsConstructor; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; -import run.halo.app.core.extension.service.UserService; +import run.halo.app.core.user.service.UserService; import run.halo.app.theme.finders.ContributorFinder; import run.halo.app.theme.finders.Finder; import run.halo.app.theme.finders.vo.ContributorVo; @@ -33,6 +33,6 @@ public Flux getContributors(List names) { return Flux.empty(); } return Flux.fromIterable(names) - .concatMap(this::getContributor); + .flatMapSequential(this::getContributor); } } diff --git a/application/src/main/java/run/halo/app/theme/finders/impl/PostFinderImpl.java b/application/src/main/java/run/halo/app/theme/finders/impl/PostFinderImpl.java index 2e2b5950c4..95be322a6d 100644 --- a/application/src/main/java/run/halo/app/theme/finders/impl/PostFinderImpl.java +++ b/application/src/main/java/run/halo/app/theme/finders/impl/PostFinderImpl.java @@ -291,7 +291,7 @@ public Mono> archives(Integer page, Integer size, Stri public Flux listAll() { return postPredicateResolver.getListOptions() .flatMapMany(listOptions -> client.listAll(Post.class, listOptions, defaultSort())) - .concatMap(postPublicQueryService::convertToListedVo); + .flatMapSequential(postPublicQueryService::convertToListedVo); } static int pageNullSafe(Integer page) { diff --git a/application/src/main/java/run/halo/app/theme/finders/impl/PostPublicQueryServiceImpl.java b/application/src/main/java/run/halo/app/theme/finders/impl/PostPublicQueryServiceImpl.java index 201444d2cc..d25320cb8f 100644 --- a/application/src/main/java/run/halo/app/theme/finders/impl/PostPublicQueryServiceImpl.java +++ b/application/src/main/java/run/halo/app/theme/finders/impl/PostPublicQueryServiceImpl.java @@ -11,13 +11,13 @@ import reactor.core.publisher.Mono; import run.halo.app.content.ContentWrapper; import run.halo.app.content.PostService; +import run.halo.app.core.counter.CounterService; +import run.halo.app.core.counter.MeterUtils; import run.halo.app.core.extension.content.Post; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ListResult; import run.halo.app.extension.PageRequest; import run.halo.app.extension.ReactiveExtensionClient; -import run.halo.app.metrics.CounterService; -import run.halo.app.metrics.MeterUtils; import run.halo.app.plugin.extensionpoint.ExtensionGetter; import run.halo.app.theme.ReactivePostContentHandler; import run.halo.app.theme.finders.CategoryFinder; @@ -67,7 +67,7 @@ public Mono> list(ListOptions queryOptions, PageRequest }) .flatMap(listOptions -> client.listBy(Post.class, listOptions, page)) .flatMap(list -> Flux.fromStream(list.get()) - .concatMap(post -> convertToListedVo(post) + .flatMapSequential(post -> convertToListedVo(post) .flatMap(postVo -> populateStats(postVo) .doOnNext(postVo::setStats).thenReturn(postVo) ) diff --git a/application/src/main/java/run/halo/app/theme/finders/impl/SinglePageConversionServiceImpl.java b/application/src/main/java/run/halo/app/theme/finders/impl/SinglePageConversionServiceImpl.java index a7e497a097..3f3ba23613 100644 --- a/application/src/main/java/run/halo/app/theme/finders/impl/SinglePageConversionServiceImpl.java +++ b/application/src/main/java/run/halo/app/theme/finders/impl/SinglePageConversionServiceImpl.java @@ -19,14 +19,14 @@ import reactor.core.publisher.Mono; import run.halo.app.content.ContentWrapper; import run.halo.app.content.SinglePageService; +import run.halo.app.core.counter.CounterService; +import run.halo.app.core.counter.MeterUtils; import run.halo.app.core.extension.content.SinglePage; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ListResult; import run.halo.app.extension.PageRequest; import run.halo.app.extension.PageRequestImpl; import run.halo.app.extension.ReactiveExtensionClient; -import run.halo.app.metrics.CounterService; -import run.halo.app.metrics.MeterUtils; import run.halo.app.plugin.extensionpoint.ExtensionGetter; import run.halo.app.theme.ReactiveSinglePageContentHandler; import run.halo.app.theme.ReactiveSinglePageContentHandler.SinglePageContentContext; @@ -140,7 +140,7 @@ public Mono> listBy(ListOptions listOptions, return client.listBy(SinglePage.class, rewroteListOptions, rewrotePageRequest) .flatMap(list -> Flux.fromStream(list.get()) - .concatMap(this::convertToListedVo) + .flatMapSequential(this::convertToListedVo) .collectList() .map(pageVos -> new ListResult<>(list.getPage(), list.getSize(), list.getTotal(), pageVos) diff --git a/application/src/main/java/run/halo/app/theme/finders/impl/TagFinderImpl.java b/application/src/main/java/run/halo/app/theme/finders/impl/TagFinderImpl.java index 0f6a135a2b..6fd0583865 100644 --- a/application/src/main/java/run/halo/app/theme/finders/impl/TagFinderImpl.java +++ b/application/src/main/java/run/halo/app/theme/finders/impl/TagFinderImpl.java @@ -48,7 +48,7 @@ public Mono getByName(String name) { @Override public Flux getByNames(List names) { return Flux.fromIterable(names) - .concatMap(this::getByName); + .flatMapSequential(this::getByName); } @Override diff --git a/application/src/main/java/run/halo/app/theme/finders/impl/ThumbnailFinderImpl.java b/application/src/main/java/run/halo/app/theme/finders/impl/ThumbnailFinderImpl.java index 19a7922ce2..e8eac936b8 100644 --- a/application/src/main/java/run/halo/app/theme/finders/impl/ThumbnailFinderImpl.java +++ b/application/src/main/java/run/halo/app/theme/finders/impl/ThumbnailFinderImpl.java @@ -2,12 +2,14 @@ import java.net.URI; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import reactor.core.publisher.Mono; import run.halo.app.core.attachment.ThumbnailService; import run.halo.app.core.attachment.ThumbnailSize; import run.halo.app.theme.finders.Finder; import run.halo.app.theme.finders.ThumbnailFinder; +@Slf4j @Finder("thumbnail") @RequiredArgsConstructor public class ThumbnailFinderImpl implements ThumbnailFinder { @@ -15,8 +17,14 @@ public class ThumbnailFinderImpl implements ThumbnailFinder { @Override public Mono gen(String uriStr, String size) { - return thumbnailService.generate(URI.create(uriStr), ThumbnailSize.fromName(size)) + return Mono.fromSupplier(() -> URI.create(uriStr)) + .flatMap(uri -> thumbnailService.generate(uri, ThumbnailSize.fromName(size))) .map(URI::toString) + .onErrorResume(Throwable.class, e -> { + log.debug("Failed to generate thumbnail for [{}], error: [{}]", uriStr, + e.getMessage()); + return Mono.just(uriStr); + }) .defaultIfEmpty(uriStr); } } diff --git a/application/src/main/java/run/halo/app/theme/finders/vo/SiteSettingVo.java b/application/src/main/java/run/halo/app/theme/finders/vo/SiteSettingVo.java index 485ee1a115..de56c2b430 100644 --- a/application/src/main/java/run/halo/app/theme/finders/vo/SiteSettingVo.java +++ b/application/src/main/java/run/halo/app/theme/finders/vo/SiteSettingVo.java @@ -25,6 +25,9 @@ public class SiteSettingVo { @With URL url; + @With + String version; + String subtitle; String logo; @@ -70,7 +73,7 @@ public static SiteSettingVo from(ConfigMap configMap) { .subtitle(basicSetting.getSubtitle()) .logo(basicSetting.getLogo()) .favicon(basicSetting.getFavicon()) - .allowRegistration(userSetting.getAllowRegistration()) + .allowRegistration(userSetting.isAllowRegistration()) .post(PostSetting.builder() .postPageSize(postSetting.getPostPageSize()) .archivePageSize(postSetting.getArchivePageSize()) diff --git a/application/src/main/java/run/halo/app/theme/message/ThemeMessageResolutionUtils.java b/application/src/main/java/run/halo/app/theme/message/ThemeMessageResolutionUtils.java index d6943dfb9d..db89e286bc 100644 --- a/application/src/main/java/run/halo/app/theme/message/ThemeMessageResolutionUtils.java +++ b/application/src/main/java/run/halo/app/theme/message/ThemeMessageResolutionUtils.java @@ -9,7 +9,6 @@ import java.io.InputStream; import java.io.InputStreamReader; import java.io.Reader; -import java.text.MessageFormat; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -32,7 +31,6 @@ public class ThemeMessageResolutionUtils { private static final Map EMPTY_MESSAGES = Collections.emptyMap(); private static final String PROPERTIES_FILE_EXTENSION = ".properties"; private static final String LOCATION = "i18n"; - private static final Object[] EMPTY_MESSAGE_PARAMETERS = new Object[0]; @Nullable private static Reader messageReader(String messageResourceName, ThemeContext theme) @@ -96,91 +94,6 @@ public static Map resolveMessagesForTemplate(final Locale locale return Collections.unmodifiableMap(combinedMessages); } - public static Map resolveMessagesForOrigin(final Class origin, - final Locale locale) { - - final Map combinedMessages = new HashMap<>(20); - - Class currentClass = origin; - combinedMessages.putAll(resolveMessagesForSpecificClass(currentClass, locale)); - - while (!currentClass.getSuperclass().equals(Object.class)) { - - currentClass = currentClass.getSuperclass(); - final Map messagesForCurrentClass = - resolveMessagesForSpecificClass(currentClass, locale); - for (final String messageKey : messagesForCurrentClass.keySet()) { - if (!combinedMessages.containsKey(messageKey)) { - combinedMessages.put(messageKey, messagesForCurrentClass.get(messageKey)); - } - } - } - - return Collections.unmodifiableMap(combinedMessages); - - } - - - private static Map resolveMessagesForSpecificClass( - final Class originClass, final Locale locale) { - - - final ClassLoader originClassLoader = originClass.getClassLoader(); - - // Compute all the resource names we should use: *_gl_ES-gheada.properties, *_gl_ES - // .properties, _gl.properties... - // The order here is important: as we will let values from more specific files - // overwrite those in less specific, - // (e.g. a value for gl_ES will have more precedence than a value for gl). So we will - // iterate these resource - // names from less specific to more specific. - final List messageResourceNames = - computeMessageResourceNamesFromBase(locale); - - // Build the combined messages - Map combinedMessages = null; - for (final String messageResourceName : messageResourceNames) { - - final InputStream inputStream = - originClassLoader.getResourceAsStream(messageResourceName); - if (inputStream != null) { - - // At this point we cannot be specified a character encoding (that's only for - // template resolution), - // so we will use the standard character encoding for .properties files, - // which is ISO-8859-1 - // (see Properties#load(InputStream) javadoc). - final InputStreamReader messageResourceReader = - new InputStreamReader(inputStream); - - final Properties messageProperties = - readMessagesResource(messageResourceReader); - if (messageProperties != null && !messageProperties.isEmpty()) { - - if (combinedMessages == null) { - combinedMessages = new HashMap<>(20); - } - - for (final Map.Entry propertyEntry : - messageProperties.entrySet()) { - combinedMessages.put((String) propertyEntry.getKey(), - (String) propertyEntry.getValue()); - } - - } - - } - - } - - if (combinedMessages == null) { - return EMPTY_MESSAGES; - } - - return Collections.unmodifiableMap(combinedMessages); - } - - private static List computeMessageResourceNamesFromBase(final Locale locale) { final List resourceNames = new ArrayList<>(5); @@ -229,33 +142,4 @@ private static Properties readMessagesResource(final Reader propertiesReader) { return properties; } - public static String formatMessage(final Locale locale, final String message, - final Object[] messageParameters) { - if (message == null) { - return null; - } - if (!isFormatCandidate(message)) { - // trying to avoid creating MessageFormat if not needed - return message; - } - final MessageFormat messageFormat = new MessageFormat(message, locale); - return messageFormat.format( - (messageParameters != null ? messageParameters : EMPTY_MESSAGE_PARAMETERS)); - } - - /* - * This will allow us to determine whether a message might actually contain parameter - * placeholders. - */ - private static boolean isFormatCandidate(final String message) { - char c; - int n = message.length(); - while (n-- != 0) { - c = message.charAt(n); - if (c == '}' || c == '\'') { - return true; - } - } - return false; - } } diff --git a/application/src/main/java/run/halo/app/theme/message/ThemeMessageResolver.java b/application/src/main/java/run/halo/app/theme/message/ThemeMessageResolver.java index 673770023b..17f141108e 100644 --- a/application/src/main/java/run/halo/app/theme/message/ThemeMessageResolver.java +++ b/application/src/main/java/run/halo/app/theme/message/ThemeMessageResolver.java @@ -1,7 +1,10 @@ package run.halo.app.theme.message; +import java.util.Collections; +import java.util.HashMap; import java.util.Locale; import java.util.Map; +import java.util.Optional; import org.thymeleaf.messageresolver.StandardMessageResolver; import org.thymeleaf.templateresource.ITemplateResource; import run.halo.app.theme.ThemeContext; @@ -22,16 +25,12 @@ public ThemeMessageResolver(ThemeContext theme) { protected Map resolveMessagesForTemplate(String template, ITemplateResource templateResource, Locale locale) { - return ThemeMessageResolutionUtils.resolveMessagesForTemplate(locale, theme); + var properties = new HashMap(); + Optional.ofNullable(ThemeMessageResolutionUtils.resolveMessagesForTemplate(locale, theme)) + .ifPresent(properties::putAll); + Optional.ofNullable(super.resolveMessagesForTemplate(template, templateResource, locale)) + .ifPresent(properties::putAll); + return Collections.unmodifiableMap(properties); } - @Override - protected Map resolveMessagesForOrigin(Class origin, Locale locale) { - return ThemeMessageResolutionUtils.resolveMessagesForOrigin(origin, locale); - } - - @Override - protected String formatMessage(Locale locale, String message, Object[] messageParameters) { - return ThemeMessageResolutionUtils.formatMessage(locale, message, messageParameters); - } } diff --git a/application/src/main/java/run/halo/app/core/extension/theme/ThemeService.java b/application/src/main/java/run/halo/app/theme/service/ThemeService.java similarity index 88% rename from application/src/main/java/run/halo/app/core/extension/theme/ThemeService.java rename to application/src/main/java/run/halo/app/theme/service/ThemeService.java index a3954a3fc3..7a6fa547a7 100644 --- a/application/src/main/java/run/halo/app/core/extension/theme/ThemeService.java +++ b/application/src/main/java/run/halo/app/theme/service/ThemeService.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.theme; +package run.halo.app.theme.service; import org.reactivestreams.Publisher; import org.springframework.core.io.buffer.DataBuffer; @@ -8,6 +8,8 @@ public interface ThemeService { + Mono installPresetTheme(); + Mono install(Publisher content); Mono upgrade(String themeName, Publisher content); diff --git a/application/src/main/java/run/halo/app/core/extension/theme/ThemeServiceImpl.java b/application/src/main/java/run/halo/app/theme/service/ThemeServiceImpl.java similarity index 85% rename from application/src/main/java/run/halo/app/core/extension/theme/ThemeServiceImpl.java rename to application/src/main/java/run/halo/app/theme/service/ThemeServiceImpl.java index a3abfed085..1b6494b708 100644 --- a/application/src/main/java/run/halo/app/core/extension/theme/ThemeServiceImpl.java +++ b/application/src/main/java/run/halo/app/theme/service/ThemeServiceImpl.java @@ -1,13 +1,15 @@ -package run.halo.app.core.extension.theme; +package run.halo.app.theme.service; import static org.springframework.util.FileSystemUtils.copyRecursively; -import static run.halo.app.core.extension.theme.ThemeUtils.loadThemeManifest; -import static run.halo.app.core.extension.theme.ThemeUtils.locateThemeManifest; -import static run.halo.app.core.extension.theme.ThemeUtils.unzipThemeTo; import static run.halo.app.infra.utils.FileUtils.createTempDir; import static run.halo.app.infra.utils.FileUtils.deleteRecursivelyAndSilently; import static run.halo.app.infra.utils.FileUtils.unzip; +import static run.halo.app.theme.service.ThemeUtils.loadThemeManifest; +import static run.halo.app.theme.service.ThemeUtils.locateThemeManifest; +import static run.halo.app.theme.service.ThemeUtils.unzipThemeTo; +import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; import java.util.HashMap; @@ -18,11 +20,17 @@ import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.reactivestreams.Publisher; +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.UrlResource; import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.retry.RetryException; import org.springframework.stereotype.Service; import org.springframework.util.Assert; +import org.springframework.util.ResourceUtils; +import org.springframework.util.StreamUtils; import org.springframework.web.server.ServerErrorException; import org.springframework.web.server.ServerWebInputException; import reactor.core.publisher.Flux; @@ -39,8 +47,12 @@ import run.halo.app.extension.Unstructured; import run.halo.app.infra.SystemVersionSupplier; import run.halo.app.infra.ThemeRootGetter; +import run.halo.app.infra.exception.ThemeAlreadyExistsException; import run.halo.app.infra.exception.ThemeUpgradeException; import run.halo.app.infra.exception.UnsatisfiedAttributeValueException; +import run.halo.app.infra.properties.HaloProperties; +import run.halo.app.infra.utils.FileUtils; +import run.halo.app.infra.utils.SettingUtils; import run.halo.app.infra.utils.VersionUtils; @Slf4j @@ -52,10 +64,52 @@ public class ThemeServiceImpl implements ThemeService { private final ThemeRootGetter themeRoot; + private final HaloProperties haloProperties; + private final SystemVersionSupplier systemVersionSupplier; private final Scheduler scheduler = Schedulers.boundedElastic(); + @Override + public Mono installPresetTheme() { + var themeProps = haloProperties.getTheme(); + var location = themeProps.getInitializer().getLocation(); + return createThemeTempPath() + .flatMap(tempPath -> Mono.usingWhen(copyPresetThemeToPath(location, tempPath), + path -> { + var content = DataBufferUtils.read(new FileSystemResource(path), + DefaultDataBufferFactory.sharedInstance, + StreamUtils.BUFFER_SIZE); + return install(content); + }, path -> deleteRecursivelyAndSilently(tempPath, scheduler) + )) + .onErrorResume(IOException.class, e -> { + log.warn("Failed to initialize theme from {}", location, e); + return Mono.empty(); + }) + .onErrorResume(ThemeAlreadyExistsException.class, e -> { + log.warn("Failed to initialize theme from {}, because it already exists", location); + return Mono.empty(); + }) + .then(); + } + + private Mono copyPresetThemeToPath(String location, Path tempDir) { + return Mono.fromCallable( + () -> { + var themeUrl = ResourceUtils.getURL(location); + var resource = new UrlResource(themeUrl); + var tempThemePath = tempDir.resolve("theme.zip"); + FileUtils.copyResource(resource, tempThemePath); + return tempThemePath; + }); + } + + private static Mono createThemeTempPath() { + return Mono.fromCallable(() -> Files.createTempDirectory("halo-theme-preset")) + .subscribeOn(Schedulers.boundedElastic()); + } + @Override public Mono install(Publisher content) { var themeRoot = this.themeRoot.get(); diff --git a/application/src/main/java/run/halo/app/core/extension/theme/ThemeUtils.java b/application/src/main/java/run/halo/app/theme/service/ThemeUtils.java similarity index 97% rename from application/src/main/java/run/halo/app/core/extension/theme/ThemeUtils.java rename to application/src/main/java/run/halo/app/theme/service/ThemeUtils.java index a0276a36e1..0b6449c5d3 100644 --- a/application/src/main/java/run/halo/app/core/extension/theme/ThemeUtils.java +++ b/application/src/main/java/run/halo/app/theme/service/ThemeUtils.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.theme; +package run.halo.app.theme.service; import static org.springframework.util.FileSystemUtils.copyRecursively; import static run.halo.app.infra.utils.FileUtils.createTempDir; @@ -16,6 +16,7 @@ import java.util.Set; import java.util.stream.BaseStream; import java.util.stream.Stream; +import lombok.experimental.UtilityClass; import lombok.extern.slf4j.Slf4j; import org.reactivestreams.Publisher; import org.springframework.core.io.FileSystemResource; @@ -38,11 +39,12 @@ import run.halo.app.infra.utils.YamlUnstructuredLoader; @Slf4j -class ThemeUtils { +@UtilityClass +public class ThemeUtils { private static final String THEME_TMP_PREFIX = "halo-theme-"; private static final String[] THEME_MANIFESTS = {"theme.yaml", "theme.yml"}; - static Flux listAllThemesFromThemeDir(Path themesDir) { + public static Flux listAllThemesFromThemeDir(Path themesDir) { return walkThemesFromPath(themesDir) .filter(Files::isDirectory) .map(ThemeUtils::findThemeManifest) diff --git a/application/src/main/resources/application-dev.yaml b/application/src/main/resources/application-dev.yaml index 4475d9699e..62a31d4a68 100644 --- a/application/src/main/resources/application-dev.yaml +++ b/application/src/main/resources/application-dev.yaml @@ -15,6 +15,9 @@ spring: use-last-modified: false halo: + security: + basic-auth: + disabled: false console: proxy: endpoint: http://localhost:3000/ diff --git a/application/src/main/resources/application.yaml b/application/src/main/resources/application.yaml index a24b43d73c..29683cea57 100644 --- a/application/src/main/resources/application.yaml +++ b/application/src/main/resources/application.yaml @@ -1,6 +1,6 @@ server: port: 8090 - forward-headers-strategy: framework + forward-headers-strategy: native compression: enabled: true error: @@ -27,6 +27,9 @@ spring: cache: cachecontrol: max-age: 365d + thymeleaf: + reactive: + maxChunkSize: 8KB cache: type: caffeine caffeine: @@ -39,6 +42,11 @@ halo: - pathPattern: /upload/** locations: - migrate-from-1.x + security: + password-reset-methods: + - name: email + href: /password-reset/email + icon: /images/password-reset-methods/email.svg springdoc: api-docs: @@ -93,7 +101,11 @@ resilience4j.ratelimiter: limitForPeriod: 3 limitRefreshPeriod: 1h timeoutDuration: 0s - send-reset-password-email: - limitForPeriod: 2 + send-password-reset-email: + limitForPeriod: 10 + limitRefreshPeriod: 1m + timeoutDuration: 0s + password-reset-verification: + limitForPeriod: 10 limitRefreshPeriod: 1m timeoutDuration: 0s diff --git a/application/src/main/resources/config/i18n/messages.properties b/application/src/main/resources/config/i18n/messages.properties index 8d1bf19bd2..565d813987 100644 --- a/application/src/main/resources/config/i18n/messages.properties +++ b/application/src/main/resources/config/i18n/messages.properties @@ -14,6 +14,7 @@ problemDetail.title.run.halo.app.infra.exception.AttachmentAlreadyExistsExceptio problemDetail.title.run.halo.app.infra.exception.FileTypeNotAllowedException=File Type Not Allowed problemDetail.title.run.halo.app.infra.exception.FileSizeExceededException=File Size Exceeded problemDetail.title.run.halo.app.infra.exception.AccessDeniedException=Access Denied +problemDetail.title.run.halo.app.infra.exception.RequestRestrictedException=Request Restricted problemDetail.title.reactor.core.Exceptions.RetryExhaustedException=Retry Exhausted problemDetail.title.run.halo.app.infra.exception.ThemeInstallationException=Theme Install Error problemDetail.title.run.halo.app.infra.exception.ThemeUpgradeException=Theme Upgrade Error @@ -29,6 +30,7 @@ problemDetail.title.run.halo.app.infra.exception.PluginDependencyException$NotFo problemDetail.title.run.halo.app.infra.exception.PluginDependencyException$WrongVersionsException=Wrong Dependency Version problemDetail.title.run.halo.app.infra.exception.PluginDependentsNotDisabledException=Dependents Not Disabled problemDetail.title.run.halo.app.infra.exception.PluginDependenciesNotEnabledException=Dependencies Not Enabled +problemDetail.title.run.halo.app.infra.exception.OAuth2UserAlreadyBoundException=User Already Bound Error problemDetail.title.internalServerError=Internal Server Error problemDetail.title.conflict=Conflict @@ -54,6 +56,7 @@ problemDetail.run.halo.app.infra.exception.PluginDependencyException$NotFoundExc problemDetail.run.halo.app.infra.exception.PluginDependencyException$WrongVersionsException=Dependencies have wrong version: {0}. problemDetail.run.halo.app.infra.exception.PluginDependentsNotDisabledException=Plugin dependents {0} are not fully disabled, please disable them first. problemDetail.run.halo.app.infra.exception.PluginDependenciesNotEnabledException=Plugin dependencies {0} are not fully enabled, please enable them first. +problemDetail.run.halo.app.infra.exception.OAuth2UserAlreadyBoundException=The user {0} has already been bound to another OAuth2 user, cannot automatically bind the current OAuth2 user. problemDetail.index.duplicateKey=The value of {0} already exists for unique index {1}, please rename it and retry. problemDetail.user.email.verify.maxAttempts=Too many verification attempts, please try again later. @@ -79,5 +82,13 @@ problemDetail.conflict=Conflict detected, please check the data and retry. problemDetail.migration.backup.notFound=The backup file does not exist or has been deleted. problemDetail.attachment.upload.fileSizeExceeded=Make sure the file size is less than {0}. problemDetail.attachment.upload.fileTypeNotSupported=Unsupported upload of {0} type files. +problemDetail.comment.waitingForApproval=Comment is awaiting approval. -title.visibility.identification.private=(Private) \ No newline at end of file +title.visibility.identification.private=(Private) +signup.error.confirm-password-not-match=The confirmation password does not match the password. +signup.error.email-code.invalid=Invalid email code. + +validation.error.email.pattern=The email format is incorrect +validation.error.username.pattern=The username can only be lowercase and can only contain letters, numbers, hyphens, and dots, starting and ending with characters. +validation.error.password.pattern=The password can only use uppercase and lowercase letters (A-Z, a-z), numbers (0-9), and the following special characters: !@#$%^&* +validation.error.password.size=The password length must be between {0} and {1} diff --git a/application/src/main/resources/config/i18n/messages_zh.properties b/application/src/main/resources/config/i18n/messages_zh.properties index 818f7e35e9..767d79b77e 100644 --- a/application/src/main/resources/config/i18n/messages_zh.properties +++ b/application/src/main/resources/config/i18n/messages_zh.properties @@ -5,6 +5,7 @@ problemDetail.title.run.halo.app.infra.exception.PluginInstallationException=插 problemDetail.title.run.halo.app.infra.exception.AttachmentAlreadyExistsException=附件已存在 problemDetail.title.run.halo.app.infra.exception.FileTypeNotAllowedException=文件类型不允许 problemDetail.title.run.halo.app.infra.exception.FileSizeExceededException=文件大小超出限制 +problemDetail.title.run.halo.app.infra.exception.RequestRestrictedException=请求受限 problemDetail.title.run.halo.app.infra.exception.DuplicateNameException=名称重复 problemDetail.title.run.halo.app.infra.exception.PluginAlreadyExistsException=插件已存在 problemDetail.title.run.halo.app.infra.exception.ThemeInstallationException=主题安装失败 @@ -17,6 +18,8 @@ problemDetail.title.run.halo.app.infra.exception.PluginDependencyException$NotFo problemDetail.title.run.halo.app.infra.exception.PluginDependencyException$WrongVersionsException=依赖版本错误 problemDetail.title.run.halo.app.infra.exception.PluginDependentsNotDisabledException=子插件未禁用 problemDetail.title.run.halo.app.infra.exception.PluginDependenciesNotEnabledException=依赖未启用 +problemDetail.title.run.halo.app.infra.exception.OAuth2UserAlreadyBoundException=用户已绑定错误 + problemDetail.title.internalServerError=服务器内部错误 problemDetail.title.conflict=冲突 @@ -31,6 +34,7 @@ problemDetail.run.halo.app.infra.exception.PluginDependencyException$NotFoundExc problemDetail.run.halo.app.infra.exception.PluginDependencyException$WrongVersionsException=依赖版本有误:{0}。 problemDetail.run.halo.app.infra.exception.PluginDependentsNotDisabledException=子插件 {0} 未完全禁用,请先禁用它们。 problemDetail.run.halo.app.infra.exception.PluginDependenciesNotEnabledException=插件依赖 {0} 未完全启用,请先启用它们。 +problemDetail.run.halo.app.infra.exception.OAuth2UserAlreadyBoundException=用户 {0} 已经绑定到另一个 OAuth2 用户,无法自动绑定当前 OAuth2 用户。 problemDetail.index.duplicateKey=唯一索引 {1} 中的值 {0} 已存在,请更名后重试。 problemDetail.user.email.verify.maxAttempts=尝试次数过多,请稍候再试。 @@ -51,5 +55,13 @@ problemDetail.conflict=检测到冲突,请检查数据后重试。 problemDetail.migration.backup.notFound=备份文件不存在或已删除。 problemDetail.attachment.upload.fileSizeExceeded=最大支持上传 {0} 大小的文件。 problemDetail.attachment.upload.fileTypeNotSupported=不支持上传 {0} 类型的文件。 +problemDetail.comment.waitingForApproval=评论审核中。 + +title.visibility.identification.private=(私有) +signup.error.confirm-password-not-match=确认密码与密码不匹配。 +signup.error.email-code.invalid=邮箱验证码无效。 -title.visibility.identification.private=(私有) \ No newline at end of file +validation.error.email.pattern=邮箱格式不正确 +validation.error.username.pattern=用户名只能小写且只能包含字母、数字、中划线和点,以字符开头和结尾 +validation.error.password.pattern=密码只能使用大小写字母 (A-Z, a-z)、数字 (0-9),以及以下特殊字符: !@#$%^&* +validation.error.password.size=密码长度必须在 {0} 到 {1} 之间 diff --git a/application/src/main/resources/extensions/attachment-local-policy.yaml b/application/src/main/resources/extensions/attachment-local-policy.yaml index c4a331c723..645895d28f 100644 --- a/application/src/main/resources/extensions/attachment-local-policy.yaml +++ b/application/src/main/resources/extensions/attachment-local-policy.yaml @@ -10,6 +10,8 @@ apiVersion: storage.halo.run/v1alpha1 kind: Policy metadata: name: default-policy + finalizers: + - system-protection spec: displayName: 本地存储 templateName: local @@ -19,6 +21,8 @@ apiVersion: v1alpha1 kind: ConfigMap metadata: name: default-policy-config + labels: + storage.halo.run/policy-owner: default-policy data: default: "{\"location\":\"\"}" --- @@ -38,7 +42,7 @@ spec: - $formkit: text name: maxFileSize label: 最大单文件大小 - validation: [['matches', '/^(0|[1-9]\d*)(?:[KMG]B)?$/']] + validation: [ [ 'matches', '/^(0|[1-9]\d*)(?:[KMG]B)?$/' ] ] validation-visibility: "live" validation-messages: matches: "输入格式错误,遵循:整数 + 大写的单位(KB, MB, GB)" diff --git a/application/src/main/resources/extensions/authproviders.yaml b/application/src/main/resources/extensions/authproviders.yaml index 58e3369c6f..82080199b1 100644 --- a/application/src/main/resources/extensions/authproviders.yaml +++ b/application/src/main/resources/extensions/authproviders.yaml @@ -9,8 +9,10 @@ metadata: - system-protection spec: displayName: Local - enabled: true description: Built-in authentication for Halo. - logo: https://www.halo.run/logo + logo: /images/login-methods/login-with-credentials.svg website: https://www.halo.run authenticationUrl: /login + method: post + rememberMeSupport: true + authType: form \ No newline at end of file diff --git a/application/src/main/resources/extensions/role-template-anonymous.yaml b/application/src/main/resources/extensions/role-template-anonymous.yaml index 8f2ffce10e..0e606e55f4 100644 --- a/application/src/main/resources/extensions/role-template-anonymous.yaml +++ b/application/src/main/resources/extensions/role-template-anonymous.yaml @@ -23,8 +23,6 @@ rules: verbs: [ "create" ] - nonResourceURLs: [ "/actuator/globalinfo", "/actuator/health", "/actuator/health/*", "/login/public-key" ] verbs: [ "get" ] - - nonResourceURLs: [ "/apis/api.console.halo.run/v1alpha1/system/initialize" ] - verbs: [ "create" ] --- apiVersion: v1alpha1 kind: "Role" diff --git a/application/src/main/resources/extensions/role-template-authenticated.yaml b/application/src/main/resources/extensions/role-template-authenticated.yaml index a5d9200bdd..f062e9e127 100644 --- a/application/src/main/resources/extensions/role-template-authenticated.yaml +++ b/application/src/main/resources/extensions/role-template-authenticated.yaml @@ -29,6 +29,9 @@ rules: resources: [ "plugins/bundle.js", "plugins/bundle.css" ] resourceNames: [ "-" ] verbs: [ "get" ] + - apiGroups: [ "uc.api.auth.halo.run" ] + resources: [ "user-connections/disconnect" ] + verbs: [ "update" ] --- apiVersion: v1alpha1 kind: "Role" diff --git a/application/src/main/resources/extensions/role-template-menu.yaml b/application/src/main/resources/extensions/role-template-menu.yaml index 24a4d71df2..a94f33e35b 100644 --- a/application/src/main/resources/extensions/role-template-menu.yaml +++ b/application/src/main/resources/extensions/role-template-menu.yaml @@ -14,6 +14,10 @@ rules: - apiGroups: [ "" ] resources: [ "menus", "menuitems" ] verbs: [ "*" ] + - apiGroups: [ "console.api.halo.run" ] + resources: [ "systemconfigs" ] + resourceNames: [ "menu" ] + verbs: [ "update" ] --- apiVersion: v1alpha1 kind: "Role" @@ -30,3 +34,7 @@ rules: - apiGroups: [ "" ] resources: [ "menus", "menuitems" ] verbs: [ "get", "list" ] + - apiGroups: [ "console.api.halo.run" ] + resources: [ "systemconfigs" ] + resourceNames: [ "menu" ] + verbs: [ "get" ] diff --git a/application/src/main/resources/extensions/role-template-plugin.yaml b/application/src/main/resources/extensions/role-template-plugin.yaml index 82ff040a29..8bdd23e394 100644 --- a/application/src/main/resources/extensions/role-template-plugin.yaml +++ b/application/src/main/resources/extensions/role-template-plugin.yaml @@ -16,8 +16,8 @@ rules: resources: [ "plugins" ] verbs: [ "create", "patch", "update", "delete", "deletecollection" ] - apiGroups: [ "api.console.halo.run" ] - resources: [ "plugins/upgrade", "plugins/resetconfig", "plugins/config", "plugins/reload", - "plugins/install-from-uri", "plugins/upgrade-from-uri", "plugins/plugin-state" ] + resources: [ "plugins/upgrade", "plugins/resetconfig", "plugins/config", "plugins/json-config", + "plugins/reload", "plugins/install-from-uri", "plugins/upgrade-from-uri", "plugins/plugin-state" ] verbs: [ "*" ] - apiGroups: [ "api.console.halo.run" ] resources: [ "plugin-presets" ] @@ -41,5 +41,5 @@ rules: resources: [ "plugins" ] verbs: [ "get", "list" ] - apiGroups: [ "api.console.halo.run" ] - resources: [ "plugins", "plugins/setting", "plugins/config" ] + resources: [ "plugins", "plugins/setting", "plugins/config", "plugins/json-config" ] verbs: [ "get", "list" ] diff --git a/application/src/main/resources/extensions/role-template-theme.yaml b/application/src/main/resources/extensions/role-template-theme.yaml index 6b6c375bf5..ea46fecbaa 100644 --- a/application/src/main/resources/extensions/role-template-theme.yaml +++ b/application/src/main/resources/extensions/role-template-theme.yaml @@ -15,8 +15,8 @@ rules: resources: [ "themes" ] verbs: [ "*" ] - apiGroups: [ "api.console.halo.run" ] - resources: [ "themes", "themes/reload", "themes/resetconfig", "themes/config", "themes/activation", - "themes/install-from-uri", "themes/upgrade-from-uri", "themes/invalidate-cache" ] + resources: [ "themes", "themes/reload", "themes/resetconfig", "themes/config", "themes/json-config", + "themes/activation", "themes/install-from-uri", "themes/upgrade-from-uri", "themes/invalidate-cache" ] verbs: [ "*" ] - nonResourceURLs: [ "/apis/api.console.halo.run/themes/install" ] verbs: [ "create" ] @@ -37,6 +37,6 @@ rules: resources: [ "themes" ] verbs: [ "get", "list" ] - apiGroups: [ "api.console.halo.run" ] - resources: [ "themes", "themes/activation", "themes/setting", "themes/config" ] + resources: [ "themes", "themes/activation", "themes/setting", "themes/config", "themes/json-config" ] verbs: [ "get", "list" ] diff --git a/application/src/main/resources/extensions/role-template-uc-attachment.yaml b/application/src/main/resources/extensions/role-template-uc-attachment.yaml new file mode 100644 index 0000000000..132b358aef --- /dev/null +++ b/application/src/main/resources/extensions/role-template-uc-attachment.yaml @@ -0,0 +1,15 @@ +apiVersion: v1alpha1 +kind: Role +metadata: + name: role-template-uc-attachment-manager + labels: + halo.run/role-template: "true" + annotations: + rbac.authorization.halo.run/module: "Attachments Management" + rbac.authorization.halo.run/display-name: "UC Attachment Manage" + rbac.authorization.halo.run/ui-permissions: | + [ "uc:attachments:manage" ] +rules: + - apiGroups: [ "uc.api.storage.halo.run" ] + resources: [ "attachments", "attachments/upload", "attachments/upload-from-url" ] + verbs: [ "create", "list" ] diff --git a/application/src/main/resources/extensions/role-template-uc-content.yaml b/application/src/main/resources/extensions/role-template-uc-content.yaml index 93f7bcd6b4..60c20d7e0c 100644 --- a/application/src/main/resources/extensions/role-template-uc-content.yaml +++ b/application/src/main/resources/extensions/role-template-uc-content.yaml @@ -39,7 +39,8 @@ metadata: # Currently, yaml definition does not support i18n, please see https://github.com/halo-dev/halo/issues/3573 rbac.authorization.halo.run/display-name: "Post Author" rbac.authorization.halo.run/dependencies: | - [ "role-template-post-contributor", "role-template-post-publisher", "role-template-post-attachment-manager" ] + [ "role-template-post-contributor", "role-template-post-publisher", "role-template-recycle-my-post", + "role-template-uc-attachment-manager" ] rules: [ ] --- @@ -99,21 +100,32 @@ rules: verbs: [ "update" ] --- +# TODO remove this in next major version apiVersion: v1alpha1 kind: Role metadata: - name: role-template-post-attachment-manager + name: role-template-recycle-my-post labels: halo.run/role-template: "true" annotations: rbac.authorization.halo.run/module: "Posts Management" - rbac.authorization.halo.run/display-name: "Post Attachment Manager" + rbac.authorization.halo.run/display-name: "Recycle My Post" rbac.authorization.halo.run/ui-permissions: | - [ "uc:attachments:manage" ] + [ "uc:posts:recycle" ] rules: - apiGroups: [ "uc.api.content.halo.run" ] - resources: [ "attachments" ] - verbs: [ "create", "update", "delete" ] - - apiGroups: [ "uc.api.content.halo.run" ] - resources: [ "attachments/upload-from-url" ] - verbs: [ "create" ] + resources: [ "posts/recycle" ] + verbs: [ "delete" ] + +--- +apiVersion: v1alpha1 +kind: Role +metadata: + name: role-template-post-attachment-manager + deletionTimestamp: 2024-09-30T14:00:41.813954138Z + labels: + halo.run/role-template: "true" + annotations: + rbac.authorization.halo.run/module: "Posts Management" + rbac.authorization.halo.run/display-name: "Post Attachment Manager" +rules: [ ] diff --git a/application/src/main/resources/extensions/system-configurable-configmap.yaml b/application/src/main/resources/extensions/system-configurable-configmap.yaml index 5d22168340..64c98e0965 100644 --- a/application/src/main/resources/extensions/system-configurable-configmap.yaml +++ b/application/src/main/resources/extensions/system-configurable-configmap.yaml @@ -8,7 +8,8 @@ data: "allowRegistration": false, "mustVerifyEmailOnRegistration": false, "defaultRole": "guest", - "avatarPolicy": "default-policy" + "avatarPolicy": "default-policy", + "ucAttachmentPolicy": "default-policy" } theme: | { @@ -50,3 +51,11 @@ data: { "search-engine": ["search-engine-lucene"] } + authProvider: | + { + "states": [{ + "name": "local", + "enabled": true, + "priority": 0 + }] + } diff --git a/application/src/main/resources/extensions/system-setting.yaml b/application/src/main/resources/extensions/system-setting.yaml index eaf4414840..60626a1243 100644 --- a/application/src/main/resources/extensions/system-setting.yaml +++ b/application/src/main/resources/extensions/system-setting.yaml @@ -118,6 +118,11 @@ spec: label: "头像存储位置" value: "default-policy" help: 指定用户上传头像的存储策略 + - $formkit: attachmentPolicySelect + name: ucAttachmentPolicy + label: "个人中心附件存储位置" + value: "default-policy" + help: 指定用户在个人中心上传的附件的存储位置 - group: comment label: 评论设置 formSchema: diff --git a/application/src/main/resources/initial-data.yaml b/application/src/main/resources/initial-data.yaml new file mode 100644 index 0000000000..66112e3ac5 --- /dev/null +++ b/application/src/main/resources/initial-data.yaml @@ -0,0 +1,239 @@ +# 提供了 timestamp、username 变量,用于初始化数据时填充时间戳和用户名 +# 初始化文章关联的分类、标签数据 +apiVersion: content.halo.run/v1alpha1 +kind: Category +metadata: + name: 76514a40-6ef1-4ed9-b58a-e26945bde3ca +spec: + displayName: 默认分类 + slug: default + description: 这是你的默认分类,如不需要,删除即可。 + cover: "" + template: "" + priority: 0 + children: [ ] +status: + permalink: "/categories/default" + +--- +apiVersion: content.halo.run/v1alpha1 +kind: Tag +metadata: + name: c33ceabb-d8f1-4711-8991-bb8f5c92ad7c +spec: + displayName: Halo + slug: halo + color: "#ffffff" + cover: "" +status: + permalink: "/tags/halo" + +--- +# 文章关联的内容 +apiVersion: content.halo.run/v1alpha1 +kind: Snapshot +metadata: + name: fb5cd6bd-998d-4ccc-984d-5cc23b0a09f9 + annotations: + content.halo.run/keep-raw: "true" +spec: + subjectRef: + group: content.halo.run + version: v1alpha1 + kind: Post + name: 5152aea5-c2e8-4717-8bba-2263d46e19d5 + rawType: HTML + rawPatch:

Hello + Halo

如果你看到了这一篇文章,那么证明你已经安装成功了,感谢使用 Halo + 进行创作,希望能够使用愉快。

相关链接

在使用过程中,有任何问题都可以通过以上链接找寻答案,或者联系我们。

这是一篇自动生成的文章,请删除这篇文章之后开始你的创作吧!

+ contentPatch:

Hello + Halo

如果你看到了这一篇文章,那么证明你已经安装成功了,感谢使用 Halo + 进行创作,希望能够使用愉快。

相关链接

在使用过程中,有任何问题都可以通过以上链接找寻答案,或者联系我们。

这是一篇自动生成的文章,请删除这篇文章之后开始你的创作吧!

+ lastModifyTime: "${timestamp}" + owner: "${username}" + contributors: + - "${username}" + +--- +# 初始化文章数据 +apiVersion: content.halo.run/v1alpha1 +kind: Post +metadata: + name: 5152aea5-c2e8-4717-8bba-2263d46e19d5 +spec: + title: Hello Halo + slug: hello-halo + releaseSnapshot: fb5cd6bd-998d-4ccc-984d-5cc23b0a09f9 + headSnapshot: fb5cd6bd-998d-4ccc-984d-5cc23b0a09f9 + baseSnapshot: fb5cd6bd-998d-4ccc-984d-5cc23b0a09f9 + owner: "${username}" + template: "" + cover: "" + deleted: false + publish: true + publishTime: "${timestamp}" + pinned: false + allowComment: true + visible: PUBLIC + priority: 0 + excerpt: + autoGenerate: false + raw: 如果你看到了这一篇文章,那么证明你已经安装成功了,感谢使用 Halo 进行创作,希望能够使用愉快。 + categories: + - 76514a40-6ef1-4ed9-b58a-e26945bde3ca + tags: + - c33ceabb-d8f1-4711-8991-bb8f5c92ad7c + htmlMetas: [ ] +status: + permalink: /archives/hello-halo + +--- +# 自定义页面关联的内容 +apiVersion: content.halo.run/v1alpha1 +kind: Snapshot +metadata: + name: c3f73cc2-194e-4cd8-9092-7386aa50a0e5 + annotations: + content.halo.run/keep-raw: "true" +spec: + subjectRef: + group: content.halo.run + version: v1alpha1 + kind: SinglePage + name: 373a5f79-f44f-441a-9df1-85a4f553ece8 + rawType: HTML + rawPatch:

关于页面

这是一个自定义页面,你可以在后台的 页面 + -> 自定义页面 + 找到它,你可以用于新建关于页面、联系我们页面等等。

这是一篇自动生成的页面,你可以在后台删除它。

+ contentPatch:

关于页面

这是一个自定义页面,你可以在后台的 页面 + -> 自定义页面 + 找到它,你可以用于新建关于页面、联系我们页面等等。

这是一篇自动生成的页面,你可以在后台删除它。

+ lastModifyTime: "${timestamp}" + owner: "${username}" + contributors: + - "${username}" + +--- +# 初始化自定义页面数据 +apiVersion: content.halo.run/v1alpha1 +kind: SinglePage +metadata: + name: 373a5f79-f44f-441a-9df1-85a4f553ece8 +spec: + title: 关于 + slug: about + template: "" + cover: "" + owner: "${username}" + deleted: false + publish: true + baseSnapshot: c3f73cc2-194e-4cd8-9092-7386aa50a0e5 + headSnapshot: c3f73cc2-194e-4cd8-9092-7386aa50a0e5 + releaseSnapshot: c3f73cc2-194e-4cd8-9092-7386aa50a0e5 + pinned: false + allowComment: true + visible: PUBLIC + version: 1 + priority: 0 + excerpt: + autoGenerate: false + raw: 这是一个自定义页面,你可以在后台的 页面 -> 自定义页面 找到它,你可以用于新建关于页面、联系我们页面等等。 + htmlMetas: [ ] +status: + permalink: "/about" + +--- +# 首页菜单项 +apiVersion: v1alpha1 +kind: MenuItem +metadata: + name: 88c3f10b-321c-4092-86a8-70db00251b74 +spec: + displayName: 首页 + href: / + children: [ ] + priority: 0 +--- +# 关联到文章作为菜单 +apiVersion: v1alpha1 +kind: MenuItem +metadata: + name: c4c814d1-0c2c-456b-8c96-4864965fee94 +spec: + displayName: "Hello Halo" + href: "/archives/hello-halo" + children: [ ] + priority: 1 + targetRef: + group: content.halo.run + version: v1alpha1 + kind: Post + name: 5152aea5-c2e8-4717-8bba-2263d46e19d5 +--- +# 关联到标签作为菜单 +apiVersion: v1alpha1 +kind: MenuItem +metadata: + name: 35869bd3-33b5-448b-91ee-cf6517a59644 +spec: + displayName: "Halo" + href: "/tags/halo" + children: [ ] + priority: 2 + targetRef: + group: content.halo.run + version: v1alpha1 + kind: Tag + name: c33ceabb-d8f1-4711-8991-bb8f5c92ad7c +--- +# 关联到自定义页面作为菜单 +apiVersion: v1alpha1 +kind: MenuItem +metadata: + name: b0d041fa-dc99-48f6-a193-8604003379cf +spec: + displayName: "关于" + href: "/about" + children: [ ] + priority: 3 + targetRef: + group: content.halo.run + version: v1alpha1 + kind: SinglePage + name: 373a5f79-f44f-441a-9df1-85a4f553ece8 +--- +apiVersion: v1alpha1 +kind: Menu +metadata: + name: primary +spec: + displayName: 主菜单 + menuItems: + - 88c3f10b-321c-4092-86a8-70db00251b74 + - c4c814d1-0c2c-456b-8c96-4864965fee94 + - 35869bd3-33b5-448b-91ee-cf6517a59644 + - b0d041fa-dc99-48f6-a193-8604003379cf diff --git a/application/src/main/resources/schema-mariadb.sql b/application/src/main/resources/schema-mariadb.sql index 370cb7ca09..9df635750a 100644 --- a/application/src/main/resources/schema-mariadb.sql +++ b/application/src/main/resources/schema-mariadb.sql @@ -1,6 +1,6 @@ create table if not exists extensions ( - name varchar(255) not null, + name varchar(255) not null COLLATE utf8mb4_bin, data longblob, version bigint, primary key (name) diff --git a/application/src/main/resources/schema-mysql.sql b/application/src/main/resources/schema-mysql.sql index 370cb7ca09..9df635750a 100644 --- a/application/src/main/resources/schema-mysql.sql +++ b/application/src/main/resources/schema-mysql.sql @@ -1,6 +1,6 @@ create table if not exists extensions ( - name varchar(255) not null, + name varchar(255) not null COLLATE utf8mb4_bin, data longblob, version bigint, primary key (name) diff --git a/application/src/main/resources/static/images/login-methods/login-with-credentials.svg b/application/src/main/resources/static/images/login-methods/login-with-credentials.svg new file mode 100644 index 0000000000..2e323aa68a --- /dev/null +++ b/application/src/main/resources/static/images/login-methods/login-with-credentials.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/application/src/main/resources/static/images/logo.png b/application/src/main/resources/static/images/logo.png new file mode 100644 index 0000000000..135bb98e53 Binary files /dev/null and b/application/src/main/resources/static/images/logo.png differ diff --git a/application/src/main/resources/static/images/password-reset-methods/email.svg b/application/src/main/resources/static/images/password-reset-methods/email.svg new file mode 100644 index 0000000000..d658b36a13 --- /dev/null +++ b/application/src/main/resources/static/images/password-reset-methods/email.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/application/src/main/resources/static/images/wordmark.svg b/application/src/main/resources/static/images/wordmark.svg new file mode 100644 index 0000000000..be75721541 --- /dev/null +++ b/application/src/main/resources/static/images/wordmark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/application/src/main/resources/static/js/main.js b/application/src/main/resources/static/js/main.js new file mode 100644 index 0000000000..241caa097b --- /dev/null +++ b/application/src/main/resources/static/js/main.js @@ -0,0 +1,169 @@ +const Toast = (function () { + let container; + + function getContainer() { + if (container) return container; + + container = document.createElement("div"); + container.style.cssText = ` + position: fixed; + top: 20px; + left: 50%; + transform: translateX(-50%); + z-index: 9999; + `; + + if (document.body) { + document.body.appendChild(container); + } else { + document.addEventListener("DOMContentLoaded", () => { + document.body.appendChild(container); + }); + } + + return container; + } + + class ToastMessage { + constructor(message, type) { + this.message = message; + this.type = type; + this.element = null; + this.create(); + } + + create() { + this.element = document.createElement("div"); + this.element.textContent = this.message; + this.element.style.cssText = ` + background-color: ${this.type === "success" ? "#4CAF50" : "#F44336"}; + color: white; + padding: 12px 24px; + border-radius: 4px; + margin-bottom: 10px; + box-shadow: 0 2px 5px rgba(0,0,0,0.2); + opacity: 0; + transition: opacity 0.3s ease-in-out; + `; + getContainer().appendChild(this.element); + + setTimeout(() => { + this.element.style.opacity = "1"; + }, 10); + + setTimeout(() => { + this.remove(); + }, 3000); + } + + remove() { + this.element.style.opacity = "0"; + setTimeout(() => { + const parent = this.element.parentNode; + if (parent) { + parent.removeChild(this.element); + } + }, 300); + } + } + + function showToast(message, type) { + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", () => { + new ToastMessage(message, type); + }); + } else { + new ToastMessage(message, type); + } + } + + return { + success: function (message) { + showToast(message, "success"); + }, + error: function (message) { + showToast(message, "error"); + }, + }; +})(); + +function sendVerificationCode(button, sendRequest) { + let timer; + const countdown = 60; + + button.addEventListener("click", () => { + button.disabled = true; + sendRequest() + .then(() => { + startCountdown(); + Toast.success(i18nResources.sendVerificationCodeSuccess); + }) + .catch((e) => { + button.disabled = false; + if (e instanceof Error) { + Toast.error(e.message); + } else { + Toast.error(i18nResources.sendVerificationCodeFailed); + } + }); + }); + + function startCountdown() { + let remainingTime = countdown; + button.disabled = true; + button.classList.add("disabled"); + + timer = setInterval(() => { + if (remainingTime > 0) { + button.textContent = `${remainingTime}s`; + remainingTime--; + } else { + clearInterval(timer); + button.textContent = "Send"; + button.disabled = false; + button.classList.remove("disabled"); + } + }, 1000); + } +} + +document.addEventListener("DOMContentLoaded", () => { + const passwordContainers = document.querySelectorAll(".toggle-password-display-flag"); + + passwordContainers.forEach((container) => { + const passwordInput = container.querySelector('input[type="password"]'); + const toggleButton = container.querySelector(".toggle-password-button"); + const displayIcon = container.querySelector(".password-display-icon"); + const hiddenIcon = container.querySelector(".password-hidden-icon"); + + if (passwordInput && toggleButton && displayIcon && hiddenIcon) { + toggleButton.addEventListener("click", () => { + if (passwordInput.type === "password") { + passwordInput.type = "text"; + displayIcon.style.display = "none"; + hiddenIcon.style.display = "block"; + } else { + passwordInput.type = "password"; + displayIcon.style.display = "block"; + hiddenIcon.style.display = "none"; + } + }); + } + }); +}); + +function setupPasswordConfirmation(passwordId, confirmPasswordId) { + const password = document.getElementById(passwordId); + const confirmPassword = document.getElementById(confirmPasswordId); + + function validatePasswordMatch() { + if (password.value !== confirmPassword.value) { + confirmPassword.setCustomValidity(i18nResources.passwordConfirmationFailed); + } else { + confirmPassword.setCustomValidity(""); + } + } + + password.addEventListener("change", validatePasswordMatch); + confirmPassword.addEventListener("input", validatePasswordMatch); +} diff --git a/application/src/main/resources/static/styles/main.css b/application/src/main/resources/static/styles/main.css new file mode 100644 index 0000000000..742e171ffc --- /dev/null +++ b/application/src/main/resources/static/styles/main.css @@ -0,0 +1,406 @@ +.gateway-page { + width: 100%; + height: 100vh; + overflow: auto; +} + +.gateway-wrapper, +.gateway-wrapper:before, +.gateway-wrapper:after, +.gateway-wrapper *, +.gateway-wrapper :before, +.gateway-wrapper :after { + box-sizing: border-box; + border-style: solid; + border-width: 0; +} + +.gateway-wrapper { + --color-primary: #4ccba0; + --color-secondary: #0e1731; + --color-link: #1f75cb; + --color-text: #374151; + --color-border: #d1d5db; + --rounded-sm: 0.125em; + --rounded-base: 0.25em; + --rounded-lg: 0.5em; + --spacing-2xl: 1.5em; + --spacing-xl: 1.25em; + --spacing-lg: 1em; + --spacing-md: 0.875em; + --spacing-sm: 0.625em; + --spacing-xs: 0.5em; + --text-xl: 1.25em; + --text-2xl: 1.5em; + --text-lg: 1.125em; + --text-base: 1em; + --text-sm: 0.875em; + --font-size-base: 16px; + --font-size-md: 14px; + padding: 5% var(--spacing-lg); + font-size: var(--font-size-base); + max-width: 28em; + margin: 0 auto; + font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, + Noto Sans, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji; + line-height: 1.5; +} + +.halo-form-wrapper { + border-radius: var(--rounded-lg); + padding: var(--spacing-2xl); + background: #fff; + border: 1px solid #dfe6ecb3; +} + +.form-title { + all: unset; + font-size: var(--text-2xl); + margin-bottom: var(--spacing-lg); + font-weight: 500; + display: block; +} + +.halo-form .form-item { + margin-bottom: var(--spacing-2xl); + flex-direction: column; + width: 100%; + display: flex; +} + +.halo-form .form-item:last-of-type { + margin-bottom: 0; +} + +.halo-form .form-item-group { + gap: var(--spacing-lg); + margin-bottom: var(--spacing-2xl); + align-items: flex-start; + display: flex; +} + +.halo-form .form-item-group .form-item { + margin-bottom: 0; +} + +.halo-form .form-input { + border-radius: var(--rounded-base); + border: 1px solid var(--color-border); + background: #fff; + height: 2.5em; + padding: 0 0.75rem; +} + +.halo-form .form-input:focus-within { + border-color: var(--color-primary); + outline-offset: "2px"; + outline: 2px solid #0000; +} + +.halo-form .form-item input { + appearance: none; + font-size: var(--text-base); + box-shadow: none; + background: none; + width: 100%; + height: 100%; + display: block; +} + +.halo-form .form-item input:focus { + outline: none; +} + +.halo-form .form-input-stack { + align-items: center; + gap: 0.5em; + display: flex; +} + +.halo-form .form-input-stack-icon { + color: var(--color-text); + cursor: pointer; + align-items: center; + display: inline-flex; +} + +.halo-form .form-input-stack-select { + all: unset; + color: var(--color-text); + font-size: var(--text-sm); + background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='m12 13.171l4.95-4.95l1.414 1.415L12 16L5.636 9.636L7.05 8.222z'/%3E%3C/svg%3E") + right 0.3em center no-repeat; + align-items: center; + padding-right: 1.85em; + display: inline-flex; +} + +.halo-form .form-input-stack-text { + color: var(--color-text); + font-size: var(--text-sm); +} + +.halo-form .form-item label { + color: var(--color-text); + font-size: var(--text-base); + margin-bottom: 0.75rem; + font-weight: 500; +} + +.halo-form .form-item .form-label-group { + justify-content: space-between; + align-items: center; + margin-bottom: 0.75em; + display: flex; +} + +.halo-form .form-item .form-label-group label { + margin-bottom: 0; +} + +.halo-form .form-item-extra-link { + color: var(--color-link); + font-size: var(--text-sm); + text-decoration: none; +} + +.halo-form .form-item-compact { + gap: var(--spacing-sm); + margin-bottom: var(--spacing-2xl); + align-items: center; + display: flex; +} + +.halo-form .form-item-compact label { + color: var(--color-text); + font-size: var(--text-sm); +} + +.halo-form button[type="submit"] { + background: var(--color-secondary); + border-radius: var(--rounded-base); + color: #fff; + cursor: pointer; + border: none; + height: 2.5em; +} + +.halo-form button[type="submit"]:hover { + opacity: 0.8; +} + +.halo-form button[type="submit"]:active { + opacity: 0.9; +} + +.halo-form button[disabled] { + cursor: not-allowed !important; +} + +.halo-form input[type="checkbox"] { + border: 1px solid var(--color-border); + border-radius: var(--rounded-sm); + appearance: none; + print-color-adjust: exact; + vertical-align: middle; + user-select: none; + color: #2563eb; + background-color: #fff; + background-origin: border-box; + flex-shrink: 0; + width: 1em; + height: 1em; + padding: 0; + display: inline-block; +} + +.halo-form input[type="checkbox"]:focus { + outline-offset: 2px; + outline: 2px solid #0000; + box-shadow: 0 0 0 2px #fff, 0 0 0 4px #2563eb, 0 0 #0000; +} + +.halo-form input[type="checkbox"]:checked { + background-color: currentColor; + background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e"); + background-position: center; + background-repeat: no-repeat; + background-size: 100% 100%; + border-color: #0000; +} + +.halo-form .form-input-group { + gap: var(--spacing-sm); + grid-template-columns: repeat(3, minmax(0, 1fr)); + align-items: center; + display: grid; + height: 2.5em; +} + +.halo-form .form-input { + grid-column: span 2 / span 2; +} + +.halo-form .form-input-group button { + border-radius: var(--rounded-base); + border: 1px solid var(--color-border); + color: var(--color-text); + font-size: var(--text-sm); + cursor: pointer; + background: #fff; + grid-column: span 1 / span 1; + height: 100%; +} + +.halo-form .form-input-group button:hover { + color: #333; + background: #f3f4f6; +} + +.halo-form .form-input-group button:active { + background: #f9fafb; +} + +.pill-items { + all: unset; + gap: var(--spacing-sm); + flex-wrap: wrap; + justify-content: center; + margin: 0; + display: flex; +} + +.pill-items li { + all: unset; + border-radius: var(--rounded-lg); + border: 1px solid #e5e7eb; + transition-property: all; + transition-duration: 0.15s; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + overflow: hidden; +} + +.pill-items li a { + gap: var(--spacing-sm); + font-size: var(--text-sm); + color: #1f2937; + align-items: center; + padding: 0.6em 0.9em; + text-decoration: none; + display: flex; +} + +.pill-items li img { + width: 1.5em; + height: 1.5em; +} + +.pill-items li:hover { + border-color: var(--color-primary); + background: #f3f4f6; +} + +.pill-items li:hover a { + color: #111827; +} + +.pill-items li:focus-within { + border-color: var(--color-primary); +} + +.divider-wrapper { + color: var(--color-text); + font-size: var(--text-sm); + gap: var(--spacing-lg); + align-items: center; + margin: 1.5em 0; + display: flex; +} + +.divider-wrapper hr { + border: 0; + border-top: 1px solid #f3f4f6; + flex-grow: 1; + overflow: hidden; +} + +.alert { + border-radius: var(--rounded-base); + margin-bottom: var(--spacing-xl); + padding: var(--spacing-md) var(--spacing-xl); + font-size: var(--text-sm); + color: var(--color-text); + border: 1px solid #e5e7eb; + position: relative; + overflow: hidden; +} + +.alert:before { + content: ""; + background: #d1d5db; + width: 0.25em; + height: 100%; + position: absolute; + top: 0; + left: 0; +} + +.alert-warning { + border-color: #fde047; +} + +.alert-warning:before { + background: #ea580c; +} + +.alert-error { + border-color: #fca5a5; +} + +.alert-error:before { + background: #dc2626; +} + +.alert-success { + border-color: #86efac; +} + +.alert-success:before { + background: #16a34a; +} + +.alert-info { + border-color: #7dd3fc; +} + +.alert-info:before { + background: #0284c7; +} + +@media (forced-colors: active) { + .halo-form input[type="checkbox"]:checked { + appearance: auto; + } +} + +@media only screen and (max-width: 768px) { + .halo-form .form-item-group { + flex-direction: column; + } +} + +@media screen and (min-width: 1201px) and (max-width: 1600px) { + .gateway-wrapper { + font-size: var(--font-size-md); + } +} + +@media screen and (min-width: 1601px) { + .gateway-wrapper { + font-size: var(--font-size-base); + } +} + +::-ms-reveal { + display: none; +} diff --git a/application/src/main/resources/templates/challenges/two-factor/totp.html b/application/src/main/resources/templates/challenges/two-factor/totp.html new file mode 100644 index 0000000000..062ef511da --- /dev/null +++ b/application/src/main/resources/templates/challenges/two-factor/totp.html @@ -0,0 +1,15 @@ + + + +
+
+
+

+
+
+
+
+ diff --git a/application/src/main/resources/templates/challenges/two-factor/totp.properties b/application/src/main/resources/templates/challenges/two-factor/totp.properties new file mode 100644 index 0000000000..1b02e431c6 --- /dev/null +++ b/application/src/main/resources/templates/challenges/two-factor/totp.properties @@ -0,0 +1 @@ +title=两步验证 diff --git a/application/src/main/resources/templates/challenges/two-factor/totp_en.properties b/application/src/main/resources/templates/challenges/two-factor/totp_en.properties new file mode 100644 index 0000000000..65a92c2045 --- /dev/null +++ b/application/src/main/resources/templates/challenges/two-factor/totp_en.properties @@ -0,0 +1 @@ +title=Two-Factor Authentication diff --git a/application/src/main/resources/templates/challenges/two-factor/totp_es.properties b/application/src/main/resources/templates/challenges/two-factor/totp_es.properties new file mode 100644 index 0000000000..0f0517887c --- /dev/null +++ b/application/src/main/resources/templates/challenges/two-factor/totp_es.properties @@ -0,0 +1 @@ +title=Autenticación en Dos Pasos diff --git a/application/src/main/resources/templates/challenges/two-factor/totp_zh_TW.properties b/application/src/main/resources/templates/challenges/two-factor/totp_zh_TW.properties new file mode 100644 index 0000000000..8b8ac41202 --- /dev/null +++ b/application/src/main/resources/templates/challenges/two-factor/totp_zh_TW.properties @@ -0,0 +1 @@ +title=兩步驗證 diff --git a/application/src/main/resources/templates/gateway_fragments/common.html b/application/src/main/resources/templates/gateway_fragments/common.html new file mode 100644 index 0000000000..127692d41c --- /dev/null +++ b/application/src/main/resources/templates/gateway_fragments/common.html @@ -0,0 +1,212 @@ + + + + + + + + + + + + + + + + +
+ +
+ + + +
+
+ +
+ + +
+ +
+ +
+
+ +
+
+ +
+
+ +
+ +
+
+ +
+
+ +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
diff --git a/application/src/main/resources/templates/gateway_fragments/common.properties b/application/src/main/resources/templates/gateway_fragments/common.properties new file mode 100644 index 0000000000..22053bc75c --- /dev/null +++ b/application/src/main/resources/templates/gateway_fragments/common.properties @@ -0,0 +1,13 @@ +socialLogin.label=社交登录 +js.sendVerificationCode.success=发送成功 +js.sendVerificationCode.failed=发送失败,请稍后再试 +js.passwordConfirmation.failed=确认密码不匹配 + +signupNotice.description=没有账号? +signupNotice.link=立即注册 +loginNotice.description=已有账号, +loginNotice.link=立即登录 +returnToSite=返回网站 + +passwordResetMethods.label=其他重置方式 +passwordResetMethods.email.displayName=通过邮件重置 \ No newline at end of file diff --git a/application/src/main/resources/templates/gateway_fragments/common_en.properties b/application/src/main/resources/templates/gateway_fragments/common_en.properties new file mode 100644 index 0000000000..70af7ab8db --- /dev/null +++ b/application/src/main/resources/templates/gateway_fragments/common_en.properties @@ -0,0 +1,13 @@ +socialLogin.label=Social Login +js.sendVerificationCode.success=Sent Successfully +js.sendVerificationCode.failed=Sending Failed, Please Try Again Later +js.passwordConfirmation.failed=Password confirmation does not match + +signupNotice.description=Don't have an account? +signupNotice.link=Sign up +loginNotice.description=Already have an account, +loginNotice.link=Login now +returnToSite=Return to site + +passwordResetMethods.label=Other Reset Methods +passwordResetMethods.email.displayName=Reset via Email \ No newline at end of file diff --git a/application/src/main/resources/templates/gateway_fragments/common_es.properties b/application/src/main/resources/templates/gateway_fragments/common_es.properties new file mode 100644 index 0000000000..db6c8cef4f --- /dev/null +++ b/application/src/main/resources/templates/gateway_fragments/common_es.properties @@ -0,0 +1,13 @@ +socialLogin.label=Inicio de Sesión Social +js.sendVerificationCode.success=Enviado con éxito +js.sendVerificationCode.failed=Error al enviar, por favor intente nuevamente más tarde +js.passwordConfirmation.failed=La confirmación de la contraseña no coincide + +signupNotice.description=¿No tienes una cuenta? +signupNotice.link=Regístrate ahora +loginNotice.description=Ya tienes una cuenta, +loginNotice.link=Inicia sesión ahora +returnToSite=Volver al sitio + +passwordResetMethods.label=Otros Métodos de Restablecimiento +passwordResetMethods.email.displayName=Restablecer por Correo Electrónico \ No newline at end of file diff --git a/application/src/main/resources/templates/gateway_fragments/common_zh_TW.properties b/application/src/main/resources/templates/gateway_fragments/common_zh_TW.properties new file mode 100644 index 0000000000..63129e1666 --- /dev/null +++ b/application/src/main/resources/templates/gateway_fragments/common_zh_TW.properties @@ -0,0 +1,13 @@ +socialLogin.label=社交登入 +js.sendVerificationCode.success=發送成功 +js.sendVerificationCode.failed=發送失敗,請稍後再試 +js.passwordConfirmation.failed=確認密碼不匹配 + +signupNotice.description=沒有帳號? +signupNotice.link=立即註冊 +loginNotice.description=已有帳號, +loginNotice.link=立即登入 +returnToSite=返回網站 + +passwordResetMethods.label=其他重置方式 +passwordResetMethods.email.displayName=通過郵件重置 \ No newline at end of file diff --git a/application/src/main/resources/templates/gateway_fragments/input.html b/application/src/main/resources/templates/gateway_fragments/input.html new file mode 100644 index 0000000000..9a21e3cdce --- /dev/null +++ b/application/src/main/resources/templates/gateway_fragments/input.html @@ -0,0 +1,32 @@ +
+
+ + +
+ + + + + +
+
+
\ No newline at end of file diff --git a/application/src/main/resources/templates/gateway_fragments/layout.html b/application/src/main/resources/templates/gateway_fragments/layout.html new file mode 100644 index 0000000000..1bcbfaad3d --- /dev/null +++ b/application/src/main/resources/templates/gateway_fragments/layout.html @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/application/src/main/resources/templates/gateway_fragments/login.html b/application/src/main/resources/templates/gateway_fragments/login.html new file mode 100644 index 0000000000..bf7eda62ae --- /dev/null +++ b/application/src/main/resources/templates/gateway_fragments/login.html @@ -0,0 +1,60 @@ +
+ + + + + + +
+ +
+ + +
+ +
+ +
+
+ +
+ +
+
+ +
+
+ +
+
\ No newline at end of file diff --git a/application/src/main/resources/templates/gateway_fragments/login.properties b/application/src/main/resources/templates/gateway_fragments/login.properties new file mode 100644 index 0000000000..bbd2288b45 --- /dev/null +++ b/application/src/main/resources/templates/gateway_fragments/login.properties @@ -0,0 +1,13 @@ +form.messages.logoutSuccess=登出成功。 +form.messages.signupSuccess=恭喜!注册成功,请立即登录。 +form.messages.oauth2Bind=当前登录未绑定账号,请尝试通过其他方式登录,登录成功后会自动绑定账号。 +form.messages.passwordReset=密码重置成功,请立即登录。 +form.error.invalidCredential=无效的凭证。 +form.error.rateLimitExceeded=请求过于频繁,请稍后再试。 +form.rememberMe.label=保持登录会话 +form.submit=登录 + +otherLogin.label=其他登录方式 + +# Rule: `formAuthProviders.${provider.metadata.name}.displayName` +formAuthProviders.local.displayName=账号密码登录 \ No newline at end of file diff --git a/application/src/main/resources/templates/gateway_fragments/login_en.properties b/application/src/main/resources/templates/gateway_fragments/login_en.properties new file mode 100644 index 0000000000..a086d11a9b --- /dev/null +++ b/application/src/main/resources/templates/gateway_fragments/login_en.properties @@ -0,0 +1,13 @@ +form.messages.logoutSuccess=Logout successfully. +form.messages.signupSuccess=Congratulations! Sign up successfully, please login now. +form.messages.oauth2Bind=The current login is not bound to an account. Please try to log in through other methods. After successful login, the account will be automatically bound. +form.messages.passwordReset=Password reset successfully, please login now. +form.error.invalidCredential=Invalid credentials. +form.error.rateLimitExceeded=Too many requests, please try again later. +form.rememberMe.label=Remember me +form.submit=Login + +otherLogin.label=Other Login + +# Rule: `formAuthProviders.${provider.metadata.name}.displayName` +formAuthProviders.local.displayName=Login with credentials \ No newline at end of file diff --git a/application/src/main/resources/templates/gateway_fragments/login_es.properties b/application/src/main/resources/templates/gateway_fragments/login_es.properties new file mode 100644 index 0000000000..461229b3c8 --- /dev/null +++ b/application/src/main/resources/templates/gateway_fragments/login_es.properties @@ -0,0 +1,13 @@ +form.messages.logoutSuccess=Cierre de sesión exitoso. +form.messages.signupSuccess=¡Felicidades! Registro exitoso, por favor inicie sesión de inmediato. +form.messages.oauth2Bind=El inicio de sesión actual no está vinculado a una cuenta. Intente iniciar sesión a través de otros métodos. Después de un inicio de sesión exitoso, la cuenta se vinculará automáticamente. +form.messages.passwordReset=¡Felicidades! La contraseña se ha restablecido con éxito. +form.error.invalidCredential=Credenciales inválidas. +form.error.rateLimitExceeded=Demasiadas solicitudes, por favor intente nuevamente más tarde. +form.rememberMe.label=Mantener sesión iniciada +form.submit=Iniciar sesión + +otherLogin.label=Otras formas de inicio de sesión + +# Rule: `formAuthProviders.${provider.metadata.name}.displayName` +formAuthProviders.local.displayName=Iniciar sesión con credenciales \ No newline at end of file diff --git a/application/src/main/resources/templates/gateway_fragments/login_zh_TW.properties b/application/src/main/resources/templates/gateway_fragments/login_zh_TW.properties new file mode 100644 index 0000000000..b485ddd937 --- /dev/null +++ b/application/src/main/resources/templates/gateway_fragments/login_zh_TW.properties @@ -0,0 +1,13 @@ +form.messages.logoutSuccess=登出成功。 +form.messages.signupSuccess=恭喜!註冊成功,請立即登入。 +form.messages.oauth2Bind=當前登入未綁定至帳戶。請嘗試通過其他方法登入。成功登入後,帳戶將自動綁定。 +form.messages.passwordReset=密碼重置成功。請立即登入。 +form.error.invalidCredential=無效的憑證。 +form.error.rateLimitExceeded=請求過於頻繁,請稍後再試。 +form.form.rememberMe.label=保持登入會話 +form.form.submit=登入 + +otherLogin.label=其他登入方式 + +# Rule: `formAuthProviders.${provider.metadata.name}.displayName` +formAuthProviders.local.displayName=帳號密碼登入 \ No newline at end of file diff --git a/application/src/main/resources/templates/gateway_fragments/logout.html b/application/src/main/resources/templates/gateway_fragments/logout.html new file mode 100644 index 0000000000..2a4c49dbdf --- /dev/null +++ b/application/src/main/resources/templates/gateway_fragments/logout.html @@ -0,0 +1,16 @@ +
+ +
+ +
+ +
+ +
+
diff --git a/application/src/main/resources/templates/gateway_fragments/logout.properties b/application/src/main/resources/templates/gateway_fragments/logout.properties new file mode 100644 index 0000000000..4d110365ed --- /dev/null +++ b/application/src/main/resources/templates/gateway_fragments/logout.properties @@ -0,0 +1,3 @@ +form.submit=退出登录 +form.cancel=取消 +form.currentUser.label=当前登录的用户: \ No newline at end of file diff --git a/application/src/main/resources/templates/gateway_fragments/logout_en.properties b/application/src/main/resources/templates/gateway_fragments/logout_en.properties new file mode 100644 index 0000000000..de423bad60 --- /dev/null +++ b/application/src/main/resources/templates/gateway_fragments/logout_en.properties @@ -0,0 +1,3 @@ +form.submit=Logout +form.cancel=Cancel +form.currentUser.label=Currently logged in user: \ No newline at end of file diff --git a/application/src/main/resources/templates/gateway_fragments/logout_es.properties b/application/src/main/resources/templates/gateway_fragments/logout_es.properties new file mode 100644 index 0000000000..f6264e15c5 --- /dev/null +++ b/application/src/main/resources/templates/gateway_fragments/logout_es.properties @@ -0,0 +1,3 @@ +form.submit=Cerrar Sesión +form.cancel=Cancelar +form.currentUser.label=Usuario actualmente conectado: diff --git a/application/src/main/resources/templates/gateway_fragments/logout_zh_TW.properties b/application/src/main/resources/templates/gateway_fragments/logout_zh_TW.properties new file mode 100644 index 0000000000..86ccd7e150 --- /dev/null +++ b/application/src/main/resources/templates/gateway_fragments/logout_zh_TW.properties @@ -0,0 +1,3 @@ +form.submit=退出登入 +form.cancel=取消 +form.currentUser.label=當前登入的使用者: \ No newline at end of file diff --git a/application/src/main/resources/templates/gateway_fragments/password_reset_email_reset.html b/application/src/main/resources/templates/gateway_fragments/password_reset_email_reset.html new file mode 100644 index 0000000000..b3ce4824b1 --- /dev/null +++ b/application/src/main/resources/templates/gateway_fragments/password_reset_email_reset.html @@ -0,0 +1,37 @@ +
+ +
+ + +

+
+
+ + +

+
+
+
+
+
+ +
+ + +
\ No newline at end of file diff --git a/application/src/main/resources/templates/gateway_fragments/password_reset_email_reset.properties b/application/src/main/resources/templates/gateway_fragments/password_reset_email_reset.properties new file mode 100644 index 0000000000..b3bfbf83db --- /dev/null +++ b/application/src/main/resources/templates/gateway_fragments/password_reset_email_reset.properties @@ -0,0 +1,5 @@ +form.password.label=密码 +form.confirmPassword.label=确认密码 +form.password.tips=密码只能使用大小写字母 (A-Z, a-z)、数字 (0-9),以及以下特殊字符: !@#$%^&*。 +form.submit=修改密码 +error.rate_limit_exceeded=您的请求过于频繁,请稍后再试。 diff --git a/application/src/main/resources/templates/gateway_fragments/password_reset_email_reset_en.properties b/application/src/main/resources/templates/gateway_fragments/password_reset_email_reset_en.properties new file mode 100644 index 0000000000..89efb84ce0 --- /dev/null +++ b/application/src/main/resources/templates/gateway_fragments/password_reset_email_reset_en.properties @@ -0,0 +1,5 @@ +form.password.label=Password +form.confirmPassword.label=Confirm Password +form.password.tips=The password can only use uppercase and lowercase letters (A-Z, a-z), numbers (0-9), and the following special characters: !@#$%^&* +form.submit=Change password +error.rate_limit_exceeded=Your request is too frequent, please try again later. diff --git a/application/src/main/resources/templates/gateway_fragments/password_reset_email_reset_es.properties b/application/src/main/resources/templates/gateway_fragments/password_reset_email_reset_es.properties new file mode 100644 index 0000000000..3d3f065905 --- /dev/null +++ b/application/src/main/resources/templates/gateway_fragments/password_reset_email_reset_es.properties @@ -0,0 +1,5 @@ +form.password.label=Contraseña +form.confirmPassword.label=Confirmar Contraseña +form.password.tips=La contraseña solo puede usar letras mayúsculas y minúsculas (A-Z, a-z), números (0-9) y los siguientes caracteres especiales: !@#$%^&*. +form.submit=Cambiar Contraseña +error.rate_limit_exceeded=Su solicitud es demasiado frecuente, por favor intente nuevamente más tarde. \ No newline at end of file diff --git a/application/src/main/resources/templates/gateway_fragments/password_reset_email_reset_zh_TW.properties b/application/src/main/resources/templates/gateway_fragments/password_reset_email_reset_zh_TW.properties new file mode 100644 index 0000000000..3667f441a6 --- /dev/null +++ b/application/src/main/resources/templates/gateway_fragments/password_reset_email_reset_zh_TW.properties @@ -0,0 +1,5 @@ +form.password.label=密碼 +form.confirmPassword.label=確認密碼 +form.password.tips=密碼只能使用大小寫字母 (A-Z, a-z)、數字 (0-9),以及以下特殊字符: !@#$%^&*。 +form.submit=修改密碼 +error.rate_limit_exceeded=您的請求過於頻繁,請稍後再試。 diff --git a/application/src/main/resources/templates/gateway_fragments/password_reset_email_send.html b/application/src/main/resources/templates/gateway_fragments/password_reset_email_send.html new file mode 100644 index 0000000000..ef20ffc11c --- /dev/null +++ b/application/src/main/resources/templates/gateway_fragments/password_reset_email_send.html @@ -0,0 +1,28 @@ + +
+
+
+
+
+ +
+
+
+
+ +
+
+ +
+
+ +
+ +
+ +
+
+ +
+
+
diff --git a/application/src/main/resources/templates/gateway_fragments/password_reset_email_send.properties b/application/src/main/resources/templates/gateway_fragments/password_reset_email_send.properties new file mode 100644 index 0000000000..3541f91c6d --- /dev/null +++ b/application/src/main/resources/templates/gateway_fragments/password_reset_email_send.properties @@ -0,0 +1,6 @@ +form.email.label=电子邮箱 +form.submit=提交 +form.sent.submit=返回到登录页面 +form.message.success=检查您的电子邮件中是否有重置密码的链接。如果几分钟内没有出现,请检查您的垃圾邮件文件夹。 +error.rate_limit_exceeded=您的请求速度太快。请稍后再试。 +error.invalid_reset_token=重置密码令牌无效。请重试。 diff --git a/application/src/main/resources/templates/gateway_fragments/password_reset_email_send_en.properties b/application/src/main/resources/templates/gateway_fragments/password_reset_email_send_en.properties new file mode 100644 index 0000000000..61f10e07fc --- /dev/null +++ b/application/src/main/resources/templates/gateway_fragments/password_reset_email_send_en.properties @@ -0,0 +1,6 @@ +form.email.label=Email +form.submit=Submit +form.sent.submit=Return to login +form.message.success=Check your email for a link to reset your password. If it doesn’t appear within a few minutes, check your spam folder. +error.rate_limit_exceeded=You are making requests too quickly. Please try again later. +error.invalid_reset_token=The reset password token is invalid. Please try again. diff --git a/application/src/main/resources/templates/gateway_fragments/password_reset_email_send_es.properties b/application/src/main/resources/templates/gateway_fragments/password_reset_email_send_es.properties new file mode 100644 index 0000000000..56606e3f39 --- /dev/null +++ b/application/src/main/resources/templates/gateway_fragments/password_reset_email_send_es.properties @@ -0,0 +1,6 @@ +form.email.label=Correo Electrónico +form.submit=Enviar +form.sent.submit=Volver a la Página de Inicio de Sesión +form.message.success=Revisa tu correo electrónico para ver el enlace de restablecimiento de contraseña. Si no aparece en unos minutos, revisa tu carpeta de spam. +error.rate_limit_exceeded=Se ha superado el límite de intentos de restablecimiento de contraseña. Por favor, inténtalo de nuevo más tarde. +error.invalid_reset_token=El enlace de restablecimiento de contraseña no es válido o ha expirado. Por favor, solicita un nuevo enlace. diff --git a/application/src/main/resources/templates/gateway_fragments/password_reset_email_send_zh_TW.properties b/application/src/main/resources/templates/gateway_fragments/password_reset_email_send_zh_TW.properties new file mode 100644 index 0000000000..ccd1bcf335 --- /dev/null +++ b/application/src/main/resources/templates/gateway_fragments/password_reset_email_send_zh_TW.properties @@ -0,0 +1,6 @@ +form.email.label=電子郵件 +form.submit=提交 +form.sent.submit=返回到登入頁面 +form.message.success=檢查您的電子郵件中是否有重置密碼的連結。如果幾分鐘內沒有出現,請檢查您的垃圾郵件資料夾。 +error.rate_limit_exceeded=您的請求過於頻繁。請稍後再試。 +error.invalid_reset_token=重置密碼連結無效。請重試。 diff --git a/application/src/main/resources/templates/gateway_fragments/signup.html b/application/src/main/resources/templates/gateway_fragments/signup.html new file mode 100644 index 0000000000..fc6fa7c71d --- /dev/null +++ b/application/src/main/resources/templates/gateway_fragments/signup.html @@ -0,0 +1,158 @@ +
+ + + +
+
+ +
+ +
+

+
+ +
+ +
+ +
+

+
+
+ +
+
+ +
+ +
+

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

+
+
+ +
+ + +

+
+ +
+ + +

+
+ +
+ +
+ + + + +
diff --git a/application/src/main/resources/templates/gateway_fragments/signup.properties b/application/src/main/resources/templates/gateway_fragments/signup.properties new file mode 100644 index 0000000000..d5275f7614 --- /dev/null +++ b/application/src/main/resources/templates/gateway_fragments/signup.properties @@ -0,0 +1,12 @@ +form.username.label=用户名 +form.displayName.label=名称 +form.email.label=电子邮箱 +form.emailCode.label=邮箱验证码 +form.emailCode.sendButton=发送 +form.emailCode.send.emptyValidation=请先输入邮箱地址 +form.password.label=密码 +form.confirmPassword.label=确认密码 +form.submit=注册 +form.error.invalidEmailCode=无效的邮箱验证码 +form.error.duplicateUsername=用户名已经被注册 +form.error.rateLimitExceeded=请求过于频繁,请稍后再试 \ No newline at end of file diff --git a/application/src/main/resources/templates/gateway_fragments/signup_en.properties b/application/src/main/resources/templates/gateway_fragments/signup_en.properties new file mode 100644 index 0000000000..3d56c1fca3 --- /dev/null +++ b/application/src/main/resources/templates/gateway_fragments/signup_en.properties @@ -0,0 +1,12 @@ +form.username.label=Username +form.displayName.label=Display Name +form.email.label=Email +form.emailCode.label=Email Verification Code +form.emailCode.sendButton=Send +form.emailCode.send.emptyValidation=Please enter your email address first +form.password.label=Password +form.confirmPassword.label=Confirm Password +form.submit=Sign Up +form.error.invalidEmailCode=Invalid Email Verification Code +form.error.duplicateUsername=Username is already taken +form.error.rateLimitExceeded=Too many requests, please try again later \ No newline at end of file diff --git a/application/src/main/resources/templates/gateway_fragments/signup_es.properties b/application/src/main/resources/templates/gateway_fragments/signup_es.properties new file mode 100644 index 0000000000..9ebb2bcbc0 --- /dev/null +++ b/application/src/main/resources/templates/gateway_fragments/signup_es.properties @@ -0,0 +1,12 @@ +form.username.label=Nombre de Usuario +form.displayName.label=Nombre +form.email.label=Correo Electrónico +form.emailCode.label=Código de Verificación +form.emailCode.sendButton=Enviar +form.emailCode.send.emptyValidation=Por favor, introduce tu dirección de correo electrónico primero +form.password.label=Contraseña +form.confirmPassword.label=Confirmar Contraseña +form.submit=Registrarse +form.error.invalidEmailCode=Código de verificación del correo inválido +form.error.duplicateUsername=El nombre de usuario ya está registrado +form.error.rateLimitExceeded=Demasiadas solicitudes, por favor intente nuevamente más tarde \ No newline at end of file diff --git a/application/src/main/resources/templates/gateway_fragments/signup_zh_TW.properties b/application/src/main/resources/templates/gateway_fragments/signup_zh_TW.properties new file mode 100644 index 0000000000..5135787d07 --- /dev/null +++ b/application/src/main/resources/templates/gateway_fragments/signup_zh_TW.properties @@ -0,0 +1,12 @@ +form.username.label=使用者名稱 +form.displayName.label=名稱 +form.email.label=電子郵件 +form.emailCode.label=郵箱驗證碼 +form.emailCode.sendButton=發送 +form.emailCode.send.emptyValidation=請先輸入電子郵件地址 +form.password.label=密碼 +form.confirmPassword.label=確認密碼 +form.submit=註冊 +form.error.invalidEmailCode=無效的郵箱驗證碼 +form.error.duplicateUsername=使用者名稱已經被註冊 +form.error.rateLimitExceeded=請求過於頻繁,請稍後再試 \ No newline at end of file diff --git a/application/src/main/resources/templates/gateway_fragments/totp.html b/application/src/main/resources/templates/gateway_fragments/totp.html new file mode 100644 index 0000000000..6cb2b78097 --- /dev/null +++ b/application/src/main/resources/templates/gateway_fragments/totp.html @@ -0,0 +1,30 @@ +
+ +
+ +
+ +
+
+
+ +
+
\ No newline at end of file diff --git a/application/src/main/resources/templates/gateway_fragments/totp.properties b/application/src/main/resources/templates/gateway_fragments/totp.properties new file mode 100644 index 0000000000..d2115d37ea --- /dev/null +++ b/application/src/main/resources/templates/gateway_fragments/totp.properties @@ -0,0 +1,3 @@ +form.messages.invalidError=错误的验证码 +form.code.label=验证码 +form.submit=验证 \ No newline at end of file diff --git a/application/src/main/resources/templates/gateway_fragments/totp_en.properties b/application/src/main/resources/templates/gateway_fragments/totp_en.properties new file mode 100644 index 0000000000..f9c456fa7c --- /dev/null +++ b/application/src/main/resources/templates/gateway_fragments/totp_en.properties @@ -0,0 +1,3 @@ +form.messages.invalidError=Invalid TOTP code +form.code.label=TOTP Code +form.submit=Verify \ No newline at end of file diff --git a/application/src/main/resources/templates/gateway_fragments/totp_es.properties b/application/src/main/resources/templates/gateway_fragments/totp_es.properties new file mode 100644 index 0000000000..2dbc8b4683 --- /dev/null +++ b/application/src/main/resources/templates/gateway_fragments/totp_es.properties @@ -0,0 +1,3 @@ +form.messages.invalidError=Código de verificación incorrecto +form.code.label=Código de Verificación +form.submit=Verificar \ No newline at end of file diff --git a/application/src/main/resources/templates/gateway_fragments/totp_zh_TW.properties b/application/src/main/resources/templates/gateway_fragments/totp_zh_TW.properties new file mode 100644 index 0000000000..9c06d0cf48 --- /dev/null +++ b/application/src/main/resources/templates/gateway_fragments/totp_zh_TW.properties @@ -0,0 +1,3 @@ +form.messages.invalidError=錯誤的驗證碼 +form.code.label=驗證碼 +form.submit=驗證 \ No newline at end of file diff --git a/application/src/main/resources/templates/login.html b/application/src/main/resources/templates/login.html new file mode 100644 index 0000000000..fd619e2c11 --- /dev/null +++ b/application/src/main/resources/templates/login.html @@ -0,0 +1,21 @@ + + + +
+
+ +
+
+
+
+
+ +
+
+
+
+
+ diff --git a/application/src/main/resources/templates/login.properties b/application/src/main/resources/templates/login.properties new file mode 100644 index 0000000000..26367c07cb --- /dev/null +++ b/application/src/main/resources/templates/login.properties @@ -0,0 +1 @@ +title=登录 \ No newline at end of file diff --git a/application/src/main/resources/templates/login_en.properties b/application/src/main/resources/templates/login_en.properties new file mode 100644 index 0000000000..eb0443eed6 --- /dev/null +++ b/application/src/main/resources/templates/login_en.properties @@ -0,0 +1 @@ +title=Login diff --git a/application/src/main/resources/templates/login_es.properties b/application/src/main/resources/templates/login_es.properties new file mode 100644 index 0000000000..44915466c1 --- /dev/null +++ b/application/src/main/resources/templates/login_es.properties @@ -0,0 +1 @@ +title=Iniciar Sesión diff --git a/application/src/main/resources/templates/login_local.html b/application/src/main/resources/templates/login_local.html new file mode 100644 index 0000000000..cc693a311a --- /dev/null +++ b/application/src/main/resources/templates/login_local.html @@ -0,0 +1,58 @@ +
+ + + + + +
+ + +
+ +
+
+
+
+ + + +
+ + +
+
diff --git a/application/src/main/resources/templates/login_local.properties b/application/src/main/resources/templates/login_local.properties new file mode 100644 index 0000000000..8163bfd74d --- /dev/null +++ b/application/src/main/resources/templates/login_local.properties @@ -0,0 +1,3 @@ +form.username.label=用户名 +form.password.label=密码 +form.password.forgot=忘记密码? diff --git a/application/src/main/resources/templates/login_local_en.properties b/application/src/main/resources/templates/login_local_en.properties new file mode 100644 index 0000000000..d0fbb09017 --- /dev/null +++ b/application/src/main/resources/templates/login_local_en.properties @@ -0,0 +1,3 @@ +form.username.label=Username +form.password.label=Password +form.password.forgot=Forgot your password? diff --git a/application/src/main/resources/templates/login_local_es.properties b/application/src/main/resources/templates/login_local_es.properties new file mode 100644 index 0000000000..a13f85b92d --- /dev/null +++ b/application/src/main/resources/templates/login_local_es.properties @@ -0,0 +1,3 @@ +form.username.label=Nombre de Usuario +form.password.label=Contraseña +form.password.forgot=¿Olvidaste tu contraseña? diff --git a/application/src/main/resources/templates/login_local_zh_TW.properties b/application/src/main/resources/templates/login_local_zh_TW.properties new file mode 100644 index 0000000000..83af5dba38 --- /dev/null +++ b/application/src/main/resources/templates/login_local_zh_TW.properties @@ -0,0 +1,3 @@ +form.username.label=使用者名稱 +form.password.label=密碼 +form.password.forgot=忘記密碼? \ No newline at end of file diff --git a/application/src/main/resources/templates/login_zh_TW.properties b/application/src/main/resources/templates/login_zh_TW.properties new file mode 100644 index 0000000000..9f1b2d337b --- /dev/null +++ b/application/src/main/resources/templates/login_zh_TW.properties @@ -0,0 +1 @@ +title=登入 diff --git a/application/src/main/resources/templates/logout.html b/application/src/main/resources/templates/logout.html new file mode 100644 index 0000000000..fd0890184f --- /dev/null +++ b/application/src/main/resources/templates/logout.html @@ -0,0 +1,55 @@ + + + +
+
+
+

+
+
+
+
+
+ + + + + diff --git a/application/src/main/resources/templates/logout.properties b/application/src/main/resources/templates/logout.properties new file mode 100644 index 0000000000..a0b22a0839 --- /dev/null +++ b/application/src/main/resources/templates/logout.properties @@ -0,0 +1,2 @@ +title=退出登录 +form.title=确定要退出登录吗? \ No newline at end of file diff --git a/application/src/main/resources/templates/logout_en.properties b/application/src/main/resources/templates/logout_en.properties new file mode 100644 index 0000000000..f2355d223a --- /dev/null +++ b/application/src/main/resources/templates/logout_en.properties @@ -0,0 +1,2 @@ +title=Logout +form.title=Are you sure want to log out? \ No newline at end of file diff --git a/application/src/main/resources/templates/logout_es.properties b/application/src/main/resources/templates/logout_es.properties new file mode 100644 index 0000000000..5179c8c2c9 --- /dev/null +++ b/application/src/main/resources/templates/logout_es.properties @@ -0,0 +1,2 @@ +title=Cerrar Sesión +form.title=¿Estás seguro de que deseas cerrar sesión? \ No newline at end of file diff --git a/application/src/main/resources/templates/logout_zh_TW.properties b/application/src/main/resources/templates/logout_zh_TW.properties new file mode 100644 index 0000000000..d707b5fba1 --- /dev/null +++ b/application/src/main/resources/templates/logout_zh_TW.properties @@ -0,0 +1,2 @@ +title=退出登入 +form.title=確定要退出登入嗎? \ No newline at end of file diff --git a/application/src/main/resources/templates/password-reset/email/reset.html b/application/src/main/resources/templates/password-reset/email/reset.html new file mode 100644 index 0000000000..6178b967c4 --- /dev/null +++ b/application/src/main/resources/templates/password-reset/email/reset.html @@ -0,0 +1,16 @@ + + + +
+
+
+

+
+
+
+
+
+ diff --git a/application/src/main/resources/templates/password-reset/email/reset.properties b/application/src/main/resources/templates/password-reset/email/reset.properties new file mode 100644 index 0000000000..5a6fc29992 --- /dev/null +++ b/application/src/main/resources/templates/password-reset/email/reset.properties @@ -0,0 +1 @@ +title=为 {0} 修改密码 diff --git a/application/src/main/resources/templates/password-reset/email/reset_en.properties b/application/src/main/resources/templates/password-reset/email/reset_en.properties new file mode 100644 index 0000000000..d4123a6b25 --- /dev/null +++ b/application/src/main/resources/templates/password-reset/email/reset_en.properties @@ -0,0 +1 @@ +title=Change password for @{0} diff --git a/application/src/main/resources/templates/password-reset/email/reset_es.properties b/application/src/main/resources/templates/password-reset/email/reset_es.properties new file mode 100644 index 0000000000..681c176738 --- /dev/null +++ b/application/src/main/resources/templates/password-reset/email/reset_es.properties @@ -0,0 +1 @@ +title=Cambiar Contraseña para {0} diff --git a/application/src/main/resources/templates/password-reset/email/reset_zh_TW.properties b/application/src/main/resources/templates/password-reset/email/reset_zh_TW.properties new file mode 100644 index 0000000000..cbe818b669 --- /dev/null +++ b/application/src/main/resources/templates/password-reset/email/reset_zh_TW.properties @@ -0,0 +1 @@ +title=為 {0} 修改密碼 diff --git a/application/src/main/resources/templates/password-reset/email/send.html b/application/src/main/resources/templates/password-reset/email/send.html new file mode 100644 index 0000000000..e10481df25 --- /dev/null +++ b/application/src/main/resources/templates/password-reset/email/send.html @@ -0,0 +1,19 @@ + + + +
+
+
+

+
+ +
+
+ +
+
+
+ diff --git a/application/src/main/resources/templates/password-reset/email/send.properties b/application/src/main/resources/templates/password-reset/email/send.properties new file mode 100644 index 0000000000..74f925d2c1 --- /dev/null +++ b/application/src/main/resources/templates/password-reset/email/send.properties @@ -0,0 +1,2 @@ +title=重置密码 +sent.title=已发送重置密码的邮件 \ No newline at end of file diff --git a/application/src/main/resources/templates/password-reset/email/send_en.properties b/application/src/main/resources/templates/password-reset/email/send_en.properties new file mode 100644 index 0000000000..9aadd2b589 --- /dev/null +++ b/application/src/main/resources/templates/password-reset/email/send_en.properties @@ -0,0 +1,2 @@ +title=Reset password +sent.title=Password reset email has been sent \ No newline at end of file diff --git a/application/src/main/resources/templates/password-reset/email/send_es.properties b/application/src/main/resources/templates/password-reset/email/send_es.properties new file mode 100644 index 0000000000..c3a202e78a --- /dev/null +++ b/application/src/main/resources/templates/password-reset/email/send_es.properties @@ -0,0 +1,2 @@ +title=Restablecer Contraseña +sent.title=Correo de Restablecimiento de Contraseña Enviado \ No newline at end of file diff --git a/application/src/main/resources/templates/password-reset/email/send_zh_TW.properties b/application/src/main/resources/templates/password-reset/email/send_zh_TW.properties new file mode 100644 index 0000000000..b879cbfb78 --- /dev/null +++ b/application/src/main/resources/templates/password-reset/email/send_zh_TW.properties @@ -0,0 +1,2 @@ +title=重置密碼 +sent.title=已發送重置密碼的郵件 \ No newline at end of file diff --git a/application/src/main/resources/templates/setup.html b/application/src/main/resources/templates/setup.html new file mode 100644 index 0000000000..aa3c2275b0 --- /dev/null +++ b/application/src/main/resources/templates/setup.html @@ -0,0 +1,125 @@ + + + + + + +
+
+ +
+

+ +
+
+ +
+ +
+

+
+ +
+ +
+ +
+

+
+ +
+ +
+ +
+

+
+ +
+ + +

+
+ +
+ + +
+ +
+ +
+
+
+ +
+
+ + +
+ diff --git a/application/src/main/resources/templates/setup.properties b/application/src/main/resources/templates/setup.properties new file mode 100644 index 0000000000..e974e8f799 --- /dev/null +++ b/application/src/main/resources/templates/setup.properties @@ -0,0 +1,9 @@ +title=系统初始化 +form.siteTitle.label=站点标题 +form.username.label=用户名 +form.email.label=电子邮箱 +form.password.label=密码 +form.confirmPassword.label=确认密码 +form.submit=初始化 +form.messages.h2.title=警告:正在使用 H2 数据库 +form.messages.h2.content=H2 数据库仅适用于开发环境和测试环境,不推荐在生产环境中使用,H2 非常容易因为操作不当导致数据文件损坏。如果必须要使用,请按时进行数据备份。 \ No newline at end of file diff --git a/application/src/main/resources/templates/setup_en.properties b/application/src/main/resources/templates/setup_en.properties new file mode 100644 index 0000000000..af468d633d --- /dev/null +++ b/application/src/main/resources/templates/setup_en.properties @@ -0,0 +1,9 @@ +title=Setup +form.siteTitle.label=Site title +form.username.label=Username +form.email.label=Email +form.password.label=Password +form.confirmPassword.label=Confirm Password +form.submit=Setup +form.messages.h2.title=Warning: Using H2 Database +form.messages.h2.content=The H2 database is only suitable for development and testing environments. It is not recommended for production environments, as H2 is very prone to data file corruption due to improper operations. If you must use it, please back up your data regularly. \ No newline at end of file diff --git a/application/src/main/resources/templates/setup_es.properties b/application/src/main/resources/templates/setup_es.properties new file mode 100644 index 0000000000..1b0c20f2df --- /dev/null +++ b/application/src/main/resources/templates/setup_es.properties @@ -0,0 +1,9 @@ +title=Configuración +form.siteTitle.label=Título del Sitio +form.username.label=Nombre de Usuario +form.email.label=Correo Electrónico +form.password.label=Contraseña +form.confirmPassword.label=Confirmar Contraseña +form.submit=Configurar +form.messages.h2.title=Advertencia: Usando la base de datos H2 +form.messages.h2.content=La base de datos H2 solo es adecuada para entornos de desarrollo y prueba. No se recomienda su uso en entornos de producción, ya que H2 es muy susceptible a la corrupción de archivos de datos debido a un manejo inadecuado. Si debe usarla, realice copias de seguridad de los datos regularmente. \ No newline at end of file diff --git a/application/src/main/resources/templates/setup_zh_TW.properties b/application/src/main/resources/templates/setup_zh_TW.properties new file mode 100644 index 0000000000..2a9b066a59 --- /dev/null +++ b/application/src/main/resources/templates/setup_zh_TW.properties @@ -0,0 +1,9 @@ +title=系統初始化 +form.siteTitle.label=站點標題 +form.username.label=使用者名稱 +form.email.label=電子郵件 +form.password.label=密碼 +form.confirmPassword.label=確認密碼 +form.submit=初始化 +form.messages.h2.title=警告:正在使用 H2 資料庫 +form.messages.h2.content=H2 資料庫僅適用於開發環境和測試環境,不建議在生產環境中使用,H2 非常容易因為操作不當導致資料檔案損壞。如果必須要使用,請按時進行資料備份。 \ No newline at end of file diff --git a/application/src/main/resources/templates/signup.html b/application/src/main/resources/templates/signup.html new file mode 100644 index 0000000000..b5295ddde4 --- /dev/null +++ b/application/src/main/resources/templates/signup.html @@ -0,0 +1,26 @@ + + + + + + + + + diff --git a/application/src/main/resources/templates/signup.properties b/application/src/main/resources/templates/signup.properties new file mode 100644 index 0000000000..3eed1c6e97 --- /dev/null +++ b/application/src/main/resources/templates/signup.properties @@ -0,0 +1 @@ +title=注册 \ No newline at end of file diff --git a/application/src/main/resources/templates/signup_en.properties b/application/src/main/resources/templates/signup_en.properties new file mode 100644 index 0000000000..6493ee3d8a --- /dev/null +++ b/application/src/main/resources/templates/signup_en.properties @@ -0,0 +1 @@ +title=Sign Up \ No newline at end of file diff --git a/application/src/main/resources/templates/signup_es.properties b/application/src/main/resources/templates/signup_es.properties new file mode 100644 index 0000000000..79e8eae797 --- /dev/null +++ b/application/src/main/resources/templates/signup_es.properties @@ -0,0 +1 @@ +title=Registrarse \ No newline at end of file diff --git a/application/src/main/resources/templates/signup_zh_TW.properties b/application/src/main/resources/templates/signup_zh_TW.properties new file mode 100644 index 0000000000..fe86a3ac8c --- /dev/null +++ b/application/src/main/resources/templates/signup_zh_TW.properties @@ -0,0 +1 @@ +title=註冊 \ No newline at end of file diff --git a/application/src/main/resources/themes/theme-earth.zip b/application/src/main/resources/themes/theme-earth.zip index aa2fcd9634..e5aa61ee31 100644 Binary files a/application/src/main/resources/themes/theme-earth.zip and b/application/src/main/resources/themes/theme-earth.zip differ diff --git a/application/src/test/java/run/halo/app/PathPrefixPredicateTest.java b/application/src/test/java/run/halo/app/PathPrefixPredicateTest.java index 1447b4dc59..6ca99b356f 100644 --- a/application/src/test/java/run/halo/app/PathPrefixPredicateTest.java +++ b/application/src/test/java/run/halo/app/PathPrefixPredicateTest.java @@ -2,6 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; +import java.net.URI; import org.junit.jupiter.api.Test; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -34,4 +35,10 @@ class TestController { } + + @Test + void urlTest() { + URI uri = URI.create("https:///path"); + System.out.println(uri); + } } diff --git a/application/src/test/java/run/halo/app/XForwardHeaderTest.java b/application/src/test/java/run/halo/app/XForwardHeaderTest.java index e0e15e9be4..5237b27904 100644 --- a/application/src/test/java/run/halo/app/XForwardHeaderTest.java +++ b/application/src/test/java/run/halo/app/XForwardHeaderTest.java @@ -17,7 +17,7 @@ import reactor.test.StepVerifier; @SpringBootTest(webEnvironment = RANDOM_PORT, - properties = "server.forward-headers-strategy=framework") + properties = "server.forward-headers-strategy=native") class XForwardHeaderTest { @LocalServerPort diff --git a/application/src/test/java/run/halo/app/config/ExtensionConfigurationTest.java b/application/src/test/java/run/halo/app/config/ExtensionConfigurationTest.java index ee0ad619e1..3c3078fc41 100644 --- a/application/src/test/java/run/halo/app/config/ExtensionConfigurationTest.java +++ b/application/src/test/java/run/halo/app/config/ExtensionConfigurationTest.java @@ -19,14 +19,14 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.reactive.server.WebTestClient; import reactor.core.publisher.Flux; import run.halo.app.core.extension.Role; -import run.halo.app.core.extension.service.RoleService; +import run.halo.app.core.user.service.RoleService; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.FakeExtension; import run.halo.app.extension.GroupVersionKind; @@ -47,7 +47,7 @@ class ExtensionConfigurationTest { @Autowired SchemeManager schemeManager; - @MockBean + @MockitoBean RoleService roleService; @BeforeEach diff --git a/application/src/test/java/run/halo/app/config/WebFluxConfigTest.java b/application/src/test/java/run/halo/app/config/WebFluxConfigTest.java index e60e2870c6..dc94b34907 100644 --- a/application/src/test/java/run/halo/app/config/WebFluxConfigTest.java +++ b/application/src/test/java/run/halo/app/config/WebFluxConfigTest.java @@ -1,6 +1,7 @@ package run.halo.app.config; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.when; import java.net.URI; @@ -9,36 +10,47 @@ import org.hamcrest.core.StringStartsWith; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.TestConfiguration; -import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.boot.test.web.server.LocalServerPort; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.web.filter.reactive.ServerWebExchangeContextFilter; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.RouterFunctions; +import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.reactive.socket.WebSocketHandler; import org.springframework.web.reactive.socket.WebSocketMessage; import org.springframework.web.reactive.socket.client.ReactorNettyWebSocketClient; import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import run.halo.app.core.endpoint.WebSocketEndpoint; import run.halo.app.core.extension.Role; -import run.halo.app.core.extension.service.RoleService; +import run.halo.app.core.user.service.RoleService; import run.halo.app.extension.GroupVersion; import run.halo.app.extension.Metadata; @SpringBootTest(properties = "halo.console.location=classpath:/console/", webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -@Import(WebFluxConfigTest.WebSocketSupportTest.TestWebSocketConfiguration.class) +@Import({ + WebFluxConfigTest.WebSocketSupportTest.TestWebSocketConfiguration.class, + WebFluxConfigTest.ServerWebExchangeContextFilterTest.TestConfig.class +}) @AutoConfigureWebTestClient class WebFluxConfigTest { @Autowired WebTestClient webClient; - @SpyBean + @MockitoSpyBean RoleService roleService; @LocalServerPort @@ -112,23 +124,32 @@ public WebSocketHandler handler() { @Nested class ConsoleRequest { + @WithMockUser + @ParameterizedTest + @ValueSource(strings = { + "/console", + "/console/index", + "/console/index.html", + "/console/dashboard", + "/console/fake" + }) + void shouldRequestConsoleIndex(String uri) { + webClient.get().uri(uri) + .exchange() + .expectStatus().isOk() + .expectBody(String.class).value(StringStartsWith.startsWith("console index")); + } + @Test - void shouldRequestConsoleIndex() { - List.of( - "/console", - "/console/index", - "/console/index.html", - "/console/dashboard", - "/console/fake" - ) - .forEach(uri -> webClient.get().uri(uri) - .exchange() - .expectStatus().isOk() - .expectBody(String.class).value(StringStartsWith.startsWith("console index")) - ); + void shouldRedirectToLoginPageIfUnauthenticated() { + webClient.get().uri("/console") + .exchange() + .expectStatus().isFound() + .expectHeader().location("/login?authentication_required"); } @Test + @WithMockUser void shouldRequestConsoleAssetsCorrectly() { webClient.get().uri("/console/assets/fake.txt") .exchange() @@ -137,6 +158,7 @@ void shouldRequestConsoleAssetsCorrectly() { } @Test + @WithMockUser void shouldResponseNotFoundWhenAssetsNotExist() { webClient.get().uri("/console/assets/not-found.txt") .exchange() @@ -154,4 +176,34 @@ void shouldRespond404WhenThemeResourceNotFound() { .expectStatus().isNotFound(); } } + + + @Nested + class ServerWebExchangeContextFilterTest { + + @TestConfiguration + static class TestConfig { + + @Bean + RouterFunction assertServerWebExchangeRoute() { + return RouterFunctions.route() + .GET("/assert-server-web-exchange", + request -> Mono.deferContextual(contextView -> { + var exchange = ServerWebExchangeContextFilter.getExchange(contextView); + assertTrue(exchange.isPresent()); + return ServerResponse.ok().build(); + })) + .build(); + } + + } + + @Test + void shouldGetExchangeFromContextView() { + webClient.get().uri("/assert-server-web-exchange") + .exchange() + .expectStatus().isOk(); + } + + } } \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/console/WebSocketServerWebExchangeMatcherTest.java b/application/src/test/java/run/halo/app/console/WebSocketServerWebExchangeMatcherTest.java index cf2944bc2c..11c40030c0 100644 --- a/application/src/test/java/run/halo/app/console/WebSocketServerWebExchangeMatcherTest.java +++ b/application/src/test/java/run/halo/app/console/WebSocketServerWebExchangeMatcherTest.java @@ -8,6 +8,7 @@ import org.springframework.mock.http.server.reactive.MockServerHttpRequest; import org.springframework.mock.web.server.MockServerWebExchange; import reactor.test.StepVerifier; +import run.halo.app.infra.console.WebSocketServerWebExchangeMatcher; class WebSocketServerWebExchangeMatcherTest { diff --git a/application/src/test/java/run/halo/app/console/WebSocketUtilsTest.java b/application/src/test/java/run/halo/app/console/WebSocketUtilsTest.java index aac0fd1bc9..225a6e91f0 100644 --- a/application/src/test/java/run/halo/app/console/WebSocketUtilsTest.java +++ b/application/src/test/java/run/halo/app/console/WebSocketUtilsTest.java @@ -6,6 +6,7 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.http.HttpHeaders; +import run.halo.app.infra.console.WebSocketUtils; class WebSocketUtilsTest { diff --git a/application/src/test/java/run/halo/app/content/CategoryPostCountUpdaterTest.java b/application/src/test/java/run/halo/app/content/CategoryPostCountUpdaterTest.java index 8b0fab57cd..7ee6fae5b1 100644 --- a/application/src/test/java/run/halo/app/content/CategoryPostCountUpdaterTest.java +++ b/application/src/test/java/run/halo/app/content/CategoryPostCountUpdaterTest.java @@ -12,8 +12,8 @@ import org.mockito.ArgumentCaptor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; @@ -46,7 +46,7 @@ class CategoryPostCountServiceIntegrationTest { @Autowired private SchemeManager schemeManager; - @SpyBean + @MockitoSpyBean private ExtensionClient client; @Autowired diff --git a/application/src/test/java/run/halo/app/content/PostIntegrationTests.java b/application/src/test/java/run/halo/app/content/PostIntegrationTests.java index 629f3bbf9d..76e301d35a 100644 --- a/application/src/test/java/run/halo/app/content/PostIntegrationTests.java +++ b/application/src/test/java/run/halo/app/content/PostIntegrationTests.java @@ -12,14 +12,14 @@ import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.reactive.server.WebTestClient; import reactor.core.publisher.Flux; import run.halo.app.core.extension.Role; import run.halo.app.core.extension.content.Post; -import run.halo.app.core.extension.service.RoleService; +import run.halo.app.core.user.service.RoleService; import run.halo.app.extension.Metadata; import run.halo.app.extension.MetadataOperator; import run.halo.app.infra.utils.JsonUtils; @@ -39,7 +39,7 @@ public class PostIntegrationTests { @Autowired private WebTestClient webTestClient; - @MockBean + @MockitoBean RoleService roleService; @BeforeEach diff --git a/application/src/test/java/run/halo/app/content/comment/CommentServiceImplIntegrationTest.java b/application/src/test/java/run/halo/app/content/comment/CommentServiceImplIntegrationTest.java index 0b16005515..ce77779e07 100644 --- a/application/src/test/java/run/halo/app/content/comment/CommentServiceImplIntegrationTest.java +++ b/application/src/test/java/run/halo/app/content/comment/CommentServiceImplIntegrationTest.java @@ -14,8 +14,8 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; @@ -48,7 +48,7 @@ class CommentRemoveTest { @Autowired private SchemeManager schemeManager; - @SpyBean + @MockitoSpyBean private ReactiveExtensionClient reactiveClient; @Autowired @@ -57,7 +57,7 @@ class CommentRemoveTest { @Autowired private IndexerFactory indexerFactory; - @SpyBean + @MockitoSpyBean private CommentServiceImpl commentService; Mono deleteImmediately(Extension extension) { diff --git a/application/src/test/java/run/halo/app/content/comment/CommentServiceImplTest.java b/application/src/test/java/run/halo/app/content/comment/CommentServiceImplTest.java index ac23f1e13b..47a14342b9 100644 --- a/application/src/test/java/run/halo/app/content/comment/CommentServiceImplTest.java +++ b/application/src/test/java/run/halo/app/content/comment/CommentServiceImplTest.java @@ -2,7 +2,6 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -12,7 +11,6 @@ import java.util.Map; import java.util.Set; import org.json.JSONException; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; @@ -31,12 +29,14 @@ import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import run.halo.app.content.TestPost; +import run.halo.app.core.counter.CounterService; +import run.halo.app.core.counter.MeterUtils; import run.halo.app.core.extension.Counter; import run.halo.app.core.extension.User; import run.halo.app.core.extension.content.Comment; import run.halo.app.core.extension.content.Post; -import run.halo.app.core.extension.service.RoleService; -import run.halo.app.core.extension.service.UserService; +import run.halo.app.core.user.service.RoleService; +import run.halo.app.core.user.service.UserService; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ListResult; import run.halo.app.extension.Metadata; @@ -46,8 +46,6 @@ import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; import run.halo.app.infra.SystemSetting; import run.halo.app.infra.utils.JsonUtils; -import run.halo.app.metrics.CounterService; -import run.halo.app.metrics.MeterUtils; import run.halo.app.plugin.extensionpoint.ExtensionGetter; import run.halo.app.security.authorization.AuthorityUtils; @@ -61,53 +59,25 @@ class CommentServiceImplTest { @Mock - private SystemConfigurableEnvironmentFetcher environmentFetcher; + SystemConfigurableEnvironmentFetcher environmentFetcher; @Mock - private ReactiveExtensionClient client; + ReactiveExtensionClient client; @Mock - private UserService userService; + UserService userService; @Mock - private RoleService roleService; + RoleService roleService; @Mock - private ExtensionGetter extensionGetter; + ExtensionGetter extensionGetter; @InjectMocks - private CommentServiceImpl commentService; + CommentServiceImpl commentService; @Mock - private CounterService counterService; - - @BeforeEach - void setUp() { - SystemSetting.Comment commentSetting = getCommentSetting(); - lenient().when(environmentFetcher.fetchComment()).thenReturn(Mono.just(commentSetting)); - - ListResult comments = new ListResult<>(1, 10, 3, comments()); - when(client.listBy(eq(Comment.class), any(ListOptions.class), any(PageRequest.class))) - .thenReturn(Mono.just(comments)); - - when(userService.getUserOrGhost(eq("A-owner"))) - .thenReturn(Mono.just(createUser("A-owner"))); - when(userService.getUserOrGhost(eq("B-owner"))) - .thenReturn(Mono.just(createUser("B-owner"))); - when(client.fetch(eq(User.class), eq("C-owner"))) - .thenReturn(Mono.empty()); - - when(roleService.contains(Set.of("USER"), - Set.of(AuthorityUtils.COMMENT_MANAGEMENT_ROLE_NAME))) - .thenReturn(Mono.just(false)); - - PostCommentSubject postCommentSubject = Mockito.mock(PostCommentSubject.class); - when(extensionGetter.getExtensions(CommentSubject.class)) - .thenReturn(Flux.just(postCommentSubject)); - - when(postCommentSubject.supports(any())).thenReturn(true); - when(postCommentSubject.get(eq("fake-post"))).thenReturn(Mono.just(post())); - } + CounterService counterService; private static User createUser(String name) { User user = new User(); @@ -122,10 +92,21 @@ private static User createUser(String name) { @Test void listComment() { + var comments = new ListResult(1, 10, 3, comments()); + when(client.listBy(eq(Comment.class), any(ListOptions.class), any(PageRequest.class))) + .thenReturn(Mono.just(comments)); + + PostCommentSubject postCommentSubject = Mockito.mock(PostCommentSubject.class); + when(extensionGetter.getExtensions(CommentSubject.class)) + .thenReturn(Flux.just(postCommentSubject)); + + when(postCommentSubject.supports(any())).thenReturn(true); + when(postCommentSubject.get(eq("fake-post"))).thenReturn(Mono.just(post())); + when(userService.getUserOrGhost(any())) .thenReturn(Mono.just(ghostUser())); - when(userService.getUserOrGhost("A-owner")) - .thenReturn(Mono.just(createUser("A-owner"))); + // when(userService.getUserOrGhost("A-owner")) + // .thenReturn(Mono.just(createUser("A-owner"))); when(userService.getUserOrGhost("B-owner")) .thenReturn(Mono.just(createUser("B-owner"))); @@ -170,6 +151,12 @@ void listComment() { @Test @WithMockUser(username = "B-owner") void create() throws JSONException { + var commentSetting = getCommentSetting(); + when(environmentFetcher.fetchComment()).thenReturn(Mono.just(commentSetting)); + when(roleService.contains(Set.of("USER"), + Set.of(AuthorityUtils.COMMENT_MANAGEMENT_ROLE_NAME))) + .thenReturn(Mono.just(false)); + CommentRequest commentRequest = new CommentRequest(); commentRequest.setRaw("fake-raw"); commentRequest.setContent("fake-content"); diff --git a/application/src/test/java/run/halo/app/content/comment/ReplyServiceImplIntegrationTest.java b/application/src/test/java/run/halo/app/content/comment/ReplyServiceImplIntegrationTest.java index c2365bdf7a..01c4c435c7 100644 --- a/application/src/test/java/run/halo/app/content/comment/ReplyServiceImplIntegrationTest.java +++ b/application/src/test/java/run/halo/app/content/comment/ReplyServiceImplIntegrationTest.java @@ -14,8 +14,8 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; @@ -56,7 +56,7 @@ private List createReplies(int size) { @Autowired private SchemeManager schemeManager; - @SpyBean + @MockitoSpyBean private ReactiveExtensionClient reactiveClient; @Autowired @@ -65,7 +65,7 @@ private List createReplies(int size) { @Autowired private IndexerFactory indexerFactory; - @SpyBean + @MockitoSpyBean private ReplyServiceImpl replyService; Mono deleteImmediately(Extension extension) { diff --git a/application/src/test/java/run/halo/app/core/attachment/PolicyConfigChangeDetectorTest.java b/application/src/test/java/run/halo/app/core/attachment/PolicyConfigChangeDetectorTest.java new file mode 100644 index 0000000000..9954a7962e --- /dev/null +++ b/application/src/test/java/run/halo/app/core/attachment/PolicyConfigChangeDetectorTest.java @@ -0,0 +1,65 @@ +package run.halo.app.core.attachment; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import run.halo.app.core.extension.attachment.Attachment; +import run.halo.app.core.extension.attachment.Policy; +import run.halo.app.extension.ConfigMap; +import run.halo.app.extension.ExtensionClient; +import run.halo.app.extension.GroupVersionKind; +import run.halo.app.extension.Metadata; +import run.halo.app.extension.controller.Reconciler; +import run.halo.app.extension.index.IndexedQueryEngine; + +/** + * Tests for {@link PolicyConfigChangeDetector}. + * + * @author guqing + * @since 2.20.0 + */ +@ExtendWith(MockitoExtension.class) +class PolicyConfigChangeDetectorTest { + + @Mock + private PolicyConfigChangeDetector.AttachmentUpdateTrigger updateTrigger; + + @Mock + private ExtensionClient client; + + @InjectMocks + private PolicyConfigChangeDetector policyConfigChangeDetector; + + @Test + void reconcileTest() { + final var spyDetector = spy(policyConfigChangeDetector); + + var configMap = new ConfigMap(); + configMap.setMetadata(new Metadata()); + configMap.getMetadata().setLabels(Map.of(Policy.POLICY_OWNER_LABEL, "fake-policy")); + + when(client.fetch(eq(ConfigMap.class), eq("fake-config"))) + .thenReturn(Optional.of(configMap)); + + var indexQueryEngine = mock(IndexedQueryEngine.class); + when(client.indexedQueryEngine()).thenReturn(indexQueryEngine); + when(indexQueryEngine.retrieveAll(eq(GroupVersionKind.fromExtension(Attachment.class)), + any(), any())).thenReturn(List.of("fake-attachment")); + + spyDetector.reconcile(new Reconciler.Request("fake-config")); + + verify(updateTrigger).addAll(List.of("fake-attachment")); + } +} diff --git a/application/src/test/java/run/halo/app/core/attachment/ThumbnailGeneratorTest.java b/application/src/test/java/run/halo/app/core/attachment/ThumbnailGeneratorTest.java index e50da39ba9..a87dca8867 100644 --- a/application/src/test/java/run/halo/app/core/attachment/ThumbnailGeneratorTest.java +++ b/application/src/test/java/run/halo/app/core/attachment/ThumbnailGeneratorTest.java @@ -4,11 +4,13 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; +import java.net.HttpURLConnection; import java.net.URL; import java.nio.file.Files; import java.nio.file.Path; @@ -57,7 +59,9 @@ void testDownloadImage_Success() throws Exception { String mockImageData = "fakeImageData"; InputStream mockInputStream = new ByteArrayInputStream(mockImageData.getBytes()); - doAnswer(invocation -> mockInputStream).when(spyImageUrl).openStream(); + var urlConnection = mock(HttpURLConnection.class); + doAnswer(invocation -> urlConnection).when(spyImageUrl).openConnection(); + doReturn(mockInputStream).when(urlConnection).getInputStream(); var path = imageDownloader.downloadFileInternal(spyImageUrl); assertThat(path).isNotNull(); @@ -81,8 +85,9 @@ void downloadImage_FileSizeLimitExceeded() throws Exception { var fileSizeByte = ThumbnailGenerator.MAX_FILE_SIZE + 10; byte[] largeImageData = new byte[fileSizeByte]; InputStream mockInputStream = new ByteArrayInputStream(largeImageData); - - doReturn(mockInputStream).when(spyImageUrl).openStream(); + var urlConnection = mock(HttpURLConnection.class); + doAnswer(invocation -> urlConnection).when(spyImageUrl).openConnection(); + doReturn(mockInputStream).when(urlConnection).getInputStream(); assertThatThrownBy(() -> imageDownloader.downloadFileInternal(spyImageUrl)) .isInstanceOf(IOException.class) .hasMessageContaining("File size exceeds the limit"); diff --git a/application/src/test/java/run/halo/app/core/attachment/impl/LocalThumbnailServiceImplTest.java b/application/src/test/java/run/halo/app/core/attachment/impl/LocalThumbnailServiceImplTest.java index 6643d3837c..54d3d2c432 100644 --- a/application/src/test/java/run/halo/app/core/attachment/impl/LocalThumbnailServiceImplTest.java +++ b/application/src/test/java/run/halo/app/core/attachment/impl/LocalThumbnailServiceImplTest.java @@ -24,7 +24,7 @@ import reactor.test.StepVerifier; import run.halo.app.core.attachment.AttachmentRootGetter; import run.halo.app.core.attachment.ThumbnailSize; -import run.halo.app.core.extension.attachment.LocalThumbnail; +import run.halo.app.core.attachment.extension.LocalThumbnail; import run.halo.app.extension.ListResult; import run.halo.app.extension.PageRequest; import run.halo.app.extension.ReactiveExtensionClient; diff --git a/application/src/test/java/run/halo/app/core/attachment/impl/ThumbnailServiceImplTest.java b/application/src/test/java/run/halo/app/core/attachment/impl/ThumbnailServiceImplTest.java index 7a22f73d4a..5461fc8742 100644 --- a/application/src/test/java/run/halo/app/core/attachment/impl/ThumbnailServiceImplTest.java +++ b/application/src/test/java/run/halo/app/core/attachment/impl/ThumbnailServiceImplTest.java @@ -29,7 +29,7 @@ import run.halo.app.core.attachment.ThumbnailProvider; import run.halo.app.core.attachment.ThumbnailSigner; import run.halo.app.core.attachment.ThumbnailSize; -import run.halo.app.core.extension.attachment.Thumbnail; +import run.halo.app.core.attachment.extension.Thumbnail; import run.halo.app.extension.ListOptions; import run.halo.app.extension.PageRequest; import run.halo.app.extension.ReactiveExtensionClient; diff --git a/application/src/test/java/run/halo/app/metrics/MeterUtilsTest.java b/application/src/test/java/run/halo/app/core/counter/MeterUtilsTest.java similarity index 99% rename from application/src/test/java/run/halo/app/metrics/MeterUtilsTest.java rename to application/src/test/java/run/halo/app/core/counter/MeterUtilsTest.java index 8c83c68573..c7d5442720 100644 --- a/application/src/test/java/run/halo/app/metrics/MeterUtilsTest.java +++ b/application/src/test/java/run/halo/app/core/counter/MeterUtilsTest.java @@ -1,4 +1,4 @@ -package run.halo.app.metrics; +package run.halo.app.core.counter; import static org.assertj.core.api.Assertions.assertThat; diff --git a/application/src/test/java/run/halo/app/core/extension/endpoint/EmailVerificationCodeTest.java b/application/src/test/java/run/halo/app/core/endpoint/console/EmailVerificationCodeTest.java similarity index 78% rename from application/src/test/java/run/halo/app/core/extension/endpoint/EmailVerificationCodeTest.java rename to application/src/test/java/run/halo/app/core/endpoint/console/EmailVerificationCodeTest.java index ee3838c103..0421bb37b2 100644 --- a/application/src/test/java/run/halo/app/core/extension/endpoint/EmailVerificationCodeTest.java +++ b/application/src/test/java/run/halo/app/core/endpoint/console/EmailVerificationCodeTest.java @@ -1,15 +1,11 @@ -package run.halo.app.core.extension.endpoint; +package run.halo.app.core.endpoint.console; import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.spy; import static org.mockito.Mockito.when; -import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.csrf; +import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.springSecurity; import io.github.resilience4j.ratelimiter.RateLimiterConfig; import io.github.resilience4j.ratelimiter.RateLimiterRegistry; -import io.github.resilience4j.reactor.ratelimiter.operator.RateLimiterOperator; import java.time.Duration; import java.util.Map; import org.junit.jupiter.api.BeforeEach; @@ -21,10 +17,11 @@ import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.junit.jupiter.SpringExtension; import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.validation.Validator; import reactor.core.publisher.Mono; import run.halo.app.core.extension.User; -import run.halo.app.core.extension.service.EmailVerificationService; -import run.halo.app.core.extension.service.UserService; +import run.halo.app.core.user.service.EmailVerificationService; +import run.halo.app.core.user.service.UserService; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; @@ -39,47 +36,52 @@ @ExtendWith(SpringExtension.class) @WithMockUser(username = "fake-user", password = "fake-password") class EmailVerificationCodeTest { + WebTestClient webClient; + @Mock ReactiveExtensionClient client; + @Mock EmailVerificationService emailVerificationService; @Mock UserService userService; + @Mock + RateLimiterRegistry rateLimiterRegistry; + + @Mock + Validator validator; + @InjectMocks UserEndpoint endpoint; @BeforeEach void setUp() { - var spyUserEndpoint = spy(endpoint); + webClient = WebTestClient.bindToRouterFunction(endpoint.endpoint()) + .apply(springSecurity()) + .build(); + } + + @Test + void sendEmailVerificationCode() { var config = RateLimiterConfig.custom() .limitRefreshPeriod(Duration.ofSeconds(10)) .limitForPeriod(1) .build(); var sendCodeRateLimiter = RateLimiterRegistry.of(config) .rateLimiter("send-email-verification-code-fake-user:hi@halo.run"); - doReturn(RateLimiterOperator.of(sendCodeRateLimiter)).when(spyUserEndpoint) - .sendEmailVerificationCodeRateLimiter(eq("fake-user"), eq("hi@halo.run")); + when(rateLimiterRegistry.rateLimiter( + "send-email-verification-code-fake-user:hi@halo.run", + "send-email-verification-code") + ).thenReturn(sendCodeRateLimiter); - var verifyEmailRateLimiter = RateLimiterRegistry.of(config) - .rateLimiter("verify-email-fake-user"); - doReturn(RateLimiterOperator.of(verifyEmailRateLimiter)).when(spyUserEndpoint) - .verificationEmailRateLimiter(eq("fake-user")); - - webClient = WebTestClient.bindToRouterFunction(spyUserEndpoint.endpoint()).build() - .mutateWith(csrf()); - } - - @Test - void sendEmailVerificationCode() { var user = new User(); user.setMetadata(new Metadata()); user.getMetadata().setName("fake-user"); user.setSpec(new User.UserSpec()); user.getSpec().setEmail("hi@halo.run"); - when(client.get(eq(User.class), eq("fake-user"))).thenReturn(Mono.just(user)); when(emailVerificationService.sendVerificationCode(anyString(), anyString())) .thenReturn(Mono.empty()); webClient.post() @@ -100,6 +102,16 @@ void sendEmailVerificationCode() { @Test void verifyEmail() { + var config = RateLimiterConfig.custom() + .limitRefreshPeriod(Duration.ofSeconds(10)) + .limitForPeriod(1) + .build(); + + var verifyEmailRateLimiter = RateLimiterRegistry.of(config) + .rateLimiter("verify-email-fake-user"); + when(rateLimiterRegistry.rateLimiter("verify-email-fake-user", "verify-email")) + .thenReturn(verifyEmailRateLimiter); + when(emailVerificationService.verify(anyString(), anyString())) .thenReturn(Mono.empty()); when(userService.confirmPassword(anyString(), anyString())) diff --git a/application/src/test/java/run/halo/app/core/extension/endpoint/PluginEndpointTest.java b/application/src/test/java/run/halo/app/core/endpoint/console/PluginEndpointTest.java similarity index 92% rename from application/src/test/java/run/halo/app/core/extension/endpoint/PluginEndpointTest.java rename to application/src/test/java/run/halo/app/core/endpoint/console/PluginEndpointTest.java index 3de56eb726..b39b1fb3de 100644 --- a/application/src/test/java/run/halo/app/core/extension/endpoint/PluginEndpointTest.java +++ b/application/src/test/java/run/halo/app/core/endpoint/console/PluginEndpointTest.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.endpoint; +package run.halo.app.core.endpoint.console; import static java.util.Objects.requireNonNull; import static org.mockito.ArgumentMatchers.any; @@ -22,6 +22,7 @@ import java.nio.file.Paths; import java.time.Instant; import java.util.List; +import java.util.Map; import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -44,7 +45,7 @@ import reactor.core.publisher.Mono; import run.halo.app.core.extension.Plugin; import run.halo.app.core.extension.Setting; -import run.halo.app.core.extension.service.PluginService; +import run.halo.app.core.user.service.SettingConfigService; import run.halo.app.extension.ConfigMap; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ListResult; @@ -53,6 +54,7 @@ import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.SystemVersionSupplier; import run.halo.app.infra.utils.FileUtils; +import run.halo.app.plugin.PluginService; @Slf4j @ExtendWith(MockitoExtension.class) @@ -67,6 +69,9 @@ class PluginEndpointTest { @Mock PluginService pluginService; + @Mock + SettingConfigService settingConfigService; + @Spy WebProperties webProperties = new WebProperties(); @@ -183,7 +188,7 @@ void setUp() throws URISyntaxException, IOException { webClient = WebTestClient.bindToRouterFunction(endpoint.endpoint()) .build(); - lenient().when(systemVersionSupplier.get()).thenReturn(Version.valueOf("0.0.0")); + lenient().when(systemVersionSupplier.get()).thenReturn(Version.parse("0.0.0")); tempDirectory = Files.createTempDirectory("halo-test-plugin-upgrade-"); plugin002 = tempDirectory.resolve("plugin-0.0.2.jar"); @@ -278,6 +283,22 @@ void updateWhenConfigMapNameMatch() { .exchange() .expectStatus().isOk(); } + + @Test + void updateJsonConfigTest() { + Plugin plugin = createPlugin("fake-plugin"); + plugin.getSpec().setConfigMapName("fake-config-map"); + + when(client.fetch(eq(Plugin.class), eq("fake-plugin"))).thenReturn(Mono.just(plugin)); + when(settingConfigService.upsertConfig(eq("fake-config-map"), any())) + .thenReturn(Mono.empty()); + + webClient.put() + .uri("/plugins/fake-plugin/json-config") + .body(Mono.just(Map.of()), Map.class) + .exchange() + .expectStatus().is2xxSuccessful(); + } } @Nested @@ -325,6 +346,23 @@ void fetchConfig() { verify(client).fetch(eq(ConfigMap.class), eq("fake-config")); verify(client).fetch(eq(Plugin.class), eq("fake")); } + + @Test + void fetchJsonConfig() { + Plugin plugin = createPlugin("fake"); + plugin.getSpec().setConfigMapName("fake-config"); + + when(settingConfigService.fetchConfig(eq("fake-config"))) + .thenReturn(Mono.empty()); + when(client.fetch(eq(Plugin.class), eq("fake"))).thenReturn(Mono.just(plugin)); + webClient.get() + .uri("/plugins/fake/json-config") + .exchange() + .expectStatus().isOk(); + + verify(settingConfigService).fetchConfig(eq("fake-config")); + verify(client).fetch(eq(Plugin.class), eq("fake")); + } } Plugin createPlugin(String name) { diff --git a/application/src/test/java/run/halo/app/core/extension/endpoint/PostEndpointTest.java b/application/src/test/java/run/halo/app/core/endpoint/console/PostEndpointTest.java similarity index 99% rename from application/src/test/java/run/halo/app/core/extension/endpoint/PostEndpointTest.java rename to application/src/test/java/run/halo/app/core/endpoint/console/PostEndpointTest.java index 7933535bdc..0d47549620 100644 --- a/application/src/test/java/run/halo/app/core/extension/endpoint/PostEndpointTest.java +++ b/application/src/test/java/run/halo/app/core/endpoint/console/PostEndpointTest.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.endpoint; +package run.halo.app.core.endpoint.console; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; diff --git a/application/src/test/java/run/halo/app/core/extension/endpoint/SinglePageEndpointTest.java b/application/src/test/java/run/halo/app/core/endpoint/console/SinglePageEndpointTest.java similarity index 98% rename from application/src/test/java/run/halo/app/core/extension/endpoint/SinglePageEndpointTest.java rename to application/src/test/java/run/halo/app/core/endpoint/console/SinglePageEndpointTest.java index 9e3fcd8734..cb911c4c25 100644 --- a/application/src/test/java/run/halo/app/core/extension/endpoint/SinglePageEndpointTest.java +++ b/application/src/test/java/run/halo/app/core/endpoint/console/SinglePageEndpointTest.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.endpoint; +package run.halo.app.core.endpoint.console; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; diff --git a/application/src/test/java/run/halo/app/core/extension/endpoint/TagEndpointTest.java b/application/src/test/java/run/halo/app/core/endpoint/console/TagEndpointTest.java similarity index 98% rename from application/src/test/java/run/halo/app/core/extension/endpoint/TagEndpointTest.java rename to application/src/test/java/run/halo/app/core/endpoint/console/TagEndpointTest.java index 1eb32141d3..c1e08dbe50 100644 --- a/application/src/test/java/run/halo/app/core/extension/endpoint/TagEndpointTest.java +++ b/application/src/test/java/run/halo/app/core/endpoint/console/TagEndpointTest.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.endpoint; +package run.halo.app.core.endpoint.console; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.same; diff --git a/application/src/test/java/run/halo/app/core/extension/endpoint/UserEndpointIntegrationTest.java b/application/src/test/java/run/halo/app/core/endpoint/console/UserEndpointIntegrationTest.java similarity index 96% rename from application/src/test/java/run/halo/app/core/extension/endpoint/UserEndpointIntegrationTest.java rename to application/src/test/java/run/halo/app/core/endpoint/console/UserEndpointIntegrationTest.java index b83f2f6db1..db061b28e4 100644 --- a/application/src/test/java/run/halo/app/core/extension/endpoint/UserEndpointIntegrationTest.java +++ b/application/src/test/java/run/halo/app/core/endpoint/console/UserEndpointIntegrationTest.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.endpoint; +package run.halo.app.core.endpoint.console; import static org.mockito.ArgumentMatchers.anySet; import static org.mockito.Mockito.when; @@ -14,15 +14,15 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.reactive.server.WebTestClient; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.core.extension.Role; import run.halo.app.core.extension.User; -import run.halo.app.core.extension.service.RoleService; +import run.halo.app.core.user.service.RoleService; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; @@ -37,7 +37,7 @@ public class UserEndpointIntegrationTest { @Autowired ReactiveExtensionClient client; - @MockBean + @MockitoBean RoleService roleService; @BeforeEach diff --git a/application/src/test/java/run/halo/app/core/extension/endpoint/UserEndpointTest.java b/application/src/test/java/run/halo/app/core/endpoint/console/UserEndpointTest.java similarity index 99% rename from application/src/test/java/run/halo/app/core/extension/endpoint/UserEndpointTest.java rename to application/src/test/java/run/halo/app/core/endpoint/console/UserEndpointTest.java index 655c9370c2..b762262586 100644 --- a/application/src/test/java/run/halo/app/core/extension/endpoint/UserEndpointTest.java +++ b/application/src/test/java/run/halo/app/core/endpoint/console/UserEndpointTest.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.endpoint; +package run.halo.app.core.endpoint.console; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; @@ -37,8 +37,8 @@ import run.halo.app.core.extension.User; import run.halo.app.core.extension.attachment.Attachment; import run.halo.app.core.extension.service.AttachmentService; -import run.halo.app.core.extension.service.RoleService; -import run.halo.app.core.extension.service.UserService; +import run.halo.app.core.user.service.RoleService; +import run.halo.app.core.user.service.UserService; import run.halo.app.extension.ListResult; import run.halo.app.extension.Metadata; import run.halo.app.extension.PageRequest; diff --git a/application/src/test/java/run/halo/app/theme/endpoint/CategoryQueryEndpointTest.java b/application/src/test/java/run/halo/app/core/endpoint/theme/CategoryQueryEndpointTest.java similarity index 98% rename from application/src/test/java/run/halo/app/theme/endpoint/CategoryQueryEndpointTest.java rename to application/src/test/java/run/halo/app/core/endpoint/theme/CategoryQueryEndpointTest.java index 9ba60c6647..2f19d170e9 100644 --- a/application/src/test/java/run/halo/app/theme/endpoint/CategoryQueryEndpointTest.java +++ b/application/src/test/java/run/halo/app/core/endpoint/theme/CategoryQueryEndpointTest.java @@ -1,4 +1,4 @@ -package run.halo.app.theme.endpoint; +package run.halo.app.core.endpoint.theme; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; diff --git a/application/src/test/java/run/halo/app/theme/endpoint/CommentFinderEndpointTest.java b/application/src/test/java/run/halo/app/core/endpoint/theme/CommentFinderEndpointTest.java similarity index 99% rename from application/src/test/java/run/halo/app/theme/endpoint/CommentFinderEndpointTest.java rename to application/src/test/java/run/halo/app/core/endpoint/theme/CommentFinderEndpointTest.java index 997b4d3c8d..80f1bc01d2 100644 --- a/application/src/test/java/run/halo/app/theme/endpoint/CommentFinderEndpointTest.java +++ b/application/src/test/java/run/halo/app/core/endpoint/theme/CommentFinderEndpointTest.java @@ -1,4 +1,4 @@ -package run.halo.app.theme.endpoint; +package run.halo.app.core.endpoint.theme; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; diff --git a/application/src/test/java/run/halo/app/theme/endpoint/MenuQueryEndpointTest.java b/application/src/test/java/run/halo/app/core/endpoint/theme/MenuQueryEndpointTest.java similarity index 98% rename from application/src/test/java/run/halo/app/theme/endpoint/MenuQueryEndpointTest.java rename to application/src/test/java/run/halo/app/core/endpoint/theme/MenuQueryEndpointTest.java index a6dbb503ae..27e1c8e5df 100644 --- a/application/src/test/java/run/halo/app/theme/endpoint/MenuQueryEndpointTest.java +++ b/application/src/test/java/run/halo/app/core/endpoint/theme/MenuQueryEndpointTest.java @@ -1,4 +1,4 @@ -package run.halo.app.theme.endpoint; +package run.halo.app.core.endpoint.theme; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.eq; diff --git a/application/src/test/java/run/halo/app/theme/endpoint/PluginQueryEndpointTest.java b/application/src/test/java/run/halo/app/core/endpoint/theme/PluginQueryEndpointTest.java similarity index 97% rename from application/src/test/java/run/halo/app/theme/endpoint/PluginQueryEndpointTest.java rename to application/src/test/java/run/halo/app/core/endpoint/theme/PluginQueryEndpointTest.java index 739b92ca9c..4b60a60918 100644 --- a/application/src/test/java/run/halo/app/theme/endpoint/PluginQueryEndpointTest.java +++ b/application/src/test/java/run/halo/app/core/endpoint/theme/PluginQueryEndpointTest.java @@ -1,4 +1,4 @@ -package run.halo.app.theme.endpoint; +package run.halo.app.core.endpoint.theme; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.when; diff --git a/application/src/test/java/run/halo/app/theme/endpoint/PostQueryEndpointTest.java b/application/src/test/java/run/halo/app/core/endpoint/theme/PostQueryEndpointTest.java similarity index 98% rename from application/src/test/java/run/halo/app/theme/endpoint/PostQueryEndpointTest.java rename to application/src/test/java/run/halo/app/core/endpoint/theme/PostQueryEndpointTest.java index f39f1396b3..94a984f469 100644 --- a/application/src/test/java/run/halo/app/theme/endpoint/PostQueryEndpointTest.java +++ b/application/src/test/java/run/halo/app/core/endpoint/theme/PostQueryEndpointTest.java @@ -1,4 +1,4 @@ -package run.halo.app.theme.endpoint; +package run.halo.app.core.endpoint.theme; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; diff --git a/application/src/test/java/run/halo/app/theme/endpoint/PublicApiUtilsTest.java b/application/src/test/java/run/halo/app/core/endpoint/theme/PublicApiUtilsTest.java similarity index 97% rename from application/src/test/java/run/halo/app/theme/endpoint/PublicApiUtilsTest.java rename to application/src/test/java/run/halo/app/core/endpoint/theme/PublicApiUtilsTest.java index 56979ed7d2..f7b32b72ef 100644 --- a/application/src/test/java/run/halo/app/theme/endpoint/PublicApiUtilsTest.java +++ b/application/src/test/java/run/halo/app/core/endpoint/theme/PublicApiUtilsTest.java @@ -1,4 +1,4 @@ -package run.halo.app.theme.endpoint; +package run.halo.app.core.endpoint.theme; import static org.assertj.core.api.Assertions.assertThat; diff --git a/application/src/test/java/run/halo/app/theme/endpoint/SinglePageQueryEndpointTest.java b/application/src/test/java/run/halo/app/core/endpoint/theme/SinglePageQueryEndpointTest.java similarity index 98% rename from application/src/test/java/run/halo/app/theme/endpoint/SinglePageQueryEndpointTest.java rename to application/src/test/java/run/halo/app/core/endpoint/theme/SinglePageQueryEndpointTest.java index 4b56b11653..83ebb9ebf3 100644 --- a/application/src/test/java/run/halo/app/theme/endpoint/SinglePageQueryEndpointTest.java +++ b/application/src/test/java/run/halo/app/core/endpoint/theme/SinglePageQueryEndpointTest.java @@ -1,4 +1,4 @@ -package run.halo.app.theme.endpoint; +package run.halo.app.core.endpoint.theme; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.eq; diff --git a/application/src/test/java/run/halo/app/theme/endpoint/ThumbnailEndpointTest.java b/application/src/test/java/run/halo/app/core/endpoint/theme/ThumbnailEndpointTest.java similarity index 97% rename from application/src/test/java/run/halo/app/theme/endpoint/ThumbnailEndpointTest.java rename to application/src/test/java/run/halo/app/core/endpoint/theme/ThumbnailEndpointTest.java index 18f3414e82..6d02dac6c7 100644 --- a/application/src/test/java/run/halo/app/theme/endpoint/ThumbnailEndpointTest.java +++ b/application/src/test/java/run/halo/app/core/endpoint/theme/ThumbnailEndpointTest.java @@ -1,4 +1,4 @@ -package run.halo.app.theme.endpoint; +package run.halo.app.core.endpoint.theme; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; diff --git a/application/src/test/java/run/halo/app/core/extension/attachment/endpoint/AttachmentEndpointTest.java b/application/src/test/java/run/halo/app/core/extension/attachment/endpoint/AttachmentEndpointTest.java index 6e95e5e79d..bd0f77d2b0 100644 --- a/application/src/test/java/run/halo/app/core/extension/attachment/endpoint/AttachmentEndpointTest.java +++ b/application/src/test/java/run/halo/app/core/extension/attachment/endpoint/AttachmentEndpointTest.java @@ -1,8 +1,6 @@ package run.halo.app.core.extension.attachment.endpoint; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.same; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -19,7 +17,6 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.core.io.buffer.DataBuffer; -import org.springframework.data.domain.Sort; import org.springframework.http.HttpStatusCode; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -28,15 +25,15 @@ import org.springframework.web.reactive.function.BodyInserters; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import run.halo.app.core.attachment.AttachmentLister; +import run.halo.app.core.attachment.endpoint.AttachmentEndpoint; import run.halo.app.core.extension.attachment.Attachment; -import run.halo.app.core.extension.attachment.Group; import run.halo.app.core.extension.attachment.Policy; import run.halo.app.core.extension.attachment.Policy.PolicySpec; -import run.halo.app.core.extension.service.impl.DefaultAttachmentService; +import run.halo.app.core.user.service.impl.DefaultAttachmentService; import run.halo.app.extension.ConfigMap; import run.halo.app.extension.ListResult; import run.halo.app.extension.Metadata; -import run.halo.app.extension.PageRequest; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.ReactiveUrlDataBufferFetcher; import run.halo.app.plugin.extensionpoint.ExtensionGetter; @@ -53,6 +50,9 @@ class AttachmentEndpointTest { @Mock ReactiveUrlDataBufferFetcher dataBufferFetcher; + @Mock + AttachmentLister attachmentLister; + AttachmentEndpoint endpoint; WebTestClient webClient; @@ -61,7 +61,7 @@ class AttachmentEndpointTest { void setUp() { var attachmentService = new DefaultAttachmentService(client, extensionGetter, dataBufferFetcher); - endpoint = new AttachmentEndpoint(attachmentService, client); + endpoint = new AttachmentEndpoint(attachmentService, attachmentLister); webClient = WebTestClient.bindToRouterFunction(endpoint.endpoint()) .apply(springSecurity()) .build(); @@ -239,11 +239,7 @@ class SearchTest { @Test void shouldListUngroupedAttachments() { - when(client.listAll(eq(Group.class), any(), any(Sort.class))) - .thenReturn(Flux.empty()); - - when(client.listBy(same(Attachment.class), any(), any(PageRequest.class))) - .thenReturn(Mono.just(ListResult.emptyResult())); + when(attachmentLister.listBy(any())).thenReturn(Mono.just(ListResult.emptyResult())); webClient .get() @@ -256,11 +252,7 @@ void shouldListUngroupedAttachments() { @Test void searchAttachmentWhenGroupIsEmpty() { - when(client.listAll(eq(Group.class), any(), any(Sort.class))) - .thenReturn(Flux.empty()); - - when(client.listBy(eq(Attachment.class), any(), any(PageRequest.class))) - .thenReturn(Mono.empty()); + when(attachmentLister.listBy(any())).thenReturn(Mono.just(ListResult.emptyResult())); webClient .get() @@ -268,7 +260,7 @@ void searchAttachmentWhenGroupIsEmpty() { .exchange() .expectStatus().isOk(); - verify(client).listBy(eq(Attachment.class), any(), any(PageRequest.class)); + verify(attachmentLister).listBy(any()); } } diff --git a/application/src/test/java/run/halo/app/core/extension/endpoint/SystemInitializationEndpointTest.java b/application/src/test/java/run/halo/app/core/extension/endpoint/SystemInitializationEndpointTest.java deleted file mode 100644 index 82cf8a24df..0000000000 --- a/application/src/test/java/run/halo/app/core/extension/endpoint/SystemInitializationEndpointTest.java +++ /dev/null @@ -1,89 +0,0 @@ -package run.halo.app.core.extension.endpoint; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; -import static org.springframework.test.web.reactive.server.WebTestClient.bindToRouterFunction; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.test.web.reactive.server.WebTestClient; -import reactor.core.publisher.Mono; -import run.halo.app.core.extension.endpoint.SystemInitializationEndpoint.SystemInitializationRequest; -import run.halo.app.extension.ConfigMap; -import run.halo.app.extension.ReactiveExtensionClient; -import run.halo.app.infra.InitializationStateGetter; -import run.halo.app.infra.SystemSetting; -import run.halo.app.security.SuperAdminInitializer; -import run.halo.app.security.SuperAdminInitializer.InitializationParam; - -/** - * Tests for {@link SystemInitializationEndpoint}. - * - * @author guqing - * @since 2.9.0 - */ -@ExtendWith(MockitoExtension.class) -class SystemInitializationEndpointTest { - - @Mock - InitializationStateGetter initializationStateGetter; - - @Mock - SuperAdminInitializer superAdminInitializer; - - @Mock - ReactiveExtensionClient client; - - @InjectMocks - SystemInitializationEndpoint initializationEndpoint; - - WebTestClient webTestClient; - - @BeforeEach - void setUp() { - webTestClient = bindToRouterFunction(initializationEndpoint.endpoint()).build(); - } - - @Test - void initializeWithoutRequestBody() { - webTestClient.post() - .uri("/system/initialize") - .exchange() - .expectStatus() - .isBadRequest(); - } - - @Test - void initializeWithRequestBody() { - var initialization = new SystemInitializationRequest(); - initialization.setUsername("faker"); - initialization.setPassword("openfaker"); - initialization.setEmail("faker@halo.run"); - initialization.setSiteTitle("Fake Site"); - - when(initializationStateGetter.userInitialized()).thenReturn(Mono.just(false)); - when(superAdminInitializer.initialize(any(InitializationParam.class))) - .thenReturn(Mono.empty()); - - var configMap = new ConfigMap(); - when(client.get(ConfigMap.class, SystemSetting.SYSTEM_CONFIG)) - .thenReturn(Mono.just(configMap)); - when(client.update(configMap)).thenReturn(Mono.just(configMap)); - - webTestClient.post().uri("/system/initialize") - .bodyValue(initialization) - .exchange() - .expectStatus().isCreated() - .expectHeader().location("/console"); - - verify(initializationStateGetter).userInitialized(); - verify(superAdminInitializer).initialize(any()); - verify(client).get(ConfigMap.class, SystemSetting.SYSTEM_CONFIG); - verify(client).update(configMap); - } -} diff --git a/application/src/test/java/run/halo/app/core/extension/service/impl/EmailPasswordRecoveryServiceImplTest.java b/application/src/test/java/run/halo/app/core/extension/service/impl/EmailPasswordRecoveryServiceImplTest.java deleted file mode 100644 index 1d95fced07..0000000000 --- a/application/src/test/java/run/halo/app/core/extension/service/impl/EmailPasswordRecoveryServiceImplTest.java +++ /dev/null @@ -1,83 +0,0 @@ -package run.halo.app.core.extension.service.impl; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.junit.jupiter.MockitoExtension; -import run.halo.app.infra.exception.RateLimitExceededException; - -/** - * Tests for {@link EmailPasswordRecoveryServiceImpl}. - * - * @author guqing - * @since 2.11.0 - */ -@ExtendWith(MockitoExtension.class) -class EmailPasswordRecoveryServiceImplTest { - - @Nested - class ResetPasswordVerificationManagerTest { - @Test - public void generateTokenTest() { - var verificationManager = - new EmailPasswordRecoveryServiceImpl.ResetPasswordVerificationManager(); - verificationManager.generateToken("fake-user"); - var result = verificationManager.contains("fake-user"); - assertThat(result).isTrue(); - - verificationManager.generateToken("guqing"); - result = verificationManager.contains("guqing"); - assertThat(result).isTrue(); - - result = verificationManager.contains("123"); - assertThat(result).isFalse(); - } - } - - @Test - public void removeTest() { - var verificationManager = - new EmailPasswordRecoveryServiceImpl.ResetPasswordVerificationManager(); - verificationManager.generateToken("fake-user"); - var result = verificationManager.contains("fake-user"); - - verificationManager.removeToken("fake-user"); - result = verificationManager.contains("fake-user"); - assertThat(result).isFalse(); - } - - @Test - void verifyTokenTestNormal() { - String username = "guqing"; - var verificationManager = - new EmailPasswordRecoveryServiceImpl.ResetPasswordVerificationManager(); - var result = verificationManager.verifyToken(username, "fake-code"); - assertThat(result).isFalse(); - - var token = verificationManager.generateToken(username); - result = verificationManager.verifyToken(username, "fake-code"); - assertThat(result).isFalse(); - - result = verificationManager.verifyToken(username, token); - assertThat(result).isTrue(); - } - - @Test - void verifyTokenFailedAfterMaxAttempts() { - String username = "guqing"; - var verificationManager = - new EmailPasswordRecoveryServiceImpl.ResetPasswordVerificationManager(); - var token = verificationManager.generateToken(username); - for (int i = 0; i <= EmailPasswordRecoveryServiceImpl.MAX_ATTEMPTS; i++) { - var result = verificationManager.verifyToken(username, "fake-code"); - assertThat(result).isFalse(); - } - - assertThatThrownBy(() -> verificationManager.verifyToken(username, token)) - .isInstanceOf(RateLimitExceededException.class) - .hasMessage("429 TOO_MANY_REQUESTS \"You have exceeded your quota\""); - } -} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/core/extension/reconciler/CommentReconcilerTest.java b/application/src/test/java/run/halo/app/core/reconciler/CommentReconcilerTest.java similarity index 97% rename from application/src/test/java/run/halo/app/core/extension/reconciler/CommentReconcilerTest.java rename to application/src/test/java/run/halo/app/core/reconciler/CommentReconcilerTest.java index 1f6af9ec04..3a1e0bd605 100644 --- a/application/src/test/java/run/halo/app/core/extension/reconciler/CommentReconcilerTest.java +++ b/application/src/test/java/run/halo/app/core/reconciler/CommentReconcilerTest.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.reconciler; +package run.halo.app.core.reconciler; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -21,6 +21,7 @@ import reactor.core.publisher.Mono; import run.halo.app.content.comment.ReplyService; import run.halo.app.core.extension.content.Comment; +import run.halo.app.core.reconciler.CommentReconciler; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ListResult; diff --git a/application/src/test/java/run/halo/app/core/extension/reconciler/MenuItemReconcilerTest.java b/application/src/test/java/run/halo/app/core/reconciler/MenuItemReconcilerTest.java similarity index 98% rename from application/src/test/java/run/halo/app/core/extension/reconciler/MenuItemReconcilerTest.java rename to application/src/test/java/run/halo/app/core/reconciler/MenuItemReconcilerTest.java index aec1993265..ece57b13ac 100644 --- a/application/src/test/java/run/halo/app/core/extension/reconciler/MenuItemReconcilerTest.java +++ b/application/src/test/java/run/halo/app/core/reconciler/MenuItemReconcilerTest.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.reconciler; +package run.halo.app.core.reconciler; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -24,6 +24,7 @@ import run.halo.app.core.extension.MenuItem.MenuItemSpec; import run.halo.app.core.extension.content.Category; import run.halo.app.core.extension.content.SinglePage; +import run.halo.app.core.reconciler.MenuItemReconciler; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.Metadata; import run.halo.app.extension.Ref; diff --git a/application/src/test/java/run/halo/app/core/extension/reconciler/PluginReconcilerTest.java b/application/src/test/java/run/halo/app/core/reconciler/PluginReconcilerTest.java similarity index 99% rename from application/src/test/java/run/halo/app/core/extension/reconciler/PluginReconcilerTest.java rename to application/src/test/java/run/halo/app/core/reconciler/PluginReconcilerTest.java index 92652fb542..a22492cfcd 100644 --- a/application/src/test/java/run/halo/app/core/extension/reconciler/PluginReconcilerTest.java +++ b/application/src/test/java/run/halo/app/core/reconciler/PluginReconcilerTest.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.reconciler; +package run.halo.app.core.reconciler; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -48,6 +48,7 @@ import run.halo.app.core.extension.Plugin; import run.halo.app.core.extension.ReverseProxy; import run.halo.app.core.extension.Setting; +import run.halo.app.core.reconciler.PluginReconciler; import run.halo.app.extension.ConfigMap; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.Metadata; diff --git a/application/src/test/java/run/halo/app/core/extension/reconciler/PostReconcilerTest.java b/application/src/test/java/run/halo/app/core/reconciler/PostReconcilerTest.java similarity index 99% rename from application/src/test/java/run/halo/app/core/extension/reconciler/PostReconcilerTest.java rename to application/src/test/java/run/halo/app/core/reconciler/PostReconcilerTest.java index b701523537..3dd75e5164 100644 --- a/application/src/test/java/run/halo/app/core/extension/reconciler/PostReconcilerTest.java +++ b/application/src/test/java/run/halo/app/core/reconciler/PostReconcilerTest.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.reconciler; +package run.halo.app.core.reconciler; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -33,6 +33,7 @@ import run.halo.app.core.extension.content.Post; import run.halo.app.core.extension.content.Snapshot; import run.halo.app.core.extension.notification.Subscription; +import run.halo.app.core.reconciler.PostReconciler; import run.halo.app.event.post.PostPublishedEvent; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.controller.Reconciler; diff --git a/application/src/test/java/run/halo/app/core/extension/reconciler/ReverseProxyReconcilerTest.java b/application/src/test/java/run/halo/app/core/reconciler/ReverseProxyReconcilerTest.java similarity index 95% rename from application/src/test/java/run/halo/app/core/extension/reconciler/ReverseProxyReconcilerTest.java rename to application/src/test/java/run/halo/app/core/reconciler/ReverseProxyReconcilerTest.java index 0f88e2392d..30b22eca53 100644 --- a/application/src/test/java/run/halo/app/core/extension/reconciler/ReverseProxyReconcilerTest.java +++ b/application/src/test/java/run/halo/app/core/reconciler/ReverseProxyReconcilerTest.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.reconciler; +package run.halo.app.core.reconciler; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; @@ -19,6 +19,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import run.halo.app.core.extension.ReverseProxy; +import run.halo.app.core.reconciler.ReverseProxyReconciler; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.Metadata; import run.halo.app.extension.controller.Reconciler; diff --git a/application/src/test/java/run/halo/app/core/extension/reconciler/SinglePageReconcilerTest.java b/application/src/test/java/run/halo/app/core/reconciler/SinglePageReconcilerTest.java similarity index 99% rename from application/src/test/java/run/halo/app/core/extension/reconciler/SinglePageReconcilerTest.java rename to application/src/test/java/run/halo/app/core/reconciler/SinglePageReconcilerTest.java index 486efadadc..8e2c7ac1df 100644 --- a/application/src/test/java/run/halo/app/core/extension/reconciler/SinglePageReconcilerTest.java +++ b/application/src/test/java/run/halo/app/core/reconciler/SinglePageReconcilerTest.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.reconciler; +package run.halo.app.core.reconciler; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -30,6 +30,7 @@ import run.halo.app.content.NotificationReasonConst; import run.halo.app.content.SinglePageService; import run.halo.app.content.TestPost; +import run.halo.app.core.counter.CounterService; import run.halo.app.core.extension.content.Post; import run.halo.app.core.extension.content.SinglePage; import run.halo.app.core.extension.content.Snapshot; @@ -38,7 +39,6 @@ import run.halo.app.extension.Metadata; import run.halo.app.extension.controller.Reconciler; import run.halo.app.infra.ExternalUrlSupplier; -import run.halo.app.metrics.CounterService; import run.halo.app.notification.NotificationCenter; import run.halo.app.plugin.extensionpoint.ExtensionGetter; diff --git a/application/src/test/java/run/halo/app/core/extension/reconciler/SystemSettingReconcilerTest.java b/application/src/test/java/run/halo/app/core/reconciler/SystemSettingReconcilerTest.java similarity index 98% rename from application/src/test/java/run/halo/app/core/extension/reconciler/SystemSettingReconcilerTest.java rename to application/src/test/java/run/halo/app/core/reconciler/SystemSettingReconcilerTest.java index 61edc6aa43..24a1518bdf 100644 --- a/application/src/test/java/run/halo/app/core/extension/reconciler/SystemSettingReconcilerTest.java +++ b/application/src/test/java/run/halo/app/core/reconciler/SystemSettingReconcilerTest.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.reconciler; +package run.halo.app.core.reconciler; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -18,6 +18,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationContext; +import run.halo.app.core.reconciler.SystemSettingReconciler; import run.halo.app.extension.ConfigMap; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.Metadata; diff --git a/application/src/test/java/run/halo/app/core/extension/reconciler/TagReconcilerTest.java b/application/src/test/java/run/halo/app/core/reconciler/TagReconcilerTest.java similarity index 97% rename from application/src/test/java/run/halo/app/core/extension/reconciler/TagReconcilerTest.java rename to application/src/test/java/run/halo/app/core/reconciler/TagReconcilerTest.java index d88fe3ca9f..522e76c3fe 100644 --- a/application/src/test/java/run/halo/app/core/extension/reconciler/TagReconcilerTest.java +++ b/application/src/test/java/run/halo/app/core/reconciler/TagReconcilerTest.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.reconciler; +package run.halo.app.core.reconciler; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -18,6 +18,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import run.halo.app.content.permalinks.TagPermalinkPolicy; import run.halo.app.core.extension.content.Tag; +import run.halo.app.core.reconciler.TagReconciler; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.Metadata; diff --git a/application/src/test/java/run/halo/app/core/extension/reconciler/ThemeReconcilerTest.java b/application/src/test/java/run/halo/app/core/reconciler/ThemeReconcilerTest.java similarity index 98% rename from application/src/test/java/run/halo/app/core/extension/reconciler/ThemeReconcilerTest.java rename to application/src/test/java/run/halo/app/core/reconciler/ThemeReconcilerTest.java index b18f928a8b..ec6eea8533 100644 --- a/application/src/test/java/run/halo/app/core/extension/reconciler/ThemeReconcilerTest.java +++ b/application/src/test/java/run/halo/app/core/reconciler/ThemeReconcilerTest.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.reconciler; +package run.halo.app.core.reconciler; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; @@ -35,6 +35,7 @@ import run.halo.app.core.extension.AnnotationSetting; import run.halo.app.core.extension.Setting; import run.halo.app.core.extension.Theme; +import run.halo.app.core.reconciler.ThemeReconciler; import run.halo.app.extension.ConfigMap; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.Metadata; @@ -74,7 +75,7 @@ class ThemeReconcilerTest { @BeforeEach void setUp() throws IOException { defaultTheme = ResourceUtils.getFile("classpath:themes/default"); - lenient().when(systemVersionSupplier.get()).thenReturn(Version.valueOf("0.0.0")); + lenient().when(systemVersionSupplier.get()).thenReturn(Version.parse("0.0.0")); } @Test @@ -190,7 +191,7 @@ void reconcileDeleteRetryWhenThrowException() { @Test void reconcileStatus() { - when(systemVersionSupplier.get()).thenReturn(Version.valueOf("2.3.0")); + when(systemVersionSupplier.get()).thenReturn(Version.parse("2.3.0")); Path testWorkDir = tempDirectory.resolve("reconcile-delete"); when(themeRoot.get()).thenReturn(testWorkDir); diff --git a/application/src/test/java/run/halo/app/core/extension/reconciler/UserReconcilerTest.java b/application/src/test/java/run/halo/app/core/reconciler/UserReconcilerTest.java similarity index 96% rename from application/src/test/java/run/halo/app/core/extension/reconciler/UserReconcilerTest.java rename to application/src/test/java/run/halo/app/core/reconciler/UserReconcilerTest.java index f54997d698..7aca878e9e 100644 --- a/application/src/test/java/run/halo/app/core/extension/reconciler/UserReconcilerTest.java +++ b/application/src/test/java/run/halo/app/core/reconciler/UserReconcilerTest.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.reconciler; +package run.halo.app.core.reconciler; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; @@ -21,7 +21,8 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.core.extension.User; -import run.halo.app.core.extension.service.RoleService; +import run.halo.app.core.reconciler.UserReconciler; +import run.halo.app.core.user.service.RoleService; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.Metadata; import run.halo.app.extension.controller.Reconciler; diff --git a/application/src/test/java/run/halo/app/core/extension/service/DefaultRoleServiceTest.java b/application/src/test/java/run/halo/app/core/user/service/DefaultRoleServiceTest.java similarity index 99% rename from application/src/test/java/run/halo/app/core/extension/service/DefaultRoleServiceTest.java rename to application/src/test/java/run/halo/app/core/user/service/DefaultRoleServiceTest.java index 2498b0e105..ccd13f203c 100644 --- a/application/src/test/java/run/halo/app/core/extension/service/DefaultRoleServiceTest.java +++ b/application/src/test/java/run/halo/app/core/user/service/DefaultRoleServiceTest.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.service; +package run.halo.app.core.user.service; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.same; @@ -29,6 +29,7 @@ import reactor.test.StepVerifier; import run.halo.app.core.extension.Role; import run.halo.app.core.extension.RoleBinding; +import run.halo.app.core.user.service.DefaultRoleService; import run.halo.app.extension.ListOptions; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; diff --git a/application/src/test/java/run/halo/app/core/user/service/impl/EmailPasswordRecoveryServiceImplTest.java b/application/src/test/java/run/halo/app/core/user/service/impl/EmailPasswordRecoveryServiceImplTest.java new file mode 100644 index 0000000000..589312724e --- /dev/null +++ b/application/src/test/java/run/halo/app/core/user/service/impl/EmailPasswordRecoveryServiceImplTest.java @@ -0,0 +1,15 @@ +package run.halo.app.core.user.service.impl; + +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +/** + * Tests for {@link EmailPasswordRecoveryServiceImpl}. + * + * @author guqing + * @since 2.11.0 + */ +@ExtendWith(MockitoExtension.class) +class EmailPasswordRecoveryServiceImplTest { + +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/core/extension/service/impl/EmailVerificationServiceImplTest.java b/application/src/test/java/run/halo/app/core/user/service/impl/EmailVerificationServiceImplTest.java similarity index 94% rename from application/src/test/java/run/halo/app/core/extension/service/impl/EmailVerificationServiceImplTest.java rename to application/src/test/java/run/halo/app/core/user/service/impl/EmailVerificationServiceImplTest.java index 81a99bf028..ab3f3369d9 100644 --- a/application/src/test/java/run/halo/app/core/extension/service/impl/EmailVerificationServiceImplTest.java +++ b/application/src/test/java/run/halo/app/core/user/service/impl/EmailVerificationServiceImplTest.java @@ -1,13 +1,14 @@ -package run.halo.app.core.extension.service.impl; +package run.halo.app.core.user.service.impl; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static run.halo.app.core.extension.service.impl.EmailVerificationServiceImpl.MAX_ATTEMPTS; +import static run.halo.app.core.user.service.impl.EmailVerificationServiceImpl.MAX_ATTEMPTS; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.junit.jupiter.MockitoExtension; +import run.halo.app.core.user.service.impl.EmailVerificationServiceImpl; import run.halo.app.infra.exception.EmailVerificationFailed; /** diff --git a/application/src/test/java/run/halo/app/core/extension/service/UserServiceImplTest.java b/application/src/test/java/run/halo/app/core/user/service/impl/UserServiceImplTest.java similarity index 75% rename from application/src/test/java/run/halo/app/core/extension/service/UserServiceImplTest.java rename to application/src/test/java/run/halo/app/core/user/service/impl/UserServiceImplTest.java index 8b63e0435c..9b1a5f5571 100644 --- a/application/src/test/java/run/halo/app/core/extension/service/UserServiceImplTest.java +++ b/application/src/test/java/run/halo/app/core/user/service/impl/UserServiceImplTest.java @@ -1,11 +1,14 @@ -package run.halo.app.core.extension.service; +package run.halo.app.core.user.service.impl; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anySet; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.assertArg; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isA; import static org.mockito.Mockito.doReturn; @@ -17,6 +20,7 @@ import static org.mockito.Mockito.when; import static run.halo.app.extension.GroupVersionKind.fromExtension; +import java.util.HashMap; import java.util.Set; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -27,6 +31,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationEventPublisher; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.web.server.ServerWebInputException; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; @@ -34,15 +39,20 @@ import run.halo.app.core.extension.RoleBinding; import run.halo.app.core.extension.RoleBinding.Subject; import run.halo.app.core.extension.User; +import run.halo.app.core.user.service.RoleService; +import run.halo.app.core.user.service.SignUpData; +import run.halo.app.core.user.service.UserPostCreatingHandler; +import run.halo.app.core.user.service.UserPreCreatingHandler; import run.halo.app.event.user.PasswordChangedEvent; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.exception.ExtensionNotFoundException; import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; import run.halo.app.infra.SystemSetting; -import run.halo.app.infra.exception.AccessDeniedException; import run.halo.app.infra.exception.DuplicateNameException; +import run.halo.app.infra.exception.UnsatisfiedAttributeValueException; import run.halo.app.infra.exception.UserNotFoundException; +import run.halo.app.plugin.extensionpoint.ExtensionGetter; @ExtendWith(MockitoExtension.class) class UserServiceImplTest { @@ -62,6 +72,9 @@ class UserServiceImplTest { @Mock RoleService roleService; + @Mock + ExtensionGetter extensionGetter; + @InjectMocks UserServiceImpl userService; @@ -114,25 +127,25 @@ class UpdateWithRawPasswordTest { @Test void shouldUpdatePasswordWithDifferentPassword() { - var oldUser = createUser("fake-password"); - var newUser = createUser("new-password"); + var oldUser = createUser("fake@password"); + var newUser = createUser("new@password"); when(client.get(User.class, "fake-user")).thenReturn( Mono.just(oldUser)); when(client.update(eq(oldUser))).thenReturn(Mono.just(newUser)); - when(passwordEncoder.matches("new-password", "fake-password")).thenReturn(false); - when(passwordEncoder.encode("new-password")).thenReturn("encoded-new-password"); + when(passwordEncoder.matches("new@password", "fake@password")).thenReturn(false); + when(passwordEncoder.encode("new@password")).thenReturn("encoded@new@password"); - StepVerifier.create(userService.updateWithRawPassword("fake-user", "new-password")) + StepVerifier.create(userService.updateWithRawPassword("fake-user", "new@password")) .expectNext(newUser) .verifyComplete(); - verify(passwordEncoder).matches("new-password", "fake-password"); - verify(passwordEncoder).encode("new-password"); + verify(passwordEncoder).matches("new@password", "fake@password"); + verify(passwordEncoder).encode("new@password"); verify(client).get(User.class, "fake-user"); verify(client).update(argThat(extension -> { var user = (User) extension; - return "encoded-new-password".equals(user.getSpec().getPassword()); + return "encoded@new@password".equals(user.getSpec().getPassword()); })); verify(eventPublisher).publishEvent(any(PasswordChangedEvent.class)); } @@ -140,21 +153,21 @@ void shouldUpdatePasswordWithDifferentPassword() { @Test void shouldUpdatePasswordIfNoPasswordBefore() { var oldUser = createUser(null); - var newUser = createUser("new-password"); + var newUser = createUser("new@password"); when(client.get(User.class, "fake-user")).thenReturn(Mono.just(oldUser)); when(client.update(oldUser)).thenReturn(Mono.just(newUser)); - when(passwordEncoder.encode("new-password")).thenReturn("encoded-new-password"); + when(passwordEncoder.encode("new@password")).thenReturn("encoded@new@password"); - StepVerifier.create(userService.updateWithRawPassword("fake-user", "new-password")) + StepVerifier.create(userService.updateWithRawPassword("fake-user", "new@password")) .expectNext(newUser) .verifyComplete(); - verify(passwordEncoder, never()).matches("new-password", null); - verify(passwordEncoder).encode("new-password"); + verify(passwordEncoder, never()).matches("new@password", null); + verify(passwordEncoder).encode("new@password"); verify(client).update(argThat(extension -> { var user = (User) extension; - return "encoded-new-password".equals(user.getSpec().getPassword()); + return "encoded@new@password".equals(user.getSpec().getPassword()); })); verify(client).get(User.class, "fake-user"); verify(eventPublisher).publishEvent(any(PasswordChangedEvent.class)); @@ -164,16 +177,16 @@ void shouldUpdatePasswordIfNoPasswordBefore() { void shouldDoNothingIfPasswordNotChanged() { userService = spy(userService); - var oldUser = createUser("fake-password"); - var newUser = createUser("new-password"); + var oldUser = createUser("fake@password"); + var newUser = createUser("new@password"); when(client.get(User.class, "fake-user")).thenReturn(Mono.just(oldUser)); - when(passwordEncoder.matches("fake-password", "fake-password")).thenReturn(true); + when(passwordEncoder.matches("fake@password", "fake@password")).thenReturn(true); - StepVerifier.create(userService.updateWithRawPassword("fake-user", "fake-password")) + StepVerifier.create(userService.updateWithRawPassword("fake-user", "fake@password")) .expectNextCount(0) .verifyComplete(); - verify(passwordEncoder, times(1)).matches("fake-password", "fake-password"); + verify(passwordEncoder, times(1)).matches("fake@password", "fake@password"); verify(passwordEncoder, never()).encode(any()); verify(client, never()).update(any()); verify(client).get(User.class, "fake-user"); @@ -186,7 +199,7 @@ void shouldThrowExceptionIfUserNotFound() { .thenReturn(Mono.error( new ExtensionNotFoundException(fromExtension(User.class), "fake-user"))); - StepVerifier.create(userService.updateWithRawPassword("fake-user", "new-password")) + StepVerifier.create(userService.updateWithRawPassword("fake-user", "new@password")) .verifyError(UserNotFoundException.class); verify(passwordEncoder, never()).matches(anyString(), anyString()); @@ -195,6 +208,16 @@ void shouldThrowExceptionIfUserNotFound() { verify(client).get(User.class, "fake-user"); } + @Test + void shouldThrowWhenPwdContainsInvalidChars() { + StepVerifier.create(userService.updateWithRawPassword("fake-user", "new-password")) + .expectError(UnsatisfiedAttributeValueException.class) + .verify(); + + verify(passwordEncoder, never()).encode(anyString()); + verify(client, never()).update(any()); + } + } User createUser(String password) { @@ -292,6 +315,7 @@ void shouldUpdateRoleBindingIfExists() { @Nested class SignUpTest { + @Test void signUpWhenRegistrationNotAllowed() { SystemSetting.User userSetting = new SystemSetting.User(); @@ -300,11 +324,14 @@ void signUpWhenRegistrationNotAllowed() { eq(SystemSetting.User.class))) .thenReturn(Mono.just(userSetting)); - User fakeUser = fakeSignUpUser("fake-user", "fake-password"); + var signUpData = createSignUpData("fake-user", "fake-password"); - userService.signUp(fakeUser, "fake-password") + userService.signUp(signUpData) .as(StepVerifier::create) - .expectError(AccessDeniedException.class) + .consumeErrorWith(e -> { + assertInstanceOf(ServerWebInputException.class, e); + assertTrue(e.getMessage().contains("registration is not allowed")); + }) .verify(); } @@ -316,11 +343,14 @@ void signUpWhenRegistrationDefaultRoleNotConfigured() { eq(SystemSetting.User.class))) .thenReturn(Mono.just(userSetting)); - User fakeUser = fakeSignUpUser("fake-user", "fake-password"); + var signUpData = createSignUpData("fake-user", "fake-password"); - userService.signUp(fakeUser, "fake-password") + userService.signUp(signUpData) .as(StepVerifier::create) - .expectError(AccessDeniedException.class) + .consumeErrorWith(e -> { + assertInstanceOf(ServerWebInputException.class, e); + assertTrue(e.getMessage().contains("default role is not configured")); + }) .verify(); } @@ -334,11 +364,12 @@ void signUpWhenRegistrationUsernameExists() { .thenReturn(Mono.just(userSetting)); when(passwordEncoder.encode(eq("fake-password"))).thenReturn("fake-password"); when(client.fetch(eq(User.class), eq("fake-user"))) - .thenReturn(Mono.just(fakeSignUpUser("test", "test"))); + .thenReturn(Mono.just(createFakeUser("test", "test"))); + when(extensionGetter.getExtensions(UserPreCreatingHandler.class)) + .thenReturn(Flux.empty()); - User fakeUser = fakeSignUpUser("fake-user", "fake-password"); - - userService.signUp(fakeUser, "fake-password") + var signUpData = createSignUpData("fake-user", "fake-password"); + userService.signUp(signUpData) .as(StepVerifier::create) .expectError(DuplicateNameException.class) .verify(); @@ -356,15 +387,30 @@ void signUpWhenRegistrationSuccessfully() { when(client.fetch(eq(User.class), eq("fake-user"))) .thenReturn(Mono.empty()); - User fakeUser = fakeSignUpUser("fake-user", "fake-password"); + User fakeUser = createFakeUser("fake-user", "fake-password"); + var signUpData = createSignUpData("fake-user", "fake-password"); when(client.fetch(eq(Role.class), anyString())).thenReturn(Mono.just(new Role())); when(client.create(any(User.class))).thenReturn(Mono.just(fakeUser)); UserServiceImpl spyUserService = spy(userService); doReturn(Mono.just(fakeUser)).when(spyUserService).grantRoles(eq("fake-user"), anySet()); - - spyUserService.signUp(fakeUser, "fake-password") + when(extensionGetter.getExtensions(UserPreCreatingHandler.class)) + .thenReturn(Flux.just(user -> { + if (user.getMetadata().getAnnotations() == null) { + user.getMetadata().setAnnotations(new HashMap<>()); + } + user.getMetadata().getAnnotations() + .put("pre.creating.handler.handled", "true"); + return Mono.empty(); + })); + when(extensionGetter.getExtensions(UserPostCreatingHandler.class)) + .thenReturn(Flux.just(user -> { + assertEquals(fakeUser, user); + return Mono.empty(); + })); + + spyUserService.signUp(signUpData) .as(StepVerifier::create) .consumeNextWith(user -> { assertThat(user.getMetadata().getName()).isEqualTo("fake-user"); @@ -372,11 +418,14 @@ void signUpWhenRegistrationSuccessfully() { }) .verifyComplete(); - verify(client).create(any(User.class)); + verify(client).create(assertArg(u -> { + var handled = u.getMetadata().getAnnotations().get("pre.creating.handler.handled"); + assertEquals("true", handled); + })); verify(spyUserService).grantRoles(eq("fake-user"), anySet()); } - User fakeSignUpUser(String name, String password) { + User createFakeUser(String name, String password) { User user = new User(); user.setMetadata(new Metadata()); user.getMetadata().setName(name); @@ -384,6 +433,13 @@ User fakeSignUpUser(String name, String password) { user.getSpec().setPassword(password); return user; } + + SignUpData createSignUpData(String name, String password) { + SignUpData signUpData = new SignUpData(); + signUpData.setUsername(name); + signUpData.setPassword(password); + return signUpData; + } } @Test diff --git a/application/src/test/java/run/halo/app/infra/DefaultExternalLinkProcessorTest.java b/application/src/test/java/run/halo/app/infra/DefaultExternalLinkProcessorTest.java index 98b09cf0ed..357dacb488 100644 --- a/application/src/test/java/run/halo/app/infra/DefaultExternalLinkProcessorTest.java +++ b/application/src/test/java/run/halo/app/infra/DefaultExternalLinkProcessorTest.java @@ -1,15 +1,26 @@ package run.halo.app.infra; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import static org.springframework.web.filter.reactive.ServerWebExchangeContextFilter.EXCHANGE_CONTEXT_ATTRIBUTE; import java.net.MalformedURLException; import java.net.URI; +import java.net.URL; +import java.util.stream.Stream; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.web.server.ServerWebExchange; +import reactor.test.StepVerifier; /** * Tests for {@link DefaultExternalLinkProcessor}. @@ -26,9 +37,10 @@ class DefaultExternalLinkProcessorTest { @InjectMocks DefaultExternalLinkProcessor externalLinkProcessor; + @Test void processWhenLinkIsEmpty() { - assertThat(externalLinkProcessor.processLink(null)).isNull(); + assertThat(externalLinkProcessor.processLink((String) null)).isNull(); assertThat(externalLinkProcessor.processLink("")).isEmpty(); } @@ -48,4 +60,66 @@ void process() throws MalformedURLException { assertThat(externalLinkProcessor.processLink("https://halo.run/test")) .isEqualTo("https://halo.run/test"); } + + @ParameterizedTest + @MethodSource("processUriTestWithoutServerWebExchangeArguments") + void processUriWithoutServerWebExchange(String link, String expectedLink) + throws MalformedURLException { + lenient().when(externalUrlSupplier.getRaw()) + .thenReturn(new URL("https://www.halo.run/context-path")); + externalLinkProcessor.processLink(URI.create(link)) + .as(StepVerifier::create) + .expectNext(URI.create(expectedLink)) + .verifyComplete(); + } + + static Stream processUriTestWithoutServerWebExchangeArguments() { + return Stream.of( + Arguments.of("http://localhost:8090/halo", "http://localhost:8090/halo"), + Arguments.of("/halo", "https://www.halo.run/context-path/halo"), + Arguments.of("halo", "https://www.halo.run/context-path/halo"), + Arguments.of("/halo?query", "https://www.halo.run/context-path/halo?query"), + Arguments.of( + "/halo?query#fragment", "https://www.halo.run/context-path/halo?query#fragment" + ), + Arguments.of("/halo/subpath", "https://www.halo.run/context-path/halo/subpath"), + Arguments.of("/halo/中文", "https://www.halo.run/context-path/halo/%E4%B8%AD%E6%96%87"), + Arguments.of("/halo/ooo%2Fooo", "https://www.halo.run/context-path/halo/ooo%2Fooo") + ); + } + + @ParameterizedTest + @MethodSource("processUriTestWithServerWebExchangeArguments") + void processUriWithServerWebExchange(String link, String expectLink) + throws MalformedURLException { + lenient().when(externalUrlSupplier.getRaw()) + .thenReturn(URI.create("https://www.halo.run").toURL()); + var request = mock(ServerHttpRequest.class); + var exchange = mock(ServerWebExchange.class); + lenient().when(exchange.getRequest()).thenReturn(request); + lenient().when(externalUrlSupplier.getURL(request)).thenReturn( + new URL("https://antoher.halo.run/context-path")); + externalLinkProcessor.processLink(URI.create(link)) + .contextWrite(context -> context.put(EXCHANGE_CONTEXT_ATTRIBUTE, exchange)) + .as(StepVerifier::create) + .expectNext(URI.create(expectLink)) + .verifyComplete(); + } + + static Stream processUriTestWithServerWebExchangeArguments() { + return Stream.of( + Arguments.of("http://localhost:8090/halo?query#fragment", + "http://localhost:8090/halo?query#fragment"), + Arguments.of("/halo", "https://antoher.halo.run/context-path/halo"), + Arguments.of("halo", "https://antoher.halo.run/context-path/halo"), + Arguments.of("/halo?query", "https://antoher.halo.run/context-path/halo?query"), + Arguments.of("/halo?query#fragment", + "https://antoher.halo.run/context-path/halo?query#fragment"), + Arguments.of("/halo/subpath", "https://antoher.halo.run/context-path/halo/subpath"), + Arguments.of("/halo/中文", + "https://antoher.halo.run/context-path/halo/%E4%B8%AD%E6%96%87"), + Arguments.of("/halo/ooo%2Fooo", "https://antoher.halo.run/context-path/halo/ooo%2Fooo") + ); + } + } diff --git a/application/src/test/java/run/halo/app/infra/DefaultSystemVersionSupplierTest.java b/application/src/test/java/run/halo/app/infra/DefaultSystemVersionSupplierTest.java index 6be7f0a5f1..217de47a9e 100644 --- a/application/src/test/java/run/halo/app/infra/DefaultSystemVersionSupplierTest.java +++ b/application/src/test/java/run/halo/app/infra/DefaultSystemVersionSupplierTest.java @@ -60,6 +60,6 @@ void getWhenBuildPropertiesAndVersionNotEmpty() { when(buildPropertiesProvider.getIfUnique()).thenReturn(buildProperties); version = systemVersionSupplier.get(); assertThat(version.toString()).isEqualTo("2.0.0-SNAPSHOT"); - assertThat(version.getPreReleaseVersion()).isEqualTo("SNAPSHOT"); + assertThat(version.preReleaseVersion().orElseThrow()).isEqualTo("SNAPSHOT"); } } \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/infra/ValidationUtilsTest.java b/application/src/test/java/run/halo/app/infra/ValidationUtilsTest.java index 6fa0991c31..1a3520fdcf 100644 --- a/application/src/test/java/run/halo/app/infra/ValidationUtilsTest.java +++ b/application/src/test/java/run/halo/app/infra/ValidationUtilsTest.java @@ -2,7 +2,6 @@ import static org.assertj.core.api.Assertions.assertThat; -import java.util.HashMap; import org.apache.commons.lang3.StringUtils; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -19,74 +18,50 @@ class ValidationUtilsTest { class NameValidationTest { @Test void nullName() { - assertThat(ValidationUtils.validateName(null)).isFalse(); + assertThat(validateName(null)).isFalse(); } @Test void emptyUsername() { - assertThat(ValidationUtils.validateName("")).isFalse(); + assertThat(validateName("")).isFalse(); } @Test void startWithIllegalCharacter() { - assertThat(ValidationUtils.validateName("-abc")).isFalse(); + assertThat(validateName("-abc")).isFalse(); } @Test void endWithIllegalCharacter() { - assertThat(ValidationUtils.validateName("abc-")).isFalse(); - assertThat(ValidationUtils.validateName("abcD")).isFalse(); + assertThat(validateName("abc-")).isFalse(); + assertThat(validateName("abcD")).isFalse(); } @Test void middleWithIllegalCharacter() { - assertThat(ValidationUtils.validateName("ab?c")).isFalse(); + assertThat(validateName("ab?c")).isFalse(); } @Test void moreThan63Characters() { - assertThat(ValidationUtils.validateName(StringUtils.repeat('a', 64))).isFalse(); + assertThat(validateName(StringUtils.repeat('a', 64))).isFalse(); } @Test void correctUsername() { - assertThat(ValidationUtils.validateName("abc")).isTrue(); - assertThat(ValidationUtils.validateName("ab-c")).isTrue(); - assertThat(ValidationUtils.validateName("1st")).isTrue(); - assertThat(ValidationUtils.validateName("ast1")).isTrue(); - assertThat(ValidationUtils.validateName("ast-1")).isTrue(); + assertThat(validateName("abc")).isTrue(); + assertThat(validateName("ab-c")).isTrue(); + assertThat(validateName("1st")).isTrue(); + assertThat(validateName("ast1")).isTrue(); + assertThat(validateName("ast-1")).isTrue(); } - } - - @Test - void validateEmailTest() { - var cases = new HashMap(); - // Valid cases - cases.put("simple@example.com", true); - cases.put("very.common@example.com", true); - cases.put("disposable.style.email.with+symbol@example.com", true); - cases.put("other.email-with-hyphen@example.com", true); - cases.put("fully-qualified-domain@example.com", true); - cases.put("user.name+tag+sorting@example.com", true); - cases.put("x@example.com", true); - cases.put("example-indeed@strange-example.com", true); - cases.put("example@s.example", true); - cases.put("john.doe@example.com", true); - cases.put("a.little.lengthy.but.fine@dept.example.com", true); - cases.put("123ada@halo.co", true); - cases.put("23ad@halo.top", true); - // Invalid cases - cases.put("Abc.example.com", false); - cases.put("admin@mailserver1", false); - cases.put("\" \"@example.org", false); - cases.put("A@b@c@example.com", false); - cases.put("a\"b(c)d,e:f;gi[j\\k]l@example.com", false); - cases.put("just\"not\"right@example.com", false); - cases.put("this is\"not\\allowed@example.com", false); - cases.put("this\\ still\\\"not\\\\allowed@example.com", false); - cases.put("123456789012345678901234567890123456789012345", false); - cases.forEach((email, expected) -> assertThat(ValidationUtils.isValidEmail(email)) - .isEqualTo(expected)); + static boolean validateName(String name) { + if (StringUtils.isBlank(name)) { + return false; + } + boolean matches = ValidationUtils.NAME_PATTERN.matcher(name).matches(); + return matches && name.length() <= 63; + } } -} \ No newline at end of file +} diff --git a/application/src/test/java/run/halo/app/infra/exception/handlers/I18nExceptionTest.java b/application/src/test/java/run/halo/app/infra/exception/handlers/I18nExceptionTest.java index e217f3cfb9..2dbc0ccfa8 100644 --- a/application/src/test/java/run/halo/app/infra/exception/handlers/I18nExceptionTest.java +++ b/application/src/test/java/run/halo/app/infra/exception/handlers/I18nExceptionTest.java @@ -1,6 +1,7 @@ package run.halo.app.infra.exception.handlers; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.csrf; import java.util.Locale; import org.junit.jupiter.api.AfterEach; @@ -121,9 +122,8 @@ void shouldGetErrorIfThrowingGeneralException() { @Test void shouldGetConflictError() { - webClient.put().uri("/response-entity/conflict-error") - .header("X-XSRF-TOKEN", "fake-token") - .cookie("XSRF-TOKEN", "fake-token") + webClient.mutate().apply(csrf()).build() + .put().uri("/response-entity/conflict-error") .exchange() .expectStatus().isEqualTo(HttpStatus.CONFLICT) .expectBody(ProblemDetail.class) diff --git a/application/src/test/java/run/halo/app/infra/properties/HaloPropertiesTest.java b/application/src/test/java/run/halo/app/infra/properties/HaloPropertiesTest.java new file mode 100644 index 0000000000..1501c50c45 --- /dev/null +++ b/application/src/test/java/run/halo/app/infra/properties/HaloPropertiesTest.java @@ -0,0 +1,45 @@ +package run.halo.app.infra.properties; + +import java.net.MalformedURLException; +import java.net.URL; +import java.util.stream.Stream; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.validation.SimpleErrors; + +class HaloPropertiesTest { + + static Stream validateTest() throws MalformedURLException { + return Stream.of( + Arguments.of(true, new URL("http://localhost:8080"), true), + Arguments.of(false, new URL("http://localhost:8080"), true), + Arguments.of(true, new URL("https://localhost:8080"), true), + Arguments.of(false, new URL("https://localhost:8080"), true), + Arguments.of(true, new URL("ftp://localhost:8080"), false), + Arguments.of(false, new URL("ftp://localhost:8080"), false), + Arguments.of(true, new URL("http:www/halo/run"), false), + Arguments.of(false, new URL("http:www/halo.run"), false), + Arguments.of(true, new URL("https:www/halo/run"), false), + Arguments.of(false, new URL("https:www/halo/run"), false), + Arguments.of(true, new URL("https:///path"), false), + Arguments.of(false, new URL("https:///path"), false), + Arguments.of(true, new URL("http:///path"), false), + Arguments.of(false, new URL("http:///path"), false), + Arguments.of(true, null, false), + Arguments.of(false, null, true) + ); + } + + @ParameterizedTest + @MethodSource + void validateTest(boolean useAbsolutePermalink, URL externalUrl, boolean valid) { + var properties = new HaloProperties(); + properties.setUseAbsolutePermalink(useAbsolutePermalink); + properties.setExternalUrl(externalUrl); + var errors = new SimpleErrors(properties); + properties.validate(properties, errors); + Assertions.assertEquals(valid, !errors.hasErrors()); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/infra/utils/FileTypeDetectUtilsTest.java b/application/src/test/java/run/halo/app/infra/utils/FileTypeDetectUtilsTest.java index a69ee0c806..1c3e407d08 100644 --- a/application/src/test/java/run/halo/app/infra/utils/FileTypeDetectUtilsTest.java +++ b/application/src/test/java/run/halo/app/infra/utils/FileTypeDetectUtilsTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import java.io.IOException; +import java.io.InputStream; import java.nio.file.Files; import org.apache.tika.mime.MimeTypeException; import org.junit.jupiter.api.Test; @@ -31,6 +32,60 @@ void detectMimeTypeTest() throws IOException { assertThat(mimeType).isEqualTo("application/zip"); } + @Test + void detectMimeTypeWithNameTest() throws IOException { + var stream = getFileInputStream("classpath:file-type-detect/index.js"); + String mimeType = FileTypeDetectUtils.detectMimeType(stream, "index.js"); + assertThat(mimeType).isEqualTo("application/javascript"); + + stream = getFileInputStream("classpath:file-type-detect/index.html"); + mimeType = + FileTypeDetectUtils.detectMimeType(stream, "index.html"); + assertThat(mimeType).isEqualTo("text/html"); + + stream = getFileInputStream("classpath:file-type-detect/test.json"); + mimeType = FileTypeDetectUtils.detectMimeType(stream, "test.json"); + assertThat(mimeType).isEqualTo("application/json"); + + stream = getFileInputStream("classpath:file-type-detect/other.xlsx"); + mimeType = FileTypeDetectUtils.detectMimeType(stream, "other.xlsx"); + assertThat(mimeType).isEqualTo( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + + // other.xlsx detect without name + stream = getFileInputStream("classpath:file-type-detect/other.xlsx"); + mimeType = FileTypeDetectUtils.detectMimeType(stream); + assertThat(mimeType).isEqualTo("application/zip"); + + // other.xlsx detect with wrong name + stream = getFileInputStream("classpath:file-type-detect/other.xlsx"); + mimeType = FileTypeDetectUtils.detectMimeType(stream, "other.txt"); + assertThat(mimeType).isEqualTo("application/zip"); + + stream = getFileInputStream("classpath:file-type-detect/test.docx"); + mimeType = FileTypeDetectUtils.detectMimeType(stream, "test.docx"); + assertThat(mimeType).isEqualTo( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document"); + + // docx detect without file name + stream = getFileInputStream("classpath:file-type-detect/test.docx"); + mimeType = FileTypeDetectUtils.detectMimeType(stream); + assertThat(mimeType).isEqualTo("application/zip"); + + stream = getFileInputStream("classpath:file-type-detect/test.svg"); + mimeType = FileTypeDetectUtils.detectMimeType(stream, "test.svg"); + assertThat(mimeType).isEqualTo("image/svg+xml"); + + stream = getFileInputStream("classpath:file-type-detect/test.png"); + mimeType = FileTypeDetectUtils.detectMimeType(stream, "test.png"); + assertThat(mimeType).isEqualTo("image/png"); + } + + private static InputStream getFileInputStream(String location) throws IOException { + var file = ResourceUtils.getFile(location); + return Files.newInputStream(file.toPath()); + } + @Test void detectFileExtensionTest() throws MimeTypeException { var ext = FileTypeDetectUtils.detectFileExtension("application/x-x509-key; format=pem"); diff --git a/application/src/test/java/run/halo/app/infra/utils/HaloUtilsTest.java b/application/src/test/java/run/halo/app/infra/utils/HaloUtilsTest.java new file mode 100644 index 0000000000..59a13c37a6 --- /dev/null +++ b/application/src/test/java/run/halo/app/infra/utils/HaloUtilsTest.java @@ -0,0 +1,25 @@ +package run.halo.app.infra.utils; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.reactive.function.server.MockServerRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import run.halo.app.theme.router.ModelConst; + +class HaloUtilsTest { + + @Test + void checkNoCache() { + var exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/").build()); + var request = MockServerRequest.builder() + .exchange(exchange) + .build(); + var applied = HaloUtils.noCache().apply(request); + assertEquals(applied, request); + assertTrue(() -> exchange.getRequiredAttribute(ModelConst.NO_CACHE)); + } + +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/core/extension/theme/SettingUtilsTest.java b/application/src/test/java/run/halo/app/infra/utils/SettingUtilsTest.java similarity index 98% rename from application/src/test/java/run/halo/app/core/extension/theme/SettingUtilsTest.java rename to application/src/test/java/run/halo/app/infra/utils/SettingUtilsTest.java index bbed171190..32ee6b6f48 100644 --- a/application/src/test/java/run/halo/app/core/extension/theme/SettingUtilsTest.java +++ b/application/src/test/java/run/halo/app/infra/utils/SettingUtilsTest.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.theme; +package run.halo.app.infra.utils; import static org.assertj.core.api.Assertions.assertThat; @@ -7,7 +7,6 @@ import org.junit.jupiter.api.Test; import org.skyscreamer.jsonassert.JSONAssert; import run.halo.app.core.extension.Setting; -import run.halo.app.infra.utils.JsonUtils; /** * Tests for {@link SettingUtils}. diff --git a/application/src/test/java/run/halo/app/migration/impl/MigrationServiceImplTest.java b/application/src/test/java/run/halo/app/migration/impl/MigrationServiceImplTest.java index e515e02c65..0d65e0134e 100644 --- a/application/src/test/java/run/halo/app/migration/impl/MigrationServiceImplTest.java +++ b/application/src/test/java/run/halo/app/migration/impl/MigrationServiceImplTest.java @@ -120,7 +120,7 @@ void restoreTest() throws IOException, URISyntaxException { expectStore.setVersion(null); when(haloProperties.getWorkDir()).thenReturn(workdir); - when(repository.deleteAll(List.of(expectStore))).thenReturn(Mono.empty()); + when(repository.deleteAll()).thenReturn(Mono.empty()); when(repository.saveAll(List.of(expectStore))).thenReturn(Flux.empty()); var content = DataBufferUtils.read(backupFile, @@ -132,7 +132,7 @@ void restoreTest() throws IOException, URISyntaxException { verify(haloProperties).getWorkDir(); - verify(repository).deleteAll(List.of(expectStore)); + verify(repository).deleteAll(); verify(repository).saveAll(List.of(expectStore)); // make sure the workdir is recovered. diff --git a/application/src/test/java/run/halo/app/notification/DefaultNotificationTemplateRenderTest.java b/application/src/test/java/run/halo/app/notification/DefaultNotificationTemplateRenderTest.java index e4d10336f2..5925740120 100644 --- a/application/src/test/java/run/halo/app/notification/DefaultNotificationTemplateRenderTest.java +++ b/application/src/test/java/run/halo/app/notification/DefaultNotificationTemplateRenderTest.java @@ -9,6 +9,7 @@ import java.net.MalformedURLException; import java.net.URI; +import java.net.URL; import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -88,7 +89,7 @@ void render() { 以下是回复的具体内容: 这是回复的内容 - + Halo http://localhost:8090 祝好! @@ -101,4 +102,27 @@ void render() { eq(SystemSetting.Basic.class)); verify(externalUrlSupplier).getRaw(); } -} + + @Test + void siteUrlTest() throws MalformedURLException { + when(environmentFetcher.fetch(eq(SystemSetting.Basic.GROUP), eq(SystemSetting.Basic.class))) + .thenReturn(Mono.just(new SystemSetting.Basic())); + + var template = "查看通知"; + var expected = "查看通知"; + + when(externalUrlSupplier.getRaw()).thenReturn(new URL("http://localhost:8090/")); + templateRender.render(template, + Map.of()) + .as(StepVerifier::create) + .expectNext(expected) + .verifyComplete(); + + when(externalUrlSupplier.getRaw()).thenReturn(new URL("http://localhost:8090")); + templateRender.render(template, + Map.of()) + .as(StepVerifier::create) + .expectNext(expected) + .verifyComplete(); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/notification/SubscriptionServiceIntegrationTest.java b/application/src/test/java/run/halo/app/notification/SubscriptionServiceIntegrationTest.java deleted file mode 100644 index 6a55506f7d..0000000000 --- a/application/src/test/java/run/halo/app/notification/SubscriptionServiceIntegrationTest.java +++ /dev/null @@ -1,171 +0,0 @@ -package run.halo.app.notification; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.atLeast; -import static org.mockito.Mockito.verify; -import static run.halo.app.extension.index.query.QueryFactory.isNull; - -import java.util.ArrayList; -import java.util.List; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.SpyBean; -import org.springframework.test.annotation.DirtiesContext; -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; -import reactor.test.StepVerifier; -import run.halo.app.core.extension.notification.Subscription; -import run.halo.app.extension.Extension; -import run.halo.app.extension.ExtensionStoreUtil; -import run.halo.app.extension.ListOptions; -import run.halo.app.extension.PageRequestImpl; -import run.halo.app.extension.ReactiveExtensionClient; -import run.halo.app.extension.SchemeManager; -import run.halo.app.extension.index.IndexerFactory; -import run.halo.app.extension.router.selector.FieldSelector; -import run.halo.app.extension.store.ReactiveExtensionStoreClient; -import run.halo.app.infra.utils.JsonUtils; - -/** - * Integration tests for {@link SubscriptionService}. - * - * @author guqing - * @since 2.15.0 - */ -@DirtiesContext -@SpringBootTest -class SubscriptionServiceIntegrationTest { - - @Autowired - private SchemeManager schemeManager; - - @SpyBean - private ReactiveExtensionClient client; - - @Autowired - private ReactiveExtensionStoreClient storeClient; - - @Autowired - private IndexerFactory indexerFactory; - - Mono deleteImmediately(Extension extension) { - var name = extension.getMetadata().getName(); - var scheme = schemeManager.get(extension.getClass()); - // un-index - var indexer = indexerFactory.getIndexer(extension.groupVersionKind()); - indexer.unIndexRecord(extension.getMetadata().getName()); - - // delete from db - var storeName = ExtensionStoreUtil.buildStoreName(scheme, name); - return storeClient.delete(storeName, extension.getMetadata().getVersion()) - .thenReturn(extension); - } - - @Nested - class RemoveInitialBatchTest { - static int size = 310; - private final List storedSubscriptions = subscriptionsForStore(); - - @Autowired - private SubscriptionService subscriptionService; - - @BeforeEach - void setUp() { - Flux.fromIterable(storedSubscriptions) - .flatMap(comment -> client.create(comment)) - .as(StepVerifier::create) - .expectNextCount(storedSubscriptions.size()) - .verifyComplete(); - } - - @AfterEach - void tearDown() { - Flux.fromIterable(storedSubscriptions) - .flatMap(SubscriptionServiceIntegrationTest.this::deleteImmediately) - .as(StepVerifier::create) - .expectNextCount(storedSubscriptions.size()) - .verifyComplete(); - } - - private List subscriptionsForStore() { - List subscriptions = new ArrayList<>(size); - for (int i = 0; i < size; i++) { - var subscription = createSubscription(); - subscription.getMetadata().setName("subscription-" + i); - subscriptions.add(subscription); - } - return subscriptions; - } - - @Test - void removeTest() { - var subscriber = new Subscription.Subscriber(); - subscriber.setName("admin"); - var interestReason = new Subscription.InterestReason(); - interestReason.setReasonType("new-comment-on-post"); - var subject = new Subscription.ReasonSubject(); - subject.setApiVersion("content.halo.run/v1alpha1"); - subject.setKind("Post"); - interestReason.setSubject(subject); - - subscriptionService.remove(subscriber, interestReason).block(); - - verify(client, atLeast(size)).delete(any(Subscription.class)); - assertCleanedUp(); - } - - @Test - void removeBySubscriberTest() { - var subscriber = new Subscription.Subscriber(); - subscriber.setName("admin"); - - subscriptionService.remove(subscriber).block(); - verify(client, atLeast(size)).delete(any(Subscription.class)); - assertCleanedUp(); - } - - private void assertCleanedUp() { - var listOptions = new ListOptions(); - listOptions.setFieldSelector(FieldSelector.of(isNull("metadata.deletionTimestamp"))); - client.listBy(Subscription.class, listOptions, PageRequestImpl.ofSize(1)) - .as(StepVerifier::create) - .consumeNextWith(result -> { - assertThat(result.getTotal()).isEqualTo(0); - assertThat(result.getItems()).isEmpty(); - }) - .verifyComplete(); - } - } - - Subscription createSubscription() { - return JsonUtils.jsonToObject(""" - { - "spec": { - "subscriber": { - "name": "admin" - }, - "unsubscribeToken": "423530c9-bec7-446e-b73b-dd98ac00ba2b", - "reason": { - "reasonType": "new-comment-on-post", - "subject": { - "name": "5152aea5-c2e8-4717-8bba-2263d46e19d5", - "apiVersion": "content.halo.run/v1alpha1", - "kind": "Post" - } - }, - "disabled": false - }, - "apiVersion": "notification.halo.run/v1alpha1", - "kind": "Subscription", - "metadata": { - "generateName": "subscription-" - } - } - """, Subscription.class); - } -} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/plugin/DefaultPluginApplicationContextFactoryTest.java b/application/src/test/java/run/halo/app/plugin/DefaultPluginApplicationContextFactoryTest.java index 0f37a595ea..bf2e4a494a 100644 --- a/application/src/test/java/run/halo/app/plugin/DefaultPluginApplicationContextFactoryTest.java +++ b/application/src/test/java/run/halo/app/plugin/DefaultPluginApplicationContextFactoryTest.java @@ -9,13 +9,13 @@ import org.junit.jupiter.api.Test; import org.pf4j.PluginWrapper; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; import run.halo.app.search.SearchService; @SpringBootTest class DefaultPluginApplicationContextFactoryTest { - @SpyBean + @MockitoSpyBean SpringPluginManager pluginManager; DefaultPluginApplicationContextFactory factory; diff --git a/application/src/test/java/run/halo/app/plugin/DefaultSettingFetcherTest.java b/application/src/test/java/run/halo/app/plugin/DefaultSettingFetcherTest.java index 5e7f0d2ee0..2a70af35b7 100644 --- a/application/src/test/java/run/halo/app/plugin/DefaultSettingFetcherTest.java +++ b/application/src/test/java/run/halo/app/plugin/DefaultSettingFetcherTest.java @@ -23,11 +23,11 @@ import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; import org.skyscreamer.jsonassert.JSONAssert; -import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.cache.Cache; import org.springframework.cache.CacheManager; import org.springframework.cache.concurrent.ConcurrentMapCache; import org.springframework.context.ApplicationContext; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import reactor.core.publisher.Mono; import run.halo.app.core.extension.Plugin; import run.halo.app.extension.ConfigMap; @@ -55,7 +55,7 @@ class DefaultSettingFetcherTest { @Mock private CacheManager cacheManager; - @MockBean + @MockitoBean private final PluginContext pluginContext = PluginContext.builder() .name("fake") .configMapName("fake-config") diff --git a/application/src/test/java/run/halo/app/core/extension/service/impl/PluginServiceImplTest.java b/application/src/test/java/run/halo/app/plugin/PluginServiceImplTest.java similarity index 94% rename from application/src/test/java/run/halo/app/core/extension/service/impl/PluginServiceImplTest.java rename to application/src/test/java/run/halo/app/plugin/PluginServiceImplTest.java index 2e79ce0fe5..94734dea6b 100644 --- a/application/src/test/java/run/halo/app/core/extension/service/impl/PluginServiceImplTest.java +++ b/application/src/test/java/run/halo/app/plugin/PluginServiceImplTest.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.service.impl; +package run.halo.app.plugin; import static java.nio.charset.StandardCharsets.UTF_8; import static java.util.Objects.requireNonNull; @@ -57,10 +57,6 @@ import run.halo.app.infra.SystemVersionSupplier; import run.halo.app.infra.exception.PluginAlreadyExistsException; import run.halo.app.infra.utils.FileUtils; -import run.halo.app.plugin.PluginConst; -import run.halo.app.plugin.PluginsRootGetter; -import run.halo.app.plugin.SpringPluginManager; -import run.halo.app.plugin.YamlPluginFinder; @ExtendWith(MockitoExtension.class) class PluginServiceImplTest { @@ -81,33 +77,6 @@ class PluginServiceImplTest { @InjectMocks PluginServiceImpl pluginService; - @Test - void getPresetsTest() { - var presets = pluginService.getPresets(); - StepVerifier.create(presets) - .assertNext(plugin -> { - assertEquals("fake-plugin", plugin.getMetadata().getName()); - assertEquals("0.0.2", plugin.getSpec().getVersion()); - assertEquals(Plugin.Phase.PENDING, plugin.getStatus().getPhase()); - }) - .verifyComplete(); - } - - @Test - void getPresetIfNotFound() { - var plugin = pluginService.getPreset("not-found-plugin"); - StepVerifier.create(plugin) - .verifyComplete(); - } - - @Test - void getPresetIfFound() { - var plugin = pluginService.getPreset("fake-plugin"); - StepVerifier.create(plugin) - .expectNextCount(1) - .verifyComplete(); - } - @Nested class InstallUpdateReloadTest { @@ -123,7 +92,7 @@ void setUp() throws URISyntaxException, IOException { getClass().getClassLoader().getResource("plugin/plugin-0.0.2")).toURI(); FileUtils.jar(Paths.get(fakePluingUri), tempDirectory.resolve("plugin-0.0.2.jar")); - lenient().when(systemVersionSupplier.get()).thenReturn(Version.valueOf("0.0.0")); + lenient().when(systemVersionSupplier.get()).thenReturn(Version.parse("0.0.0")); } @Test diff --git a/application/src/test/java/run/halo/app/plugin/extensionpoint/DefaultExtensionGetterTest.java b/application/src/test/java/run/halo/app/plugin/extensionpoint/DefaultExtensionGetterTest.java index bab1b6e7df..b876aa10c7 100644 --- a/application/src/test/java/run/halo/app/plugin/extensionpoint/DefaultExtensionGetterTest.java +++ b/application/src/test/java/run/halo/app/plugin/extensionpoint/DefaultExtensionGetterTest.java @@ -1,5 +1,6 @@ package run.halo.app.plugin.extensionpoint; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.same; import static org.mockito.Mockito.mock; @@ -44,6 +45,9 @@ class DefaultExtensionGetterTest { @Mock BeanFactory beanFactory; + @Mock + ObjectProvider extensionPointObjectProvider; + @InjectMocks DefaultExtensionGetter getter; @@ -209,6 +213,20 @@ void shouldGetMultiInstanceExtensionWhileExtensionPointEnabledNotSet() { .verifyComplete(); } + @Test + void shouldGetExtensionsFromPluginManagerAndApplicationContext() { + var extensionFromPlugin = new FakeExtensionPointDefaultImpl(); + var extensionFromAppContext = new FakeExtensionPointImpl(); + when(pluginManager.getExtensions(FakeExtensionPoint.class)) + .thenReturn(List.of(extensionFromPlugin)); + when(beanFactory.getBeanProvider(FakeExtensionPoint.class)) + .thenReturn(extensionPointObjectProvider); + when(extensionPointObjectProvider.orderedStream()) + .thenReturn(Stream.of(extensionFromAppContext)); + var extensions = getter.getExtensionList(FakeExtensionPoint.class); + assertEquals(List.of(extensionFromAppContext, extensionFromPlugin), extensions); + } + interface FakeExtensionPoint extends ExtensionPoint { } diff --git a/application/src/test/java/run/halo/app/security/AuthProviderServiceImplTest.java b/application/src/test/java/run/halo/app/security/AuthProviderServiceImplTest.java index 6487ee3750..6260cc6860 100644 --- a/application/src/test/java/run/halo/app/security/AuthProviderServiceImplTest.java +++ b/application/src/test/java/run/halo/app/security/AuthProviderServiceImplTest.java @@ -4,18 +4,21 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.same; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.HashMap; -import java.util.Set; import org.json.JSONException; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.skyscreamer.jsonassert.JSONAssert; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.data.domain.Sort; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.junit.jupiter.SpringExtension; import reactor.core.publisher.Flux; @@ -24,8 +27,10 @@ import run.halo.app.core.extension.AuthProvider; import run.halo.app.core.extension.UserConnection; import run.halo.app.extension.ConfigMap; +import run.halo.app.extension.ListOptions; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; import run.halo.app.infra.SystemSetting; import run.halo.app.infra.utils.JsonUtils; @@ -37,14 +42,26 @@ */ @ExtendWith(SpringExtension.class) class AuthProviderServiceImplTest { + + @Mock + ReactiveExtensionClient client; + + @Mock + ObjectProvider systemFetchProvider; + @Mock - private ReactiveExtensionClient client; + SystemConfigurableEnvironmentFetcher systemConfigFetcher; @InjectMocks - private AuthProviderServiceImpl authProviderService; + AuthProviderServiceImpl authProviderService; + + @BeforeEach + void setUp() { + when(systemFetchProvider.getIfUnique()).thenReturn(systemConfigFetcher); + } @Test - void testEnable() { + void testEnable() throws JSONException { // Create a test auth provider AuthProvider authProvider = createAuthProvider("github"); when(client.get(eq(AuthProvider.class), eq("github"))).thenReturn(Mono.just(authProvider)); @@ -52,65 +69,70 @@ void testEnable() { ArgumentCaptor captor = ArgumentCaptor.forClass(ConfigMap.class); when(client.update(captor.capture())).thenReturn(Mono.empty()); - ConfigMap configMap = new ConfigMap(); - configMap.setData(new HashMap<>()); - when(client.fetch(eq(ConfigMap.class), eq(SystemSetting.SYSTEM_CONFIG))) - .thenReturn(Mono.just(configMap)); - - AuthProvider local = createAuthProvider("local"); - local.getMetadata().getLabels().put(AuthProvider.PRIVILEGED_LABEL, "true"); - when(client.list(eq(AuthProvider.class), any(), any())).thenReturn(Flux.just(local)); + pileSystemConfigMap(); // Call the method being tested - Mono result = authProviderService.enable("github"); + authProviderService.enable("github") + .as(StepVerifier::create) + .expectNext(authProvider) + .verifyComplete(); - assertEquals(authProvider, result.block()); ConfigMap value = captor.getValue(); - String providerSettingStr = value.getData().get(SystemSetting.AuthProvider.GROUP); - Set enabled = - JsonUtils.jsonToObject(providerSettingStr, SystemSetting.AuthProvider.class) - .getEnabled(); - assertThat(enabled).containsExactly("github"); + JSONAssert.assertEquals(""" + { + "enabled":["github"], + "states": [ + { + "name": "github", + "enabled": true, + "priority": 0 + } + ] + } + """, + value.getData().get(SystemSetting.AuthProvider.GROUP), + true); // Verify the result verify(client).get(AuthProvider.class, "github"); - verify(client).fetch(eq(ConfigMap.class), eq(SystemSetting.SYSTEM_CONFIG)); } @Test - void testDisable() { + void testDisable() throws JSONException { // Create a test auth provider AuthProvider authProvider = createAuthProvider("github"); when(client.get(eq(AuthProvider.class), eq("github"))).thenReturn(Mono.just(authProvider)); AuthProvider local = createAuthProvider("local"); local.getMetadata().getLabels().put(AuthProvider.PRIVILEGED_LABEL, "true"); - when(client.list(eq(AuthProvider.class), any(), any())).thenReturn(Flux.just(local)); ArgumentCaptor captor = ArgumentCaptor.forClass(ConfigMap.class); when(client.update(captor.capture())).thenReturn(Mono.empty()); - ConfigMap configMap = new ConfigMap(); - configMap.setData(new HashMap<>()); - configMap.getData().put(SystemSetting.AuthProvider.GROUP, "{\"enabled\":[\"github\"]}"); - when(client.fetch(eq(ConfigMap.class), eq(SystemSetting.SYSTEM_CONFIG))) - .thenReturn(Mono.just(configMap)); + pileSystemConfigMap(); // Call the method being tested Mono result = authProviderService.disable("github"); assertEquals(authProvider, result.block()); ConfigMap value = captor.getValue(); - String providerSettingStr = value.getData().get(SystemSetting.AuthProvider.GROUP); - Set enabled = - JsonUtils.jsonToObject(providerSettingStr, SystemSetting.AuthProvider.class) - .getEnabled(); - assertThat(enabled).isEmpty(); + JSONAssert.assertEquals(""" + { + "enabled":[], + "states": [ + { + "name": "github", + "enabled": false, + "priority": 0 + } + ] + } + """, + value.getData().get(SystemSetting.AuthProvider.GROUP), + true); // Verify the result verify(client).get(AuthProvider.class, "github"); - verify(client).fetch(eq(ConfigMap.class), eq(SystemSetting.SYSTEM_CONFIG)); } - @Test @WithMockUser(username = "admin") void listAll() { @@ -122,15 +144,12 @@ void listAll() { AuthProvider gitee = createAuthProvider("gitee"); - when(client.list(eq(AuthProvider.class), any(), any())) + when(client.listAll(same(AuthProvider.class), any(ListOptions.class), any(Sort.class))) .thenReturn(Flux.just(github, gitlab, gitee)); - when(client.list(eq(UserConnection.class), any(), any())).thenReturn(Flux.empty()); + when(client.listAll(same(UserConnection.class), any(ListOptions.class), any(Sort.class))) + .thenReturn(Flux.empty()); - ConfigMap configMap = new ConfigMap(); - configMap.setData(new HashMap<>()); - configMap.getData().put(SystemSetting.AuthProvider.GROUP, "{\"enabled\":[\"github\"]}"); - when(client.fetch(eq(ConfigMap.class), eq(SystemSetting.SYSTEM_CONFIG))) - .thenReturn(Mono.just(configMap)); + pileSystemConfigMap(); authProviderService.listAll() .as(StepVerifier::create) @@ -139,29 +158,36 @@ void listAll() { try { JSONAssert.assertEquals(""" [{ - "name": "github", - "displayName": "github", - "bindingUrl": "fake-binding-url", - "enabled": true, - "isBound": false, - "supportsBinding": false, - "privileged": false - }, { - "name": "gitlab", - "displayName": "gitlab", - "bindingUrl": "fake-binding-url", - "enabled": false, - "isBound": false, - "supportsBinding": false, - "privileged": false - },{ - - "name": "gitee", - "displayName": "gitee", - "enabled": false, - "isBound": false, - "supportsBinding": false, - "privileged": false + "name": "gitee", + "displayName": "gitee", + "authType": "OAUTH2", + "isBound": false, + "enabled": false, + "priority": 0, + "supportsBinding": false, + "privileged": false + }, + { + "name": "github", + "displayName": "github", + "bindingUrl": "fake-binding-url", + "authType": "OAUTH2", + "isBound": false, + "enabled": false, + "priority": 0, + "supportsBinding": false, + "privileged": false + }, + { + "name": "gitlab", + "displayName": "gitlab", + "bindingUrl": "fake-binding-url", + "authType": "OAUTH2", + "isBound": false, + "enabled": false, + "priority": 0, + "supportsBinding": false, + "privileged": false }] """, JsonUtils.objectToJson(result), @@ -180,6 +206,14 @@ AuthProvider createAuthProvider(String name) { authProvider.getMetadata().setLabels(new HashMap<>()); authProvider.setSpec(new AuthProvider.AuthProviderSpec()); authProvider.getSpec().setDisplayName(name); + authProvider.getSpec().setAuthType(AuthProvider.AuthType.OAUTH2); return authProvider; } + + void pileSystemConfigMap() { + ConfigMap configMap = new ConfigMap(); + configMap.setData(new HashMap<>()); + when(systemConfigFetcher.getConfigMap()) + .thenReturn(Mono.just(configMap)); + } } \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/security/DefaultServerAuthenticationEntryPointTest.java b/application/src/test/java/run/halo/app/security/DefaultServerAuthenticationEntryPointTest.java index f473bfca63..cc0a6366b7 100644 --- a/application/src/test/java/run/halo/app/security/DefaultServerAuthenticationEntryPointTest.java +++ b/application/src/test/java/run/halo/app/security/DefaultServerAuthenticationEntryPointTest.java @@ -3,24 +3,33 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.springframework.http.HttpHeaders.WWW_AUTHENTICATE; +import java.net.URI; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.mock.http.server.reactive.MockServerHttpRequest; import org.springframework.mock.web.server.MockServerWebExchange; import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException; +import org.springframework.security.web.server.savedrequest.ServerRequestCache; +import reactor.core.publisher.Mono; import reactor.test.StepVerifier; @ExtendWith(MockitoExtension.class) class DefaultServerAuthenticationEntryPointTest { + @Mock + ServerRequestCache requestCache; + @InjectMocks DefaultServerAuthenticationEntryPoint entryPoint; @Test - void commence() { + void commenceForXhrRequest() { var mockReq = MockServerHttpRequest.get("/protected") + .header("X-Requested-With", "XMLHttpRequest") .build(); var mockExchange = MockServerWebExchange.builder(mockReq) .build(); @@ -32,4 +41,18 @@ void commence() { assertEquals("FormLogin realm=\"console\"", headers.getFirst(WWW_AUTHENTICATE)); } + @Test + void commenceForNormalRequest() { + var mockReq = MockServerHttpRequest.get("/protected") + .build(); + var mockExchange = MockServerWebExchange.builder(mockReq) + .build(); + Mockito.when(requestCache.saveRequest(mockExchange)).thenReturn(Mono.empty()); + var commenceMono = entryPoint.commence(mockExchange, + new AuthenticationCredentialsNotFoundException("Not Found")); + StepVerifier.create(commenceMono) + .verifyComplete(); + assertEquals(URI.create("/login?authentication_required"), + mockExchange.getResponse().getHeaders().getLocation()); + } } \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/security/DefaultUserDetailServiceTest.java b/application/src/test/java/run/halo/app/security/DefaultUserDetailServiceTest.java index 123f4362b6..58cc144c24 100644 --- a/application/src/test/java/run/halo/app/security/DefaultUserDetailServiceTest.java +++ b/application/src/test/java/run/halo/app/security/DefaultUserDetailServiceTest.java @@ -23,8 +23,8 @@ import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import run.halo.app.core.extension.Role; -import run.halo.app.core.extension.service.RoleService; -import run.halo.app.core.extension.service.UserService; +import run.halo.app.core.user.service.RoleService; +import run.halo.app.core.user.service.UserService; import run.halo.app.extension.Metadata; import run.halo.app.infra.exception.UserNotFoundException; diff --git a/application/src/test/java/run/halo/app/security/HaloServerRequestCacheTest.java b/application/src/test/java/run/halo/app/security/HaloServerRequestCacheTest.java new file mode 100644 index 0000000000..eabd2f0ea1 --- /dev/null +++ b/application/src/test/java/run/halo/app/security/HaloServerRequestCacheTest.java @@ -0,0 +1,101 @@ +package run.halo.app.security; + +import java.net.URI; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.web.server.session.DefaultWebSessionManager; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +class HaloServerRequestCacheTest { + + HaloServerRequestCache requestCache; + + @BeforeEach + void setUp() { + requestCache = new HaloServerRequestCache(); + } + + @Test + void shouldNotSaveIfPageNotCacheable() { + var mockExchange = + MockServerWebExchange.from(MockServerHttpRequest.get("/login")); + requestCache.saveRequest(mockExchange) + .then(requestCache.getRedirectUri(mockExchange)) + .as(StepVerifier::create) + .verifyComplete(); + } + + @Test + void shouldSaveIfPageCacheable() { + var mockExchange = MockServerWebExchange.from( + MockServerHttpRequest.get("/archives") + .queryParam("q", "v") + .accept(MediaType.TEXT_HTML) + ); + requestCache.saveRequest(mockExchange) + .then(requestCache.getRedirectUri(mockExchange)) + .as(StepVerifier::create) + .expectNext(URI.create("/archives?q=v")) + .verifyComplete(); + } + + @Test + void shouldSaveIfRedirectUriPresent() { + var mockExchange = MockServerWebExchange.from( + MockServerHttpRequest.get("/login") + .queryParam("redirect_uri", "/halo?q=v#fragment") + ); + requestCache.saveRequest(mockExchange) + .then(requestCache.getRedirectUri(mockExchange)) + .as(StepVerifier::create) + .expectNext(URI.create("/halo?q=v#fragment")) + .verifyComplete(); + } + + @Test + void shouldRemoveIfRedirectUriFound() { + var sessionManager = new DefaultWebSessionManager(); + var mockExchange = MockServerWebExchange.builder(MockServerHttpRequest.get("/login") + .queryParam("redirect_uri", "/halo") + ) + .sessionManager(sessionManager) + .build(); + var removeExchange = mockExchange.mutate() + .request(builder -> builder.uri(URI.create("/halo"))) + .build(); + requestCache.saveRequest(mockExchange) + .then(Mono.defer(() -> requestCache.removeMatchingRequest(removeExchange))) + .as(StepVerifier::create) + .assertNext(request -> { + Assertions.assertEquals(URI.create("/halo"), request.getURI()); + }) + .verifyComplete(); + } + + @Test + void shouldRemoveIfRedirectUriFoundAndContainsFragment() { + var sessionManager = new DefaultWebSessionManager(); + var mockExchange = + MockServerWebExchange.builder(MockServerHttpRequest.get("/login") + .queryParam("redirect_uri", "/halo#fragment") + ) + .sessionManager(sessionManager) + .build(); + var removeExchange = mockExchange.mutate() + .request(builder -> builder.uri(URI.create("/halo"))) + .build(); + requestCache.saveRequest(mockExchange) + .then(Mono.defer(() -> requestCache.removeMatchingRequest(removeExchange))) + .as(StepVerifier::create) + .assertNext(request -> { + Assertions.assertEquals(URI.create("/halo"), request.getURI()); + }) + .verifyComplete(); + } + +} diff --git a/application/src/test/java/run/halo/app/security/InitializeRedirectionWebFilterTest.java b/application/src/test/java/run/halo/app/security/InitializeRedirectionWebFilterTest.java index 9d7a1d81bd..2e9d839918 100644 --- a/application/src/test/java/run/halo/app/security/InitializeRedirectionWebFilterTest.java +++ b/application/src/test/java/run/halo/app/security/InitializeRedirectionWebFilterTest.java @@ -2,6 +2,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -14,6 +15,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; import org.springframework.mock.http.server.reactive.MockServerHttpRequest; import org.springframework.mock.web.server.MockServerWebExchange; import org.springframework.security.web.server.ServerRedirectStrategy; @@ -50,49 +52,57 @@ void shouldRedirectWhenSystemNotInitialized() { when(initializationStateGetter.userInitialized()).thenReturn(Mono.just(false)); WebFilterChain chain = mock(WebFilterChain.class); + var paths = new String[] {"/", "/console/test", "/uc/test", "/login", "/signup"}; + for (String path : paths) { + MockServerHttpRequest request = MockServerHttpRequest.get(path) + .accept(MediaType.TEXT_HTML).build(); + MockServerWebExchange exchange = MockServerWebExchange.from(request); - MockServerHttpRequest request = MockServerHttpRequest.get("/").build(); - MockServerWebExchange exchange = MockServerWebExchange.from(request); + when(serverRedirectStrategy.sendRedirect(any(), any())).thenReturn(Mono.empty().then()); - when(serverRedirectStrategy.sendRedirect(any(), any())).thenReturn(Mono.empty().then()); + Mono result = filter.filter(exchange, chain); - Mono result = filter.filter(exchange, chain); + StepVerifier.create(result) + .expectNextCount(0) + .expectComplete() + .verify(); - StepVerifier.create(result) - .expectNextCount(0) - .expectComplete() - .verify(); - - verify(serverRedirectStrategy).sendRedirect(eq(exchange), eq(URI.create("/console"))); - verify(chain, never()).filter(eq(exchange)); + verify(serverRedirectStrategy).sendRedirect(eq(exchange), + eq(URI.create("/system/setup"))); + verify(chain, never()).filter(eq(exchange)); + } } @Test void shouldNotRedirectWhenSystemInitialized() { - when(initializationStateGetter.userInitialized()).thenReturn(Mono.just(true)); + lenient().when(initializationStateGetter.userInitialized()).thenReturn(Mono.just(true)); WebFilterChain chain = mock(WebFilterChain.class); - MockServerHttpRequest request = MockServerHttpRequest.get("/").build(); - MockServerWebExchange exchange = MockServerWebExchange.from(request); - when(chain.filter(any())).thenReturn(Mono.empty().then()); - Mono result = filter.filter(exchange, chain); - - StepVerifier.create(result) - .expectNextCount(0) - .expectComplete() - .verify(); - - verify(serverRedirectStrategy, never()).sendRedirect(eq(exchange), - eq(URI.create("/console"))); - verify(chain).filter(eq(exchange)); + var paths = new String[] {"/test", "/apis/test", "system/setup", "/logout"}; + for (String path : paths) { + MockServerHttpRequest request = MockServerHttpRequest.get(path) + .accept(MediaType.TEXT_HTML).build(); + MockServerWebExchange exchange = MockServerWebExchange.from(request); + when(chain.filter(any())).thenReturn(Mono.empty().then()); + Mono result = filter.filter(exchange, chain); + + StepVerifier.create(result) + .expectNextCount(0) + .expectComplete() + .verify(); + + verify(serverRedirectStrategy, never()).sendRedirect(eq(exchange), any()); + verify(chain).filter(eq(exchange)); + } } @Test - void shouldNotRedirectWhenNotHomePage() { + void shouldNotRedirectTest() { WebFilterChain chain = mock(WebFilterChain.class); - MockServerHttpRequest request = MockServerHttpRequest.get("/test").build(); + MockServerHttpRequest request = MockServerHttpRequest.get("/test") + .accept(MediaType.TEXT_HTML).build(); MockServerWebExchange exchange = MockServerWebExchange.from(request); when(chain.filter(any())).thenReturn(Mono.empty().then()); Mono result = filter.filter(exchange, chain); @@ -102,8 +112,7 @@ void shouldNotRedirectWhenNotHomePage() { .expectComplete() .verify(); - verify(serverRedirectStrategy, never()).sendRedirect(eq(exchange), - eq(URI.create("/console"))); + verify(serverRedirectStrategy, never()).sendRedirect(eq(exchange), any()); verify(chain).filter(eq(exchange)); } } diff --git a/application/src/test/java/run/halo/app/security/SuperAdminInitializerTest.java b/application/src/test/java/run/halo/app/security/SuperAdminInitializerTest.java deleted file mode 100644 index 909bee4f7c..0000000000 --- a/application/src/test/java/run/halo/app/security/SuperAdminInitializerTest.java +++ /dev/null @@ -1,62 +0,0 @@ -package run.halo.app.security; - -import static org.mockito.ArgumentMatchers.argThat; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; - -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; -import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.SpyBean; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.test.web.reactive.server.WebTestClient; -import run.halo.app.core.extension.Role; -import run.halo.app.core.extension.RoleBinding; -import run.halo.app.core.extension.User; -import run.halo.app.extension.ReactiveExtensionClient; - -@Disabled -@SpringBootTest(properties = {"halo.security.initializer.disabled=false", - "halo.security.initializer.super-admin-username=fake-admin", - "halo.security.initializer.super-admin-password=fake-password", - "halo.required-extension-disabled=true", - "halo.theme.initializer.disabled=true"}) -@AutoConfigureWebTestClient -@AutoConfigureTestDatabase -class SuperAdminInitializerTest { - - @SpyBean - ReactiveExtensionClient client; - - @Autowired - WebTestClient webClient; - - @Autowired - PasswordEncoder encoder; - - @Test - void checkSuperAdminInitialization() { - verify(client, times(1)).create(argThat(extension -> { - if (extension instanceof User user) { - return "fake-admin".equals(user.getMetadata().getName()) - && encoder.matches("fake-password", user.getSpec().getPassword()); - } - return false; - })); - verify(client, times(1)).create(argThat(extension -> { - if (extension instanceof Role role) { - return "super-role".equals(role.getMetadata().getName()); - } - return false; - })); - verify(client, times(1)).create(argThat(extension -> { - if (extension instanceof RoleBinding roleBinding) { - return "fake-admin-super-role-binding".equals(roleBinding.getMetadata().getName()); - } - return false; - })); - } -} diff --git a/application/src/test/java/run/halo/app/security/authentication/login/LoginAuthenticationConverterTest.java b/application/src/test/java/run/halo/app/security/authentication/login/LoginAuthenticationConverterTest.java index a40207b96f..445895a348 100644 --- a/application/src/test/java/run/halo/app/security/authentication/login/LoginAuthenticationConverterTest.java +++ b/application/src/test/java/run/halo/app/security/authentication/login/LoginAuthenticationConverterTest.java @@ -27,8 +27,8 @@ import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; -import run.halo.app.infra.exception.RateLimitExceededException; import run.halo.app.security.authentication.CryptoService; +import run.halo.app.security.authentication.exception.TooManyRequestsException; @ExtendWith(MockitoExtension.class) class LoginAuthenticationConverterTest { @@ -77,7 +77,7 @@ void shouldTriggerRateLimit() { when(rateLimiterRegistry.rateLimiter("authentication-from-ip-unknown", "authentication")) .thenReturn(rateLimiter); StepVerifier.create(converter.convert(exchange)) - .expectError(RateLimitExceededException.class) + .expectError(TooManyRequestsException.class) .verify(); verify(cryptoService, never()).decrypt(password.getBytes()); diff --git a/application/src/test/java/run/halo/app/security/authorization/AuthorityUtilsTest.java b/application/src/test/java/run/halo/app/security/authorization/AuthorityUtilsTest.java index f9266434b0..1d9052a340 100644 --- a/application/src/test/java/run/halo/app/security/authorization/AuthorityUtilsTest.java +++ b/application/src/test/java/run/halo/app/security/authorization/AuthorityUtilsTest.java @@ -3,16 +3,12 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.mock; import static run.halo.app.security.authorization.AuthorityUtils.authoritiesToRoles; import static run.halo.app.security.authorization.AuthorityUtils.containsSuperRole; -import static run.halo.app.security.authorization.AuthorityUtils.isRealUser; import java.util.List; import java.util.Set; import org.junit.jupiter.api.Test; -import org.springframework.security.authentication.RememberMeAuthenticationToken; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; class AuthorityUtilsTest { @@ -29,7 +25,7 @@ void authoritiesToRolesTest() { var roles = authoritiesToRoles(authorities); - assertEquals(Set.of("admin", "owner", "manager", "faker", "system:read"), roles); + assertEquals(Set.of("admin", "owner", "manager"), roles); } @Test @@ -39,9 +35,4 @@ void containsSuperRoleTest() { assertFalse(containsSuperRole(Set.of("admin"))); } - @Test - void shouldReturnTrueWhenAuthenticationIsRealUser() { - assertTrue(isRealUser(mock(UsernamePasswordAuthenticationToken.class))); - assertTrue(isRealUser(mock(RememberMeAuthenticationToken.class))); - } } \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/security/authorization/AuthorizationTest.java b/application/src/test/java/run/halo/app/security/authorization/AuthorizationTest.java index 23d2e7998a..2dce90adce 100644 --- a/application/src/test/java/run/halo/app/security/authorization/AuthorizationTest.java +++ b/application/src/test/java/run/halo/app/security/authorization/AuthorizationTest.java @@ -17,7 +17,6 @@ import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.TestConfiguration; -import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; import org.springframework.http.MediaType; @@ -26,6 +25,7 @@ import org.springframework.security.core.userdetails.ReactiveUserDetailsService; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.RouterFunctions; @@ -35,7 +35,7 @@ import reactor.core.publisher.Mono; import run.halo.app.core.extension.Role; import run.halo.app.core.extension.Role.PolicyRule; -import run.halo.app.core.extension.service.RoleService; +import run.halo.app.core.user.service.RoleService; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.Metadata; import run.halo.app.infra.AnonymousUserConst; @@ -49,13 +49,13 @@ class AuthorizationTest { @Autowired WebTestClient webClient; - @SpyBean + @MockitoSpyBean ReactiveUserDetailsService userDetailsService; - @SpyBean + @MockitoSpyBean ReactiveUserDetailsPasswordService userDetailsPasswordService; - @SpyBean + @MockitoSpyBean RoleService roleService; @Autowired @@ -70,12 +70,18 @@ void setUp() { void anonymousUserAccessProtectedApi() { when(userDetailsService.findByUsername(eq(AnonymousUserConst.PRINCIPAL))) .thenReturn(Mono.empty()); - when(roleService.listDependenciesFlux(anySet())).thenReturn(Flux.empty()); - webClient.get().uri("/apis/fake.halo.run/v1/posts").exchange().expectStatus() - .isUnauthorized(); + webClient.get().uri("/apis/fake.halo.run/v1/posts") + .header("X-Requested-With", "XMLHttpRequest") + .exchange() + .expectStatus().isUnauthorized(); - verify(roleService).listDependenciesFlux(anySet()); + webClient.get().uri("/apis/fake.halo.run/v1/posts") + .exchange() + .expectStatus().isFound() + .expectHeader().location("/login?authentication_required"); + + verify(roleService, times(2)).listDependenciesFlux(anySet()); } @Test @@ -97,13 +103,19 @@ void anonymousUserAccessAuthenticationFreeApi() { .isOk() .expectBody(String.class).isEqualTo("returned posts"); - verify(roleService).listDependenciesFlux(anySet()); - - webClient.get().uri("/apis/fake.halo.run/v1/posts/hello-halo").exchange() + webClient.get().uri("/apis/fake.halo.run/v1/posts/hello-halo") + .header("X-Requested-With", "XMLHttpRequest") + .exchange() .expectStatus() .isUnauthorized(); - verify(roleService, times(2)).listDependenciesFlux(anySet()); + webClient.get().uri("/apis/fake.halo.run/v1/posts/hello-halo") + .exchange() + .expectStatus() + .isFound() + .expectHeader().location("/login?authentication_required"); + + verify(roleService, times(3)).listDependenciesFlux(anySet()); } @Test diff --git a/application/src/test/java/run/halo/app/security/authorization/DefaultRuleResolverTest.java b/application/src/test/java/run/halo/app/security/authorization/DefaultRuleResolverTest.java index a311bac5de..9190fbafb6 100644 --- a/application/src/test/java/run/halo/app/security/authorization/DefaultRuleResolverTest.java +++ b/application/src/test/java/run/halo/app/security/authorization/DefaultRuleResolverTest.java @@ -21,7 +21,7 @@ import reactor.test.StepVerifier; import run.halo.app.core.extension.Role; import run.halo.app.core.extension.Role.PolicyRule; -import run.halo.app.core.extension.service.RoleService; +import run.halo.app.core.user.service.RoleService; import run.halo.app.extension.Metadata; @ExtendWith(MockitoExtension.class) @@ -37,7 +37,7 @@ class DefaultRuleResolverTest { void visitRules() { when(roleService.listDependenciesFlux(Set.of("ruleReadPost"))) .thenReturn(Flux.just(mockRole())); - var fakeUser = new User("admin", "123456", createAuthorityList("ruleReadPost")); + var fakeUser = new User("admin", "123456", createAuthorityList("ROLE_ruleReadPost")); var authentication = authenticated(fakeUser, fakeUser.getPassword(), fakeUser.getAuthorities()); @@ -59,7 +59,7 @@ void visitRules() { void visitRulesForUserspaceScope() { when(roleService.listDependenciesFlux(Set.of("ruleReadPost"))) .thenReturn(Flux.just(mockRole())); - var fakeUser = new User("admin", "123456", createAuthorityList("ruleReadPost")); + var fakeUser = new User("admin", "123456", createAuthorityList("ROLE_ruleReadPost")); var authentication = authenticated(fakeUser, fakeUser.getPassword(), fakeUser.getAuthorities()); var cases = List.of( diff --git a/application/src/test/java/run/halo/app/security/jackson2/HaloSecurityJacksonModuleTest.java b/application/src/test/java/run/halo/app/security/jackson2/HaloSecurityJacksonModuleTest.java index 55804bbf7c..a548c25c69 100644 --- a/application/src/test/java/run/halo/app/security/jackson2/HaloSecurityJacksonModuleTest.java +++ b/application/src/test/java/run/halo/app/security/jackson2/HaloSecurityJacksonModuleTest.java @@ -4,6 +4,8 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import java.util.Map; import java.util.function.Function; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -14,7 +16,10 @@ import org.springframework.security.core.context.SecurityContextImpl; import org.springframework.security.core.userdetails.User; import org.springframework.security.jackson2.SecurityJackson2Modules; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; import run.halo.app.security.authentication.login.HaloUser; +import run.halo.app.security.authentication.oauth2.HaloOAuth2AuthenticationToken; import run.halo.app.security.authentication.twofactor.TwoFactorAuthentication; class HaloSecurityJacksonModuleTest { @@ -39,10 +44,21 @@ void codecHaloUserTest() throws JsonProcessingException { @Test void codecTwoFactorAuthenticationTokenTest() throws JsonProcessingException { - codecAssert(haloUser -> new TwoFactorAuthentication( - UsernamePasswordAuthenticationToken.authenticated(haloUser, + codecAssert(haloUser -> { + var authentication = UsernamePasswordAuthenticationToken.authenticated(haloUser, haloUser.getPassword(), - haloUser.getAuthorities()))); + haloUser.getAuthorities()); + return new TwoFactorAuthentication(authentication); + }); + } + + @Test + void codecHaloOAuth2AuthenticationTokenTest() throws JsonProcessingException { + codecAssert(haloUser -> { + var oauth2User = new DefaultOAuth2User(List.of(), Map.of("name", "halo"), "name"); + var oauth2Token = new OAuth2AuthenticationToken(oauth2User, List.of(), "github"); + return new HaloOAuth2AuthenticationToken(haloUser, oauth2Token); + }); } void codecAssert(Function authenticationConverter) diff --git a/application/src/test/java/run/halo/app/security/preauth/SystemSetupEndpointTest.java b/application/src/test/java/run/halo/app/security/preauth/SystemSetupEndpointTest.java new file mode 100644 index 0000000000..178d636723 --- /dev/null +++ b/application/src/test/java/run/halo/app/security/preauth/SystemSetupEndpointTest.java @@ -0,0 +1,30 @@ +package run.halo.app.security.preauth; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Properties; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link SystemSetupEndpoint}. + * + * @author guqing + * @since 2.20.0 + */ +class SystemSetupEndpointTest { + + @Test + void placeholderTest() { + var properties = new Properties(); + properties.setProperty("username", "guqing"); + properties.setProperty("timestamp", "2024-09-30"); + var str = SystemSetupEndpoint.PROPERTY_PLACEHOLDER_HELPER.replacePlaceholders(""" + ${username} + ${timestamp} + """, properties); + assertThat(str).isEqualTo(""" + guqing + 2024-09-30 + """); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/theme/ReactiveFinderExpressionParserTests.java b/application/src/test/java/run/halo/app/theme/ReactiveFinderExpressionParserTests.java index cd6f5bd02d..20010b0f33 100644 --- a/application/src/test/java/run/halo/app/theme/ReactiveFinderExpressionParserTests.java +++ b/application/src/test/java/run/halo/app/theme/ReactiveFinderExpressionParserTests.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ArrayNode; @@ -14,6 +15,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.context.ApplicationContext; import org.thymeleaf.IEngineConfiguration; import org.thymeleaf.TemplateEngine; @@ -29,13 +31,13 @@ import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; import run.halo.app.infra.SystemSetting; import run.halo.app.infra.utils.JsonUtils; +import run.halo.app.plugin.extensionpoint.ExtensionGetter; import run.halo.app.theme.dialect.HaloProcessorDialect; /** * Tests expression parser for reactive return value. * * @author guqing - * @see ReactivePropertyAccessor * @see ReactiveSpelVariableExpressionEvaluator * @since 2.0.0 */ @@ -44,6 +46,9 @@ public class ReactiveFinderExpressionParserTests { @Mock private ApplicationContext applicationContext; + @Mock + private ObjectProvider extensionGetterProvider; + @Mock private SystemConfigurableEnvironmentFetcher environmentFetcher; @@ -62,6 +67,9 @@ public IStandardVariableExpressionEvaluator getVariableExpressionEvaluator() { templateEngine.addTemplateResolver(new TestTemplateResolver()); lenient().when(applicationContext.getBean(eq(SystemConfigurableEnvironmentFetcher.class))) .thenReturn(environmentFetcher); + when(applicationContext.getBeanProvider(ExtensionGetter.class)) + .thenReturn(extensionGetterProvider); + when(extensionGetterProvider.getIfUnique()).thenReturn(null); lenient().when(environmentFetcher.fetchComment()) .thenReturn(Mono.just(new SystemSetting.Comment())); } @@ -155,7 +163,7 @@ protected ITemplateResource computeTemplateResource(IEngineConfiguration configu var mapMono = /*[[${target.mapMono.foo}]]*/; var arrayNodeMono = /*[[${target.arrayNodeMono.get(0).foo}]]*/; - """); + """); } } diff --git a/application/src/test/java/run/halo/app/theme/SiteSettingVariablesAcquirerTest.java b/application/src/test/java/run/halo/app/theme/SiteSettingVariablesAcquirerTest.java index 8fd8066abc..11d6dce143 100644 --- a/application/src/test/java/run/halo/app/theme/SiteSettingVariablesAcquirerTest.java +++ b/application/src/test/java/run/halo/app/theme/SiteSettingVariablesAcquirerTest.java @@ -6,6 +6,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.github.zafarkhaja.semver.Version; import java.net.MalformedURLException; import java.net.URL; import java.util.Map; @@ -20,6 +21,7 @@ import run.halo.app.extension.ConfigMap; import run.halo.app.infra.ExternalUrlSupplier; import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; +import run.halo.app.infra.SystemVersionSupplier; import run.halo.app.theme.finders.vo.SiteSettingVo; /** @@ -32,6 +34,10 @@ public class SiteSettingVariablesAcquirerTest { @Mock private ExternalUrlSupplier externalUrlSupplier; + + @Mock + private SystemVersionSupplier systemVersionSupplier; + @Mock private SystemConfigurableEnvironmentFetcher environmentFetcher; @@ -45,6 +51,7 @@ void acquireWhenExternalUrlSet() throws MalformedURLException { var url = new URL("https://halo.run"); when(externalUrlSupplier.getURL(any())).thenReturn(url); + when(systemVersionSupplier.get()).thenReturn(Version.parse("0.0.0-alpha.1")); when(environmentFetcher.getConfigMap()).thenReturn(Mono.just(configMap)); siteSettingVariablesAcquirer.acquire(mock(ServerWebExchange.class)) @@ -52,9 +59,13 @@ void acquireWhenExternalUrlSet() throws MalformedURLException { .consumeNextWith(result -> { assertThat(result).containsKey("site"); assertThat(result.get("site")).isInstanceOf(SiteSettingVo.class); - assertThat((SiteSettingVo) result.get("site")) + var site = (SiteSettingVo) result.get("site"); + assertThat(site) .extracting(SiteSettingVo::getUrl) .isEqualTo(url); + assertThat(site) + .extracting(SiteSettingVo::getVersion) + .isEqualTo("0.0.0-alpha.1"); }) .verifyComplete(); verify(externalUrlSupplier).getURL(any()); diff --git a/application/src/test/java/run/halo/app/theme/ThemeIntegrationTest.java b/application/src/test/java/run/halo/app/theme/ThemeIntegrationTest.java index fd0505382f..95a246fcbe 100644 --- a/application/src/test/java/run/halo/app/theme/ThemeIntegrationTest.java +++ b/application/src/test/java/run/halo/app/theme/ThemeIntegrationTest.java @@ -1,6 +1,10 @@ package run.halo.app.theme; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.when; +import static org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers.pathMatchers; import java.util.LinkedHashSet; import java.util.List; @@ -11,11 +15,13 @@ import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.TestConfiguration; -import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; +import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher.MatchResult; import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.RouterFunctions; @@ -26,6 +32,9 @@ import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.Metadata; import run.halo.app.infra.InitializationStateGetter; +import run.halo.app.infra.utils.HaloUtils; +import run.halo.app.security.AfterSecurityWebFilter; +import run.halo.app.theme.router.ModelConst; @SpringBootTest @Import(ThemeIntegrationTest.TestConfig.class) @@ -36,7 +45,7 @@ public class ThemeIntegrationTest { @Autowired WebTestClient webClient; - @MockBean + @MockitoBean InitializationStateGetter initializationStateGetter; @Autowired @@ -78,11 +87,53 @@ RouterFunction noTemplateExistsRoute() { .build(); } + @Bean + RouterFunction noCacheRoute() { + return RouterFunctions.route() + .GET( + "/should-not-cache", + request -> ServerResponse.ok().render("no-template-exists") + ) + .before(HaloUtils.noCache()) + .build(); + } + + @Bean + AfterSecurityWebFilter poweredByHaloTemplateEngineCheckFilter() { + var matcher = pathMatchers(HttpMethod.GET, "/should-not-cache"); + return (exchange, chain) -> chain.filter(exchange) + .flatMap(v -> matcher.matches(exchange) + .filter(MatchResult::isMatch) + .switchIfEmpty(Mono.fromRunnable(() -> { + assertNull(exchange.getAttribute(ModelConst.NO_CACHE)); + assertTrue(exchange.getRequiredAttribute( + ModelConst.POWERED_BY_HALO_TEMPLATE_ENGINE) + ); + }).then(Mono.empty())) + .doOnNext(m -> { + assertTrue(exchange.getRequiredAttribute(ModelConst.NO_CACHE)); + assertFalse(exchange.getRequiredAttribute( + ModelConst.POWERED_BY_HALO_TEMPLATE_ENGINE) + ); + }) + ) + .then(); + } + } @Test void shouldRespondNotFoundIfNoTemplateFound() { - webClient.get().uri("/no-template-exists") + webClient.get() + .uri("/no-template-exists") + .accept(MediaType.TEXT_HTML) + .exchange() + .expectStatus().isNotFound() + .expectBody(String.class) + .value(Matchers.containsString("Template no-template-exists was not found")); + + webClient.get() + .uri("/should-not-cache") .accept(MediaType.TEXT_HTML) .exchange() .expectStatus().isNotFound() diff --git a/application/src/test/java/run/halo/app/theme/ThemeLocaleContextResolverTest.java b/application/src/test/java/run/halo/app/theme/ThemeLocaleContextResolverTest.java index 8ab62e89e2..a1ada782e3 100644 --- a/application/src/test/java/run/halo/app/theme/ThemeLocaleContextResolverTest.java +++ b/application/src/test/java/run/halo/app/theme/ThemeLocaleContextResolverTest.java @@ -12,7 +12,7 @@ import static java.util.Locale.UK; import static java.util.Locale.US; import static org.assertj.core.api.Assertions.assertThat; -import static run.halo.app.theme.ThemeLocaleContextResolver.DEFAULT_PARAMETER_NAME; +import static run.halo.app.theme.ThemeLocaleContextResolver.LANGUAGE_COOKIE_NAME; import static run.halo.app.theme.ThemeLocaleContextResolver.TIME_ZONE_COOKIE_NAME; import java.util.Arrays; @@ -62,6 +62,10 @@ public void resolveFromParam() { .isEqualTo(ENGLISH); assertThat(this.resolver.resolveLocaleContext(exchangeForParam("zh")).getLocale()) .isEqualTo(CHINESE); + assertThat(this.resolver.resolveLocaleContext(exchangeForParam("zh-CN")).getLocale()) + .isEqualTo(CHINA); + assertThat(this.resolver.resolveLocaleContext(exchangeForParam("zh-cn")).getLocale()) + .isEqualTo(CHINA); } @Test @@ -183,7 +187,7 @@ private ServerWebExchange exchangeTimeZone(Locale... locales) { return MockServerWebExchange.from( MockServerHttpRequest.get("").acceptLanguageAsLocales(locales) .cookie(new HttpCookie(TIME_ZONE_COOKIE_NAME, "America/Adak")) - .cookie(new HttpCookie(DEFAULT_PARAMETER_NAME, "en"))); + .cookie(new HttpCookie(LANGUAGE_COOKIE_NAME, "en"))); } private ServerWebExchange exchangeForParam(String language) { diff --git a/application/src/test/java/run/halo/app/theme/ViewNameResolverTest.java b/application/src/test/java/run/halo/app/theme/ViewNameResolverTest.java index 2c8c6021b6..14c656a648 100644 --- a/application/src/test/java/run/halo/app/theme/ViewNameResolverTest.java +++ b/application/src/test/java/run/halo/app/theme/ViewNameResolverTest.java @@ -2,14 +2,13 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; -import java.io.File; import java.io.IOException; import java.net.URI; import java.net.URISyntaxException; import java.nio.file.Files; +import java.nio.file.Path; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -35,40 +34,41 @@ class ViewNameResolverTest { @Mock - private ThemeResolver themeResolver; + ThemeResolver themeResolver; @Mock - private ThymeleafProperties thymeleafProperties; + ThymeleafProperties thymeleafProperties; @InjectMocks - private DefaultViewNameResolver viewNameResolver; + DefaultViewNameResolver viewNameResolver; @TempDir - private File themePath; + Path themePath; @BeforeEach void setUp() throws IOException { when(thymeleafProperties.getSuffix()).thenReturn(ThymeleafProperties.DEFAULT_SUFFIX); + } - var templatesPath = themePath.toPath().resolve("templates"); + @Test + void resolveViewNameOrDefault() throws URISyntaxException, IOException { + var templatesPath = themePath.resolve("templates"); if (!Files.exists(templatesPath)) { Files.createDirectory(templatesPath); } Files.createFile(templatesPath.resolve("post_news.html")); Files.createFile(templatesPath.resolve("post_docs.html")); - when(themeResolver.getTheme(any())) + + var exchange = Mockito.mock(ServerWebExchange.class); + when(themeResolver.getTheme(exchange)) .thenReturn(Mono.fromSupplier(() -> ThemeContext.builder() .name("fake-theme") - .path(themePath.toPath()) + .path(themePath) .active(true) .build()) ); - } - @Test - void resolveViewNameOrDefault() throws URISyntaxException { - ServerWebExchange exchange = Mockito.mock(ServerWebExchange.class); MockServerRequest request = MockServerRequest.builder() .uri(new URI("/")).method(HttpMethod.GET) .exchange(exchange) diff --git a/application/src/test/java/run/halo/app/theme/dialect/CommentElementTagProcessorTest.java b/application/src/test/java/run/halo/app/theme/dialect/CommentElementTagProcessorTest.java index 2a0726b2e7..a348af54d8 100644 --- a/application/src/test/java/run/halo/app/theme/dialect/CommentElementTagProcessorTest.java +++ b/application/src/test/java/run/halo/app/theme/dialect/CommentElementTagProcessorTest.java @@ -13,6 +13,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.context.ApplicationContext; import org.thymeleaf.IEngineConfiguration; import org.thymeleaf.TemplateEngine; @@ -48,6 +49,9 @@ class CommentElementTagProcessorTest { @Mock private ExtensionGetter extensionGetter; + @Mock + private ObjectProvider extensionGetterProvider; + @Mock private SystemConfigurableEnvironmentFetcher environmentFetcher; @@ -61,6 +65,9 @@ void setUp() { templateEngine.addTemplateResolver(new TestTemplateResolver()); lenient().when(applicationContext.getBean(eq(ExtensionGetter.class))) .thenReturn(extensionGetter); + when(applicationContext.getBeanProvider(ExtensionGetter.class)) + .thenReturn(extensionGetterProvider); + when(extensionGetterProvider.getIfUnique()).thenReturn(null); } @Test diff --git a/application/src/test/java/run/halo/app/theme/dialect/GeneratorMetaProcessorTest.java b/application/src/test/java/run/halo/app/theme/dialect/GeneratorMetaProcessorTest.java index a732d7aecd..92d2bf7a13 100644 --- a/application/src/test/java/run/halo/app/theme/dialect/GeneratorMetaProcessorTest.java +++ b/application/src/test/java/run/halo/app/theme/dialect/GeneratorMetaProcessorTest.java @@ -11,7 +11,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.util.ResourceUtils; import org.springframework.web.server.ServerWebExchange; @@ -27,10 +27,10 @@ class GeneratorMetaProcessorTest { @Autowired WebTestClient webClient; - @MockBean + @MockitoBean InitializationStateGetter initializationStateGetter; - @MockBean + @MockitoBean ThemeResolver themeResolver; @BeforeEach diff --git a/application/src/test/java/run/halo/app/theme/dialect/HaloPostTemplateHandlerTest.java b/application/src/test/java/run/halo/app/theme/dialect/HaloPostTemplateHandlerTest.java new file mode 100644 index 0000000000..4881d032fa --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/dialect/HaloPostTemplateHandlerTest.java @@ -0,0 +1,157 @@ +package run.halo.app.theme.dialect; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.thymeleaf.spring6.expression.ThymeleafEvaluationContext.THYMELEAF_EVALUATION_CONTEXT_CONTEXT_VARIABLE_NAME; + +import java.util.List; +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.context.ApplicationContext; +import org.thymeleaf.context.ITemplateContext; +import org.thymeleaf.engine.ITemplateHandler; +import org.thymeleaf.model.IOpenElementTag; +import org.thymeleaf.model.IStandaloneElementTag; +import org.thymeleaf.spring6.expression.ThymeleafEvaluationContext; +import reactor.core.publisher.Mono; +import run.halo.app.plugin.extensionpoint.ExtensionGetter; + +@ExtendWith(MockitoExtension.class) +class HaloPostTemplateHandlerTest { + + HaloPostTemplateHandler postHandler; + + @Mock + ITemplateContext templateContext; + + @Mock + ITemplateHandler next; + + @Mock + ApplicationContext applicationContext; + + @Mock + IStandaloneElementTag standaloneElementTag; + + @Mock + IOpenElementTag openElementTag; + + @Mock + ObjectProvider extensionGetterProvider; + + @Mock + ExtensionGetter extensionGetter; + + + @BeforeEach + void setUp() { + postHandler = new HaloPostTemplateHandler(); + var evaluationContext = mock(ThymeleafEvaluationContext.class); + when(evaluationContext.getApplicationContext()).thenReturn(applicationContext); + when(templateContext.getVariable(THYMELEAF_EVALUATION_CONTEXT_CONTEXT_VARIABLE_NAME)) + .thenReturn(evaluationContext); + when(applicationContext.getBeanProvider(ExtensionGetter.class)) + .thenReturn(extensionGetterProvider); + when(extensionGetterProvider.getIfUnique()).thenReturn(extensionGetter); + } + + @ParameterizedTest + @MethodSource("provideEmptyElementTagProcessors") + void shouldHandleStandaloneElementIfNoElementTagProcessors( + List processors + ) { + when(extensionGetter.getExtensionList(ElementTagPostProcessor.class)) + .thenReturn(processors); + + postHandler.setContext(templateContext); + postHandler.setNext(next); + postHandler.handleStandaloneElement(standaloneElementTag); + verify(next).handleStandaloneElement(standaloneElementTag); + } + + @Test + void shouldHandleStandaloneElementIfOneElementTagProcessorProvided() { + var processor = mock(ElementTagPostProcessor.class); + var newTag = mock(IStandaloneElementTag.class); + when(processor.process(SecureTemplateContextWrapper.wrap(templateContext), + standaloneElementTag)) + .thenReturn(Mono.just(newTag)); + when(extensionGetter.getExtensionList(ElementTagPostProcessor.class)) + .thenReturn(List.of(processor)); + + postHandler.setContext(templateContext); + postHandler.setNext(next); + postHandler.handleStandaloneElement(standaloneElementTag); + verify(next).handleStandaloneElement(newTag); + } + + @Test + void shouldHandleStandaloneElementIfTagTypeChanged() { + var processor = mock(ElementTagPostProcessor.class); + var newTag = mock(IStandaloneElementTag.class); + when(processor.process(SecureTemplateContextWrapper.wrap(templateContext), + standaloneElementTag)) + .thenReturn(Mono.just(newTag)); + when(extensionGetter.getExtensionList(ElementTagPostProcessor.class)) + .thenReturn(List.of(processor)); + + postHandler.setContext(templateContext); + postHandler.setNext(next); + postHandler.handleStandaloneElement(standaloneElementTag); + verify(next).handleStandaloneElement(newTag); + } + + @Test + void shouldHandleStandaloneElementIfMoreElementTagProcessorsProvided() { + var processor1 = mock(ElementTagPostProcessor.class); + var processor2 = mock(ElementTagPostProcessor.class); + var newTag1 = mock(IStandaloneElementTag.class); + var newTag2 = mock(IStandaloneElementTag.class); + when(processor1.process(SecureTemplateContextWrapper.wrap(templateContext), + standaloneElementTag)) + .thenReturn(Mono.just(newTag1)); + when(processor2.process(SecureTemplateContextWrapper.wrap(templateContext), newTag1)) + .thenReturn(Mono.just(newTag2)); + when(extensionGetter.getExtensionList(ElementTagPostProcessor.class)) + .thenReturn(List.of(processor1, processor2)); + + postHandler.setContext(templateContext); + postHandler.setNext(next); + postHandler.handleStandaloneElement(standaloneElementTag); + verify(next).handleStandaloneElement(newTag2); + } + + @Test + void shouldNotHandleIfProcessedTagTypeChanged() { + var processor = mock(ElementTagPostProcessor.class); + var newTag = mock(IOpenElementTag.class); + when(processor.process(SecureTemplateContextWrapper.wrap(templateContext), + standaloneElementTag)) + .thenReturn(Mono.just(newTag)); + when(extensionGetter.getExtensionList(ElementTagPostProcessor.class)) + .thenReturn(List.of(processor)); + + postHandler.setContext(templateContext); + postHandler.setNext(next); + assertThrows(ClassCastException.class, + () -> postHandler.handleStandaloneElement(standaloneElementTag) + ); + } + + static Stream> provideEmptyElementTagProcessors() { + return Stream.of( + null, + List.of() + ); + } + +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/theme/dialect/InjectionExcluderProcessorTest.java b/application/src/test/java/run/halo/app/theme/dialect/InjectionExcluderProcessorTest.java new file mode 100644 index 0000000000..89a767fb16 --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/dialect/InjectionExcluderProcessorTest.java @@ -0,0 +1,62 @@ +package run.halo.app.theme.dialect; + + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link InjectionExcluderProcessor}. + * + * @author guqing + * @since 2.20.0 + */ +class InjectionExcluderProcessorTest { + + @Nested + class PageInjectionExcluderTest { + final InjectionExcluderProcessor.PageInjectionExcluder pageInjectionExcluder = + new InjectionExcluderProcessor.PageInjectionExcluder(); + + @Test + void excludeTest() { + var cases = new String[] { + "login", + "signup", + "logout", + "password-reset/email/reset", + "error/404", + "error/500", + "challenges/totp" + }; + + for (String templateName : cases) { + assertThat(pageInjectionExcluder.isExcluded(templateName)).isTrue(); + } + } + + @Test + void shouldNotExcludeTest() { + var cases = new String[] { + "index", + "post", + "page", + "category", + "tag", + "archive", + "search", + "feed", + "sitemap", + "robots", + "custom", + "error", + "login.html", + }; + + for (String templateName : cases) { + assertThat(pageInjectionExcluder.isExcluded(templateName)).isFalse(); + } + } + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/theme/endpoint/PublicUserEndpointTest.java b/application/src/test/java/run/halo/app/theme/endpoint/PublicUserEndpointTest.java deleted file mode 100644 index 8acdefcc09..0000000000 --- a/application/src/test/java/run/halo/app/theme/endpoint/PublicUserEndpointTest.java +++ /dev/null @@ -1,92 +0,0 @@ -package run.halo.app.theme.endpoint; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import io.github.resilience4j.ratelimiter.RateLimiter; -import io.github.resilience4j.ratelimiter.RateLimiterRegistry; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.security.core.userdetails.ReactiveUserDetailsService; -import org.springframework.security.web.server.context.ServerSecurityContextRepository; -import org.springframework.test.web.reactive.server.WebTestClient; -import reactor.core.publisher.Mono; -import run.halo.app.core.extension.User; -import run.halo.app.core.extension.service.UserService; -import run.halo.app.extension.Metadata; -import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; -import run.halo.app.infra.SystemSetting; - -/** - * Tests for {@link PublicUserEndpoint}. - * - * @author guqing - * @since 2.4.0 - */ -@ExtendWith(MockitoExtension.class) -class PublicUserEndpointTest { - @Mock - private UserService userService; - @Mock - private ServerSecurityContextRepository securityContextRepository; - @Mock - private ReactiveUserDetailsService reactiveUserDetailsService; - @Mock - SystemConfigurableEnvironmentFetcher environmentFetcher; - @Mock - RateLimiterRegistry rateLimiterRegistry; - - @InjectMocks - private PublicUserEndpoint publicUserEndpoint; - - private WebTestClient webClient; - - @BeforeEach - void setUp() { - webClient = WebTestClient.bindToRouterFunction(publicUserEndpoint.endpoint()) - .build(); - } - - @Test - void signUp() { - User user = new User(); - user.setMetadata(new Metadata()); - user.getMetadata().setName("fake-user"); - user.setSpec(new User.UserSpec()); - user.getSpec().setDisplayName("hello"); - user.getSpec().setBio("bio"); - - when(userService.signUp(any(User.class), anyString())).thenReturn(Mono.just(user)); - when(securityContextRepository.save(any(), any())).thenReturn(Mono.empty()); - when(reactiveUserDetailsService.findByUsername(anyString())).thenReturn(Mono.just( - org.springframework.security.core.userdetails.User.withUsername("fake-user") - .password("123456") - .authorities("test-role") - .build())); - SystemSetting.User userSetting = mock(SystemSetting.User.class); - when(environmentFetcher.fetch(SystemSetting.User.GROUP, SystemSetting.User.class)) - .thenReturn(Mono.just(userSetting)); - - when(rateLimiterRegistry.rateLimiter("signup-from-ip-127.0.0.1", "signup")) - .thenReturn(RateLimiter.ofDefaults("signup")); - - webClient.post() - .uri("/users/-/signup") - .header("X-Forwarded-For", "127.0.0.1") - .bodyValue(new PublicUserEndpoint.SignUpRequest(user, "fake-password", "")) - .exchange() - .expectStatus().isOk(); - - verify(userService).signUp(any(User.class), anyString()); - verify(securityContextRepository).save(any(), any()); - verify(reactiveUserDetailsService).findByUsername(eq("fake-user")); - } -} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/core/extension/theme/ThemeEndpointTest.java b/application/src/test/java/run/halo/app/theme/endpoint/ThemeEndpointTest.java similarity index 88% rename from application/src/test/java/run/halo/app/core/extension/theme/ThemeEndpointTest.java rename to application/src/test/java/run/halo/app/theme/endpoint/ThemeEndpointTest.java index 4fc3194bbe..1ad89941a1 100644 --- a/application/src/test/java/run/halo/app/core/extension/theme/ThemeEndpointTest.java +++ b/application/src/test/java/run/halo/app/theme/endpoint/ThemeEndpointTest.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.theme; +package run.halo.app.theme.endpoint; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; @@ -14,6 +14,7 @@ import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Map; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; @@ -33,6 +34,7 @@ import reactor.core.publisher.Mono; import run.halo.app.core.extension.Setting; import run.halo.app.core.extension.Theme; +import run.halo.app.core.user.service.SettingConfigService; import run.halo.app.extension.ConfigMap; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; @@ -41,6 +43,7 @@ import run.halo.app.infra.SystemSetting; import run.halo.app.infra.ThemeRootGetter; import run.halo.app.theme.TemplateEngineManager; +import run.halo.app.theme.service.ThemeService; /** * Tests for {@link ThemeEndpoint}. @@ -69,6 +72,9 @@ class ThemeEndpointTest { @Mock private ReactiveUrlDataBufferFetcher urlDataBufferFetcher; + @Mock + private SettingConfigService settingConfigService; + @InjectMocks ThemeEndpoint themeEndpoint; @@ -298,8 +304,25 @@ void updateWhenConfigMapNameMatch() { .exchange() .expectStatus().isOk(); } - } + @Test + void updateJsonConfigTest() { + Theme theme = new Theme(); + theme.setMetadata(new Metadata()); + theme.setSpec(new Theme.ThemeSpec()); + theme.getSpec().setConfigMapName("fake-config-map"); + + when(client.fetch(eq(Theme.class), eq("fake-theme"))).thenReturn(Mono.just(theme)); + when(settingConfigService.upsertConfig(eq("fake-config-map"), any())) + .thenReturn(Mono.empty()); + + webTestClient.put() + .uri("/themes/fake-theme/json-config") + .body(Mono.just(Map.of()), Map.class) + .exchange() + .expectStatus().is2xxSuccessful(); + } + } @Test void fetchActivatedTheme() { @@ -360,4 +383,24 @@ void fetchThemeConfig() { verify(client).fetch(eq(ConfigMap.class), eq("fake-config")); verify(client).fetch(eq(Theme.class), eq("fake")); } + + @Test + void fetchThemeJsonConfigTest() { + Theme theme = new Theme(); + theme.setMetadata(new Metadata()); + theme.getMetadata().setName("fake"); + theme.setSpec(new Theme.ThemeSpec()); + theme.getSpec().setConfigMapName("fake-config"); + + when(settingConfigService.fetchConfig(eq("fake-config"))).thenReturn(Mono.empty()); + + when(client.fetch(eq(Theme.class), eq("fake"))).thenReturn(Mono.just(theme)); + webTestClient.get() + .uri("/themes/fake/json-config") + .exchange() + .expectStatus().isOk(); + + verify(settingConfigService).fetchConfig(eq("fake-config")); + verify(client).fetch(eq(Theme.class), eq("fake")); + } } \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/theme/finders/impl/CommentPublicQueryServiceImplTest.java b/application/src/test/java/run/halo/app/theme/finders/impl/CommentPublicQueryServiceImplTest.java index f1c883340f..a27a027059 100644 --- a/application/src/test/java/run/halo/app/theme/finders/impl/CommentPublicQueryServiceImplTest.java +++ b/application/src/test/java/run/halo/app/theme/finders/impl/CommentPublicQueryServiceImplTest.java @@ -20,11 +20,12 @@ import org.skyscreamer.jsonassert.JSONAssert; import org.springframework.test.context.junit.jupiter.SpringExtension; import reactor.core.publisher.Mono; +import run.halo.app.core.counter.CounterService; import run.halo.app.core.extension.Counter; import run.halo.app.core.extension.User; import run.halo.app.core.extension.content.Comment; import run.halo.app.core.extension.content.Post; -import run.halo.app.core.extension.service.UserService; +import run.halo.app.core.user.service.UserService; import run.halo.app.extension.GroupVersionKind; import run.halo.app.extension.ListResult; import run.halo.app.extension.Metadata; @@ -32,7 +33,6 @@ import run.halo.app.extension.Ref; import run.halo.app.infra.AnonymousUserConst; import run.halo.app.infra.utils.JsonUtils; -import run.halo.app.metrics.CounterService; /** * Tests for {@link CommentFinderImpl}. @@ -44,21 +44,19 @@ class CommentPublicQueryServiceImplTest { @Mock - private ReactiveExtensionClient client; + ReactiveExtensionClient client; + @Mock - private UserService userService; + UserService userService; @Mock - private CounterService counterService; + CounterService counterService; @InjectMocks - private CommentPublicQueryServiceImpl commentPublicQueryService; + CommentPublicQueryServiceImpl commentPublicQueryService; @BeforeEach void setUp() { - User ghost = createUser(); - ghost.getMetadata().setName("ghost"); - when(userService.getUserOrGhost(eq("ghost"))).thenReturn(Mono.just(ghost)); when(userService.getUserOrGhost(eq("fake-user"))).thenReturn(Mono.just(createUser())); } diff --git a/application/src/test/java/run/halo/app/theme/finders/impl/PostFinderImplTest.java b/application/src/test/java/run/halo/app/theme/finders/impl/PostFinderImplTest.java index b6d383cf1e..d119c2278e 100644 --- a/application/src/test/java/run/halo/app/theme/finders/impl/PostFinderImplTest.java +++ b/application/src/test/java/run/halo/app/theme/finders/impl/PostFinderImplTest.java @@ -19,12 +19,12 @@ import org.springframework.data.domain.Sort; import reactor.core.publisher.Mono; import run.halo.app.content.PostService; +import run.halo.app.core.counter.CounterService; import run.halo.app.core.extension.content.Post; import run.halo.app.extension.ListResult; import run.halo.app.extension.Metadata; import run.halo.app.extension.PageRequest; import run.halo.app.extension.ReactiveExtensionClient; -import run.halo.app.metrics.CounterService; import run.halo.app.theme.finders.CategoryFinder; import run.halo.app.theme.finders.ContributorFinder; import run.halo.app.theme.finders.PostPublicQueryService; diff --git a/application/src/test/java/run/halo/app/theme/finders/impl/ThumbnailFinderImplTest.java b/application/src/test/java/run/halo/app/theme/finders/impl/ThumbnailFinderImplTest.java new file mode 100644 index 0000000000..3fb8d6e3b9 --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/finders/impl/ThumbnailFinderImplTest.java @@ -0,0 +1,54 @@ +package run.halo.app.theme.finders.impl; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.net.URI; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; +import run.halo.app.core.attachment.ThumbnailService; + +/** + * Tests for {@link ThumbnailFinderImpl}. + * + * @author guqing + * @since 2.20.0 + */ +@ExtendWith(MockitoExtension.class) +class ThumbnailFinderImplTest { + + @Mock + ThumbnailService thumbnailService; + + @InjectMocks + ThumbnailFinderImpl thumbnailFinder; + + @Test + void shouldNotGenWhenUriIsInvalid() { + thumbnailFinder.gen("invalid uri", "l") + .as(StepVerifier::create) + .expectNext("invalid uri") + .verifyComplete(); + + verify(thumbnailService, times(0)).generate(any(), any()); + } + + @Test + void shouldGenWhenUriIsValid() { + when(thumbnailService.generate(any(), any())) + .thenReturn(Mono.just(URI.create("/test-thumb.jpg"))); + thumbnailFinder.gen("/test.jpg", "l") + .as(StepVerifier::create) + .expectNext("/test-thumb.jpg") + .verifyComplete(); + + verify(thumbnailService).generate(any(), any()); + } +} diff --git a/application/src/test/java/run/halo/app/theme/message/ThemeMessageResolutionUtilsTest.java b/application/src/test/java/run/halo/app/theme/message/ThemeMessageResolutionUtilsTest.java index 924f1edcff..f504f5904e 100644 --- a/application/src/test/java/run/halo/app/theme/message/ThemeMessageResolutionUtilsTest.java +++ b/application/src/test/java/run/halo/app/theme/message/ThemeMessageResolutionUtilsTest.java @@ -29,24 +29,16 @@ void setUp() throws FileNotFoundException { void resolveMessagesForTemplateForDefault() throws URISyntaxException { Map properties = ThemeMessageResolutionUtils.resolveMessagesForTemplate(Locale.CHINESE, getTheme()); - assertThat(properties).hasSize(1); - assertThat(properties).containsEntry("index.welcome", "欢迎来到首页"); + assertThat(properties).isEqualTo(Map.of("index.welcome", "欢迎来到首页", + "title", "来自 i18n/zh.properties 的标题")); } @Test void resolveMessagesForTemplateForEnglish() throws URISyntaxException { Map properties = ThemeMessageResolutionUtils.resolveMessagesForTemplate(Locale.ENGLISH, getTheme()); - assertThat(properties).hasSize(1); - assertThat(properties).containsEntry("index.welcome", "Welcome to the index"); - } - - @Test - void messageFormat() { - String s = - ThemeMessageResolutionUtils.formatMessage(Locale.ENGLISH, "Welcome {0} to the index", - new Object[] {"Halo"}); - assertThat(s).isEqualTo("Welcome Halo to the index"); + assertThat(properties).isEqualTo(Map.of("index.welcome", "Welcome to the index", + "title", "这是来自 i18n/default.properties 的标题")); } ThemeContext getTheme() throws URISyntaxException { diff --git a/application/src/test/java/run/halo/app/theme/message/ThemeMessageResolverIntegrationTest.java b/application/src/test/java/run/halo/app/theme/message/ThemeMessageResolverIntegrationTest.java index 6948c4e96c..ad7c7b7cf8 100644 --- a/application/src/test/java/run/halo/app/theme/message/ThemeMessageResolverIntegrationTest.java +++ b/application/src/test/java/run/halo/app/theme/message/ThemeMessageResolverIntegrationTest.java @@ -13,9 +13,9 @@ import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.TestConfiguration; -import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.context.annotation.Bean; import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.util.ResourceUtils; import org.springframework.web.reactive.function.server.RequestPredicates; @@ -38,14 +38,14 @@ @AutoConfigureWebTestClient public class ThemeMessageResolverIntegrationTest { - @SpyBean + @MockitoSpyBean private ThemeResolver themeResolver; private URL defaultThemeUrl; private URL otherThemeUrl; - @SpyBean + @MockitoSpyBean private InitializationStateGetter initializationStateGetter; @Autowired @@ -93,6 +93,9 @@ void shouldUseDefaultWhenLanguageNotSupport() { .expectStatus() .isOk() .expectBody() + // make sure the "templates/index.properties" file is precedence over the + // "i18n/default.properties". + .xpath("/html/head/title").isEqualTo("Title from index.properties") .xpath("/html/body/div[1]").isEqualTo("foo") .xpath("/html/body/div[2]").isEqualTo("欢迎来到首页"); } @@ -105,7 +108,7 @@ void switchTheme() throws URISyntaxException { .expectStatus() .isOk() .expectBody() - .xpath("/html/head/title").isEqualTo("Title") + .xpath("/html/head/title").isEqualTo("来自 index_zh.properties 的标题") .xpath("/html/body/div[1]").isEqualTo("zh") .xpath("/html/body/div[2]").isEqualTo("欢迎来到首页") ; diff --git a/application/src/test/java/run/halo/app/theme/router/PreviewRouterFunctionTest.java b/application/src/test/java/run/halo/app/theme/router/PreviewRouterFunctionTest.java index 683401a1d2..baed96cb4a 100644 --- a/application/src/test/java/run/halo/app/theme/router/PreviewRouterFunctionTest.java +++ b/application/src/test/java/run/halo/app/theme/router/PreviewRouterFunctionTest.java @@ -43,36 +43,40 @@ @ExtendWith(SpringExtension.class) class PreviewRouterFunctionTest { @Mock - private ReactiveExtensionClient client; + ReactiveExtensionClient client; @Mock - private PostPublicQueryService postPublicQueryService; + PostPublicQueryService postPublicQueryService; @Mock - private ViewNameResolver viewNameResolver; + ViewNameResolver viewNameResolver; @Mock - private ViewResolver viewResolver; + ViewResolver viewResolver; @Mock - private PostService postService; + PostService postService; @Mock - private SinglePageConversionService singlePageConversionService; + SinglePageConversionService singlePageConversionService; @InjectMocks - private PreviewRouterFunction previewRouterFunction; + PreviewRouterFunction previewRouterFunction; - private WebTestClient webTestClient; + WebTestClient webTestClient; @BeforeEach - public void setUp() { + void setUp() { webTestClient = WebTestClient.bindToRouterFunction(previewRouterFunction.previewRouter()) .handlerStrategies(HandlerStrategies.builder() .viewResolver(viewResolver) .build()) .build(); + } + @Test + @WithMockUser(username = "testuser") + void previewPost() { when(viewResolver.resolveViewName(any(), any())) .thenReturn(Mono.just(new EmptyView() { @Override @@ -81,11 +85,7 @@ public Mono render(Map model, MediaType contentType, return super.render(model, contentType, exchange); } })); - } - @Test - @WithMockUser(username = "testuser") - public void previewPost() { Post post = new Post(); post.setMetadata(new Metadata()); post.getMetadata().setName("post1"); @@ -123,6 +123,15 @@ public void previewPostWhenUnAuthenticated() { @Test @WithMockUser(username = "testuser") public void previewSinglePage() { + when(viewResolver.resolveViewName(any(), any())) + .thenReturn(Mono.just(new EmptyView() { + @Override + public Mono render(Map model, MediaType contentType, + ServerWebExchange exchange) { + return super.render(model, contentType, exchange); + } + })); + SinglePage singlePage = new SinglePage(); singlePage.setMetadata(new Metadata()); singlePage.getMetadata().setName("page1"); diff --git a/application/src/test/java/run/halo/app/core/extension/theme/ThemeServiceImplTest.java b/application/src/test/java/run/halo/app/theme/service/ThemeServiceImplTest.java similarity index 99% rename from application/src/test/java/run/halo/app/core/extension/theme/ThemeServiceImplTest.java rename to application/src/test/java/run/halo/app/theme/service/ThemeServiceImplTest.java index 045b5f14e7..184e38a573 100644 --- a/application/src/test/java/run/halo/app/core/extension/theme/ThemeServiceImplTest.java +++ b/application/src/test/java/run/halo/app/theme/service/ThemeServiceImplTest.java @@ -1,4 +1,4 @@ -package run.halo.app.core.extension.theme; +package run.halo.app.theme.service; import static java.nio.file.Files.createTempDirectory; import static org.assertj.core.api.Assertions.assertThat; @@ -78,7 +78,7 @@ void setUp() throws IOException { // init the folder Files.createDirectory(themeRoot.get()); - lenient().when(systemVersionSupplier.get()).thenReturn(Version.valueOf("0.0.0")); + lenient().when(systemVersionSupplier.get()).thenReturn(Version.parse("0.0.0")); } @AfterEach diff --git a/application/src/test/java/run/halo/app/webfilter/LocaleChangeWebFilterTest.java b/application/src/test/java/run/halo/app/webfilter/LocaleChangeWebFilterTest.java new file mode 100644 index 0000000000..417bc132ec --- /dev/null +++ b/application/src/test/java/run/halo/app/webfilter/LocaleChangeWebFilterTest.java @@ -0,0 +1,91 @@ +package run.halo.app.webfilter; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.http.MediaType; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.web.server.WebFilterChain; +import reactor.core.publisher.Mono; +import run.halo.app.infra.webfilter.LocaleChangeWebFilter; + +class LocaleChangeWebFilterTest { + + LocaleChangeWebFilter filter; + + @BeforeEach + void setUp() { + filter = new LocaleChangeWebFilter(); + } + + @Test + void shouldRespondLanguageCookie() { + WebFilterChain webFilterChain = filterExchange -> { + var languageCookie = filterExchange.getResponse().getCookies().getFirst("language"); + assertNotNull(languageCookie); + assertEquals("zh-CN", languageCookie.getValue()); + return Mono.empty(); + }; + var exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/home") + .accept(MediaType.TEXT_HTML) + .queryParam("language", "zh-CN") + .build() + ); + this.filter.filter(exchange, webFilterChain).block(); + } + + @Test + void shouldRespondLanguageCookieWithUndefinedLanguageTag() { + WebFilterChain webFilterChain = filterExchange -> { + var languageCookie = filterExchange.getResponse().getCookies().getFirst("language"); + assertNotNull(languageCookie); + assertEquals("und", languageCookie.getValue()); + return Mono.empty(); + }; + var exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/home") + .accept(MediaType.TEXT_HTML) + .queryParam("language", "invalid_language_tag") + .build() + ); + this.filter.filter(exchange, webFilterChain).block(); + } + + @ParameterizedTest + @MethodSource("provideInvalidRequest") + void shouldNotRespondLanguageCookieIfRequestNotMatch(MockServerHttpRequest mockRequest) { + WebFilterChain webFilterChain = filterExchange -> { + var languageCookie = filterExchange.getResponse().getCookies().getFirst("language"); + assertNull(languageCookie); + return Mono.empty(); + }; + var exchange = MockServerWebExchange.from(mockRequest); + this.filter.filter(exchange, webFilterChain).block(); + } + + static Stream provideInvalidRequest() { + return Stream.of( + MockServerHttpRequest.get("/home") + .accept(MediaType.ALL) + .queryParam("language", "zh-CN") + .build(), + MockServerHttpRequest.get("/home") + .accept(MediaType.APPLICATION_JSON) + .queryParam("language", "zh-CN") + .build(), + MockServerHttpRequest.post("/home") + .accept(MediaType.TEXT_HTML) + .queryParam("language", "zh-CN") + .build(), + MockServerHttpRequest.get("/home") + .accept(MediaType.TEXT_HTML) + .build() + ); + } +} \ No newline at end of file diff --git a/application/src/test/resources/file-type-detect/index.html b/application/src/test/resources/file-type-detect/index.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/application/src/test/resources/file-type-detect/index.js b/application/src/test/resources/file-type-detect/index.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/application/src/test/resources/file-type-detect/other.xlsx b/application/src/test/resources/file-type-detect/other.xlsx new file mode 100644 index 0000000000..1d2b9647f0 Binary files /dev/null and b/application/src/test/resources/file-type-detect/other.xlsx differ diff --git a/application/src/test/resources/file-type-detect/test.docx b/application/src/test/resources/file-type-detect/test.docx new file mode 100644 index 0000000000..2b083a6fad Binary files /dev/null and b/application/src/test/resources/file-type-detect/test.docx differ diff --git a/application/src/test/resources/file-type-detect/test.json b/application/src/test/resources/file-type-detect/test.json new file mode 100644 index 0000000000..e69de29bb2 diff --git a/application/src/test/resources/file-type-detect/test.png b/application/src/test/resources/file-type-detect/test.png new file mode 100644 index 0000000000..820549a92c Binary files /dev/null and b/application/src/test/resources/file-type-detect/test.png differ diff --git a/application/src/test/resources/file-type-detect/test.svg b/application/src/test/resources/file-type-detect/test.svg new file mode 100644 index 0000000000..e93f41998c --- /dev/null +++ b/application/src/test/resources/file-type-detect/test.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/application/src/test/resources/themes/default/i18n/default.properties b/application/src/test/resources/themes/default/i18n/default.properties index 0321c81406..3527949999 100644 --- a/application/src/test/resources/themes/default/i18n/default.properties +++ b/application/src/test/resources/themes/default/i18n/default.properties @@ -1 +1,2 @@ -index.welcome=\u6B22\u8FCE\u6765\u5230\u9996\u9875 \ No newline at end of file +index.welcome=欢迎来到首页 +title=这是来自 i18n/default.properties 的标题 diff --git a/application/src/test/resources/themes/default/i18n/zh.properties b/application/src/test/resources/themes/default/i18n/zh.properties new file mode 100644 index 0000000000..dbc13ede32 --- /dev/null +++ b/application/src/test/resources/themes/default/i18n/zh.properties @@ -0,0 +1 @@ +title=来自 i18n/zh.properties 的标题 diff --git a/application/src/test/resources/themes/default/templates/index.html b/application/src/test/resources/themes/default/templates/index.html index 7d38411c43..08c0bc4309 100644 --- a/application/src/test/resources/themes/default/templates/index.html +++ b/application/src/test/resources/themes/default/templates/index.html @@ -2,7 +2,7 @@ - Title + Title index diff --git a/application/src/test/resources/themes/default/templates/index.properties b/application/src/test/resources/themes/default/templates/index.properties new file mode 100644 index 0000000000..44e41acd8c --- /dev/null +++ b/application/src/test/resources/themes/default/templates/index.properties @@ -0,0 +1 @@ +title=Title from index.properties diff --git a/application/src/test/resources/themes/default/templates/index_zh.properties b/application/src/test/resources/themes/default/templates/index_zh.properties new file mode 100644 index 0000000000..300ca1cfc8 --- /dev/null +++ b/application/src/test/resources/themes/default/templates/index_zh.properties @@ -0,0 +1 @@ +title=来自 index_zh.properties 的标题 diff --git a/build.gradle b/build.gradle index ec0f114240..1919920d06 100644 --- a/build.gradle +++ b/build.gradle @@ -1,5 +1,5 @@ plugins { - id 'org.springframework.boot' version '3.3.3' apply false + id 'org.springframework.boot' version '3.4.0-RC1' apply false id 'io.spring.dependency-management' version '1.1.6' apply false id "com.gorylenko.gradle-git-properties" version "2.4.1" apply false id "de.undercouch.download" version "5.6.0" apply false @@ -7,4 +7,5 @@ plugins { id 'org.gradle.crypto.checksum' version '1.4.0' apply false id "com.github.node-gradle.node" version "7.0.2" apply false id "org.springdoc.openapi-gradle-plugin" version "1.9.0" apply false + id "com.github.ben-manes.versions" version "0.51.0" apply false } diff --git a/e2e/testsuite.yaml b/e2e/testsuite.yaml index 32f2dc0c10..05c17f9c34 100644 --- a/e2e/testsuite.yaml +++ b/e2e/testsuite.yaml @@ -8,22 +8,18 @@ param: notificationName: "{{randAlpha 6}}" auth: "Basic YWRtaW46MTIzNDU2" items: -- name: init +- name: setup request: - api: /api.console.halo.run/v1alpha1/system/initialize + api: | + {{default "http://halo:8090" (env "SERVER")}}/system/setup method: POST header: - Content-Type: application/json + Content-Type: application/x-www-form-urlencoded + Accept: application/json body: | - { - "siteTitle": "testing", - "username": "admin", - "password": "123456", - "email": "testing@halo.com", - "password_confirm": "123456" - } + siteTitle=testing&username={{.param.userName}}&password=123456&email=testing@halo.run expect: - statusCode: 201 + statusCode: 204 - name: createPost request: api: /api.console.halo.run/v1alpha1/posts diff --git a/gradle.properties b/gradle.properties index ad074883b9..a26f15cd30 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1 @@ -version=2.20.0-SNAPSHOT +version=2.20.8-SNAPSHOT diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index d64cd49177..e6441136f3 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index a4413138c9..df97d72b8b 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index 1aa94a4269..b740cf1339 100755 --- a/gradlew +++ b/gradlew @@ -55,7 +55,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. diff --git a/platform/application/build.gradle b/platform/application/build.gradle index 13bb84bdd1..396df394c7 100644 --- a/platform/application/build.gradle +++ b/platform/application/build.gradle @@ -15,15 +15,16 @@ ext { base62 = "0.1.3" pf4j = '3.12.0' javaDiffUtils = "4.12" - guava = "32.0.1-jre" - jsoup = '1.15.3' + guava = "33.3.1-jre" + jsoup = '1.18.1' jsonPatch = "1.13" springDocOpenAPI = "2.6.0" - lucene = "9.11.1" + lucene = "9.12.0" resilience4jVersion = "2.2.0" twoFactorAuth = "1.3" tika = "2.9.2" imgscalr = '4.2' + exifExtractor = '2.19.0' } javaPlatform { @@ -58,6 +59,7 @@ dependencies { api "com.j256.two-factor-auth:two-factor-auth:$twoFactorAuth" api "org.apache.tika:tika-core:$tika" api "org.imgscalr:imgscalr-lib:$imgscalr" + api "com.drewnoakes:metadata-extractor:$exifExtractor" } } diff --git a/ui/.changeset/README.md b/ui/.changeset/README.md deleted file mode 100644 index e5b6d8d6a6..0000000000 --- a/ui/.changeset/README.md +++ /dev/null @@ -1,8 +0,0 @@ -# Changesets - -Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works -with multi-package repos, or single-package repos to help you version and publish your code. You can -find the full documentation for it [in our repository](https://github.com/changesets/changesets) - -We have a quick list of common questions to get you started engaging with this project in -[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) diff --git a/ui/.changeset/config.json b/ui/.changeset/config.json deleted file mode 100644 index ff9fe79d7c..0000000000 --- a/ui/.changeset/config.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "$schema": "https://unpkg.com/@changesets/config@2.0.1/schema.json", - "changelog": "@changesets/cli/changelog", - "commit": false, - "fixed": [], - "linked": [["@halo-dev/*"]], - "access": "public", - "baseBranch": "next", - "updateInternalDependencies": "patch", - "ignore": [] -} diff --git a/ui/.changeset/pre.json b/ui/.changeset/pre.json deleted file mode 100644 index 694d1d353c..0000000000 --- a/ui/.changeset/pre.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "mode": "pre", - "tag": "alpha", - "initialVersions": { - "@halo-dev/components": "0.0.0-alpha.0", - "@halo-dev/console-shared": "0.0.0-alpha.0" - }, - "changesets": [] -} diff --git a/ui/.eslintrc.cjs b/ui/.eslintrc.cjs index 63132e5164..a5fe9af584 100644 --- a/ui/.eslintrc.cjs +++ b/ui/.eslintrc.cjs @@ -18,12 +18,6 @@ module.exports = { "@typescript-eslint/ban-ts-comment": 0, "vue/no-v-html": 0, }, - overrides: [ - { - files: ["cypress/integration/**.spec.{js,ts,jsx,tsx}"], - extends: ["plugin:cypress/recommended"], - }, - ], ignorePatterns: ["!.storybook", "packages/api-client"], parserOptions: { ecmaVersion: "latest", diff --git a/ui/.vscode/settings.json b/ui/.vscode/settings.json index 347d413bd8..f728502ec1 100644 --- a/ui/.vscode/settings.json +++ b/ui/.vscode/settings.json @@ -30,9 +30,5 @@ }, "[yaml]": { "editor.defaultFormatter": "esbenp.prettier-vscode" - }, - "editor.codeActionsOnSave": { - "source.organizeImports": "explicit", - "source.fixAll": "never" } } diff --git a/ui/OWNERS b/ui/OWNERS deleted file mode 100644 index 66b425df70..0000000000 --- a/ui/OWNERS +++ /dev/null @@ -1,15 +0,0 @@ -reviewers: -- ruibaby -- guqing -- JohnNiang -- lan-yonghui -- wan92hen -- QuentinHsu -- Aanko -- wzrove -- LIlGG - -approvers: -- ruibaby -- guqing -- JohnNiang diff --git a/ui/console-src/composables/use-slugify.ts b/ui/console-src/composables/use-slugify.ts index b94c1b4004..4768f153f9 100644 --- a/ui/console-src/composables/use-slugify.ts +++ b/ui/console-src/composables/use-slugify.ts @@ -3,28 +3,20 @@ import { FormType } from "@/types/slug"; import { randomUUID } from "@/utils/id"; import ShortUniqueId from "short-unique-id"; import { slugify } from "transliteration"; -import { watch, type Ref } from "vue"; +import { computed, watch, type Ref } from "vue"; + const uid = new ShortUniqueId(); -const Strategy = { - generateByTitle: (value: string) => { - if (!value) return ""; - return slugify(value, { trim: true }); - }, - shortUUID: (value: string) => { - if (!value) return ""; - return uid.randomUUID(8); - }, - UUID: (value: string) => { - if (!value) return ""; - return randomUUID(); - }, - timestamp: (value: string) => { - if (!value) return ""; - return new Date().getTime().toString(); - }, + +type SlugStrategy = (value?: string) => string; + +const strategies: Record = { + generateByTitle: (value?: string) => slugify(value || "", { trim: true }), + shortUUID: () => uid.randomUUID(8), + UUID: () => randomUUID(), + timestamp: () => new Date().getTime().toString(), }; -const onceList = ["shortUUID", "UUID", "timestamp"]; +const onceStrategies = new Set(["shortUUID", "UUID", "timestamp"]); export default function useSlugify( source: Ref, @@ -32,35 +24,43 @@ export default function useSlugify( auto: Ref, formType: FormType ) { - const handleGenerateSlug = (forceUpdate = false, formType: FormType) => { - const globalInfoStore = useGlobalInfoStore(); - const mode = globalInfoStore.globalInfo?.postSlugGenerationStrategy; + const globalInfoStore = useGlobalInfoStore(); - if (!mode) { - return; - } - if (formType != FormType.POST) { - target.value = Strategy["generateByTitle"](source.value); - return; - } - if (forceUpdate) { - target.value = Strategy[mode](source.value); + const currentStrategy = computed( + () => + globalInfoStore.globalInfo?.postSlugGenerationStrategy || + "generateByTitle" + ); + + const generateSlug = (value: string): string => { + const strategy = + formType === FormType.POST + ? strategies[currentStrategy.value] + : strategies.generateByTitle; + + return strategy(value); + }; + + const handleGenerateSlug = (forceUpdate = false) => { + if ( + !forceUpdate && + onceStrategies.has(currentStrategy.value) && + target.value + ) { return; } - if (onceList.includes(mode) && target.value) return; - target.value = Strategy[mode](source.value); + + target.value = generateSlug(source.value); }; watch( - () => source.value, + source, () => { if (auto.value) { - handleGenerateSlug(false, formType); + handleGenerateSlug(true); } }, - { - immediate: true, - } + { immediate: true } ); return { diff --git a/ui/console-src/layouts/BasicLayout.vue b/ui/console-src/layouts/BasicLayout.vue index b1fc71be74..0eff4a6604 100644 --- a/ui/console-src/layouts/BasicLayout.vue +++ b/ui/console-src/layouts/BasicLayout.vue @@ -1,6 +1,5 @@ +import type { ListedAuthProvider } from "@halo-dev/api-client"; +import { VCard } from "@halo-dev/components"; +import { useQueryClient } from "@tanstack/vue-query"; +import { VueDraggable } from "vue-draggable-plus"; +import AuthProviderListItem from "./AuthProviderListItem.vue"; + +const queryClient = useQueryClient(); + +const modelValue = defineModel({ default: [] }); + +const { loading = false } = defineProps<{ + loading?: boolean; + title: string; +}>(); + +const emit = defineEmits<{ + (e: "update"): void; +}>(); + +function onReload() { + queryClient.invalidateQueries({ queryKey: ["auth-providers"] }); +} + + diff --git a/ui/console-src/modules/system/auth-providers/module.ts b/ui/console-src/modules/system/auth-providers/module.ts index a2cb8d910c..80205ac469 100644 --- a/ui/console-src/modules/system/auth-providers/module.ts +++ b/ui/console-src/modules/system/auth-providers/module.ts @@ -16,6 +16,7 @@ export default definePlugin({ meta: { title: "core.identity_authentication.title", searchable: true, + permissions: ["*"], }, }, { @@ -24,6 +25,7 @@ export default definePlugin({ component: AuthProviderDetail, meta: { title: "core.identity_authentication.detail.title", + permissions: ["*"], }, }, ], diff --git a/ui/console-src/modules/system/backup/tabs/Restore.vue b/ui/console-src/modules/system/backup/tabs/Restore.vue index 6a0f940c7c..a071a62a12 100644 --- a/ui/console-src/modules/system/backup/tabs/Restore.vue +++ b/ui/console-src/modules/system/backup/tabs/Restore.vue @@ -114,7 +114,9 @@ useQuery({
  • {{ $t("core.backup.restore.tips.first") }}
  • - {{ $t("core.backup.restore.tips.second") }} + + {{ $t("core.backup.restore.tips.second") }} +
  • {{ $t("core.backup.restore.tips.third") }} diff --git a/ui/console-src/modules/system/plugins/components/entity-fields/ReloadField.vue b/ui/console-src/modules/system/plugins/components/entity-fields/ReloadField.vue index 920287d66a..f6ea8c0314 100644 --- a/ui/console-src/modules/system/plugins/components/entity-fields/ReloadField.vue +++ b/ui/console-src/modules/system/plugins/components/entity-fields/ReloadField.vue @@ -1,6 +1,6 @@ - diff --git a/ui/console-src/views/system/Login.vue b/ui/console-src/views/system/Login.vue deleted file mode 100644 index 76d70bacc4..0000000000 --- a/ui/console-src/views/system/Login.vue +++ /dev/null @@ -1,85 +0,0 @@ - - diff --git a/ui/console-src/views/system/Redirect.vue b/ui/console-src/views/system/Redirect.vue deleted file mode 100644 index 639a4ff691..0000000000 --- a/ui/console-src/views/system/Redirect.vue +++ /dev/null @@ -1,41 +0,0 @@ - - diff --git a/ui/console-src/views/system/ResetPassword.vue b/ui/console-src/views/system/ResetPassword.vue deleted file mode 100644 index 3486127804..0000000000 --- a/ui/console-src/views/system/ResetPassword.vue +++ /dev/null @@ -1,81 +0,0 @@ - - - diff --git a/ui/console-src/views/system/Setup.vue b/ui/console-src/views/system/Setup.vue deleted file mode 100644 index 8a5f7bb5fa..0000000000 --- a/ui/console-src/views/system/Setup.vue +++ /dev/null @@ -1,134 +0,0 @@ - - - diff --git a/ui/console-src/views/system/SetupInitialData.vue b/ui/console-src/views/system/SetupInitialData.vue deleted file mode 100644 index 7af6125e2e..0000000000 --- a/ui/console-src/views/system/SetupInitialData.vue +++ /dev/null @@ -1,202 +0,0 @@ - - - diff --git a/ui/console-src/views/system/setup-data/category.json b/ui/console-src/views/system/setup-data/category.json deleted file mode 100644 index d65a2fab61..0000000000 --- a/ui/console-src/views/system/setup-data/category.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "spec": { - "displayName": "默认分类", - "slug": "default", - "description": "这是你的默认分类,如不需要,删除即可。", - "cover": "", - "template": "", - "priority": 0, - "children": [] - }, - "apiVersion": "content.halo.run/v1alpha1", - "kind": "Category", - "metadata": { - "name": "76514a40-6ef1-4ed9-b58a-e26945bde3ca" - } -} diff --git a/ui/console-src/views/system/setup-data/menu-items.json b/ui/console-src/views/system/setup-data/menu-items.json deleted file mode 100644 index 603f19dbd9..0000000000 --- a/ui/console-src/views/system/setup-data/menu-items.json +++ /dev/null @@ -1,62 +0,0 @@ -[ - { - "spec": { - "displayName": "首页", - "href": "/", - "children": [], - "priority": 0 - }, - "apiVersion": "v1alpha1", - "kind": "MenuItem", - "metadata": { "name": "88c3f10b-321c-4092-86a8-70db00251b74" } - }, - { - "spec": { - "children": [], - "priority": 1, - "targetRef": { - "group": "content.halo.run", - "version": "v1alpha1", - "kind": "Post", - "name": "5152aea5-c2e8-4717-8bba-2263d46e19d5" - } - }, - "apiVersion": "v1alpha1", - "kind": "MenuItem", - "metadata": { "name": "c4c814d1-0c2c-456b-8c96-4864965fee94" } - }, - { - "spec": { - "displayName": "", - "href": "", - "children": [], - "priority": 2, - "targetRef": { - "group": "content.halo.run", - "version": "v1alpha1", - "kind": "Tag", - "name": "c33ceabb-d8f1-4711-8991-bb8f5c92ad7c" - } - }, - "apiVersion": "v1alpha1", - "kind": "MenuItem", - "metadata": { "name": "35869bd3-33b5-448b-91ee-cf6517a59644" } - }, - { - "spec": { - "displayName": "", - "href": "", - "children": [], - "priority": 3, - "targetRef": { - "group": "content.halo.run", - "version": "v1alpha1", - "kind": "SinglePage", - "name": "373a5f79-f44f-441a-9df1-85a4f553ece8" - } - }, - "apiVersion": "v1alpha1", - "kind": "MenuItem", - "metadata": { "name": "b0d041fa-dc99-48f6-a193-8604003379cf" } - } -] diff --git a/ui/console-src/views/system/setup-data/menu.json b/ui/console-src/views/system/setup-data/menu.json deleted file mode 100644 index c1526b53a3..0000000000 --- a/ui/console-src/views/system/setup-data/menu.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "spec": { - "displayName": "主菜单", - "menuItems": [ - "88c3f10b-321c-4092-86a8-70db00251b74", - "c4c814d1-0c2c-456b-8c96-4864965fee94", - "35869bd3-33b5-448b-91ee-cf6517a59644", - "b0d041fa-dc99-48f6-a193-8604003379cf" - ] - }, - "apiVersion": "v1alpha1", - "kind": "Menu", - "metadata": { "name": "primary" } -} diff --git a/ui/console-src/views/system/setup-data/post.json b/ui/console-src/views/system/setup-data/post.json deleted file mode 100644 index d3f275546a..0000000000 --- a/ui/console-src/views/system/setup-data/post.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "post": { - "spec": { - "title": "Hello Halo", - "slug": "hello-halo", - "template": "", - "cover": "", - "deleted": false, - "publish": false, - "publishTime": "", - "pinned": false, - "allowComment": true, - "visible": "PUBLIC", - "version": 1, - "priority": 0, - "excerpt": { - "autoGenerate": false, - "raw": "如果你看到了这一篇文章,那么证明你已经安装成功了,感谢使用 Halo 进行创作,希望能够使用愉快。" - }, - "categories": ["76514a40-6ef1-4ed9-b58a-e26945bde3ca"], - "tags": ["c33ceabb-d8f1-4711-8991-bb8f5c92ad7c"], - "htmlMetas": [] - }, - "apiVersion": "content.halo.run/v1alpha1", - "kind": "Post", - "metadata": { - "name": "5152aea5-c2e8-4717-8bba-2263d46e19d5" - } - }, - "content": { - "raw": "

    Hello Halo

    如果你看到了这一篇文章,那么证明你已经安装成功了,感谢使用 Halo 进行创作,希望能够使用愉快。

    相关链接

    在使用过程中,有任何问题都可以通过以上链接找寻答案,或者联系我们。

    这是一篇自动生成的文章,请删除这篇文章之后开始你的创作吧!

    ", - "content": "

    Hello Halo

    如果你看到了这一篇文章,那么证明你已经安装成功了,感谢使用 Halo 进行创作,希望能够使用愉快。

    相关链接

    在使用过程中,有任何问题都可以通过以上链接找寻答案,或者联系我们。

    这是一篇自动生成的文章,请删除这篇文章之后开始你的创作吧!

    ", - "rawType": "HTML" - } -} diff --git a/ui/console-src/views/system/setup-data/singlePage.json b/ui/console-src/views/system/setup-data/singlePage.json deleted file mode 100644 index 3c605d8eb5..0000000000 --- a/ui/console-src/views/system/setup-data/singlePage.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "page": { - "spec": { - "title": "关于", - "slug": "about", - "template": "", - "cover": "", - "deleted": false, - "publish": false, - "publishTime": "", - "pinned": false, - "allowComment": true, - "visible": "PUBLIC", - "version": 1, - "priority": 0, - "excerpt": { - "autoGenerate": false, - "raw": "这是一个自定义页面,你可以在后台的 页面 -> 自定义页面 找到它,你可以用于新建关于页面、联系我们页面等等。" - }, - "htmlMetas": [] - }, - "apiVersion": "content.halo.run/v1alpha1", - "kind": "SinglePage", - "metadata": { "name": "373a5f79-f44f-441a-9df1-85a4f553ece8" } - }, - "content": { - "raw": "

    关于页面

    这是一个自定义页面,你可以在后台的 页面 -> 自定义页面 找到它,你可以用于新建关于页面、联系我们页面等等。

    这是一篇自动生成的页面,你可以在后台删除它。

    ", - "content": "

    关于页面

    这是一个自定义页面,你可以在后台的 页面 -> 自定义页面 找到它,你可以用于新建关于页面、联系我们页面等等。

    这是一篇自动生成的页面,你可以在后台删除它。

    ", - "rawType": "HTML" - } -} diff --git a/ui/console-src/views/system/setup-data/tag.json b/ui/console-src/views/system/setup-data/tag.json deleted file mode 100644 index c82a115616..0000000000 --- a/ui/console-src/views/system/setup-data/tag.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "spec": { - "displayName": "Halo", - "slug": "halo", - "color": "#ffffff", - "cover": "" - }, - "apiVersion": "content.halo.run/v1alpha1", - "kind": "Tag", - "metadata": { - "name": "c33ceabb-d8f1-4711-8991-bb8f5c92ad7c" - } -} diff --git a/ui/cypress.json b/ui/cypress.json deleted file mode 100644 index 6ba19871b9..0000000000 --- a/ui/cypress.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "baseUrl": "http://localhost:5050" -} diff --git a/ui/cypress/fixtures/example.json b/ui/cypress/fixtures/example.json deleted file mode 100644 index 02e4254378..0000000000 --- a/ui/cypress/fixtures/example.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "Using fixtures to represent data", - "email": "hello@cypress.io", - "body": "Fixtures are a great way to mock data for responses to routes" -} diff --git a/ui/cypress/integration/example.spec.ts b/ui/cypress/integration/example.spec.ts deleted file mode 100644 index 3130c0011a..0000000000 --- a/ui/cypress/integration/example.spec.ts +++ /dev/null @@ -1,8 +0,0 @@ -// https://docs.cypress.io/api/introduction/api.html - -describe("My First Test", () => { - it("visits the app root url", () => { - cy.visit("/"); - cy.contains("h1", "You did it!"); - }); -}); diff --git a/ui/cypress/plugins/index.ts b/ui/cypress/plugins/index.ts deleted file mode 100644 index 49dc983852..0000000000 --- a/ui/cypress/plugins/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* eslint-env node */ -// *********************************************************** -// This example plugins/index.ts can be used to load plugins -// -// You can change the location of this file or turn off loading -// the plugins file with the 'pluginsFile' configuration option. -// -// You can read more here: -// https://on.cypress.io/plugins-guide -// *********************************************************** - -// This function is called when a project is opened or re-opened (e.g. due to -// the project's config changing) - -export default ((on, config) => { - // `on` is used to hook into various events Cypress emits - // `config` is the resolved Cypress config - return config; -}) as Cypress.PluginConfig; diff --git a/ui/cypress/plugins/tsconfig.json b/ui/cypress/plugins/tsconfig.json deleted file mode 100644 index d815dc399e..0000000000 --- a/ui/cypress/plugins/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "extends": "@vue/tsconfig/tsconfig.node.json", - "include": [ - "./**/*" - ], - "compilerOptions": { - "module": "CommonJS", - "preserveValueImports": false, - "types": [ - "node", - "cypress/types/cypress" - ] - } -} diff --git a/ui/cypress/support/commands.ts b/ui/cypress/support/commands.ts deleted file mode 100644 index 119ab03f7c..0000000000 --- a/ui/cypress/support/commands.ts +++ /dev/null @@ -1,25 +0,0 @@ -// *********************************************** -// This example commands.js shows you how to -// create various custom commands and overwrite -// existing commands. -// -// For more comprehensive examples of custom -// commands please read more here: -// https://on.cypress.io/custom-commands -// *********************************************** -// -// -// -- This is a parent command -- -// Cypress.Commands.add('login', (email, password) => { ... }) -// -// -// -- This is a child command -- -// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) -// -// -// -- This is a dual command -- -// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) -// -// -// -- This will overwrite an existing command -- -// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) diff --git a/ui/cypress/support/index.ts b/ui/cypress/support/index.ts deleted file mode 100644 index d076cec9fd..0000000000 --- a/ui/cypress/support/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -// *********************************************************** -// This example support/index.js is processed and -// loaded automatically before your test files. -// -// This is a great place to put global configuration and -// behavior that modifies Cypress. -// -// You can change the location of this file or turn off -// automatically serving support files with the -// 'supportFile' configuration option. -// -// You can read more here: -// https://on.cypress.io/configuration -// *********************************************************** - -// Import commands.js using ES2015 syntax: -import "./commands"; - -// Alternatively you can use CommonJS syntax: -// require('./commands') diff --git a/ui/cypress/tsconfig.json b/ui/cypress/tsconfig.json deleted file mode 100644 index 8e5ea04ba1..0000000000 --- a/ui/cypress/tsconfig.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "extends": "@vue/tsconfig/tsconfig.web.json", - "include": [ - "./integration/**/*", - "./support/**/*" - ], - "compilerOptions": { - "isolatedModules": false, - "target": "es5", - "lib": [ - "es5", - "dom" - ], - "types": [ - "cypress" - ] - } -} diff --git a/ui/docs/custom-formkit-input/README.md b/ui/docs/custom-formkit-input/README.md index 15b40e508e..72a4c8de2c 100644 --- a/ui/docs/custom-formkit-input/README.md +++ b/ui/docs/custom-formkit-input/README.md @@ -38,6 +38,7 @@ 9. removeControl: 是否显示删除按钮,默认为 `true` - `menuCheckbox`:选择一组菜单 - `menuRadio`:选择一个菜单 +- `menuSelect`: 通用菜单选择组件,支持单选、多选、排序 - `menuItemSelect`:选择菜单项 - `postSelect`:选择文章 - `singlePageSelect`:选择自定义页面 @@ -65,7 +66,7 @@ 4. `remote`:标识当前是否由用户自定义的远程数据源。 5. `remoteOption`:当 `remote` 为 `true` 时,此配置项必须存在,用于为 Select 组件提供处理搜索及查询键值对的方法。 6. `remoteOptimize`:是否开启远程数据源优化,默认为 `true`。开启后,将会对远程数据源进行优化,减少请求次数。仅在动态数据源下有效。 - 7. `allowCreate`:是否允许创建新选项,默认为 `false`。仅在静态数据源下有效。 + 7. `allowCreate`:是否允许创建新选项,默认为 `false`。仅在静态数据源下有效,需要同时开启 `searchable`。 8. `clearable`:是否允许清空选项,默认为 `false`。 9. `multiple`:是否多选,默认为 `false`。 10. `maxCount`:多选时最大可选数量,默认为 `Infinity`。仅在多选时有效。 @@ -231,10 +232,11 @@ const handleSelectPostAuthorRemote = { itemsField: items labelField: post.spec.title valueField: post.metadata.name + fieldSelectorKey: metadata.name ``` > [!NOTE] -> 当远程数据具有分页时,可能会出现默认选项不在第一页的情况,此时 Select 组件将会发送另一个查询请求,以获取默认选项的数据。此接口会携带如下参数 `fieldSelector: ${requestOption.valueField}=(value1,value2,value3)`。 +> 当远程数据具有分页时,可能会出现默认选项不在第一页的情况,此时 Select 组件将会发送另一个查询请求,以获取默认选项的数据。此接口会携带如下参数 `fieldSelector: ${requestOption.fieldSelectorKey}=(value1,value2,value3)`。 > 其中,value1, value2, value3 为默认选项的值。返回值与查询一致,通过 `requestOption` 解析。 diff --git a/ui/package.json b/ui/package.json index 6c5af2e395..dba96c1efb 100644 --- a/ui/package.json +++ b/ui/package.json @@ -12,22 +12,18 @@ "build:uc": "vue-tsc --noEmit -p tsconfig.app.json --composite false && vite build --config ./vite.uc.config.ts", "build:console": "vue-tsc --noEmit -p tsconfig.app.json --composite false && vite build --config ./vite.config.ts", "build:packages": "pnpm --filter \"./packages/**\" build", - "preview": "vite preview --port 5050", "api-client:gen": "pnpm --filter \"./packages/api-client\" gen", "test:unit": "vitest --environment jsdom --run && pnpm run test:unit:packages", "test:unit:watch": "vitest --environment jsdom --watch", "test:unit:ui": "vitest --environment jsdom --watch --ui", "test:unit:coverage": "vitest run --environment jsdom --coverage", - "test:e2e": "start-server-and-test preview http://127.0.0.1:5050/ \"cypress open\"", - "test:e2e:ci": "start-server-and-test preview http://127.0.0.1:5050/ \"cypress run\"", "typecheck": "vue-tsc --noEmit -p tsconfig.app.json --composite false && pnpm run typecheck:packages", "lint": "eslint \"./src\" \"./console-src\" \"./uc-src\" --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --ignore-path .gitignore --max-warnings=0 -f html -o build/lint-result/index.html && pnpm run lint:packages", "prettier": "prettier --write \"./{src,uc-src,console-src}/**/*.{vue,js,jsx,ts,tsx,css,scss,json,yml,yaml,html}\" && pnpm run prettier:packages", "typecheck:packages": "pnpm --parallel --filter \"./packages/**\" run typecheck", "lint:packages": "pnpm --parallel --filter \"./packages/**\" lint", "prettier:packages": "pnpm --parallel --filter \"./packages/**\" prettier", - "test:unit:packages": "pnpm --parallel --filter \"./packages/**\" run test:unit", - "link:editor": "pnpm link --global @halo-dev/richtext-editor" + "test:unit:packages": "pnpm --parallel --filter \"./packages/**\" run test:unit" }, "lint-staged": { "*.{vue,js,jsx,ts,tsx,css,scss,json,yml,yaml,html}": [ @@ -64,7 +60,7 @@ "@halo-dev/console-shared": "workspace:*", "@halo-dev/richtext-editor": "workspace:*", "@tanstack/vue-query": "^4.29.1", - "@tiptap/extension-character-count": "^2.6.5", + "@tiptap/extension-character-count": "^2.8.0", "@uppy/core": "^3.11.3", "@uppy/dashboard": "^3.8.3", "@uppy/drag-drop": "^3.1.0", @@ -100,15 +96,14 @@ "short-unique-id": "^5.0.2", "transliteration": "^2.3.5", "ua-parser-js": "^1.0.38", - "vue": "^3.4.27", - "vue-demi": "^0.14.7", + "vue": "^3.5.11", + "vue-demi": "^0.14.10", "vue-draggable-plus": "^0.4.1", "vue-grid-layout": "3.0.0-beta1", "vue-i18n": "^9.13.1", "vue-router": "^4.3.2" }, "devDependencies": { - "@changesets/cli": "^2.25.2", "@iconify/json": "^2.2.235", "@intlify/unplugin-vue-i18n": "^4.0.0", "@rushstack/eslint-patch": "^1.3.2", @@ -122,19 +117,17 @@ "@types/qs": "^6.9.7", "@types/randomstring": "^1.1.8", "@types/ua-parser-js": "^0.7.39", - "@vitejs/plugin-vue": "^5.1.2", + "@vitejs/plugin-vue": "^5.1.4", "@vitejs/plugin-vue-jsx": "^4.0.1", "@vitest/ui": "^0.34.1", - "@vue/compiler-sfc": "^3.4.27", + "@vue/compiler-sfc": "^3.5.11", "@vue/eslint-config-prettier": "^7.1.0", "@vue/eslint-config-typescript": "^11.0.3", "@vue/test-utils": "^2.4.6", "@vue/tsconfig": "^0.5.1", "autoprefixer": "^10.4.14", "c8": "^7.12.0", - "cypress": "^10.11.0", "eslint": "^8.43.0", - "eslint-plugin-cypress": "^2.13.3", "eslint-plugin-vue": "^9.17.0", "husky": "^8.0.3", "jsdom": "^20.0.3", @@ -143,6 +136,7 @@ "postcss": "^8.4.21", "postcss-viewport-height-correction": "^1.1.1", "prettier": "^2.8.8", + "prettier-plugin-organize-imports": "^4.1.0", "prettier-plugin-tailwindcss": "^0.1.13", "randomstring": "^1.2.3", "rollup-plugin-gzip": "^3.1.0", @@ -151,7 +145,7 @@ "tailwindcss": "^3.2.7", "tailwindcss-safe-area": "^0.2.2", "tailwindcss-themer": "^2.0.3", - "typescript": "~5.5.4", + "typescript": "~5.6.2", "unplugin-icons": "^0.19.2", "vite": "^5.4.1", "vite-plugin-externals": "^0.6.2", @@ -160,6 +154,6 @@ "vite-plugin-static-copy": "^1.0.6", "vite-plugin-vue-devtools": "^7.3.8", "vitest": "^0.34.1", - "vue-tsc": "^2.0.29" + "vue-tsc": "^2.1.6" } } diff --git a/ui/packages/api-client/entry/api-client.ts b/ui/packages/api-client/entry/api-client.ts index 2b63f9ab53..abfb44573d 100644 --- a/ui/packages/api-client/entry/api-client.ts +++ b/ui/packages/api-client/entry/api-client.ts @@ -19,7 +19,6 @@ import { ExtensionPointDefinitionV1alpha1Api, GroupV1alpha1Api, IndicesV1alpha1ConsoleApi, - LoginApi, MenuItemV1alpha1Api, MenuV1alpha1Api, MenuV1alpha1PublicApi, @@ -27,6 +26,7 @@ import { MigrationV1alpha1ConsoleApi, NotificationTemplateV1alpha1Api, NotificationV1alpha1Api, + NotificationV1alpha1PublicApi, NotificationV1alpha1UcApi, NotifierDescriptorV1alpha1Api, NotifierV1alpha1ConsoleApi, @@ -55,6 +55,7 @@ import { SnapshotV1alpha1Api, SnapshotV1alpha1UcApi, SubscriptionV1alpha1Api, + SystemConfigV1alpha1ConsoleApi, SystemV1alpha1ConsoleApi, SystemV1alpha1PublicApi, TagV1alpha1Api, @@ -65,7 +66,6 @@ import { UserConnectionV1alpha1Api, UserV1alpha1Api, UserV1alpha1ConsoleApi, - UserV1alpha1PublicApi, } from "../src"; const defaultAxiosInstance = axios.create({ @@ -280,7 +280,6 @@ function createConsoleApiClient(axiosInstance: AxiosInstance) { baseURL, axiosInstance ), - login: new LoginApi(undefined, baseURL, axiosInstance), storage: { attachment: new AttachmentV1alpha1ConsoleApi( undefined, @@ -320,6 +319,13 @@ function createConsoleApiClient(axiosInstance: AxiosInstance) { theme: { theme: new ThemeV1alpha1ConsoleApi(undefined, baseURL, axiosInstance), }, + configMap: { + system: new SystemConfigV1alpha1ConsoleApi( + undefined, + baseURL, + axiosInstance + ), + }, }; } @@ -423,7 +429,6 @@ function createPublicApiClient(axiosInstance: AxiosInstance) { return { menu: new MenuV1alpha1PublicApi(undefined, baseURL, axiosInstance), stats: new SystemV1alpha1PublicApi(undefined, baseURL, axiosInstance), - user: new UserV1alpha1PublicApi(undefined, baseURL, axiosInstance), content: { post: new PostV1alpha1PublicApi(undefined, baseURL, axiosInstance), comment: new CommentV1alpha1PublicApi(undefined, baseURL, axiosInstance), @@ -431,6 +436,11 @@ function createPublicApiClient(axiosInstance: AxiosInstance) { metrics: { metrics: new MetricsV1alpha1PublicApi(undefined, baseURL, axiosInstance), }, + notification: new NotificationV1alpha1PublicApi( + undefined, + baseURL, + axiosInstance + ), }; } @@ -448,6 +458,5 @@ export { createPublicApiClient, createUcApiClient, defaultPublicApiClient as publicApiClient, - defaultUcApiClient as ucApiClient + defaultUcApiClient as ucApiClient, }; - diff --git a/ui/packages/api-client/package.json b/ui/packages/api-client/package.json index 7e781c6849..2a08afd2b5 100644 --- a/ui/packages/api-client/package.json +++ b/ui/packages/api-client/package.json @@ -50,7 +50,7 @@ "rimraf": "^5.0.7", "typescript": "~5.5.4", "unbuild": "^0.7.6", - "vite-plugin-dts": "^4.0.3" + "vite-plugin-dts": "^4.2.2" }, "peerDependencies": { "axios": "^1.7.x" diff --git a/ui/packages/api-client/src/.openapi-generator/FILES b/ui/packages/api-client/src/.openapi-generator/FILES index 0a78264347..cc42c1c805 100644 --- a/ui/packages/api-client/src/.openapi-generator/FILES +++ b/ui/packages/api-client/src/.openapi-generator/FILES @@ -3,7 +3,6 @@ .openapi-generator-ignore api.ts api/annotation-setting-v1alpha1-api.ts -api/api-notification-halo-run-v1alpha1-subscription-api.ts api/attachment-v1alpha1-api.ts api/attachment-v1alpha1-console-api.ts api/attachment-v1alpha1-uc-api.ts @@ -17,6 +16,7 @@ api/comment-v1alpha1-console-api.ts api/comment-v1alpha1-public-api.ts api/config-map-v1alpha1-api.ts api/counter-v1alpha1-api.ts +api/default-api.ts api/device-v1alpha1-api.ts api/device-v1alpha1-uc-api.ts api/extension-definition-v1alpha1-api.ts @@ -25,7 +25,6 @@ api/group-v1alpha1-api.ts api/index-v1alpha1-public-api.ts api/indices-v1alpha1-console-api.ts api/local-thumbnail-v1alpha1-api.ts -api/login-api.ts api/menu-item-v1alpha1-api.ts api/menu-v1alpha1-api.ts api/menu-v1alpha1-public-api.ts @@ -33,6 +32,7 @@ api/metrics-v1alpha1-public-api.ts api/migration-v1alpha1-console-api.ts api/notification-template-v1alpha1-api.ts api/notification-v1alpha1-api.ts +api/notification-v1alpha1-public-api.ts api/notification-v1alpha1-uc-api.ts api/notifier-descriptor-v1alpha1-api.ts api/notifier-v1alpha1-console-api.ts @@ -65,6 +65,7 @@ api/single-page-v1alpha1-public-api.ts api/snapshot-v1alpha1-api.ts api/snapshot-v1alpha1-uc-api.ts api/subscription-v1alpha1-api.ts +api/system-config-v1alpha1-console-api.ts api/system-v1alpha1-console-api.ts api/system-v1alpha1-public-api.ts api/tag-v1alpha1-api.ts @@ -76,9 +77,9 @@ api/thumbnail-v1alpha1-api.ts api/thumbnail-v1alpha1-public-api.ts api/two-factor-auth-v1alpha1-uc-api.ts api/user-connection-v1alpha1-api.ts +api/user-connection-v1alpha1-uc-api.ts api/user-v1alpha1-api.ts api/user-v1alpha1-console-api.ts -api/user-v1alpha1-public-api.ts base.ts common.ts configuration.ts @@ -216,7 +217,6 @@ models/notifier-info.ts models/notifier-setting-ref.ts models/owner-info.ts models/password-request.ts -models/password-reset-email-request.ts models/pat-spec.ts models/personal-access-token-list.ts models/personal-access-token.ts @@ -239,7 +239,6 @@ models/post-spec.ts models/post-status.ts models/post-vo.ts models/post.ts -models/public-key-response.ts models/reason-attributes.ts models/reason-list.ts models/reason-property.ts @@ -255,7 +254,6 @@ models/reason-type-spec.ts models/reason-type.ts models/reason.ts models/ref.ts -models/register-verify-email-request.ts models/remember-me-token-list.ts models/remember-me-token-spec.ts models/remember-me-token.ts @@ -268,7 +266,6 @@ models/reply-status.ts models/reply-vo-list.ts models/reply-vo.ts models/reply.ts -models/reset-password-request.ts models/reverse-proxy-list.ts models/reverse-proxy-rule.ts models/reverse-proxy.ts @@ -291,7 +288,6 @@ models/setting-list.ts models/setting-ref.ts models/setting-spec.ts models/setting.ts -models/sign-up-request.ts models/single-page-list.ts models/single-page-request.ts models/single-page-spec.ts @@ -309,7 +305,6 @@ models/subscription-list.ts models/subscription-spec.ts models/subscription-subscriber.ts models/subscription.ts -models/system-initialization-request.ts models/tag-list.ts models/tag-spec.ts models/tag-status.ts @@ -329,6 +324,7 @@ models/thumbnail.ts models/totp-auth-link-response.ts models/totp-request.ts models/two-factor-auth-settings.ts +models/uc-upload-request-form-data.ts models/upgrade-from-uri-request.ts models/upload-from-url-request.ts models/user-connection-list.ts diff --git a/ui/packages/api-client/src/api.ts b/ui/packages/api-client/src/api.ts index a91ddcf6cf..a2b1159d2a 100644 --- a/ui/packages/api-client/src/api.ts +++ b/ui/packages/api-client/src/api.ts @@ -15,7 +15,6 @@ export * from './api/annotation-setting-v1alpha1-api'; -export * from './api/api-notification-halo-run-v1alpha1-subscription-api'; export * from './api/attachment-v1alpha1-api'; export * from './api/attachment-v1alpha1-console-api'; export * from './api/attachment-v1alpha1-uc-api'; @@ -29,6 +28,7 @@ export * from './api/comment-v1alpha1-console-api'; export * from './api/comment-v1alpha1-public-api'; export * from './api/config-map-v1alpha1-api'; export * from './api/counter-v1alpha1-api'; +export * from './api/default-api'; export * from './api/device-v1alpha1-api'; export * from './api/device-v1alpha1-uc-api'; export * from './api/extension-definition-v1alpha1-api'; @@ -37,7 +37,6 @@ export * from './api/group-v1alpha1-api'; export * from './api/index-v1alpha1-public-api'; export * from './api/indices-v1alpha1-console-api'; export * from './api/local-thumbnail-v1alpha1-api'; -export * from './api/login-api'; export * from './api/menu-item-v1alpha1-api'; export * from './api/menu-v1alpha1-api'; export * from './api/menu-v1alpha1-public-api'; @@ -45,6 +44,7 @@ export * from './api/metrics-v1alpha1-public-api'; export * from './api/migration-v1alpha1-console-api'; export * from './api/notification-template-v1alpha1-api'; export * from './api/notification-v1alpha1-api'; +export * from './api/notification-v1alpha1-public-api'; export * from './api/notification-v1alpha1-uc-api'; export * from './api/notifier-descriptor-v1alpha1-api'; export * from './api/notifier-v1alpha1-console-api'; @@ -77,6 +77,7 @@ export * from './api/single-page-v1alpha1-public-api'; export * from './api/snapshot-v1alpha1-api'; export * from './api/snapshot-v1alpha1-uc-api'; export * from './api/subscription-v1alpha1-api'; +export * from './api/system-config-v1alpha1-console-api'; export * from './api/system-v1alpha1-console-api'; export * from './api/system-v1alpha1-public-api'; export * from './api/tag-v1alpha1-api'; @@ -88,7 +89,7 @@ export * from './api/thumbnail-v1alpha1-api'; export * from './api/thumbnail-v1alpha1-public-api'; export * from './api/two-factor-auth-v1alpha1-uc-api'; export * from './api/user-connection-v1alpha1-api'; +export * from './api/user-connection-v1alpha1-uc-api'; export * from './api/user-v1alpha1-api'; export * from './api/user-v1alpha1-console-api'; -export * from './api/user-v1alpha1-public-api'; diff --git a/ui/packages/api-client/src/api/attachment-v1alpha1-uc-api.ts b/ui/packages/api-client/src/api/attachment-v1alpha1-uc-api.ts index 109d280a7f..26cdda13d3 100644 --- a/ui/packages/api-client/src/api/attachment-v1alpha1-uc-api.ts +++ b/ui/packages/api-client/src/api/attachment-v1alpha1-uc-api.ts @@ -24,6 +24,10 @@ import { BASE_PATH, COLLECTION_FORMATS, RequestArgs, BaseAPI, RequiredError, ope // @ts-ignore import { Attachment } from '../models'; // @ts-ignore +import { AttachmentList } from '../models'; +// @ts-ignore +import { UcUploadRequestFormData } from '../models'; +// @ts-ignore import { UploadFromUrlRequest } from '../models'; /** * AttachmentV1alpha1UcApi - axios parameter creator @@ -43,7 +47,7 @@ export const AttachmentV1alpha1UcApiAxiosParamCreator = function (configuration? createAttachmentForPost: async (file: File, waitForPermalink?: boolean, postName?: string, singlePageName?: string, options: RawAxiosRequestConfig = {}): Promise => { // verify required parameter 'file' is not null or undefined assertParamExists('createAttachmentForPost', 'file', file) - const localVarPath = `/apis/uc.api.content.halo.run/v1alpha1/attachments`; + const localVarPath = `/apis/uc.api.storage.halo.run/v1alpha1/attachments`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); let baseOptions; @@ -104,7 +108,7 @@ export const AttachmentV1alpha1UcApiAxiosParamCreator = function (configuration? externalTransferAttachment1: async (uploadFromUrlRequest: UploadFromUrlRequest, waitForPermalink?: boolean, options: RawAxiosRequestConfig = {}): Promise => { // verify required parameter 'uploadFromUrlRequest' is not null or undefined assertParamExists('externalTransferAttachment1', 'uploadFromUrlRequest', uploadFromUrlRequest) - const localVarPath = `/apis/uc.api.content.halo.run/v1alpha1/attachments/-/upload-from-url`; + const localVarPath = `/apis/uc.api.storage.halo.run/v1alpha1/attachments/-/upload-from-url`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); let baseOptions; @@ -137,6 +141,136 @@ export const AttachmentV1alpha1UcApiAxiosParamCreator = function (configuration? localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; localVarRequestOptions.data = serializeDataIfNeeded(uploadFromUrlRequest, localVarRequestOptions, configuration) + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * List attachments of the current user uploaded. + * @param {number} [page] Page number. Default is 0. + * @param {number} [size] Size number. Default is 0. + * @param {Array} [labelSelector] Label selector. e.g.: hidden!=true + * @param {Array} [fieldSelector] Field selector. e.g.: metadata.name==halo + * @param {Array} [sort] Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported. + * @param {boolean} [ungrouped] Filter attachments without group. This parameter will ignore group parameter. + * @param {string} [keyword] Keyword for searching. + * @param {Array} [accepts] Acceptable media types. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + listMyAttachments: async (page?: number, size?: number, labelSelector?: Array, fieldSelector?: Array, sort?: Array, ungrouped?: boolean, keyword?: string, accepts?: Array, options: RawAxiosRequestConfig = {}): Promise => { + const localVarPath = `/apis/uc.api.storage.halo.run/v1alpha1/attachments`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication basicAuth required + // http basic authentication required + setBasicAuthToObject(localVarRequestOptions, configuration) + + // authentication bearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + if (page !== undefined) { + localVarQueryParameter['page'] = page; + } + + if (size !== undefined) { + localVarQueryParameter['size'] = size; + } + + if (labelSelector) { + localVarQueryParameter['labelSelector'] = labelSelector; + } + + if (fieldSelector) { + localVarQueryParameter['fieldSelector'] = fieldSelector; + } + + if (sort) { + localVarQueryParameter['sort'] = sort; + } + + if (ungrouped !== undefined) { + localVarQueryParameter['ungrouped'] = ungrouped; + } + + if (keyword !== undefined) { + localVarQueryParameter['keyword'] = keyword; + } + + if (accepts) { + localVarQueryParameter['accepts'] = accepts; + } + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * Upload attachment to user center storage. + * @param {File} file + * @param {UcUploadRequestFormData} [formData] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + uploadUcAttachment: async (file: File, formData?: UcUploadRequestFormData, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'file' is not null or undefined + assertParamExists('uploadUcAttachment', 'file', file) + const localVarPath = `/apis/uc.api.storage.halo.run/v1alpha1/attachments/-/upload`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + const localVarFormParams = new ((configuration && configuration.formDataCtor) || FormData)(); + + // authentication basicAuth required + // http basic authentication required + setBasicAuthToObject(localVarRequestOptions, configuration) + + // authentication bearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + if (file !== undefined) { + localVarFormParams.append('file', file as any); + } + + if (formData !== undefined) { + localVarFormParams.append('formData', new Blob([JSON.stringify(formData)], { type: "application/json", })); + } + + + localVarHeaderParameter['Content-Type'] = 'multipart/form-data'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = localVarFormParams; + return { url: toPathString(localVarUrlObj), options: localVarRequestOptions, @@ -180,6 +314,38 @@ export const AttachmentV1alpha1UcApiFp = function(configuration?: Configuration) const localVarOperationServerBasePath = operationServerMap['AttachmentV1alpha1UcApi.externalTransferAttachment1']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, + /** + * List attachments of the current user uploaded. + * @param {number} [page] Page number. Default is 0. + * @param {number} [size] Size number. Default is 0. + * @param {Array} [labelSelector] Label selector. e.g.: hidden!=true + * @param {Array} [fieldSelector] Field selector. e.g.: metadata.name==halo + * @param {Array} [sort] Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported. + * @param {boolean} [ungrouped] Filter attachments without group. This parameter will ignore group parameter. + * @param {string} [keyword] Keyword for searching. + * @param {Array} [accepts] Acceptable media types. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async listMyAttachments(page?: number, size?: number, labelSelector?: Array, fieldSelector?: Array, sort?: Array, ungrouped?: boolean, keyword?: string, accepts?: Array, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.listMyAttachments(page, size, labelSelector, fieldSelector, sort, ungrouped, keyword, accepts, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['AttachmentV1alpha1UcApi.listMyAttachments']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + /** + * Upload attachment to user center storage. + * @param {File} file + * @param {UcUploadRequestFormData} [formData] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async uploadUcAttachment(file: File, formData?: UcUploadRequestFormData, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.uploadUcAttachment(file, formData, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['AttachmentV1alpha1UcApi.uploadUcAttachment']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, } }; @@ -208,6 +374,24 @@ export const AttachmentV1alpha1UcApiFactory = function (configuration?: Configur externalTransferAttachment1(requestParameters: AttachmentV1alpha1UcApiExternalTransferAttachment1Request, options?: RawAxiosRequestConfig): AxiosPromise { return localVarFp.externalTransferAttachment1(requestParameters.uploadFromUrlRequest, requestParameters.waitForPermalink, options).then((request) => request(axios, basePath)); }, + /** + * List attachments of the current user uploaded. + * @param {AttachmentV1alpha1UcApiListMyAttachmentsRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + listMyAttachments(requestParameters: AttachmentV1alpha1UcApiListMyAttachmentsRequest = {}, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.listMyAttachments(requestParameters.page, requestParameters.size, requestParameters.labelSelector, requestParameters.fieldSelector, requestParameters.sort, requestParameters.ungrouped, requestParameters.keyword, requestParameters.accepts, options).then((request) => request(axios, basePath)); + }, + /** + * Upload attachment to user center storage. + * @param {AttachmentV1alpha1UcApiUploadUcAttachmentRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + uploadUcAttachment(requestParameters: AttachmentV1alpha1UcApiUploadUcAttachmentRequest, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.uploadUcAttachment(requestParameters.file, requestParameters.formData, options).then((request) => request(axios, basePath)); + }, }; }; @@ -267,6 +451,90 @@ export interface AttachmentV1alpha1UcApiExternalTransferAttachment1Request { readonly waitForPermalink?: boolean } +/** + * Request parameters for listMyAttachments operation in AttachmentV1alpha1UcApi. + * @export + * @interface AttachmentV1alpha1UcApiListMyAttachmentsRequest + */ +export interface AttachmentV1alpha1UcApiListMyAttachmentsRequest { + /** + * Page number. Default is 0. + * @type {number} + * @memberof AttachmentV1alpha1UcApiListMyAttachments + */ + readonly page?: number + + /** + * Size number. Default is 0. + * @type {number} + * @memberof AttachmentV1alpha1UcApiListMyAttachments + */ + readonly size?: number + + /** + * Label selector. e.g.: hidden!=true + * @type {Array} + * @memberof AttachmentV1alpha1UcApiListMyAttachments + */ + readonly labelSelector?: Array + + /** + * Field selector. e.g.: metadata.name==halo + * @type {Array} + * @memberof AttachmentV1alpha1UcApiListMyAttachments + */ + readonly fieldSelector?: Array + + /** + * Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported. + * @type {Array} + * @memberof AttachmentV1alpha1UcApiListMyAttachments + */ + readonly sort?: Array + + /** + * Filter attachments without group. This parameter will ignore group parameter. + * @type {boolean} + * @memberof AttachmentV1alpha1UcApiListMyAttachments + */ + readonly ungrouped?: boolean + + /** + * Keyword for searching. + * @type {string} + * @memberof AttachmentV1alpha1UcApiListMyAttachments + */ + readonly keyword?: string + + /** + * Acceptable media types. + * @type {Array} + * @memberof AttachmentV1alpha1UcApiListMyAttachments + */ + readonly accepts?: Array +} + +/** + * Request parameters for uploadUcAttachment operation in AttachmentV1alpha1UcApi. + * @export + * @interface AttachmentV1alpha1UcApiUploadUcAttachmentRequest + */ +export interface AttachmentV1alpha1UcApiUploadUcAttachmentRequest { + /** + * + * @type {File} + * @memberof AttachmentV1alpha1UcApiUploadUcAttachment + */ + readonly file: File + + /** + * + * @type {UcUploadRequestFormData} + * @memberof AttachmentV1alpha1UcApiUploadUcAttachment + */ + readonly formData?: UcUploadRequestFormData +} + /** * AttachmentV1alpha1UcApi - object-oriented interface * @export @@ -295,5 +563,27 @@ export class AttachmentV1alpha1UcApi extends BaseAPI { public externalTransferAttachment1(requestParameters: AttachmentV1alpha1UcApiExternalTransferAttachment1Request, options?: RawAxiosRequestConfig) { return AttachmentV1alpha1UcApiFp(this.configuration).externalTransferAttachment1(requestParameters.uploadFromUrlRequest, requestParameters.waitForPermalink, options).then((request) => request(this.axios, this.basePath)); } + + /** + * List attachments of the current user uploaded. + * @param {AttachmentV1alpha1UcApiListMyAttachmentsRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AttachmentV1alpha1UcApi + */ + public listMyAttachments(requestParameters: AttachmentV1alpha1UcApiListMyAttachmentsRequest = {}, options?: RawAxiosRequestConfig) { + return AttachmentV1alpha1UcApiFp(this.configuration).listMyAttachments(requestParameters.page, requestParameters.size, requestParameters.labelSelector, requestParameters.fieldSelector, requestParameters.sort, requestParameters.ungrouped, requestParameters.keyword, requestParameters.accepts, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * Upload attachment to user center storage. + * @param {AttachmentV1alpha1UcApiUploadUcAttachmentRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AttachmentV1alpha1UcApi + */ + public uploadUcAttachment(requestParameters: AttachmentV1alpha1UcApiUploadUcAttachmentRequest, options?: RawAxiosRequestConfig) { + return AttachmentV1alpha1UcApiFp(this.configuration).uploadUcAttachment(requestParameters.file, requestParameters.formData, options).then((request) => request(this.axios, this.basePath)); + } } diff --git a/ui/packages/api-client/src/api/login-api.ts b/ui/packages/api-client/src/api/default-api.ts similarity index 64% rename from ui/packages/api-client/src/api/login-api.ts rename to ui/packages/api-client/src/api/default-api.ts index ad409be6b9..0c7914d1d3 100644 --- a/ui/packages/api-client/src/api/login-api.ts +++ b/ui/packages/api-client/src/api/default-api.ts @@ -21,21 +21,19 @@ import globalAxios from 'axios'; import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObject, setBearerAuthToObject, setOAuthToObject, setSearchParams, serializeDataIfNeeded, toPathString, createRequestFunction } from '../common'; // @ts-ignore import { BASE_PATH, COLLECTION_FORMATS, RequestArgs, BaseAPI, RequiredError, operationServerMap } from '../base'; -// @ts-ignore -import { PublicKeyResponse } from '../models'; /** - * LoginApi - axios parameter creator + * DefaultApi - axios parameter creator * @export */ -export const LoginApiAxiosParamCreator = function (configuration?: Configuration) { +export const DefaultApiAxiosParamCreator = function (configuration?: Configuration) { return { /** - * Read public key for encrypting password. + * * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getPublicKey: async (options: RawAxiosRequestConfig = {}): Promise => { - const localVarPath = `/login/public-key`; + setNoCacheForSetUpPage: async (options: RawAxiosRequestConfig = {}): Promise => { + const localVarPath = `/system/setup`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); let baseOptions; @@ -43,7 +41,7 @@ export const LoginApiAxiosParamCreator = function (configuration?: Configuration baseOptions = configuration.baseOptions; } - const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; const localVarHeaderParameter = {} as any; const localVarQueryParameter = {} as any; @@ -70,59 +68,59 @@ export const LoginApiAxiosParamCreator = function (configuration?: Configuration }; /** - * LoginApi - functional programming interface + * DefaultApi - functional programming interface * @export */ -export const LoginApiFp = function(configuration?: Configuration) { - const localVarAxiosParamCreator = LoginApiAxiosParamCreator(configuration) +export const DefaultApiFp = function(configuration?: Configuration) { + const localVarAxiosParamCreator = DefaultApiAxiosParamCreator(configuration) return { /** - * Read public key for encrypting password. + * * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async getPublicKey(options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.getPublicKey(options); + async setNoCacheForSetUpPage(options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.setNoCacheForSetUpPage(options); const localVarOperationServerIndex = configuration?.serverIndex ?? 0; - const localVarOperationServerBasePath = operationServerMap['LoginApi.getPublicKey']?.[localVarOperationServerIndex]?.url; + const localVarOperationServerBasePath = operationServerMap['DefaultApi.setNoCacheForSetUpPage']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, } }; /** - * LoginApi - factory interface + * DefaultApi - factory interface * @export */ -export const LoginApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { - const localVarFp = LoginApiFp(configuration) +export const DefaultApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { + const localVarFp = DefaultApiFp(configuration) return { /** - * Read public key for encrypting password. + * * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getPublicKey(options?: RawAxiosRequestConfig): AxiosPromise { - return localVarFp.getPublicKey(options).then((request) => request(axios, basePath)); + setNoCacheForSetUpPage(options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.setNoCacheForSetUpPage(options).then((request) => request(axios, basePath)); }, }; }; /** - * LoginApi - object-oriented interface + * DefaultApi - object-oriented interface * @export - * @class LoginApi + * @class DefaultApi * @extends {BaseAPI} */ -export class LoginApi extends BaseAPI { +export class DefaultApi extends BaseAPI { /** - * Read public key for encrypting password. + * * @param {*} [options] Override http request option. * @throws {RequiredError} - * @memberof LoginApi + * @memberof DefaultApi */ - public getPublicKey(options?: RawAxiosRequestConfig) { - return LoginApiFp(this.configuration).getPublicKey(options).then((request) => request(this.axios, this.basePath)); + public setNoCacheForSetUpPage(options?: RawAxiosRequestConfig) { + return DefaultApiFp(this.configuration).setNoCacheForSetUpPage(options).then((request) => request(this.axios, this.basePath)); } } diff --git a/ui/packages/api-client/src/api/api-notification-halo-run-v1alpha1-subscription-api.ts b/ui/packages/api-client/src/api/notification-v1alpha1-public-api.ts similarity index 67% rename from ui/packages/api-client/src/api/api-notification-halo-run-v1alpha1-subscription-api.ts rename to ui/packages/api-client/src/api/notification-v1alpha1-public-api.ts index 5b1612ed7c..18830ddfac 100644 --- a/ui/packages/api-client/src/api/api-notification-halo-run-v1alpha1-subscription-api.ts +++ b/ui/packages/api-client/src/api/notification-v1alpha1-public-api.ts @@ -22,10 +22,10 @@ import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObj // @ts-ignore import { BASE_PATH, COLLECTION_FORMATS, RequestArgs, BaseAPI, RequiredError, operationServerMap } from '../base'; /** - * ApiNotificationHaloRunV1alpha1SubscriptionApi - axios parameter creator + * NotificationV1alpha1PublicApi - axios parameter creator * @export */ -export const ApiNotificationHaloRunV1alpha1SubscriptionApiAxiosParamCreator = function (configuration?: Configuration) { +export const NotificationV1alpha1PublicApiAxiosParamCreator = function (configuration?: Configuration) { return { /** * Unsubscribe a subscription @@ -79,11 +79,11 @@ export const ApiNotificationHaloRunV1alpha1SubscriptionApiAxiosParamCreator = fu }; /** - * ApiNotificationHaloRunV1alpha1SubscriptionApi - functional programming interface + * NotificationV1alpha1PublicApi - functional programming interface * @export */ -export const ApiNotificationHaloRunV1alpha1SubscriptionApiFp = function(configuration?: Configuration) { - const localVarAxiosParamCreator = ApiNotificationHaloRunV1alpha1SubscriptionApiAxiosParamCreator(configuration) +export const NotificationV1alpha1PublicApiFp = function(configuration?: Configuration) { + const localVarAxiosParamCreator = NotificationV1alpha1PublicApiAxiosParamCreator(configuration) return { /** * Unsubscribe a subscription @@ -95,68 +95,68 @@ export const ApiNotificationHaloRunV1alpha1SubscriptionApiFp = function(configur async unsubscribe(name: string, token: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { const localVarAxiosArgs = await localVarAxiosParamCreator.unsubscribe(name, token, options); const localVarOperationServerIndex = configuration?.serverIndex ?? 0; - const localVarOperationServerBasePath = operationServerMap['ApiNotificationHaloRunV1alpha1SubscriptionApi.unsubscribe']?.[localVarOperationServerIndex]?.url; + const localVarOperationServerBasePath = operationServerMap['NotificationV1alpha1PublicApi.unsubscribe']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, } }; /** - * ApiNotificationHaloRunV1alpha1SubscriptionApi - factory interface + * NotificationV1alpha1PublicApi - factory interface * @export */ -export const ApiNotificationHaloRunV1alpha1SubscriptionApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { - const localVarFp = ApiNotificationHaloRunV1alpha1SubscriptionApiFp(configuration) +export const NotificationV1alpha1PublicApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { + const localVarFp = NotificationV1alpha1PublicApiFp(configuration) return { /** * Unsubscribe a subscription - * @param {ApiNotificationHaloRunV1alpha1SubscriptionApiUnsubscribeRequest} requestParameters Request parameters. + * @param {NotificationV1alpha1PublicApiUnsubscribeRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - unsubscribe(requestParameters: ApiNotificationHaloRunV1alpha1SubscriptionApiUnsubscribeRequest, options?: RawAxiosRequestConfig): AxiosPromise { + unsubscribe(requestParameters: NotificationV1alpha1PublicApiUnsubscribeRequest, options?: RawAxiosRequestConfig): AxiosPromise { return localVarFp.unsubscribe(requestParameters.name, requestParameters.token, options).then((request) => request(axios, basePath)); }, }; }; /** - * Request parameters for unsubscribe operation in ApiNotificationHaloRunV1alpha1SubscriptionApi. + * Request parameters for unsubscribe operation in NotificationV1alpha1PublicApi. * @export - * @interface ApiNotificationHaloRunV1alpha1SubscriptionApiUnsubscribeRequest + * @interface NotificationV1alpha1PublicApiUnsubscribeRequest */ -export interface ApiNotificationHaloRunV1alpha1SubscriptionApiUnsubscribeRequest { +export interface NotificationV1alpha1PublicApiUnsubscribeRequest { /** * Subscription name * @type {string} - * @memberof ApiNotificationHaloRunV1alpha1SubscriptionApiUnsubscribe + * @memberof NotificationV1alpha1PublicApiUnsubscribe */ readonly name: string /** * Unsubscribe token * @type {string} - * @memberof ApiNotificationHaloRunV1alpha1SubscriptionApiUnsubscribe + * @memberof NotificationV1alpha1PublicApiUnsubscribe */ readonly token: string } /** - * ApiNotificationHaloRunV1alpha1SubscriptionApi - object-oriented interface + * NotificationV1alpha1PublicApi - object-oriented interface * @export - * @class ApiNotificationHaloRunV1alpha1SubscriptionApi + * @class NotificationV1alpha1PublicApi * @extends {BaseAPI} */ -export class ApiNotificationHaloRunV1alpha1SubscriptionApi extends BaseAPI { +export class NotificationV1alpha1PublicApi extends BaseAPI { /** * Unsubscribe a subscription - * @param {ApiNotificationHaloRunV1alpha1SubscriptionApiUnsubscribeRequest} requestParameters Request parameters. + * @param {NotificationV1alpha1PublicApiUnsubscribeRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. * @throws {RequiredError} - * @memberof ApiNotificationHaloRunV1alpha1SubscriptionApi + * @memberof NotificationV1alpha1PublicApi */ - public unsubscribe(requestParameters: ApiNotificationHaloRunV1alpha1SubscriptionApiUnsubscribeRequest, options?: RawAxiosRequestConfig) { - return ApiNotificationHaloRunV1alpha1SubscriptionApiFp(this.configuration).unsubscribe(requestParameters.name, requestParameters.token, options).then((request) => request(this.axios, this.basePath)); + public unsubscribe(requestParameters: NotificationV1alpha1PublicApiUnsubscribeRequest, options?: RawAxiosRequestConfig) { + return NotificationV1alpha1PublicApiFp(this.configuration).unsubscribe(requestParameters.name, requestParameters.token, options).then((request) => request(this.axios, this.basePath)); } } diff --git a/ui/packages/api-client/src/api/plugin-v1alpha1-console-api.ts b/ui/packages/api-client/src/api/plugin-v1alpha1-console-api.ts index efde5318e8..eed35161d6 100644 --- a/ui/packages/api-client/src/api/plugin-v1alpha1-console-api.ts +++ b/ui/packages/api-client/src/api/plugin-v1alpha1-console-api.ts @@ -163,7 +163,7 @@ export const PluginV1alpha1ConsoleApiAxiosParamCreator = function (configuration }; }, /** - * Fetch configMap of plugin by configured configMapName. + * Fetch configMap of plugin by configured configMapName. it is deprecated since 2.20.0 * @param {string} name * @param {*} [options] Override http request option. * @throws {RequiredError} @@ -194,6 +194,47 @@ export const PluginV1alpha1ConsoleApiAxiosParamCreator = function (configuration + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * Fetch converted json config of plugin by configured configMapName. + * @param {string} name + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + fetchPluginJsonConfig: async (name: string, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'name' is not null or undefined + assertParamExists('fetchPluginJsonConfig', 'name', name) + const localVarPath = `/apis/api.console.halo.run/v1alpha1/plugins/{name}/json-config` + .replace(`{${"name"}}`, encodeURIComponent(String(name))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication basicAuth required + // http basic authentication required + setBasicAuthToObject(localVarRequestOptions, configuration) + + // authentication bearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; @@ -343,43 +384,6 @@ export const PluginV1alpha1ConsoleApiAxiosParamCreator = function (configuration options: localVarRequestOptions, }; }, - /** - * List all plugin presets in the system. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - listPluginPresets: async (options: RawAxiosRequestConfig = {}): Promise => { - const localVarPath = `/apis/api.console.halo.run/v1alpha1/plugin-presets`; - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); - let baseOptions; - if (configuration) { - baseOptions = configuration.baseOptions; - } - - const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; - const localVarHeaderParameter = {} as any; - const localVarQueryParameter = {} as any; - - // authentication basicAuth required - // http basic authentication required - setBasicAuthToObject(localVarRequestOptions, configuration) - - // authentication bearerAuth required - // http bearer authentication required - await setBearerAuthToObject(localVarHeaderParameter, configuration) - - - - setSearchParams(localVarUrlObj, localVarQueryParameter); - let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; - localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - - return { - url: toPathString(localVarUrlObj), - options: localVarRequestOptions, - }; - }, /** * List plugins using query criteria and sort params * @param {number} [page] Page number. Default is 0. @@ -535,10 +539,11 @@ export const PluginV1alpha1ConsoleApiAxiosParamCreator = function (configuration }; }, /** - * Update the configMap of plugin setting. + * Update the configMap of plugin setting, it is deprecated since 2.20.0 * @param {string} name * @param {ConfigMap} configMap * @param {*} [options] Override http request option. + * @deprecated * @throws {RequiredError} */ updatePluginConfig: async (name: string, configMap: ConfigMap, options: RawAxiosRequestConfig = {}): Promise => { @@ -581,6 +586,53 @@ export const PluginV1alpha1ConsoleApiAxiosParamCreator = function (configuration options: localVarRequestOptions, }; }, + /** + * Update the config of plugin setting. + * @param {string} name + * @param {object} body + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + updatePluginJsonConfig: async (name: string, body: object, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'name' is not null or undefined + assertParamExists('updatePluginJsonConfig', 'name', name) + // verify required parameter 'body' is not null or undefined + assertParamExists('updatePluginJsonConfig', 'body', body) + const localVarPath = `/apis/api.console.halo.run/v1alpha1/plugins/{name}/json-config` + .replace(`{${"name"}}`, encodeURIComponent(String(name))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication basicAuth required + // http basic authentication required + setBasicAuthToObject(localVarRequestOptions, configuration) + + // authentication bearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(body, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * Upgrade a plugin by uploading a Jar file * @param {string} name @@ -734,7 +786,7 @@ export const PluginV1alpha1ConsoleApiFp = function(configuration?: Configuration return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, /** - * Fetch configMap of plugin by configured configMapName. + * Fetch configMap of plugin by configured configMapName. it is deprecated since 2.20.0 * @param {string} name * @param {*} [options] Override http request option. * @throws {RequiredError} @@ -745,6 +797,18 @@ export const PluginV1alpha1ConsoleApiFp = function(configuration?: Configuration const localVarOperationServerBasePath = operationServerMap['PluginV1alpha1ConsoleApi.fetchPluginConfig']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, + /** + * Fetch converted json config of plugin by configured configMapName. + * @param {string} name + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async fetchPluginJsonConfig(name: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.fetchPluginJsonConfig(name, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['PluginV1alpha1ConsoleApi.fetchPluginJsonConfig']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, /** * Fetch setting of plugin. * @param {string} name @@ -783,17 +847,6 @@ export const PluginV1alpha1ConsoleApiFp = function(configuration?: Configuration const localVarOperationServerBasePath = operationServerMap['PluginV1alpha1ConsoleApi.installPluginFromUri']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, - /** - * List all plugin presets in the system. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async listPluginPresets(options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { - const localVarAxiosArgs = await localVarAxiosParamCreator.listPluginPresets(options); - const localVarOperationServerIndex = configuration?.serverIndex ?? 0; - const localVarOperationServerBasePath = operationServerMap['PluginV1alpha1ConsoleApi.listPluginPresets']?.[localVarOperationServerIndex]?.url; - return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); - }, /** * List plugins using query criteria and sort params * @param {number} [page] Page number. Default is 0. @@ -837,10 +890,11 @@ export const PluginV1alpha1ConsoleApiFp = function(configuration?: Configuration return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, /** - * Update the configMap of plugin setting. + * Update the configMap of plugin setting, it is deprecated since 2.20.0 * @param {string} name * @param {ConfigMap} configMap * @param {*} [options] Override http request option. + * @deprecated * @throws {RequiredError} */ async updatePluginConfig(name: string, configMap: ConfigMap, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { @@ -849,6 +903,19 @@ export const PluginV1alpha1ConsoleApiFp = function(configuration?: Configuration const localVarOperationServerBasePath = operationServerMap['PluginV1alpha1ConsoleApi.updatePluginConfig']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, + /** + * Update the config of plugin setting. + * @param {string} name + * @param {object} body + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async updatePluginJsonConfig(name: string, body: object, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.updatePluginJsonConfig(name, body, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['PluginV1alpha1ConsoleApi.updatePluginJsonConfig']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, /** * Upgrade a plugin by uploading a Jar file * @param {string} name @@ -913,7 +980,7 @@ export const PluginV1alpha1ConsoleApiFactory = function (configuration?: Configu return localVarFp.fetchJsBundle(options).then((request) => request(axios, basePath)); }, /** - * Fetch configMap of plugin by configured configMapName. + * Fetch configMap of plugin by configured configMapName. it is deprecated since 2.20.0 * @param {PluginV1alpha1ConsoleApiFetchPluginConfigRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. * @throws {RequiredError} @@ -921,6 +988,15 @@ export const PluginV1alpha1ConsoleApiFactory = function (configuration?: Configu fetchPluginConfig(requestParameters: PluginV1alpha1ConsoleApiFetchPluginConfigRequest, options?: RawAxiosRequestConfig): AxiosPromise { return localVarFp.fetchPluginConfig(requestParameters.name, options).then((request) => request(axios, basePath)); }, + /** + * Fetch converted json config of plugin by configured configMapName. + * @param {PluginV1alpha1ConsoleApiFetchPluginJsonConfigRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + fetchPluginJsonConfig(requestParameters: PluginV1alpha1ConsoleApiFetchPluginJsonConfigRequest, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.fetchPluginJsonConfig(requestParameters.name, options).then((request) => request(axios, basePath)); + }, /** * Fetch setting of plugin. * @param {PluginV1alpha1ConsoleApiFetchPluginSettingRequest} requestParameters Request parameters. @@ -948,14 +1024,6 @@ export const PluginV1alpha1ConsoleApiFactory = function (configuration?: Configu installPluginFromUri(requestParameters: PluginV1alpha1ConsoleApiInstallPluginFromUriRequest, options?: RawAxiosRequestConfig): AxiosPromise { return localVarFp.installPluginFromUri(requestParameters.installFromUriRequest, options).then((request) => request(axios, basePath)); }, - /** - * List all plugin presets in the system. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - listPluginPresets(options?: RawAxiosRequestConfig): AxiosPromise> { - return localVarFp.listPluginPresets(options).then((request) => request(axios, basePath)); - }, /** * List plugins using query criteria and sort params * @param {PluginV1alpha1ConsoleApiListPluginsRequest} requestParameters Request parameters. @@ -984,14 +1052,24 @@ export const PluginV1alpha1ConsoleApiFactory = function (configuration?: Configu return localVarFp.resetPluginConfig(requestParameters.name, options).then((request) => request(axios, basePath)); }, /** - * Update the configMap of plugin setting. + * Update the configMap of plugin setting, it is deprecated since 2.20.0 * @param {PluginV1alpha1ConsoleApiUpdatePluginConfigRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. + * @deprecated * @throws {RequiredError} */ updatePluginConfig(requestParameters: PluginV1alpha1ConsoleApiUpdatePluginConfigRequest, options?: RawAxiosRequestConfig): AxiosPromise { return localVarFp.updatePluginConfig(requestParameters.name, requestParameters.configMap, options).then((request) => request(axios, basePath)); }, + /** + * Update the config of plugin setting. + * @param {PluginV1alpha1ConsoleApiUpdatePluginJsonConfigRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + updatePluginJsonConfig(requestParameters: PluginV1alpha1ConsoleApiUpdatePluginJsonConfigRequest, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.updatePluginJsonConfig(requestParameters.name, requestParameters.body, options).then((request) => request(axios, basePath)); + }, /** * Upgrade a plugin by uploading a Jar file * @param {PluginV1alpha1ConsoleApiUpgradePluginRequest} requestParameters Request parameters. @@ -1048,6 +1126,20 @@ export interface PluginV1alpha1ConsoleApiFetchPluginConfigRequest { readonly name: string } +/** + * Request parameters for fetchPluginJsonConfig operation in PluginV1alpha1ConsoleApi. + * @export + * @interface PluginV1alpha1ConsoleApiFetchPluginJsonConfigRequest + */ +export interface PluginV1alpha1ConsoleApiFetchPluginJsonConfigRequest { + /** + * + * @type {string} + * @memberof PluginV1alpha1ConsoleApiFetchPluginJsonConfig + */ + readonly name: string +} + /** * Request parameters for fetchPluginSetting operation in PluginV1alpha1ConsoleApi. * @export @@ -1209,6 +1301,27 @@ export interface PluginV1alpha1ConsoleApiUpdatePluginConfigRequest { readonly configMap: ConfigMap } +/** + * Request parameters for updatePluginJsonConfig operation in PluginV1alpha1ConsoleApi. + * @export + * @interface PluginV1alpha1ConsoleApiUpdatePluginJsonConfigRequest + */ +export interface PluginV1alpha1ConsoleApiUpdatePluginJsonConfigRequest { + /** + * + * @type {string} + * @memberof PluginV1alpha1ConsoleApiUpdatePluginJsonConfig + */ + readonly name: string + + /** + * + * @type {object} + * @memberof PluginV1alpha1ConsoleApiUpdatePluginJsonConfig + */ + readonly body: object +} + /** * Request parameters for upgradePlugin operation in PluginV1alpha1ConsoleApi. * @export @@ -1304,7 +1417,7 @@ export class PluginV1alpha1ConsoleApi extends BaseAPI { } /** - * Fetch configMap of plugin by configured configMapName. + * Fetch configMap of plugin by configured configMapName. it is deprecated since 2.20.0 * @param {PluginV1alpha1ConsoleApiFetchPluginConfigRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. * @throws {RequiredError} @@ -1314,6 +1427,17 @@ export class PluginV1alpha1ConsoleApi extends BaseAPI { return PluginV1alpha1ConsoleApiFp(this.configuration).fetchPluginConfig(requestParameters.name, options).then((request) => request(this.axios, this.basePath)); } + /** + * Fetch converted json config of plugin by configured configMapName. + * @param {PluginV1alpha1ConsoleApiFetchPluginJsonConfigRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof PluginV1alpha1ConsoleApi + */ + public fetchPluginJsonConfig(requestParameters: PluginV1alpha1ConsoleApiFetchPluginJsonConfigRequest, options?: RawAxiosRequestConfig) { + return PluginV1alpha1ConsoleApiFp(this.configuration).fetchPluginJsonConfig(requestParameters.name, options).then((request) => request(this.axios, this.basePath)); + } + /** * Fetch setting of plugin. * @param {PluginV1alpha1ConsoleApiFetchPluginSettingRequest} requestParameters Request parameters. @@ -1347,16 +1471,6 @@ export class PluginV1alpha1ConsoleApi extends BaseAPI { return PluginV1alpha1ConsoleApiFp(this.configuration).installPluginFromUri(requestParameters.installFromUriRequest, options).then((request) => request(this.axios, this.basePath)); } - /** - * List all plugin presets in the system. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - * @memberof PluginV1alpha1ConsoleApi - */ - public listPluginPresets(options?: RawAxiosRequestConfig) { - return PluginV1alpha1ConsoleApiFp(this.configuration).listPluginPresets(options).then((request) => request(this.axios, this.basePath)); - } - /** * List plugins using query criteria and sort params * @param {PluginV1alpha1ConsoleApiListPluginsRequest} requestParameters Request parameters. @@ -1391,9 +1505,10 @@ export class PluginV1alpha1ConsoleApi extends BaseAPI { } /** - * Update the configMap of plugin setting. + * Update the configMap of plugin setting, it is deprecated since 2.20.0 * @param {PluginV1alpha1ConsoleApiUpdatePluginConfigRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. + * @deprecated * @throws {RequiredError} * @memberof PluginV1alpha1ConsoleApi */ @@ -1401,6 +1516,17 @@ export class PluginV1alpha1ConsoleApi extends BaseAPI { return PluginV1alpha1ConsoleApiFp(this.configuration).updatePluginConfig(requestParameters.name, requestParameters.configMap, options).then((request) => request(this.axios, this.basePath)); } + /** + * Update the config of plugin setting. + * @param {PluginV1alpha1ConsoleApiUpdatePluginJsonConfigRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof PluginV1alpha1ConsoleApi + */ + public updatePluginJsonConfig(requestParameters: PluginV1alpha1ConsoleApiUpdatePluginJsonConfigRequest, options?: RawAxiosRequestConfig) { + return PluginV1alpha1ConsoleApiFp(this.configuration).updatePluginJsonConfig(requestParameters.name, requestParameters.body, options).then((request) => request(this.axios, this.basePath)); + } + /** * Upgrade a plugin by uploading a Jar file * @param {PluginV1alpha1ConsoleApiUpgradePluginRequest} requestParameters Request parameters. diff --git a/ui/packages/api-client/src/api/post-v1alpha1-uc-api.ts b/ui/packages/api-client/src/api/post-v1alpha1-uc-api.ts index 1a2d08f138..34733b7f23 100644 --- a/ui/packages/api-client/src/api/post-v1alpha1-uc-api.ts +++ b/ui/packages/api-client/src/api/post-v1alpha1-uc-api.ts @@ -270,6 +270,47 @@ export const PostV1alpha1UcApiAxiosParamCreator = function (configuration?: Conf + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * Move my post to recycle bin. + * @param {string} name Post name + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + recycleMyPost: async (name: string, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'name' is not null or undefined + assertParamExists('recycleMyPost', 'name', name) + const localVarPath = `/apis/uc.api.content.halo.run/v1alpha1/posts/{name}/recycle` + .replace(`{${"name"}}`, encodeURIComponent(String(name))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'DELETE', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication basicAuth required + // http basic authentication required + setBasicAuthToObject(localVarRequestOptions, configuration) + + // authentication bearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; @@ -488,6 +529,18 @@ export const PostV1alpha1UcApiFp = function(configuration?: Configuration) { const localVarOperationServerBasePath = operationServerMap['PostV1alpha1UcApi.publishMyPost']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, + /** + * Move my post to recycle bin. + * @param {string} name Post name + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async recycleMyPost(name: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.recycleMyPost(name, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['PostV1alpha1UcApi.recycleMyPost']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, /** * Unpublish my post. * @param {string} name Post name @@ -581,6 +634,15 @@ export const PostV1alpha1UcApiFactory = function (configuration?: Configuration, publishMyPost(requestParameters: PostV1alpha1UcApiPublishMyPostRequest, options?: RawAxiosRequestConfig): AxiosPromise { return localVarFp.publishMyPost(requestParameters.name, options).then((request) => request(axios, basePath)); }, + /** + * Move my post to recycle bin. + * @param {PostV1alpha1UcApiRecycleMyPostRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + recycleMyPost(requestParameters: PostV1alpha1UcApiRecycleMyPostRequest, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.recycleMyPost(requestParameters.name, options).then((request) => request(axios, basePath)); + }, /** * Unpublish my post. * @param {PostV1alpha1UcApiUnpublishMyPostRequest} requestParameters Request parameters. @@ -737,6 +799,20 @@ export interface PostV1alpha1UcApiPublishMyPostRequest { readonly name: string } +/** + * Request parameters for recycleMyPost operation in PostV1alpha1UcApi. + * @export + * @interface PostV1alpha1UcApiRecycleMyPostRequest + */ +export interface PostV1alpha1UcApiRecycleMyPostRequest { + /** + * Post name + * @type {string} + * @memberof PostV1alpha1UcApiRecycleMyPost + */ + readonly name: string +} + /** * Request parameters for unpublishMyPost operation in PostV1alpha1UcApi. * @export @@ -855,6 +931,17 @@ export class PostV1alpha1UcApi extends BaseAPI { return PostV1alpha1UcApiFp(this.configuration).publishMyPost(requestParameters.name, options).then((request) => request(this.axios, this.basePath)); } + /** + * Move my post to recycle bin. + * @param {PostV1alpha1UcApiRecycleMyPostRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof PostV1alpha1UcApi + */ + public recycleMyPost(requestParameters: PostV1alpha1UcApiRecycleMyPostRequest, options?: RawAxiosRequestConfig) { + return PostV1alpha1UcApiFp(this.configuration).recycleMyPost(requestParameters.name, options).then((request) => request(this.axios, this.basePath)); + } + /** * Unpublish my post. * @param {PostV1alpha1UcApiUnpublishMyPostRequest} requestParameters Request parameters. diff --git a/ui/packages/api-client/src/api/system-config-v1alpha1-console-api.ts b/ui/packages/api-client/src/api/system-config-v1alpha1-console-api.ts new file mode 100644 index 0000000000..edc76f3662 --- /dev/null +++ b/ui/packages/api-client/src/api/system-config-v1alpha1-console-api.ts @@ -0,0 +1,246 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Halo + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 2.20.0-SNAPSHOT + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +import type { Configuration } from '../configuration'; +import type { AxiosPromise, AxiosInstance, RawAxiosRequestConfig } from 'axios'; +import globalAxios from 'axios'; +// Some imports not used depending on template conditions +// @ts-ignore +import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObject, setBearerAuthToObject, setOAuthToObject, setSearchParams, serializeDataIfNeeded, toPathString, createRequestFunction } from '../common'; +// @ts-ignore +import { BASE_PATH, COLLECTION_FORMATS, RequestArgs, BaseAPI, RequiredError, operationServerMap } from '../base'; +/** + * SystemConfigV1alpha1ConsoleApi - axios parameter creator + * @export + */ +export const SystemConfigV1alpha1ConsoleApiAxiosParamCreator = function (configuration?: Configuration) { + return { + /** + * Get system config by group + * @param {string} group Group of the system config + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getSystemConfigByGroup: async (group: string, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'group' is not null or undefined + assertParamExists('getSystemConfigByGroup', 'group', group) + const localVarPath = `/apis/console.api.halo.run/v1alpha1/systemconfigs/{group}` + .replace(`{${"group"}}`, encodeURIComponent(String(group))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication basicAuth required + // http basic authentication required + setBasicAuthToObject(localVarRequestOptions, configuration) + + // authentication bearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * Update system config by group + * @param {string} group Group of the system config + * @param {object} [body] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + updateSystemConfigByGroup: async (group: string, body?: object, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'group' is not null or undefined + assertParamExists('updateSystemConfigByGroup', 'group', group) + const localVarPath = `/apis/console.api.halo.run/v1alpha1/systemconfigs/{group}` + .replace(`{${"group"}}`, encodeURIComponent(String(group))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication basicAuth required + // http basic authentication required + setBasicAuthToObject(localVarRequestOptions, configuration) + + // authentication bearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(body, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + } +}; + +/** + * SystemConfigV1alpha1ConsoleApi - functional programming interface + * @export + */ +export const SystemConfigV1alpha1ConsoleApiFp = function(configuration?: Configuration) { + const localVarAxiosParamCreator = SystemConfigV1alpha1ConsoleApiAxiosParamCreator(configuration) + return { + /** + * Get system config by group + * @param {string} group Group of the system config + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getSystemConfigByGroup(group: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getSystemConfigByGroup(group, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['SystemConfigV1alpha1ConsoleApi.getSystemConfigByGroup']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + /** + * Update system config by group + * @param {string} group Group of the system config + * @param {object} [body] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async updateSystemConfigByGroup(group: string, body?: object, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.updateSystemConfigByGroup(group, body, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['SystemConfigV1alpha1ConsoleApi.updateSystemConfigByGroup']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + } +}; + +/** + * SystemConfigV1alpha1ConsoleApi - factory interface + * @export + */ +export const SystemConfigV1alpha1ConsoleApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { + const localVarFp = SystemConfigV1alpha1ConsoleApiFp(configuration) + return { + /** + * Get system config by group + * @param {SystemConfigV1alpha1ConsoleApiGetSystemConfigByGroupRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getSystemConfigByGroup(requestParameters: SystemConfigV1alpha1ConsoleApiGetSystemConfigByGroupRequest, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.getSystemConfigByGroup(requestParameters.group, options).then((request) => request(axios, basePath)); + }, + /** + * Update system config by group + * @param {SystemConfigV1alpha1ConsoleApiUpdateSystemConfigByGroupRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + updateSystemConfigByGroup(requestParameters: SystemConfigV1alpha1ConsoleApiUpdateSystemConfigByGroupRequest, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.updateSystemConfigByGroup(requestParameters.group, requestParameters.body, options).then((request) => request(axios, basePath)); + }, + }; +}; + +/** + * Request parameters for getSystemConfigByGroup operation in SystemConfigV1alpha1ConsoleApi. + * @export + * @interface SystemConfigV1alpha1ConsoleApiGetSystemConfigByGroupRequest + */ +export interface SystemConfigV1alpha1ConsoleApiGetSystemConfigByGroupRequest { + /** + * Group of the system config + * @type {string} + * @memberof SystemConfigV1alpha1ConsoleApiGetSystemConfigByGroup + */ + readonly group: string +} + +/** + * Request parameters for updateSystemConfigByGroup operation in SystemConfigV1alpha1ConsoleApi. + * @export + * @interface SystemConfigV1alpha1ConsoleApiUpdateSystemConfigByGroupRequest + */ +export interface SystemConfigV1alpha1ConsoleApiUpdateSystemConfigByGroupRequest { + /** + * Group of the system config + * @type {string} + * @memberof SystemConfigV1alpha1ConsoleApiUpdateSystemConfigByGroup + */ + readonly group: string + + /** + * + * @type {object} + * @memberof SystemConfigV1alpha1ConsoleApiUpdateSystemConfigByGroup + */ + readonly body?: object +} + +/** + * SystemConfigV1alpha1ConsoleApi - object-oriented interface + * @export + * @class SystemConfigV1alpha1ConsoleApi + * @extends {BaseAPI} + */ +export class SystemConfigV1alpha1ConsoleApi extends BaseAPI { + /** + * Get system config by group + * @param {SystemConfigV1alpha1ConsoleApiGetSystemConfigByGroupRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof SystemConfigV1alpha1ConsoleApi + */ + public getSystemConfigByGroup(requestParameters: SystemConfigV1alpha1ConsoleApiGetSystemConfigByGroupRequest, options?: RawAxiosRequestConfig) { + return SystemConfigV1alpha1ConsoleApiFp(this.configuration).getSystemConfigByGroup(requestParameters.group, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * Update system config by group + * @param {SystemConfigV1alpha1ConsoleApiUpdateSystemConfigByGroupRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof SystemConfigV1alpha1ConsoleApi + */ + public updateSystemConfigByGroup(requestParameters: SystemConfigV1alpha1ConsoleApiUpdateSystemConfigByGroupRequest, options?: RawAxiosRequestConfig) { + return SystemConfigV1alpha1ConsoleApiFp(this.configuration).updateSystemConfigByGroup(requestParameters.group, requestParameters.body, options).then((request) => request(this.axios, this.basePath)); + } +} + diff --git a/ui/packages/api-client/src/api/system-v1alpha1-console-api.ts b/ui/packages/api-client/src/api/system-v1alpha1-console-api.ts index 098ce7f1af..b923088d23 100644 --- a/ui/packages/api-client/src/api/system-v1alpha1-console-api.ts +++ b/ui/packages/api-client/src/api/system-v1alpha1-console-api.ts @@ -23,8 +23,6 @@ import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObj import { BASE_PATH, COLLECTION_FORMATS, RequestArgs, BaseAPI, RequiredError, operationServerMap } from '../base'; // @ts-ignore import { DashboardStats } from '../models'; -// @ts-ignore -import { SystemInitializationRequest } from '../models'; /** * SystemV1alpha1ConsoleApi - axios parameter creator * @export @@ -63,47 +61,6 @@ export const SystemV1alpha1ConsoleApiAxiosParamCreator = function (configuration let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - return { - url: toPathString(localVarUrlObj), - options: localVarRequestOptions, - }; - }, - /** - * Initialize system - * @param {SystemInitializationRequest} [systemInitializationRequest] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - initialize: async (systemInitializationRequest?: SystemInitializationRequest, options: RawAxiosRequestConfig = {}): Promise => { - const localVarPath = `/apis/api.console.halo.run/v1alpha1/system/initialize`; - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); - let baseOptions; - if (configuration) { - baseOptions = configuration.baseOptions; - } - - const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; - const localVarHeaderParameter = {} as any; - const localVarQueryParameter = {} as any; - - // authentication basicAuth required - // http basic authentication required - setBasicAuthToObject(localVarRequestOptions, configuration) - - // authentication bearerAuth required - // http bearer authentication required - await setBearerAuthToObject(localVarHeaderParameter, configuration) - - - - localVarHeaderParameter['Content-Type'] = 'application/json'; - - setSearchParams(localVarUrlObj, localVarQueryParameter); - let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; - localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - localVarRequestOptions.data = serializeDataIfNeeded(systemInitializationRequest, localVarRequestOptions, configuration) - return { url: toPathString(localVarUrlObj), options: localVarRequestOptions, @@ -130,18 +87,6 @@ export const SystemV1alpha1ConsoleApiFp = function(configuration?: Configuration const localVarOperationServerBasePath = operationServerMap['SystemV1alpha1ConsoleApi.getStats']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, - /** - * Initialize system - * @param {SystemInitializationRequest} [systemInitializationRequest] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async initialize(systemInitializationRequest?: SystemInitializationRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.initialize(systemInitializationRequest, options); - const localVarOperationServerIndex = configuration?.serverIndex ?? 0; - const localVarOperationServerBasePath = operationServerMap['SystemV1alpha1ConsoleApi.initialize']?.[localVarOperationServerIndex]?.url; - return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); - }, } }; @@ -160,32 +105,9 @@ export const SystemV1alpha1ConsoleApiFactory = function (configuration?: Configu getStats(options?: RawAxiosRequestConfig): AxiosPromise { return localVarFp.getStats(options).then((request) => request(axios, basePath)); }, - /** - * Initialize system - * @param {SystemV1alpha1ConsoleApiInitializeRequest} requestParameters Request parameters. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - initialize(requestParameters: SystemV1alpha1ConsoleApiInitializeRequest = {}, options?: RawAxiosRequestConfig): AxiosPromise { - return localVarFp.initialize(requestParameters.systemInitializationRequest, options).then((request) => request(axios, basePath)); - }, }; }; -/** - * Request parameters for initialize operation in SystemV1alpha1ConsoleApi. - * @export - * @interface SystemV1alpha1ConsoleApiInitializeRequest - */ -export interface SystemV1alpha1ConsoleApiInitializeRequest { - /** - * - * @type {SystemInitializationRequest} - * @memberof SystemV1alpha1ConsoleApiInitialize - */ - readonly systemInitializationRequest?: SystemInitializationRequest -} - /** * SystemV1alpha1ConsoleApi - object-oriented interface * @export @@ -202,16 +124,5 @@ export class SystemV1alpha1ConsoleApi extends BaseAPI { public getStats(options?: RawAxiosRequestConfig) { return SystemV1alpha1ConsoleApiFp(this.configuration).getStats(options).then((request) => request(this.axios, this.basePath)); } - - /** - * Initialize system - * @param {SystemV1alpha1ConsoleApiInitializeRequest} requestParameters Request parameters. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - * @memberof SystemV1alpha1ConsoleApi - */ - public initialize(requestParameters: SystemV1alpha1ConsoleApiInitializeRequest = {}, options?: RawAxiosRequestConfig) { - return SystemV1alpha1ConsoleApiFp(this.configuration).initialize(requestParameters.systemInitializationRequest, options).then((request) => request(this.axios, this.basePath)); - } } diff --git a/ui/packages/api-client/src/api/system-v1alpha1-public-api.ts b/ui/packages/api-client/src/api/system-v1alpha1-public-api.ts index f6367f2c5a..88b47ab9ca 100644 --- a/ui/packages/api-client/src/api/system-v1alpha1-public-api.ts +++ b/ui/packages/api-client/src/api/system-v1alpha1-public-api.ts @@ -29,6 +29,43 @@ import { SiteStatsVo } from '../models'; */ export const SystemV1alpha1PublicApiAxiosParamCreator = function (configuration?: Configuration) { return { + /** + * Jump to setup page + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + jumpToSetupPage: async (options: RawAxiosRequestConfig = {}): Promise => { + const localVarPath = `/system/setup`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication basicAuth required + // http basic authentication required + setBasicAuthToObject(localVarRequestOptions, configuration) + + // authentication bearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * Gets site stats * @param {*} [options] Override http request option. @@ -76,6 +113,17 @@ export const SystemV1alpha1PublicApiAxiosParamCreator = function (configuration? export const SystemV1alpha1PublicApiFp = function(configuration?: Configuration) { const localVarAxiosParamCreator = SystemV1alpha1PublicApiAxiosParamCreator(configuration) return { + /** + * Jump to setup page + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async jumpToSetupPage(options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.jumpToSetupPage(options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['SystemV1alpha1PublicApi.jumpToSetupPage']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, /** * Gets site stats * @param {*} [options] Override http request option. @@ -97,6 +145,14 @@ export const SystemV1alpha1PublicApiFp = function(configuration?: Configuration) export const SystemV1alpha1PublicApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { const localVarFp = SystemV1alpha1PublicApiFp(configuration) return { + /** + * Jump to setup page + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + jumpToSetupPage(options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.jumpToSetupPage(options).then((request) => request(axios, basePath)); + }, /** * Gets site stats * @param {*} [options] Override http request option. @@ -115,6 +171,16 @@ export const SystemV1alpha1PublicApiFactory = function (configuration?: Configur * @extends {BaseAPI} */ export class SystemV1alpha1PublicApi extends BaseAPI { + /** + * Jump to setup page + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof SystemV1alpha1PublicApi + */ + public jumpToSetupPage(options?: RawAxiosRequestConfig) { + return SystemV1alpha1PublicApiFp(this.configuration).jumpToSetupPage(options).then((request) => request(this.axios, this.basePath)); + } + /** * Gets site stats * @param {*} [options] Override http request option. diff --git a/ui/packages/api-client/src/api/theme-v1alpha1-console-api.ts b/ui/packages/api-client/src/api/theme-v1alpha1-console-api.ts index 2e1fa3df53..08cc168ec2 100644 --- a/ui/packages/api-client/src/api/theme-v1alpha1-console-api.ts +++ b/ui/packages/api-client/src/api/theme-v1alpha1-console-api.ts @@ -118,9 +118,10 @@ export const ThemeV1alpha1ConsoleApiAxiosParamCreator = function (configuration? }; }, /** - * Fetch configMap of theme by configured configMapName. + * Fetch configMap of theme by configured configMapName. It is deprecated. * @param {string} name * @param {*} [options] Override http request option. + * @deprecated * @throws {RequiredError} */ fetchThemeConfig: async (name: string, options: RawAxiosRequestConfig = {}): Promise => { @@ -149,6 +150,47 @@ export const ThemeV1alpha1ConsoleApiAxiosParamCreator = function (configuration? + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * Fetch converted json config of theme by configured configMapName. + * @param {string} name + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + fetchThemeJsonConfig: async (name: string, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'name' is not null or undefined + assertParamExists('fetchThemeJsonConfig', 'name', name) + const localVarPath = `/apis/api.console.halo.run/v1alpha1/themes/{name}/json-config` + .replace(`{${"name"}}`, encodeURIComponent(String(name))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication basicAuth required + // http basic authentication required + setBasicAuthToObject(localVarRequestOptions, configuration) + + // authentication bearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; @@ -465,10 +507,11 @@ export const ThemeV1alpha1ConsoleApiAxiosParamCreator = function (configuration? }; }, /** - * Update the configMap of theme setting. + * Update the configMap of theme setting. It is deprecated. * @param {string} name * @param {ConfigMap} configMap * @param {*} [options] Override http request option. + * @deprecated * @throws {RequiredError} */ updateThemeConfig: async (name: string, configMap: ConfigMap, options: RawAxiosRequestConfig = {}): Promise => { @@ -511,6 +554,53 @@ export const ThemeV1alpha1ConsoleApiAxiosParamCreator = function (configuration? options: localVarRequestOptions, }; }, + /** + * Update the configMap of theme setting. + * @param {string} name + * @param {object} body + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + updateThemeJsonConfig: async (name: string, body: object, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'name' is not null or undefined + assertParamExists('updateThemeJsonConfig', 'name', name) + // verify required parameter 'body' is not null or undefined + assertParamExists('updateThemeJsonConfig', 'body', body) + const localVarPath = `/apis/api.console.halo.run/v1alpha1/themes/{name}/json-config` + .replace(`{${"name"}}`, encodeURIComponent(String(name))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication basicAuth required + // http basic authentication required + setBasicAuthToObject(localVarRequestOptions, configuration) + + // authentication bearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(body, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * Upgrade theme * @param {string} name @@ -644,9 +734,10 @@ export const ThemeV1alpha1ConsoleApiFp = function(configuration?: Configuration) return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, /** - * Fetch configMap of theme by configured configMapName. + * Fetch configMap of theme by configured configMapName. It is deprecated. * @param {string} name * @param {*} [options] Override http request option. + * @deprecated * @throws {RequiredError} */ async fetchThemeConfig(name: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { @@ -655,6 +746,18 @@ export const ThemeV1alpha1ConsoleApiFp = function(configuration?: Configuration) const localVarOperationServerBasePath = operationServerMap['ThemeV1alpha1ConsoleApi.fetchThemeConfig']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, + /** + * Fetch converted json config of theme by configured configMapName. + * @param {string} name + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async fetchThemeJsonConfig(name: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.fetchThemeJsonConfig(name, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['ThemeV1alpha1ConsoleApi.fetchThemeJsonConfig']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, /** * Fetch setting of theme. * @param {string} name @@ -743,10 +846,11 @@ export const ThemeV1alpha1ConsoleApiFp = function(configuration?: Configuration) return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, /** - * Update the configMap of theme setting. + * Update the configMap of theme setting. It is deprecated. * @param {string} name * @param {ConfigMap} configMap * @param {*} [options] Override http request option. + * @deprecated * @throws {RequiredError} */ async updateThemeConfig(name: string, configMap: ConfigMap, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { @@ -755,6 +859,19 @@ export const ThemeV1alpha1ConsoleApiFp = function(configuration?: Configuration) const localVarOperationServerBasePath = operationServerMap['ThemeV1alpha1ConsoleApi.updateThemeConfig']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, + /** + * Update the configMap of theme setting. + * @param {string} name + * @param {object} body + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async updateThemeJsonConfig(name: string, body: object, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.updateThemeJsonConfig(name, body, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['ThemeV1alpha1ConsoleApi.updateThemeJsonConfig']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, /** * Upgrade theme * @param {string} name @@ -809,14 +926,24 @@ export const ThemeV1alpha1ConsoleApiFactory = function (configuration?: Configur return localVarFp.fetchActivatedTheme(options).then((request) => request(axios, basePath)); }, /** - * Fetch configMap of theme by configured configMapName. + * Fetch configMap of theme by configured configMapName. It is deprecated. * @param {ThemeV1alpha1ConsoleApiFetchThemeConfigRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. + * @deprecated * @throws {RequiredError} */ fetchThemeConfig(requestParameters: ThemeV1alpha1ConsoleApiFetchThemeConfigRequest, options?: RawAxiosRequestConfig): AxiosPromise { return localVarFp.fetchThemeConfig(requestParameters.name, options).then((request) => request(axios, basePath)); }, + /** + * Fetch converted json config of theme by configured configMapName. + * @param {ThemeV1alpha1ConsoleApiFetchThemeJsonConfigRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + fetchThemeJsonConfig(requestParameters: ThemeV1alpha1ConsoleApiFetchThemeJsonConfigRequest, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.fetchThemeJsonConfig(requestParameters.name, options).then((request) => request(axios, basePath)); + }, /** * Fetch setting of theme. * @param {ThemeV1alpha1ConsoleApiFetchThemeSettingRequest} requestParameters Request parameters. @@ -880,14 +1007,24 @@ export const ThemeV1alpha1ConsoleApiFactory = function (configuration?: Configur return localVarFp.resetThemeConfig(requestParameters.name, options).then((request) => request(axios, basePath)); }, /** - * Update the configMap of theme setting. + * Update the configMap of theme setting. It is deprecated. * @param {ThemeV1alpha1ConsoleApiUpdateThemeConfigRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. + * @deprecated * @throws {RequiredError} */ updateThemeConfig(requestParameters: ThemeV1alpha1ConsoleApiUpdateThemeConfigRequest, options?: RawAxiosRequestConfig): AxiosPromise { return localVarFp.updateThemeConfig(requestParameters.name, requestParameters.configMap, options).then((request) => request(axios, basePath)); }, + /** + * Update the configMap of theme setting. + * @param {ThemeV1alpha1ConsoleApiUpdateThemeJsonConfigRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + updateThemeJsonConfig(requestParameters: ThemeV1alpha1ConsoleApiUpdateThemeJsonConfigRequest, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.updateThemeJsonConfig(requestParameters.name, requestParameters.body, options).then((request) => request(axios, basePath)); + }, /** * Upgrade theme * @param {ThemeV1alpha1ConsoleApiUpgradeThemeRequest} requestParameters Request parameters. @@ -937,6 +1074,20 @@ export interface ThemeV1alpha1ConsoleApiFetchThemeConfigRequest { readonly name: string } +/** + * Request parameters for fetchThemeJsonConfig operation in ThemeV1alpha1ConsoleApi. + * @export + * @interface ThemeV1alpha1ConsoleApiFetchThemeJsonConfigRequest + */ +export interface ThemeV1alpha1ConsoleApiFetchThemeJsonConfigRequest { + /** + * + * @type {string} + * @memberof ThemeV1alpha1ConsoleApiFetchThemeJsonConfig + */ + readonly name: string +} + /** * Request parameters for fetchThemeSetting operation in ThemeV1alpha1ConsoleApi. * @export @@ -1070,6 +1221,27 @@ export interface ThemeV1alpha1ConsoleApiUpdateThemeConfigRequest { readonly configMap: ConfigMap } +/** + * Request parameters for updateThemeJsonConfig operation in ThemeV1alpha1ConsoleApi. + * @export + * @interface ThemeV1alpha1ConsoleApiUpdateThemeJsonConfigRequest + */ +export interface ThemeV1alpha1ConsoleApiUpdateThemeJsonConfigRequest { + /** + * + * @type {string} + * @memberof ThemeV1alpha1ConsoleApiUpdateThemeJsonConfig + */ + readonly name: string + + /** + * + * @type {object} + * @memberof ThemeV1alpha1ConsoleApiUpdateThemeJsonConfig + */ + readonly body: object +} + /** * Request parameters for upgradeTheme operation in ThemeV1alpha1ConsoleApi. * @export @@ -1141,9 +1313,10 @@ export class ThemeV1alpha1ConsoleApi extends BaseAPI { } /** - * Fetch configMap of theme by configured configMapName. + * Fetch configMap of theme by configured configMapName. It is deprecated. * @param {ThemeV1alpha1ConsoleApiFetchThemeConfigRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. + * @deprecated * @throws {RequiredError} * @memberof ThemeV1alpha1ConsoleApi */ @@ -1151,6 +1324,17 @@ export class ThemeV1alpha1ConsoleApi extends BaseAPI { return ThemeV1alpha1ConsoleApiFp(this.configuration).fetchThemeConfig(requestParameters.name, options).then((request) => request(this.axios, this.basePath)); } + /** + * Fetch converted json config of theme by configured configMapName. + * @param {ThemeV1alpha1ConsoleApiFetchThemeJsonConfigRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ThemeV1alpha1ConsoleApi + */ + public fetchThemeJsonConfig(requestParameters: ThemeV1alpha1ConsoleApiFetchThemeJsonConfigRequest, options?: RawAxiosRequestConfig) { + return ThemeV1alpha1ConsoleApiFp(this.configuration).fetchThemeJsonConfig(requestParameters.name, options).then((request) => request(this.axios, this.basePath)); + } + /** * Fetch setting of theme. * @param {ThemeV1alpha1ConsoleApiFetchThemeSettingRequest} requestParameters Request parameters. @@ -1228,9 +1412,10 @@ export class ThemeV1alpha1ConsoleApi extends BaseAPI { } /** - * Update the configMap of theme setting. + * Update the configMap of theme setting. It is deprecated. * @param {ThemeV1alpha1ConsoleApiUpdateThemeConfigRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. + * @deprecated * @throws {RequiredError} * @memberof ThemeV1alpha1ConsoleApi */ @@ -1238,6 +1423,17 @@ export class ThemeV1alpha1ConsoleApi extends BaseAPI { return ThemeV1alpha1ConsoleApiFp(this.configuration).updateThemeConfig(requestParameters.name, requestParameters.configMap, options).then((request) => request(this.axios, this.basePath)); } + /** + * Update the configMap of theme setting. + * @param {ThemeV1alpha1ConsoleApiUpdateThemeJsonConfigRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ThemeV1alpha1ConsoleApi + */ + public updateThemeJsonConfig(requestParameters: ThemeV1alpha1ConsoleApiUpdateThemeJsonConfigRequest, options?: RawAxiosRequestConfig) { + return ThemeV1alpha1ConsoleApiFp(this.configuration).updateThemeJsonConfig(requestParameters.name, requestParameters.body, options).then((request) => request(this.axios, this.basePath)); + } + /** * Upgrade theme * @param {ThemeV1alpha1ConsoleApiUpgradeThemeRequest} requestParameters Request parameters. diff --git a/ui/packages/api-client/src/api/user-connection-v1alpha1-uc-api.ts b/ui/packages/api-client/src/api/user-connection-v1alpha1-uc-api.ts new file mode 100644 index 0000000000..635be3a93f --- /dev/null +++ b/ui/packages/api-client/src/api/user-connection-v1alpha1-uc-api.ts @@ -0,0 +1,149 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Halo + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 2.20.0-SNAPSHOT + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +import type { Configuration } from '../configuration'; +import type { AxiosPromise, AxiosInstance, RawAxiosRequestConfig } from 'axios'; +import globalAxios from 'axios'; +// Some imports not used depending on template conditions +// @ts-ignore +import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObject, setBearerAuthToObject, setOAuthToObject, setSearchParams, serializeDataIfNeeded, toPathString, createRequestFunction } from '../common'; +// @ts-ignore +import { BASE_PATH, COLLECTION_FORMATS, RequestArgs, BaseAPI, RequiredError, operationServerMap } from '../base'; +// @ts-ignore +import { UserConnection } from '../models'; +/** + * UserConnectionV1alpha1UcApi - axios parameter creator + * @export + */ +export const UserConnectionV1alpha1UcApiAxiosParamCreator = function (configuration?: Configuration) { + return { + /** + * Disconnect my connection from a third-party platform. + * @param {string} registerId The registration ID of the third-party platform. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + disconnectMyConnection: async (registerId: string, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'registerId' is not null or undefined + assertParamExists('disconnectMyConnection', 'registerId', registerId) + const localVarPath = `/apis/uc.api.auth.halo.run/v1alpha1/user-connections/{registerId}/disconnect` + .replace(`{${"registerId"}}`, encodeURIComponent(String(registerId))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication basicAuth required + // http basic authentication required + setBasicAuthToObject(localVarRequestOptions, configuration) + + // authentication bearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + } +}; + +/** + * UserConnectionV1alpha1UcApi - functional programming interface + * @export + */ +export const UserConnectionV1alpha1UcApiFp = function(configuration?: Configuration) { + const localVarAxiosParamCreator = UserConnectionV1alpha1UcApiAxiosParamCreator(configuration) + return { + /** + * Disconnect my connection from a third-party platform. + * @param {string} registerId The registration ID of the third-party platform. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async disconnectMyConnection(registerId: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.disconnectMyConnection(registerId, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['UserConnectionV1alpha1UcApi.disconnectMyConnection']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + } +}; + +/** + * UserConnectionV1alpha1UcApi - factory interface + * @export + */ +export const UserConnectionV1alpha1UcApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { + const localVarFp = UserConnectionV1alpha1UcApiFp(configuration) + return { + /** + * Disconnect my connection from a third-party platform. + * @param {UserConnectionV1alpha1UcApiDisconnectMyConnectionRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + disconnectMyConnection(requestParameters: UserConnectionV1alpha1UcApiDisconnectMyConnectionRequest, options?: RawAxiosRequestConfig): AxiosPromise> { + return localVarFp.disconnectMyConnection(requestParameters.registerId, options).then((request) => request(axios, basePath)); + }, + }; +}; + +/** + * Request parameters for disconnectMyConnection operation in UserConnectionV1alpha1UcApi. + * @export + * @interface UserConnectionV1alpha1UcApiDisconnectMyConnectionRequest + */ +export interface UserConnectionV1alpha1UcApiDisconnectMyConnectionRequest { + /** + * The registration ID of the third-party platform. + * @type {string} + * @memberof UserConnectionV1alpha1UcApiDisconnectMyConnection + */ + readonly registerId: string +} + +/** + * UserConnectionV1alpha1UcApi - object-oriented interface + * @export + * @class UserConnectionV1alpha1UcApi + * @extends {BaseAPI} + */ +export class UserConnectionV1alpha1UcApi extends BaseAPI { + /** + * Disconnect my connection from a third-party platform. + * @param {UserConnectionV1alpha1UcApiDisconnectMyConnectionRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof UserConnectionV1alpha1UcApi + */ + public disconnectMyConnection(requestParameters: UserConnectionV1alpha1UcApiDisconnectMyConnectionRequest, options?: RawAxiosRequestConfig) { + return UserConnectionV1alpha1UcApiFp(this.configuration).disconnectMyConnection(requestParameters.registerId, options).then((request) => request(this.axios, this.basePath)); + } +} + diff --git a/ui/packages/api-client/src/api/user-v1alpha1-public-api.ts b/ui/packages/api-client/src/api/user-v1alpha1-public-api.ts deleted file mode 100644 index ed77275cd9..0000000000 --- a/ui/packages/api-client/src/api/user-v1alpha1-public-api.ts +++ /dev/null @@ -1,438 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Halo - * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - * - * The version of the OpenAPI document: 2.20.0-SNAPSHOT - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - -import type { Configuration } from '../configuration'; -import type { AxiosPromise, AxiosInstance, RawAxiosRequestConfig } from 'axios'; -import globalAxios from 'axios'; -// Some imports not used depending on template conditions -// @ts-ignore -import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObject, setBearerAuthToObject, setOAuthToObject, setSearchParams, serializeDataIfNeeded, toPathString, createRequestFunction } from '../common'; -// @ts-ignore -import { BASE_PATH, COLLECTION_FORMATS, RequestArgs, BaseAPI, RequiredError, operationServerMap } from '../base'; -// @ts-ignore -import { PasswordResetEmailRequest } from '../models'; -// @ts-ignore -import { RegisterVerifyEmailRequest } from '../models'; -// @ts-ignore -import { ResetPasswordRequest } from '../models'; -// @ts-ignore -import { SignUpRequest } from '../models'; -// @ts-ignore -import { User } from '../models'; -/** - * UserV1alpha1PublicApi - axios parameter creator - * @export - */ -export const UserV1alpha1PublicApiAxiosParamCreator = function (configuration?: Configuration) { - return { - /** - * Reset password by token - * @param {string} name The name of the user - * @param {ResetPasswordRequest} resetPasswordRequest - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - resetPasswordByToken: async (name: string, resetPasswordRequest: ResetPasswordRequest, options: RawAxiosRequestConfig = {}): Promise => { - // verify required parameter 'name' is not null or undefined - assertParamExists('resetPasswordByToken', 'name', name) - // verify required parameter 'resetPasswordRequest' is not null or undefined - assertParamExists('resetPasswordByToken', 'resetPasswordRequest', resetPasswordRequest) - const localVarPath = `/apis/api.halo.run/v1alpha1/users/{name}/reset-password` - .replace(`{${"name"}}`, encodeURIComponent(String(name))); - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); - let baseOptions; - if (configuration) { - baseOptions = configuration.baseOptions; - } - - const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options}; - const localVarHeaderParameter = {} as any; - const localVarQueryParameter = {} as any; - - // authentication basicAuth required - // http basic authentication required - setBasicAuthToObject(localVarRequestOptions, configuration) - - // authentication bearerAuth required - // http bearer authentication required - await setBearerAuthToObject(localVarHeaderParameter, configuration) - - - - localVarHeaderParameter['Content-Type'] = 'application/json'; - - setSearchParams(localVarUrlObj, localVarQueryParameter); - let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; - localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - localVarRequestOptions.data = serializeDataIfNeeded(resetPasswordRequest, localVarRequestOptions, configuration) - - return { - url: toPathString(localVarUrlObj), - options: localVarRequestOptions, - }; - }, - /** - * Send password reset email when forgot password - * @param {PasswordResetEmailRequest} passwordResetEmailRequest - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - sendPasswordResetEmail: async (passwordResetEmailRequest: PasswordResetEmailRequest, options: RawAxiosRequestConfig = {}): Promise => { - // verify required parameter 'passwordResetEmailRequest' is not null or undefined - assertParamExists('sendPasswordResetEmail', 'passwordResetEmailRequest', passwordResetEmailRequest) - const localVarPath = `/apis/api.halo.run/v1alpha1/users/-/send-password-reset-email`; - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); - let baseOptions; - if (configuration) { - baseOptions = configuration.baseOptions; - } - - const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; - const localVarHeaderParameter = {} as any; - const localVarQueryParameter = {} as any; - - // authentication basicAuth required - // http basic authentication required - setBasicAuthToObject(localVarRequestOptions, configuration) - - // authentication bearerAuth required - // http bearer authentication required - await setBearerAuthToObject(localVarHeaderParameter, configuration) - - - - localVarHeaderParameter['Content-Type'] = 'application/json'; - - setSearchParams(localVarUrlObj, localVarQueryParameter); - let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; - localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - localVarRequestOptions.data = serializeDataIfNeeded(passwordResetEmailRequest, localVarRequestOptions, configuration) - - return { - url: toPathString(localVarUrlObj), - options: localVarRequestOptions, - }; - }, - /** - * Send registration verification email, which can be called when mustVerifyEmailOnRegistration in user settings is true - * @param {RegisterVerifyEmailRequest} registerVerifyEmailRequest - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - sendRegisterVerifyEmail: async (registerVerifyEmailRequest: RegisterVerifyEmailRequest, options: RawAxiosRequestConfig = {}): Promise => { - // verify required parameter 'registerVerifyEmailRequest' is not null or undefined - assertParamExists('sendRegisterVerifyEmail', 'registerVerifyEmailRequest', registerVerifyEmailRequest) - const localVarPath = `/apis/api.halo.run/v1alpha1/users/-/send-register-verify-email`; - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); - let baseOptions; - if (configuration) { - baseOptions = configuration.baseOptions; - } - - const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; - const localVarHeaderParameter = {} as any; - const localVarQueryParameter = {} as any; - - // authentication basicAuth required - // http basic authentication required - setBasicAuthToObject(localVarRequestOptions, configuration) - - // authentication bearerAuth required - // http bearer authentication required - await setBearerAuthToObject(localVarHeaderParameter, configuration) - - - - localVarHeaderParameter['Content-Type'] = 'application/json'; - - setSearchParams(localVarUrlObj, localVarQueryParameter); - let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; - localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - localVarRequestOptions.data = serializeDataIfNeeded(registerVerifyEmailRequest, localVarRequestOptions, configuration) - - return { - url: toPathString(localVarUrlObj), - options: localVarRequestOptions, - }; - }, - /** - * Sign up a new user - * @param {SignUpRequest} signUpRequest - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - signUp: async (signUpRequest: SignUpRequest, options: RawAxiosRequestConfig = {}): Promise => { - // verify required parameter 'signUpRequest' is not null or undefined - assertParamExists('signUp', 'signUpRequest', signUpRequest) - const localVarPath = `/apis/api.halo.run/v1alpha1/users/-/signup`; - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); - let baseOptions; - if (configuration) { - baseOptions = configuration.baseOptions; - } - - const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; - const localVarHeaderParameter = {} as any; - const localVarQueryParameter = {} as any; - - // authentication basicAuth required - // http basic authentication required - setBasicAuthToObject(localVarRequestOptions, configuration) - - // authentication bearerAuth required - // http bearer authentication required - await setBearerAuthToObject(localVarHeaderParameter, configuration) - - - - localVarHeaderParameter['Content-Type'] = 'application/json'; - - setSearchParams(localVarUrlObj, localVarQueryParameter); - let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; - localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - localVarRequestOptions.data = serializeDataIfNeeded(signUpRequest, localVarRequestOptions, configuration) - - return { - url: toPathString(localVarUrlObj), - options: localVarRequestOptions, - }; - }, - } -}; - -/** - * UserV1alpha1PublicApi - functional programming interface - * @export - */ -export const UserV1alpha1PublicApiFp = function(configuration?: Configuration) { - const localVarAxiosParamCreator = UserV1alpha1PublicApiAxiosParamCreator(configuration) - return { - /** - * Reset password by token - * @param {string} name The name of the user - * @param {ResetPasswordRequest} resetPasswordRequest - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async resetPasswordByToken(name: string, resetPasswordRequest: ResetPasswordRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.resetPasswordByToken(name, resetPasswordRequest, options); - const localVarOperationServerIndex = configuration?.serverIndex ?? 0; - const localVarOperationServerBasePath = operationServerMap['UserV1alpha1PublicApi.resetPasswordByToken']?.[localVarOperationServerIndex]?.url; - return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); - }, - /** - * Send password reset email when forgot password - * @param {PasswordResetEmailRequest} passwordResetEmailRequest - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async sendPasswordResetEmail(passwordResetEmailRequest: PasswordResetEmailRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.sendPasswordResetEmail(passwordResetEmailRequest, options); - const localVarOperationServerIndex = configuration?.serverIndex ?? 0; - const localVarOperationServerBasePath = operationServerMap['UserV1alpha1PublicApi.sendPasswordResetEmail']?.[localVarOperationServerIndex]?.url; - return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); - }, - /** - * Send registration verification email, which can be called when mustVerifyEmailOnRegistration in user settings is true - * @param {RegisterVerifyEmailRequest} registerVerifyEmailRequest - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async sendRegisterVerifyEmail(registerVerifyEmailRequest: RegisterVerifyEmailRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.sendRegisterVerifyEmail(registerVerifyEmailRequest, options); - const localVarOperationServerIndex = configuration?.serverIndex ?? 0; - const localVarOperationServerBasePath = operationServerMap['UserV1alpha1PublicApi.sendRegisterVerifyEmail']?.[localVarOperationServerIndex]?.url; - return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); - }, - /** - * Sign up a new user - * @param {SignUpRequest} signUpRequest - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async signUp(signUpRequest: SignUpRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.signUp(signUpRequest, options); - const localVarOperationServerIndex = configuration?.serverIndex ?? 0; - const localVarOperationServerBasePath = operationServerMap['UserV1alpha1PublicApi.signUp']?.[localVarOperationServerIndex]?.url; - return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); - }, - } -}; - -/** - * UserV1alpha1PublicApi - factory interface - * @export - */ -export const UserV1alpha1PublicApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { - const localVarFp = UserV1alpha1PublicApiFp(configuration) - return { - /** - * Reset password by token - * @param {UserV1alpha1PublicApiResetPasswordByTokenRequest} requestParameters Request parameters. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - resetPasswordByToken(requestParameters: UserV1alpha1PublicApiResetPasswordByTokenRequest, options?: RawAxiosRequestConfig): AxiosPromise { - return localVarFp.resetPasswordByToken(requestParameters.name, requestParameters.resetPasswordRequest, options).then((request) => request(axios, basePath)); - }, - /** - * Send password reset email when forgot password - * @param {UserV1alpha1PublicApiSendPasswordResetEmailRequest} requestParameters Request parameters. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - sendPasswordResetEmail(requestParameters: UserV1alpha1PublicApiSendPasswordResetEmailRequest, options?: RawAxiosRequestConfig): AxiosPromise { - return localVarFp.sendPasswordResetEmail(requestParameters.passwordResetEmailRequest, options).then((request) => request(axios, basePath)); - }, - /** - * Send registration verification email, which can be called when mustVerifyEmailOnRegistration in user settings is true - * @param {UserV1alpha1PublicApiSendRegisterVerifyEmailRequest} requestParameters Request parameters. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - sendRegisterVerifyEmail(requestParameters: UserV1alpha1PublicApiSendRegisterVerifyEmailRequest, options?: RawAxiosRequestConfig): AxiosPromise { - return localVarFp.sendRegisterVerifyEmail(requestParameters.registerVerifyEmailRequest, options).then((request) => request(axios, basePath)); - }, - /** - * Sign up a new user - * @param {UserV1alpha1PublicApiSignUpRequest} requestParameters Request parameters. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - signUp(requestParameters: UserV1alpha1PublicApiSignUpRequest, options?: RawAxiosRequestConfig): AxiosPromise { - return localVarFp.signUp(requestParameters.signUpRequest, options).then((request) => request(axios, basePath)); - }, - }; -}; - -/** - * Request parameters for resetPasswordByToken operation in UserV1alpha1PublicApi. - * @export - * @interface UserV1alpha1PublicApiResetPasswordByTokenRequest - */ -export interface UserV1alpha1PublicApiResetPasswordByTokenRequest { - /** - * The name of the user - * @type {string} - * @memberof UserV1alpha1PublicApiResetPasswordByToken - */ - readonly name: string - - /** - * - * @type {ResetPasswordRequest} - * @memberof UserV1alpha1PublicApiResetPasswordByToken - */ - readonly resetPasswordRequest: ResetPasswordRequest -} - -/** - * Request parameters for sendPasswordResetEmail operation in UserV1alpha1PublicApi. - * @export - * @interface UserV1alpha1PublicApiSendPasswordResetEmailRequest - */ -export interface UserV1alpha1PublicApiSendPasswordResetEmailRequest { - /** - * - * @type {PasswordResetEmailRequest} - * @memberof UserV1alpha1PublicApiSendPasswordResetEmail - */ - readonly passwordResetEmailRequest: PasswordResetEmailRequest -} - -/** - * Request parameters for sendRegisterVerifyEmail operation in UserV1alpha1PublicApi. - * @export - * @interface UserV1alpha1PublicApiSendRegisterVerifyEmailRequest - */ -export interface UserV1alpha1PublicApiSendRegisterVerifyEmailRequest { - /** - * - * @type {RegisterVerifyEmailRequest} - * @memberof UserV1alpha1PublicApiSendRegisterVerifyEmail - */ - readonly registerVerifyEmailRequest: RegisterVerifyEmailRequest -} - -/** - * Request parameters for signUp operation in UserV1alpha1PublicApi. - * @export - * @interface UserV1alpha1PublicApiSignUpRequest - */ -export interface UserV1alpha1PublicApiSignUpRequest { - /** - * - * @type {SignUpRequest} - * @memberof UserV1alpha1PublicApiSignUp - */ - readonly signUpRequest: SignUpRequest -} - -/** - * UserV1alpha1PublicApi - object-oriented interface - * @export - * @class UserV1alpha1PublicApi - * @extends {BaseAPI} - */ -export class UserV1alpha1PublicApi extends BaseAPI { - /** - * Reset password by token - * @param {UserV1alpha1PublicApiResetPasswordByTokenRequest} requestParameters Request parameters. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - * @memberof UserV1alpha1PublicApi - */ - public resetPasswordByToken(requestParameters: UserV1alpha1PublicApiResetPasswordByTokenRequest, options?: RawAxiosRequestConfig) { - return UserV1alpha1PublicApiFp(this.configuration).resetPasswordByToken(requestParameters.name, requestParameters.resetPasswordRequest, options).then((request) => request(this.axios, this.basePath)); - } - - /** - * Send password reset email when forgot password - * @param {UserV1alpha1PublicApiSendPasswordResetEmailRequest} requestParameters Request parameters. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - * @memberof UserV1alpha1PublicApi - */ - public sendPasswordResetEmail(requestParameters: UserV1alpha1PublicApiSendPasswordResetEmailRequest, options?: RawAxiosRequestConfig) { - return UserV1alpha1PublicApiFp(this.configuration).sendPasswordResetEmail(requestParameters.passwordResetEmailRequest, options).then((request) => request(this.axios, this.basePath)); - } - - /** - * Send registration verification email, which can be called when mustVerifyEmailOnRegistration in user settings is true - * @param {UserV1alpha1PublicApiSendRegisterVerifyEmailRequest} requestParameters Request parameters. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - * @memberof UserV1alpha1PublicApi - */ - public sendRegisterVerifyEmail(requestParameters: UserV1alpha1PublicApiSendRegisterVerifyEmailRequest, options?: RawAxiosRequestConfig) { - return UserV1alpha1PublicApiFp(this.configuration).sendRegisterVerifyEmail(requestParameters.registerVerifyEmailRequest, options).then((request) => request(this.axios, this.basePath)); - } - - /** - * Sign up a new user - * @param {UserV1alpha1PublicApiSignUpRequest} requestParameters Request parameters. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - * @memberof UserV1alpha1PublicApi - */ - public signUp(requestParameters: UserV1alpha1PublicApiSignUpRequest, options?: RawAxiosRequestConfig) { - return UserV1alpha1PublicApiFp(this.configuration).signUp(requestParameters.signUpRequest, options).then((request) => request(this.axios, this.basePath)); - } -} - diff --git a/ui/packages/api-client/src/models/auth-provider-spec.ts b/ui/packages/api-client/src/models/auth-provider-spec.ts index 0da332c6f3..6a83cb797b 100644 --- a/ui/packages/api-client/src/models/auth-provider-spec.ts +++ b/ui/packages/api-client/src/models/auth-provider-spec.ts @@ -26,6 +26,12 @@ import { SettingRef } from './setting-ref'; * @interface AuthProviderSpec */ export interface AuthProviderSpec { + /** + * + * @type {string} + * @memberof AuthProviderSpec + */ + 'authType': AuthProviderSpecAuthTypeEnum; /** * Authentication url of the auth provider * @type {string} @@ -70,10 +76,16 @@ export interface AuthProviderSpec { 'logo'?: string; /** * - * @type {number} + * @type {string} * @memberof AuthProviderSpec */ - 'priority'?: number; + 'method'?: string; + /** + * + * @type {boolean} + * @memberof AuthProviderSpec + */ + 'rememberMeSupport'?: boolean; /** * * @type {SettingRef} @@ -94,3 +106,11 @@ export interface AuthProviderSpec { 'website'?: string; } +export const AuthProviderSpecAuthTypeEnum = { + Form: 'FORM', + Oauth2: 'OAUTH2' +} as const; + +export type AuthProviderSpecAuthTypeEnum = typeof AuthProviderSpecAuthTypeEnum[keyof typeof AuthProviderSpecAuthTypeEnum]; + + diff --git a/ui/packages/api-client/src/models/index.ts b/ui/packages/api-client/src/models/index.ts index d6c4372ece..901db6c75e 100644 --- a/ui/packages/api-client/src/models/index.ts +++ b/ui/packages/api-client/src/models/index.ts @@ -129,7 +129,6 @@ export * from './notifier-info'; export * from './notifier-setting-ref'; export * from './owner-info'; export * from './password-request'; -export * from './password-reset-email-request'; export * from './pat-spec'; export * from './personal-access-token'; export * from './personal-access-token-list'; @@ -152,7 +151,6 @@ export * from './post-request'; export * from './post-spec'; export * from './post-status'; export * from './post-vo'; -export * from './public-key-response'; export * from './reason'; export * from './reason-attributes'; export * from './reason-list'; @@ -168,7 +166,6 @@ export * from './reason-type-notifier-matrix'; export * from './reason-type-notifier-request'; export * from './reason-type-spec'; export * from './ref'; -export * from './register-verify-email-request'; export * from './remember-me-token'; export * from './remember-me-token-list'; export * from './remember-me-token-spec'; @@ -181,7 +178,6 @@ export * from './reply-spec'; export * from './reply-status'; export * from './reply-vo'; export * from './reply-vo-list'; -export * from './reset-password-request'; export * from './reverse-proxy'; export * from './reverse-proxy-list'; export * from './reverse-proxy-rule'; @@ -204,7 +200,6 @@ export * from './setting-form'; export * from './setting-list'; export * from './setting-ref'; export * from './setting-spec'; -export * from './sign-up-request'; export * from './single-page'; export * from './single-page-list'; export * from './single-page-request'; @@ -222,7 +217,6 @@ export * from './subscription'; export * from './subscription-list'; export * from './subscription-spec'; export * from './subscription-subscriber'; -export * from './system-initialization-request'; export * from './tag'; export * from './tag-list'; export * from './tag-spec'; @@ -242,6 +236,7 @@ export * from './thumbnail-spec'; export * from './totp-auth-link-response'; export * from './totp-request'; export * from './two-factor-auth-settings'; +export * from './uc-upload-request-form-data'; export * from './upgrade-from-uri-request'; export * from './upload-from-url-request'; export * from './user'; diff --git a/ui/packages/api-client/src/models/listed-auth-provider.ts b/ui/packages/api-client/src/models/listed-auth-provider.ts index b684d9b3c5..4cc81669ed 100644 --- a/ui/packages/api-client/src/models/listed-auth-provider.ts +++ b/ui/packages/api-client/src/models/listed-auth-provider.ts @@ -20,6 +20,12 @@ * @interface ListedAuthProvider */ export interface ListedAuthProvider { + /** + * + * @type {string} + * @memberof ListedAuthProvider + */ + 'authType'?: ListedAuthProviderAuthTypeEnum; /** * * @type {string} @@ -74,6 +80,12 @@ export interface ListedAuthProvider { * @memberof ListedAuthProvider */ 'name': string; + /** + * + * @type {number} + * @memberof ListedAuthProvider + */ + 'priority'?: number; /** * * @type {boolean} @@ -100,3 +112,11 @@ export interface ListedAuthProvider { 'website'?: string; } +export const ListedAuthProviderAuthTypeEnum = { + Form: 'FORM', + Oauth2: 'OAUTH2' +} as const; + +export type ListedAuthProviderAuthTypeEnum = typeof ListedAuthProviderAuthTypeEnum[keyof typeof ListedAuthProviderAuthTypeEnum]; + + diff --git a/ui/packages/api-client/src/models/password-reset-email-request.ts b/ui/packages/api-client/src/models/password-reset-email-request.ts deleted file mode 100644 index d6aedbe246..0000000000 --- a/ui/packages/api-client/src/models/password-reset-email-request.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Halo - * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - * - * The version of the OpenAPI document: 2.20.0-SNAPSHOT - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * - * @export - * @interface PasswordResetEmailRequest - */ -export interface PasswordResetEmailRequest { - /** - * - * @type {string} - * @memberof PasswordResetEmailRequest - */ - 'email': string; - /** - * - * @type {string} - * @memberof PasswordResetEmailRequest - */ - 'username': string; -} - diff --git a/ui/packages/api-client/src/models/post-status.ts b/ui/packages/api-client/src/models/post-status.ts index 41bec331f0..407f82ff14 100644 --- a/ui/packages/api-client/src/models/post-status.ts +++ b/ui/packages/api-client/src/models/post-status.ts @@ -82,6 +82,6 @@ export interface PostStatus { * @type {string} * @memberof PostStatus */ - 'phase': string; + 'phase'?: string; } diff --git a/ui/packages/api-client/src/models/public-key-response.ts b/ui/packages/api-client/src/models/public-key-response.ts deleted file mode 100644 index 1829449314..0000000000 --- a/ui/packages/api-client/src/models/public-key-response.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Halo - * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - * - * The version of the OpenAPI document: 2.20.0-SNAPSHOT - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * - * @export - * @interface PublicKeyResponse - */ -export interface PublicKeyResponse { - /** - * - * @type {string} - * @memberof PublicKeyResponse - */ - 'base64Format'?: string; -} - diff --git a/ui/packages/api-client/src/models/register-verify-email-request.ts b/ui/packages/api-client/src/models/register-verify-email-request.ts deleted file mode 100644 index 19f4e0ff20..0000000000 --- a/ui/packages/api-client/src/models/register-verify-email-request.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Halo - * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - * - * The version of the OpenAPI document: 2.20.0-SNAPSHOT - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * - * @export - * @interface RegisterVerifyEmailRequest - */ -export interface RegisterVerifyEmailRequest { - /** - * - * @type {string} - * @memberof RegisterVerifyEmailRequest - */ - 'email': string; -} - diff --git a/ui/packages/api-client/src/models/sign-up-request.ts b/ui/packages/api-client/src/models/sign-up-request.ts deleted file mode 100644 index 6db669e86a..0000000000 --- a/ui/packages/api-client/src/models/sign-up-request.ts +++ /dev/null @@ -1,45 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Halo - * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - * - * The version of the OpenAPI document: 2.20.0-SNAPSHOT - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - -// May contain unused imports in some cases -// @ts-ignore -import { User } from './user'; - -/** - * - * @export - * @interface SignUpRequest - */ -export interface SignUpRequest { - /** - * - * @type {string} - * @memberof SignUpRequest - */ - 'password': string; - /** - * - * @type {User} - * @memberof SignUpRequest - */ - 'user': User; - /** - * - * @type {string} - * @memberof SignUpRequest - */ - 'verifyCode'?: string; -} - diff --git a/ui/packages/api-client/src/models/single-page-status.ts b/ui/packages/api-client/src/models/single-page-status.ts index fff6d27d0e..d74e35b6bb 100644 --- a/ui/packages/api-client/src/models/single-page-status.ts +++ b/ui/packages/api-client/src/models/single-page-status.ts @@ -82,6 +82,6 @@ export interface SinglePageStatus { * @type {string} * @memberof SinglePageStatus */ - 'phase': string; + 'phase'?: string; } diff --git a/ui/packages/api-client/src/models/system-initialization-request.ts b/ui/packages/api-client/src/models/system-initialization-request.ts deleted file mode 100644 index f974ee3a5e..0000000000 --- a/ui/packages/api-client/src/models/system-initialization-request.ts +++ /dev/null @@ -1,48 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Halo - * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - * - * The version of the OpenAPI document: 2.20.0-SNAPSHOT - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * - * @export - * @interface SystemInitializationRequest - */ -export interface SystemInitializationRequest { - /** - * - * @type {string} - * @memberof SystemInitializationRequest - */ - 'email'?: string; - /** - * - * @type {string} - * @memberof SystemInitializationRequest - */ - 'password': string; - /** - * - * @type {string} - * @memberof SystemInitializationRequest - */ - 'siteTitle'?: string; - /** - * - * @type {string} - * @memberof SystemInitializationRequest - */ - 'username': string; -} - diff --git a/ui/packages/api-client/src/models/reset-password-request.ts b/ui/packages/api-client/src/models/uc-upload-request-form-data.ts similarity index 58% rename from ui/packages/api-client/src/models/reset-password-request.ts rename to ui/packages/api-client/src/models/uc-upload-request-form-data.ts index ebe725ef6f..f04f8734f0 100644 --- a/ui/packages/api-client/src/models/reset-password-request.ts +++ b/ui/packages/api-client/src/models/uc-upload-request-form-data.ts @@ -17,20 +17,22 @@ /** * * @export - * @interface ResetPasswordRequest + * @interface UcUploadRequestFormData */ -export interface ResetPasswordRequest { +export interface UcUploadRequestFormData { + [key: string]: Array | any; + /** * - * @type {string} - * @memberof ResetPasswordRequest + * @type {{ [key: string]: object; }} + * @memberof UcUploadRequestFormData */ - 'newPassword': string; + 'all'?: { [key: string]: object; }; /** * - * @type {string} - * @memberof ResetPasswordRequest + * @type {boolean} + * @memberof UcUploadRequestFormData */ - 'token': string; + 'empty'?: boolean; } diff --git a/ui/packages/api-client/src/models/user-connection-spec.ts b/ui/packages/api-client/src/models/user-connection-spec.ts index efb96e7c4d..31994c9196 100644 --- a/ui/packages/api-client/src/models/user-connection-spec.ts +++ b/ui/packages/api-client/src/models/user-connection-spec.ts @@ -20,48 +20,12 @@ * @interface UserConnectionSpec */ export interface UserConnectionSpec { - /** - * - * @type {string} - * @memberof UserConnectionSpec - */ - 'accessToken': string; - /** - * - * @type {string} - * @memberof UserConnectionSpec - */ - 'avatarUrl'?: string; - /** - * - * @type {string} - * @memberof UserConnectionSpec - */ - 'displayName': string; - /** - * - * @type {string} - * @memberof UserConnectionSpec - */ - 'expiresAt'?: string; - /** - * - * @type {string} - * @memberof UserConnectionSpec - */ - 'profileUrl'?: string; /** * * @type {string} * @memberof UserConnectionSpec */ 'providerUserId': string; - /** - * - * @type {string} - * @memberof UserConnectionSpec - */ - 'refreshToken'?: string; /** * * @type {string} diff --git a/ui/packages/components/package.json b/ui/packages/components/package.json index 2296e08ced..567b27498d 100644 --- a/ui/packages/components/package.json +++ b/ui/packages/components/package.json @@ -58,10 +58,10 @@ "react-dom": "^18.2.0", "storybook": "^7.6.3", "unplugin-icons": "^0.14.15", - "vite-plugin-dts": "^4.0.3" + "vite-plugin-dts": "^4.2.2" }, "peerDependencies": { - "vue": "^3.4.27", + "vue": "^3.5.11", "vue-router": "^4.3.2" }, "exports": { diff --git a/ui/packages/components/src/components/dialog/Dialog.stories.ts b/ui/packages/components/src/components/dialog/Dialog.stories.ts index 7ab6390a01..e726a9afb6 100644 --- a/ui/packages/components/src/components/dialog/Dialog.stories.ts +++ b/ui/packages/components/src/components/dialog/Dialog.stories.ts @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/vue3"; -import { VDialog } from "."; +import { Dialog, VDialog } from "."; import { VButton } from "../button"; const meta: Meta = { @@ -12,7 +12,11 @@ const meta: Meta = { height: 400, setup() { const showDialog = () => { - args.visible = true; + Dialog.success({ + title: "Hi", + // @ts-ignore + type: args.type, + }); }; return { @@ -23,7 +27,6 @@ const meta: Meta = { template: `
    点击显示Dialog -
    `, }), diff --git a/ui/packages/components/src/components/dialog/Dialog.vue b/ui/packages/components/src/components/dialog/Dialog.vue index 94b2608fb2..26abc23002 100644 --- a/ui/packages/components/src/components/dialog/Dialog.vue +++ b/ui/packages/components/src/components/dialog/Dialog.vue @@ -1,6 +1,5 @@