diff --git a/.asf.yaml b/.asf.yaml index 7505cd7f9..9832b8387 100644 --- a/.asf.yaml +++ b/.asf.yaml @@ -43,6 +43,6 @@ github: notifications: commits: commits@answer.apache.org - issues: commits@answer.apache.org - pullrequests: commits@answer.apache.org - discussions: commits@answer.apache.org + issues: issues@answer.apache.org + pullrequests: issues@answer.apache.org + discussions: issues@answer.apache.org diff --git a/.github/workflows/build-binary-for-release.yml b/.github/workflows/build-binary-for-release.yml index ec97f042d..49c53051a 100644 --- a/.github/workflows/build-binary-for-release.yml +++ b/.github/workflows/build-binary-for-release.yml @@ -44,7 +44,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v3 with: - go-version: 1.22 + go-version: 1.23 - name: Run GoReleaser uses: goreleaser/goreleaser-action@v4 with: diff --git a/Dockerfile b/Dockerfile index 929f321da..2e90a4301 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,7 +15,7 @@ # specific language governing permissions and limitations # under the License. -FROM golang:1.22-alpine AS golang-builder +FROM golang:1.23-alpine AS golang-builder LABEL maintainer="linkinstar@apache.org" ARG GOPROXY diff --git a/Makefile b/Makefile index d88d5085b..0319a0198 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ .PHONY: build clean ui -VERSION=1.5.1 +VERSION=1.6.0 BIN=answer DIR_SRC=./cmd/answer DOCKER_CMD=docker diff --git a/README.md b/README.md index 8f5ae243b..a05b68f81 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ To learn more about the project, visit [answer.apache.org](https://answer.apache ### Running with docker ```bash -docker run -d -p 9080:80 -v answer-data:/data --name answer apache/answer:1.5.1 +docker run -d -p 9080:80 -v answer-data:/data --name answer apache/answer:1.6.0 ``` For more information, see [Installation](https://answer.apache.org/docs/installation). @@ -40,7 +40,7 @@ You can also check out the [plugins here](https://answer.apache.org/plugins). ### Prerequisites -- Golang >= 1.22 +- Golang >= 1.23 - Node.js >= 20 - pnpm >= 9 - [mockgen](https://github.com/uber-go/mock?tab=readme-ov-file#installation) >= 1.6.0 diff --git a/cmd/main.go b/cmd/main.go index 5bc26572b..35256c073 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -49,7 +49,7 @@ var ( // Time is the build time of the project Time = "" // GoVersion is the go version of the project - GoVersion = "1.22" + GoVersion = "1.23" // log level logLevel = os.Getenv("LOG_LEVEL") // log path diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index f98deb424..947b6605d 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -291,7 +291,7 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database, renderController := controller.NewRenderController() pluginAPIRouter := router.NewPluginAPIRouter(connectorController, userCenterController, captchaController, embedController, renderController) ginEngine := server.NewHTTPServer(debug, staticRouter, answerAPIRouter, swaggerRouter, uiRouter, authUserMiddleware, avatarMiddleware, shortIDMiddleware, templateRouter, pluginAPIRouter, uiConf) - scheduledTaskManager := cron.NewScheduledTaskManager(siteInfoCommonService, questionService, fileRecordService, serviceConf) + scheduledTaskManager := cron.NewScheduledTaskManager(siteInfoCommonService, questionService, fileRecordService, userAdminService, serviceConf) application := newApplication(serverConf, ginEngine, scheduledTaskManager) return application, func() { cleanup2() diff --git a/configs/config.yaml b/configs/config.yaml index 69c4e3034..d14072785 100644 --- a/configs/config.yaml +++ b/configs/config.yaml @@ -40,4 +40,5 @@ ui: public_url: '/' api_url: '/' base_url: '' + api_base_url: '' diff --git a/docs/docs.go b/docs/docs.go index 82ab3c779..263e6775e 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -2134,7 +2134,7 @@ const docTemplate = `{ "application/json" ], "tags": [ - "api-answer" + "Answer" ], "summary": "Update Answer", "parameters": [ @@ -2152,7 +2152,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "type": "string" + "$ref": "#/definitions/handler.RespBody" } } } @@ -2163,7 +2163,7 @@ const docTemplate = `{ "ApiKeyAuth": [] } ], - "description": "Insert Answer", + "description": "add answer", "consumes": [ "application/json" ], @@ -2171,12 +2171,12 @@ const docTemplate = `{ "application/json" ], "tags": [ - "api-answer" + "Answer" ], - "summary": "Insert Answer", + "summary": "Add Answer", "parameters": [ { - "description": "AnswerAddReq", + "description": "add answer request", "name": "data", "in": "body", "required": true, @@ -2189,7 +2189,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "type": "string" + "$ref": "#/definitions/handler.RespBody" } } } @@ -2208,7 +2208,7 @@ const docTemplate = `{ "application/json" ], "tags": [ - "api-answer" + "Answer" ], "summary": "delete answer", "parameters": [ @@ -2239,7 +2239,7 @@ const docTemplate = `{ "ApiKeyAuth": [] } ], - "description": "Accepted", + "description": "Accept Answer", "consumes": [ "application/json" ], @@ -2247,9 +2247,9 @@ const docTemplate = `{ "application/json" ], "tags": [ - "api-answer" + "Answer" ], - "summary": "Accepted", + "summary": "Accept Answer", "parameters": [ { "description": "AcceptAnswerReq", @@ -2265,7 +2265,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "type": "string" + "$ref": "#/definitions/handler.RespBody" } } } @@ -2273,7 +2273,7 @@ const docTemplate = `{ }, "/answer/api/v1/answer/info": { "get": { - "description": "Get Answer", + "description": "Get Answer Detail", "consumes": [ "application/json" ], @@ -2281,14 +2281,13 @@ const docTemplate = `{ "application/json" ], "tags": [ - "api-answer" + "Answer" ], - "summary": "Get Answer", + "summary": "Get Answer Detail", "parameters": [ { "type": "string", - "default": "1", - "description": "Answer TagID", + "description": "id", "name": "id", "in": "query", "required": true @@ -2298,7 +2297,19 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "type": "string" + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/schema.GetAnswerInfoResp" + } + } + } + ] } } } @@ -2314,7 +2325,7 @@ const docTemplate = `{ "application/json" ], "tags": [ - "api-answer" + "Answer" ], "summary": "AnswerList", "parameters": [ @@ -2364,7 +2375,7 @@ const docTemplate = `{ "ApiKeyAuth": [] } ], - "description": "recover deleted answer", + "description": "recover the deleted answer", "consumes": [ "application/json" ], @@ -7992,6 +8003,60 @@ const docTemplate = `{ } } }, + "schema.AnswerInfo": { + "type": "object", + "properties": { + "accepted": { + "type": "integer" + }, + "collected": { + "type": "boolean" + }, + "content": { + "type": "string" + }, + "create_time": { + "type": "integer" + }, + "html": { + "type": "string" + }, + "id": { + "type": "string" + }, + "member_actions": { + "description": "MemberActions", + "type": "array", + "items": { + "$ref": "#/definitions/schema.PermissionMemberAction" + } + }, + "question_id": { + "type": "string" + }, + "question_info": { + "$ref": "#/definitions/schema.QuestionInfoResp" + }, + "status": { + "type": "integer" + }, + "update_time": { + "type": "integer" + }, + "update_user_info": { + "$ref": "#/definitions/schema.UserBasicInfo" + }, + "user_info": { + "$ref": "#/definitions/schema.UserBasicInfo" + }, + "vote_count": { + "type": "integer" + }, + "vote_status": { + "type": "string" + } + } + }, "schema.AnswerUpdateReq": { "type": "object", "required": [ @@ -8352,6 +8417,17 @@ const docTemplate = `{ } } }, + "schema.GetAnswerInfoResp": { + "type": "object", + "properties": { + "info": { + "$ref": "#/definitions/schema.AnswerInfo" + }, + "question": { + "$ref": "#/definitions/schema.QuestionInfoResp" + } + } + }, "schema.GetBadgeInfoResp": { "type": "object", "properties": { @@ -8679,6 +8755,10 @@ const docTemplate = `{ "description": "user status", "type": "string" }, + "suspended_until": { + "description": "suspended until timestamp", + "type": "integer" + }, "username": { "description": "username", "type": "string" @@ -8795,6 +8875,10 @@ const docTemplate = `{ "status_msg": { "type": "string" }, + "suspended_until": { + "description": "suspended until timestamp", + "type": "integer" + }, "username": { "description": "username", "type": "string" @@ -9444,6 +9528,10 @@ const docTemplate = `{ "description": "suspended time", "type": "integer" }, + "suspended_until": { + "description": "suspended until time", + "type": "integer" + }, "user_id": { "description": "user id", "type": "string" @@ -9585,6 +9673,41 @@ const docTemplate = `{ } } }, + "schema.Operation": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "level": { + "$ref": "#/definitions/schema.OperationLevel" + }, + "msg": { + "type": "string" + }, + "time": { + "type": "integer" + }, + "type": { + "type": "string" + } + } + }, + "schema.OperationLevel": { + "type": "string", + "enum": [ + "info", + "danger", + "warning", + "secondary" + ], + "x-enum-varnames": [ + "OperationLevelInfo", + "OperationLevelDanger", + "OperationLevelWarning", + "OperationLevelSecondary" + ] + }, "schema.OperationQuestionReq": { "type": "object", "required": [ @@ -9738,6 +9861,117 @@ const docTemplate = `{ } } }, + "schema.QuestionInfoResp": { + "type": "object", + "properties": { + "accepted_answer_id": { + "type": "string" + }, + "answer_count": { + "type": "integer" + }, + "answered": { + "type": "boolean" + }, + "collected": { + "type": "boolean" + }, + "collection_count": { + "type": "integer" + }, + "content": { + "type": "string" + }, + "create_time": { + "type": "integer" + }, + "description": { + "type": "string" + }, + "edit_time": { + "type": "integer" + }, + "extends_actions": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.PermissionMemberAction" + } + }, + "first_answer_id": { + "type": "string" + }, + "follow_count": { + "type": "integer" + }, + "html": { + "type": "string" + }, + "id": { + "type": "string" + }, + "is_followed": { + "type": "boolean" + }, + "last_answer_id": { + "type": "string" + }, + "last_answered_user_info": { + "$ref": "#/definitions/schema.UserBasicInfo" + }, + "member_actions": { + "description": "MemberActions", + "type": "array", + "items": { + "$ref": "#/definitions/schema.PermissionMemberAction" + } + }, + "operation": { + "$ref": "#/definitions/schema.Operation" + }, + "pin": { + "type": "integer" + }, + "show": { + "type": "integer" + }, + "status": { + "type": "integer" + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.TagResp" + } + }, + "title": { + "type": "string" + }, + "unique_view_count": { + "type": "integer" + }, + "update_time": { + "type": "integer" + }, + "update_user_info": { + "$ref": "#/definitions/schema.UserBasicInfo" + }, + "url_title": { + "type": "string" + }, + "user_info": { + "$ref": "#/definitions/schema.UserBasicInfo" + }, + "view_count": { + "type": "integer" + }, + "vote_count": { + "type": "integer" + }, + "vote_status": { + "type": "string" + } + } + }, "schema.QuestionPageReq": { "type": "object", "properties": { @@ -10474,10 +10708,21 @@ const docTemplate = `{ "schema.SiteInterfaceReq": { "type": "object", "required": [ + "default_avatar", "language", "time_zone" ], "properties": { + "default_avatar": { + "type": "string", + "enum": [ + "system", + "gravatar" + ] + }, + "gravatar_base_url": { + "type": "string" + }, "language": { "type": "string", "maxLength": 128 @@ -10491,10 +10736,21 @@ const docTemplate = `{ "schema.SiteInterfaceResp": { "type": "object", "required": [ + "default_avatar", "language", "time_zone" ], "properties": { + "default_avatar": { + "type": "string", + "enum": [ + "system", + "gravatar" + ] + }, + "gravatar_base_url": { + "type": "string" + }, "language": { "type": "string", "maxLength": 128 @@ -11399,6 +11655,22 @@ const docTemplate = `{ "inactive" ] }, + "suspend_duration": { + "type": "string", + "enum": [ + "24h", + "48h", + "72h", + "7d", + "14d", + "1m", + "2m", + "3m", + "6m", + "1y", + "forever" + ] + }, "user_id": { "type": "string" } @@ -11428,6 +11700,9 @@ const docTemplate = `{ "status": { "type": "string" }, + "suspended_until": { + "type": "integer" + }, "username": { "type": "string" }, @@ -11590,6 +11865,10 @@ const docTemplate = `{ "description": "user status", "type": "string" }, + "suspended_until": { + "description": "suspended until timestamp", + "type": "integer" + }, "username": { "description": "username", "type": "string" diff --git a/docs/img/screenshot.png b/docs/img/screenshot.png index 555de5002..8a823a875 100644 Binary files a/docs/img/screenshot.png and b/docs/img/screenshot.png differ diff --git a/docs/swagger.json b/docs/swagger.json index 689255a42..8cc85e263 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -2107,7 +2107,7 @@ "application/json" ], "tags": [ - "api-answer" + "Answer" ], "summary": "Update Answer", "parameters": [ @@ -2125,7 +2125,7 @@ "200": { "description": "OK", "schema": { - "type": "string" + "$ref": "#/definitions/handler.RespBody" } } } @@ -2136,7 +2136,7 @@ "ApiKeyAuth": [] } ], - "description": "Insert Answer", + "description": "add answer", "consumes": [ "application/json" ], @@ -2144,12 +2144,12 @@ "application/json" ], "tags": [ - "api-answer" + "Answer" ], - "summary": "Insert Answer", + "summary": "Add Answer", "parameters": [ { - "description": "AnswerAddReq", + "description": "add answer request", "name": "data", "in": "body", "required": true, @@ -2162,7 +2162,7 @@ "200": { "description": "OK", "schema": { - "type": "string" + "$ref": "#/definitions/handler.RespBody" } } } @@ -2181,7 +2181,7 @@ "application/json" ], "tags": [ - "api-answer" + "Answer" ], "summary": "delete answer", "parameters": [ @@ -2212,7 +2212,7 @@ "ApiKeyAuth": [] } ], - "description": "Accepted", + "description": "Accept Answer", "consumes": [ "application/json" ], @@ -2220,9 +2220,9 @@ "application/json" ], "tags": [ - "api-answer" + "Answer" ], - "summary": "Accepted", + "summary": "Accept Answer", "parameters": [ { "description": "AcceptAnswerReq", @@ -2238,7 +2238,7 @@ "200": { "description": "OK", "schema": { - "type": "string" + "$ref": "#/definitions/handler.RespBody" } } } @@ -2246,7 +2246,7 @@ }, "/answer/api/v1/answer/info": { "get": { - "description": "Get Answer", + "description": "Get Answer Detail", "consumes": [ "application/json" ], @@ -2254,14 +2254,13 @@ "application/json" ], "tags": [ - "api-answer" + "Answer" ], - "summary": "Get Answer", + "summary": "Get Answer Detail", "parameters": [ { "type": "string", - "default": "1", - "description": "Answer TagID", + "description": "id", "name": "id", "in": "query", "required": true @@ -2271,7 +2270,19 @@ "200": { "description": "OK", "schema": { - "type": "string" + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/schema.GetAnswerInfoResp" + } + } + } + ] } } } @@ -2287,7 +2298,7 @@ "application/json" ], "tags": [ - "api-answer" + "Answer" ], "summary": "AnswerList", "parameters": [ @@ -2337,7 +2348,7 @@ "ApiKeyAuth": [] } ], - "description": "recover deleted answer", + "description": "recover the deleted answer", "consumes": [ "application/json" ], @@ -7965,6 +7976,60 @@ } } }, + "schema.AnswerInfo": { + "type": "object", + "properties": { + "accepted": { + "type": "integer" + }, + "collected": { + "type": "boolean" + }, + "content": { + "type": "string" + }, + "create_time": { + "type": "integer" + }, + "html": { + "type": "string" + }, + "id": { + "type": "string" + }, + "member_actions": { + "description": "MemberActions", + "type": "array", + "items": { + "$ref": "#/definitions/schema.PermissionMemberAction" + } + }, + "question_id": { + "type": "string" + }, + "question_info": { + "$ref": "#/definitions/schema.QuestionInfoResp" + }, + "status": { + "type": "integer" + }, + "update_time": { + "type": "integer" + }, + "update_user_info": { + "$ref": "#/definitions/schema.UserBasicInfo" + }, + "user_info": { + "$ref": "#/definitions/schema.UserBasicInfo" + }, + "vote_count": { + "type": "integer" + }, + "vote_status": { + "type": "string" + } + } + }, "schema.AnswerUpdateReq": { "type": "object", "required": [ @@ -8325,6 +8390,17 @@ } } }, + "schema.GetAnswerInfoResp": { + "type": "object", + "properties": { + "info": { + "$ref": "#/definitions/schema.AnswerInfo" + }, + "question": { + "$ref": "#/definitions/schema.QuestionInfoResp" + } + } + }, "schema.GetBadgeInfoResp": { "type": "object", "properties": { @@ -8652,6 +8728,10 @@ "description": "user status", "type": "string" }, + "suspended_until": { + "description": "suspended until timestamp", + "type": "integer" + }, "username": { "description": "username", "type": "string" @@ -8768,6 +8848,10 @@ "status_msg": { "type": "string" }, + "suspended_until": { + "description": "suspended until timestamp", + "type": "integer" + }, "username": { "description": "username", "type": "string" @@ -9417,6 +9501,10 @@ "description": "suspended time", "type": "integer" }, + "suspended_until": { + "description": "suspended until time", + "type": "integer" + }, "user_id": { "description": "user id", "type": "string" @@ -9558,6 +9646,41 @@ } } }, + "schema.Operation": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "level": { + "$ref": "#/definitions/schema.OperationLevel" + }, + "msg": { + "type": "string" + }, + "time": { + "type": "integer" + }, + "type": { + "type": "string" + } + } + }, + "schema.OperationLevel": { + "type": "string", + "enum": [ + "info", + "danger", + "warning", + "secondary" + ], + "x-enum-varnames": [ + "OperationLevelInfo", + "OperationLevelDanger", + "OperationLevelWarning", + "OperationLevelSecondary" + ] + }, "schema.OperationQuestionReq": { "type": "object", "required": [ @@ -9711,6 +9834,117 @@ } } }, + "schema.QuestionInfoResp": { + "type": "object", + "properties": { + "accepted_answer_id": { + "type": "string" + }, + "answer_count": { + "type": "integer" + }, + "answered": { + "type": "boolean" + }, + "collected": { + "type": "boolean" + }, + "collection_count": { + "type": "integer" + }, + "content": { + "type": "string" + }, + "create_time": { + "type": "integer" + }, + "description": { + "type": "string" + }, + "edit_time": { + "type": "integer" + }, + "extends_actions": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.PermissionMemberAction" + } + }, + "first_answer_id": { + "type": "string" + }, + "follow_count": { + "type": "integer" + }, + "html": { + "type": "string" + }, + "id": { + "type": "string" + }, + "is_followed": { + "type": "boolean" + }, + "last_answer_id": { + "type": "string" + }, + "last_answered_user_info": { + "$ref": "#/definitions/schema.UserBasicInfo" + }, + "member_actions": { + "description": "MemberActions", + "type": "array", + "items": { + "$ref": "#/definitions/schema.PermissionMemberAction" + } + }, + "operation": { + "$ref": "#/definitions/schema.Operation" + }, + "pin": { + "type": "integer" + }, + "show": { + "type": "integer" + }, + "status": { + "type": "integer" + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.TagResp" + } + }, + "title": { + "type": "string" + }, + "unique_view_count": { + "type": "integer" + }, + "update_time": { + "type": "integer" + }, + "update_user_info": { + "$ref": "#/definitions/schema.UserBasicInfo" + }, + "url_title": { + "type": "string" + }, + "user_info": { + "$ref": "#/definitions/schema.UserBasicInfo" + }, + "view_count": { + "type": "integer" + }, + "vote_count": { + "type": "integer" + }, + "vote_status": { + "type": "string" + } + } + }, "schema.QuestionPageReq": { "type": "object", "properties": { @@ -10447,10 +10681,21 @@ "schema.SiteInterfaceReq": { "type": "object", "required": [ + "default_avatar", "language", "time_zone" ], "properties": { + "default_avatar": { + "type": "string", + "enum": [ + "system", + "gravatar" + ] + }, + "gravatar_base_url": { + "type": "string" + }, "language": { "type": "string", "maxLength": 128 @@ -10464,10 +10709,21 @@ "schema.SiteInterfaceResp": { "type": "object", "required": [ + "default_avatar", "language", "time_zone" ], "properties": { + "default_avatar": { + "type": "string", + "enum": [ + "system", + "gravatar" + ] + }, + "gravatar_base_url": { + "type": "string" + }, "language": { "type": "string", "maxLength": 128 @@ -11372,6 +11628,22 @@ "inactive" ] }, + "suspend_duration": { + "type": "string", + "enum": [ + "24h", + "48h", + "72h", + "7d", + "14d", + "1m", + "2m", + "3m", + "6m", + "1y", + "forever" + ] + }, "user_id": { "type": "string" } @@ -11401,6 +11673,9 @@ "status": { "type": "string" }, + "suspended_until": { + "type": "integer" + }, "username": { "type": "string" }, @@ -11563,6 +11838,10 @@ "description": "user status", "type": "string" }, + "suspended_until": { + "description": "suspended until timestamp", + "type": "integer" + }, "username": { "description": "username", "type": "string" diff --git a/docs/swagger.yaml b/docs/swagger.yaml index a399b3bfd..9cdf248e1 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -349,6 +349,42 @@ definitions: required: - content type: object + schema.AnswerInfo: + properties: + accepted: + type: integer + collected: + type: boolean + content: + type: string + create_time: + type: integer + html: + type: string + id: + type: string + member_actions: + description: MemberActions + items: + $ref: '#/definitions/schema.PermissionMemberAction' + type: array + question_id: + type: string + question_info: + $ref: '#/definitions/schema.QuestionInfoResp' + status: + type: integer + update_time: + type: integer + update_user_info: + $ref: '#/definitions/schema.UserBasicInfo' + user_info: + $ref: '#/definitions/schema.UserBasicInfo' + vote_count: + type: integer + vote_status: + type: string + type: object schema.AnswerUpdateReq: properties: captcha_code: @@ -595,6 +631,13 @@ definitions: description: if user is followed object will be true,otherwise false type: boolean type: object + schema.GetAnswerInfoResp: + properties: + info: + $ref: '#/definitions/schema.AnswerInfo' + question: + $ref: '#/definitions/schema.QuestionInfoResp' + type: object schema.GetBadgeInfoResp: properties: award_count: @@ -830,6 +873,9 @@ definitions: status: description: user status type: string + suspended_until: + description: suspended until timestamp + type: integer username: description: username type: string @@ -916,6 +962,9 @@ definitions: type: string status_msg: type: string + suspended_until: + description: suspended until timestamp + type: integer username: description: username type: string @@ -1358,6 +1407,9 @@ definitions: suspended_at: description: suspended time type: integer + suspended_until: + description: suspended until time + type: integer user_id: description: user id type: string @@ -1455,6 +1507,31 @@ definitions: toast_return_message: type: boolean type: object + schema.Operation: + properties: + description: + type: string + level: + $ref: '#/definitions/schema.OperationLevel' + msg: + type: string + time: + type: integer + type: + type: string + type: object + schema.OperationLevel: + enum: + - info + - danger + - warning + - secondary + type: string + x-enum-varnames: + - OperationLevelInfo + - OperationLevelDanger + - OperationLevelWarning + - OperationLevelSecondary schema.OperationQuestionReq: properties: id: @@ -1565,6 +1642,80 @@ definitions: - tags - title type: object + schema.QuestionInfoResp: + properties: + accepted_answer_id: + type: string + answer_count: + type: integer + answered: + type: boolean + collected: + type: boolean + collection_count: + type: integer + content: + type: string + create_time: + type: integer + description: + type: string + edit_time: + type: integer + extends_actions: + items: + $ref: '#/definitions/schema.PermissionMemberAction' + type: array + first_answer_id: + type: string + follow_count: + type: integer + html: + type: string + id: + type: string + is_followed: + type: boolean + last_answer_id: + type: string + last_answered_user_info: + $ref: '#/definitions/schema.UserBasicInfo' + member_actions: + description: MemberActions + items: + $ref: '#/definitions/schema.PermissionMemberAction' + type: array + operation: + $ref: '#/definitions/schema.Operation' + pin: + type: integer + show: + type: integer + status: + type: integer + tags: + items: + $ref: '#/definitions/schema.TagResp' + type: array + title: + type: string + unique_view_count: + type: integer + update_time: + type: integer + update_user_info: + $ref: '#/definitions/schema.UserBasicInfo' + url_title: + type: string + user_info: + $ref: '#/definitions/schema.UserBasicInfo' + view_count: + type: integer + vote_count: + type: integer + vote_status: + type: string + type: object schema.QuestionPageReq: properties: in_days: @@ -2072,6 +2223,13 @@ definitions: type: object schema.SiteInterfaceReq: properties: + default_avatar: + enum: + - system + - gravatar + type: string + gravatar_base_url: + type: string language: maxLength: 128 type: string @@ -2079,11 +2237,19 @@ definitions: maxLength: 128 type: string required: + - default_avatar - language - time_zone type: object schema.SiteInterfaceResp: properties: + default_avatar: + enum: + - system + - gravatar + type: string + gravatar_base_url: + type: string language: maxLength: 128 type: string @@ -2091,6 +2257,7 @@ definitions: maxLength: 128 type: string required: + - default_avatar - language - time_zone type: object @@ -2703,6 +2870,20 @@ definitions: - deleted - inactive type: string + suspend_duration: + enum: + - 24h + - 48h + - 72h + - 7d + - 14d + - 1m + - 2m + - 3m + - 6m + - 1y + - forever + type: string user_id: type: string required: @@ -2725,6 +2906,8 @@ definitions: type: integer status: type: string + suspended_until: + type: integer username: type: string website: @@ -2842,6 +3025,9 @@ definitions: status: description: user status type: string + suspended_until: + description: suspended until timestamp + type: integer username: description: username type: string @@ -4263,13 +4449,13 @@ paths: - ApiKeyAuth: [] summary: delete answer tags: - - api-answer + - Answer post: consumes: - application/json - description: Insert Answer + description: add answer parameters: - - description: AnswerAddReq + - description: add answer request in: body name: data required: true @@ -4281,12 +4467,12 @@ paths: "200": description: OK schema: - type: string + $ref: '#/definitions/handler.RespBody' security: - ApiKeyAuth: [] - summary: Insert Answer + summary: Add Answer tags: - - api-answer + - Answer put: consumes: - application/json @@ -4304,17 +4490,17 @@ paths: "200": description: OK schema: - type: string + $ref: '#/definitions/handler.RespBody' security: - ApiKeyAuth: [] summary: Update Answer tags: - - api-answer + - Answer /answer/api/v1/answer/acceptance: post: consumes: - application/json - description: Accepted + description: Accept Answer parameters: - description: AcceptAnswerReq in: body @@ -4328,20 +4514,19 @@ paths: "200": description: OK schema: - type: string + $ref: '#/definitions/handler.RespBody' security: - ApiKeyAuth: [] - summary: Accepted + summary: Accept Answer tags: - - api-answer + - Answer /answer/api/v1/answer/info: get: consumes: - application/json - description: Get Answer + description: Get Answer Detail parameters: - - default: "1" - description: Answer TagID + - description: id in: query name: id required: true @@ -4352,10 +4537,15 @@ paths: "200": description: OK schema: - type: string - summary: Get Answer + allOf: + - $ref: '#/definitions/handler.RespBody' + - properties: + data: + $ref: '#/definitions/schema.GetAnswerInfoResp' + type: object + summary: Get Answer Detail tags: - - api-answer + - Answer /answer/api/v1/answer/page: get: consumes: @@ -4391,12 +4581,12 @@ paths: type: string summary: AnswerList tags: - - api-answer + - Answer /answer/api/v1/answer/recover: post: consumes: - application/json - description: recover deleted answer + description: recover the deleted answer parameters: - description: answer in: body diff --git a/go.mod b/go.mod index 89c6876b5..6578d5cef 100644 --- a/go.mod +++ b/go.mod @@ -17,7 +17,7 @@ module github.com/apache/answer -go 1.22.0 +go 1.23.0 require ( github.com/Machiel/slugify v1.0.1 @@ -57,10 +57,10 @@ require ( github.com/tidwall/gjson v1.17.3 github.com/yuin/goldmark v1.7.4 go.uber.org/mock v0.5.0 - golang.org/x/crypto v0.27.0 + golang.org/x/crypto v0.36.0 golang.org/x/image v0.20.0 - golang.org/x/net v0.29.0 - golang.org/x/text v0.18.0 + golang.org/x/net v0.38.0 + golang.org/x/text v0.23.0 gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.33.0 @@ -160,7 +160,7 @@ require ( go.uber.org/zap v1.27.0 // indirect golang.org/x/arch v0.10.0 // indirect golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect - golang.org/x/sys v0.25.0 // indirect + golang.org/x/sys v0.31.0 // indirect golang.org/x/tools v0.25.0 // indirect google.golang.org/protobuf v1.34.2 // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect diff --git a/go.sum b/go.sum index 446401f58..afe9b1ea9 100644 --- a/go.sum +++ b/go.sum @@ -685,8 +685,8 @@ golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWP golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= -golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk= golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY= @@ -731,8 +731,8 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo= -golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -743,8 +743,8 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= -golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -779,8 +779,8 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= -golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -793,8 +793,8 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= -golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/i18n/en_US.yaml b/i18n/en_US.yaml index cb05e5e13..da34fcb16 100644 --- a/i18n/en_US.yaml +++ b/i18n/en_US.yaml @@ -307,6 +307,14 @@ backend: other: "Error {{.Field}} format near '{{.Content}}' at line {{.Line}}. {{.ExtraMessage}}" add_bulk_users_amount_error: other: "The number of users you add at once should be in the range of 1-{{.MaxAmount}}." + status_suspended_forever: + other: "This user was suspended forever. This user doesn't meet a community guideline." + status_suspended_until: + other: "This user was suspended until {{.SuspendedUntil}}. This user doesn't meet a community guideline." + status_deleted: + other: "This user was deleted." + status_inactive: + other: "This user is inactive." config: read_config_failed: other: Read config failed @@ -820,7 +828,7 @@ ui: tag_wiki: tag wiki create_tag: Create Tag edit_tag: Edit Tag - ask_a_question: Add Question + ask_a_question: Create Question edit_question: Edit Question edit_answer: Edit Answer search: Search @@ -1070,6 +1078,9 @@ ui: day: day hours: hours days: days + month: month + months: months + year: year reaction: heart: heart smile: smile @@ -1128,10 +1139,10 @@ ui: more: More wiki: Wiki ask: - title: Add Question + title: Create Question edit_title: Edit Question default_reason: Edit question - default_first_reason: Add question + default_first_reason: Create question similar_questions: Similar questions form: fields: @@ -1139,7 +1150,7 @@ ui: label: Revision title: label: Title - placeholder: Be specific and imagine you're asking a question to another person + placeholder: What's your topic? Be specific. msg: empty: Title cannot be empty. range: Title up to 150 characters @@ -1168,7 +1179,7 @@ ui: add_btn: Add tag create_btn: Create new tag search_tag: Search tag - hint: "Describe what your question is about, at least one tag is required." + hint: "Describe what your content is about, at least one tag is required." no_result: No tags matched tag_required_text: Required tag (at least one) header: @@ -1382,12 +1393,12 @@ ui: review: Your revision will show after review. sent_success: Sent successfully related_question: - title: Related Questions + title: Related answers: answers linked_question: - title: Linked Questions - description: Questions linked to - no_linked_question: No questions linked from this question. + title: Linked + description: Posts linked to + no_linked_question: No contents linked from this content. invite_to_answer: title: Invite People desc: Invite people you think can answer. @@ -1534,6 +1545,7 @@ ui: follow: Follow following: Following counts: "{{count}} Results" + counts_loading: "... Results" more: More sort_btns: relevance: Relevance @@ -1586,6 +1598,7 @@ ui: all_questions: All Questions x_questions: "{{ count }} Questions" x_answers: "{{ count }} answers" + x_posts: "{{ count }} Posts" questions: Questions answers: Answers newest: Newest @@ -1930,6 +1943,7 @@ ui: created_at: Created time delete_at: Deleted time suspend_at: Suspended time + suspend_until: Suspend until status: Status role: Role action: Action @@ -1964,6 +1978,8 @@ ui: suspend_user: title: Suspend this user content: A suspended user can't log in. + label: How long will the user be suspended for? + forever: Forever questions: page_title: Questions unlisted: Unlisted @@ -2024,6 +2040,12 @@ ui: label: Timezone msg: Timezone cannot be empty. text: Choose a city in the same timezone as you. + avatar: + label: Default avatar + text: For users without a custom avatar of their own. + gravatar_base_url: + label: Gravatar base URL + text: URL of the Gravatar provider's API base. Ignored when empty. smtp: page_title: SMTP from_email: diff --git a/i18n/zh_CN.yaml b/i18n/zh_CN.yaml index 9852ba605..f1365b53d 100644 --- a/i18n/zh_CN.yaml +++ b/i18n/zh_CN.yaml @@ -306,6 +306,14 @@ backend: other: "发生错误,{{.Field}} 格式错误,在 '{{.Content}}' 行数 {{.Line}}. {{.ExtraMessage}}" add_bulk_users_amount_error: other: "一次性添加的用户数量应在 1-{{.MaxAmount}} 之间。" + status_suspended_forever: + other: "该用户已被永久封禁。该用户不符合社区准则。" + status_suspended_until: + other: "该用户已被封禁至 {{.SuspendedUntil}}。该用户不符合社区准则。" + status_deleted: + other: "该用户已被删除。" + status_inactive: + other: "该用户未激活。" config: read_config_failed: other: 读取配置失败 @@ -809,7 +817,7 @@ ui: tag_wiki: 标签维基 create_tag: 创建标签 edit_tag: 编辑标签 - ask_a_question: 提问题 + ask_a_question: 创建问题 edit_question: 编辑问题 edit_answer: 编辑回答 search: 搜索 @@ -1056,6 +1064,9 @@ ui: day: 天 hours: 小时 days: 日 + month: 月 + months: 月 + year: 年 reaction: heart: 爱心 smile: 微笑 @@ -1111,10 +1122,10 @@ ui: more: 更多 wiki: 维基 ask: - title: 新增问题 + title: 创建问题 edit_title: 编辑问题 default_reason: 编辑问题 - default_first_reason: 新增问题 + default_first_reason: 创建问题 similar_questions: 相似问题 form: fields: @@ -1122,7 +1133,7 @@ ui: label: 修订版本 title: label: 标题 - placeholder: 请详细描述你的问题,想象你在问一个人 + placeholder: 你的主题是什么?请具体说明。 msg: empty: 标题不能为空。 range: 标题最多 150 个字符 @@ -1150,7 +1161,7 @@ ui: add_btn: 添加标签 create_btn: 创建新标签 search_tag: 搜索标签 - hint: "描述您的问题是关于什么,至少需要一个标签。" + hint: "描述您的内容是关于什么,至少需要一个标签。" no_result: 没有匹配的标签 tag_required_text: 必选标签(至少一个) header: @@ -1356,12 +1367,12 @@ ui: review: 您的修订将在审阅通过后显示。 sent_success: 发送成功 related_question: - title: 相关问题 + title: 相似 answers: 个回答 linked_question: - title: 关联的问题 - description: 问题关联到 - no_linked_question: 这个问题没有相关联的问题。 + title: 关联 + description: 帖子关联到 + no_linked_question: 没有与之关联的贴子。 invite_to_answer: title: 受邀人 desc: 邀请你认为可能知道答案的人。 @@ -1501,6 +1512,7 @@ ui: follow: 关注 following: 已关注 counts: "{{count}} 个结果" + counts_loading: "... 个结果" more: 更多 sort_btns: relevance: 相关性 @@ -1552,6 +1564,7 @@ ui: all_questions: 全部问题 x_questions: "{{ count }} 个问题" x_answers: "{{ count }} 个回答" + x_posts: "{{ count }} 个帖子" questions: 问题 answers: 回答 newest: 最新 @@ -1890,6 +1903,7 @@ ui: created_at: 创建时间 delete_at: 删除时间 suspend_at: 封禁时间 + suspend_until: 封禁到期 status: 状态 role: 角色 action: 操作 @@ -1924,6 +1938,8 @@ ui: suspend_user: title: 挂起此用户 content: 被封禁的用户将无法登录。 + label: 用户将被封禁多长时间? + forever: 永久 questions: page_title: 问题 unlisted: 已隐藏 @@ -1984,6 +2000,12 @@ ui: label: 时区 msg: 时区不能为空。 text: 选择一个与您相同时区的城市。 + avatar: + label: 默认头像 + text: 没有自定义头像的用户。 + gravatar_base_url: + label: Gravatar 根路径 URL + text: Gravatar 提供商的 API 基础的 URL。当为空时忽略。 smtp: page_title: SMTP from_email: diff --git a/internal/base/cron/cron.go b/internal/base/cron/cron.go index 98e59decf..1d8008ad6 100644 --- a/internal/base/cron/cron.go +++ b/internal/base/cron/cron.go @@ -27,6 +27,7 @@ import ( "github.com/apache/answer/internal/service/file_record" "github.com/apache/answer/internal/service/service_config" "github.com/apache/answer/internal/service/siteinfo_common" + "github.com/apache/answer/internal/service/user_admin" "github.com/robfig/cron/v3" "github.com/segmentfault/pacman/log" ) @@ -36,6 +37,7 @@ type ScheduledTaskManager struct { siteInfoService siteinfo_common.SiteInfoCommonService questionService *content.QuestionService fileRecordService *file_record.FileRecordService + userAdminService *user_admin.UserAdminService serviceConfig *service_config.ServiceConfig } @@ -44,12 +46,14 @@ func NewScheduledTaskManager( siteInfoService siteinfo_common.SiteInfoCommonService, questionService *content.QuestionService, fileRecordService *file_record.FileRecordService, + userAdminService *user_admin.UserAdminService, serviceConfig *service_config.ServiceConfig, ) *ScheduledTaskManager { manager := &ScheduledTaskManager{ siteInfoService: siteInfoService, questionService: questionService, fileRecordService: fileRecordService, + userAdminService: userAdminService, serviceConfig: serviceConfig, } return manager @@ -78,6 +82,19 @@ func (s *ScheduledTaskManager) Run() { log.Error(err) } + // Check for expired user suspensions every 10 minutes + _, err = c.AddFunc("*/10 * * * *", func() { + ctx := context.Background() + log.Infof("checking expired user suspensions") + err := s.userAdminService.CheckAndUnsuspendExpiredUsers(ctx) + if err != nil { + log.Errorf("failed to check expired user suspensions: %v", err) + } + }) + if err != nil { + log.Error(err) + } + if s.serviceConfig.CleanUpUploads { log.Infof("clean up uploads cron enabled") diff --git a/internal/base/reason/reason.go b/internal/base/reason/reason.go index f318e3359..953c316ed 100644 --- a/internal/base/reason/reason.go +++ b/internal/base/reason/reason.go @@ -111,6 +111,10 @@ const ( MetaObjectNotFound = "error.meta.object_not_found" BadgeObjectNotFound = "error.badge.object_not_found" StatusInvalid = "error.common.status_invalid" + UserStatusInactive = "error.user.status_inactive" + UserStatusSuspendedForever = "error.user.status_suspended_forever" + UserStatusSuspendedUntil = "error.user.status_suspended_until" + UserStatusDeleted = "error.user.status_deleted" ) // user external login reasons diff --git a/internal/cli/build.go b/internal/cli/build.go index 2f43a9116..43a7aa9fe 100644 --- a/internal/cli/build.go +++ b/internal/cli/build.go @@ -62,7 +62,7 @@ func main() { ` goModTpl = `module answer -go 1.22 +go 1.23 ` ) diff --git a/internal/controller/answer_controller.go b/internal/controller/answer_controller.go index 157e1b253..7c5aca1db 100644 --- a/internal/controller/answer_controller.go +++ b/internal/controller/answer_controller.go @@ -69,7 +69,7 @@ func NewAnswerController( // RemoveAnswer delete answer // @Summary delete answer // @Description delete answer -// @Tags api-answer +// @Tags Answer // @Accept json // @Produce json // @Security ApiKeyAuth @@ -119,7 +119,7 @@ func (ac *AnswerController) RemoveAnswer(ctx *gin.Context) { // RecoverAnswer recover answer // @Summary recover answer -// @Description recover deleted answer +// @Description recover the deleted answer // @Tags Answer // @Accept json // @Produce json @@ -151,16 +151,16 @@ func (ac *AnswerController) RecoverAnswer(ctx *gin.Context) { handler.HandleResponse(ctx, err, nil) } -// Get godoc -// @Summary Get Answer -// @Description Get Answer -// @Tags api-answer -// @Accept json -// @Produce json -// @Param id query string true "Answer TagID" default(1) -// @Router /answer/api/v1/answer/info [get] -// @Success 200 {string} string "" -func (ac *AnswerController) Get(ctx *gin.Context) { +// GetAnswerInfo get answer info +// @Summary Get Answer Detail +// @Description Get Answer Detail +// @Tags Answer +// @Accept json +// @Produce json +// @Param id query string true "id" +// @Success 200 {object} handler.RespBody{data=schema.GetAnswerInfoResp} +// @Router /answer/api/v1/answer/info [get] +func (ac *AnswerController) GetAnswerInfo(ctx *gin.Context) { id := ctx.Query("id") id = uid.DeShortID(id) userID := middleware.GetLoginUserIDFromContext(ctx) @@ -174,23 +174,23 @@ func (ac *AnswerController) Get(ctx *gin.Context) { handler.HandleResponse(ctx, fmt.Errorf(""), gin.H{}) return } - handler.HandleResponse(ctx, err, gin.H{ - "info": info, - "question": questionInfo, + handler.HandleResponse(ctx, err, &schema.GetAnswerInfoResp{ + Info: info, + Question: questionInfo, }) } -// Add godoc -// @Summary Insert Answer -// @Description Insert Answer -// @Tags api-answer -// @Accept json -// @Produce json +// AddAnswer add answer +// @Summary Add Answer +// @Description add answer +// @Tags Answer +// @Accept json +// @Produce json // @Security ApiKeyAuth -// @Param data body schema.AnswerAddReq true "AnswerAddReq" -// @Success 200 {string} string "" +// @Param data body schema.AnswerAddReq true "add answer request" +// @Success 200 {object} handler.RespBody{} // @Router /answer/api/v1/answer [post] -func (ac *AnswerController) Add(ctx *gin.Context) { +func (ac *AnswerController) AddAnswer(ctx *gin.Context) { req := &schema.AnswerAddReq{} if handler.BindAndCheck(ctx, req) { return @@ -292,17 +292,17 @@ func (ac *AnswerController) Add(ctx *gin.Context) { }) } -// Update godoc +// UpdateAnswer update answer // @Summary Update Answer // @Description Update Answer -// @Tags api-answer -// @Accept json -// @Produce json +// @Tags Answer +// @Accept json +// @Produce json // @Security ApiKeyAuth -// @Param data body schema.AnswerUpdateReq true "AnswerUpdateReq" -// @Success 200 {string} string "" +// @Param data body schema.AnswerUpdateReq true "AnswerUpdateReq" +// @Success 200 {object} handler.RespBody{} // @Router /answer/api/v1/answer [put] -func (ac *AnswerController) Update(ctx *gin.Context) { +func (ac *AnswerController) UpdateAnswer(ctx *gin.Context) { req := &schema.AnswerUpdateReq{} if handler.BindAndCheck(ctx, req) { return @@ -360,9 +360,9 @@ func (ac *AnswerController) Update(ctx *gin.Context) { // AnswerList godoc // @Summary AnswerList // @Description AnswerList
order (default or updated) -// @Tags api-answer -// @Accept json -// @Produce json +// @Tags Answer +// @Accept json +// @Produce json // @Param question_id query string true "question_id" // @Param order query string true "order" // @Param page query string true "page" @@ -402,17 +402,17 @@ func (ac *AnswerController) AnswerList(ctx *gin.Context) { }) } -// Accepted godoc -// @Summary Accepted -// @Description Accepted -// @Tags api-answer -// @Accept json -// @Produce json +// AcceptAnswer accept answer +// @Summary Accept Answer +// @Description Accept Answer +// @Tags Answer +// @Accept json +// @Produce json // @Security ApiKeyAuth -// @Param data body schema.AcceptAnswerReq true "AcceptAnswerReq" -// @Success 200 {string} string "" +// @Param data body schema.AcceptAnswerReq true "AcceptAnswerReq" +// @Success 200 {object} handler.RespBody{} // @Router /answer/api/v1/answer/acceptance [post] -func (ac *AnswerController) Accepted(ctx *gin.Context) { +func (ac *AnswerController) AcceptAnswer(ctx *gin.Context) { req := &schema.AcceptAnswerReq{} if handler.BindAndCheck(ctx, req) { return diff --git a/internal/entity/user_entity.go b/internal/entity/user_entity.go index cfc2b6c29..66d612926 100644 --- a/internal/entity/user_entity.go +++ b/internal/entity/user_entity.go @@ -36,12 +36,16 @@ const ( UserAdminFlag = 1 ) +// PermanentSuspensionTime is a fixed time representing permanent suspension (2099-12-31 23:59:59) +var PermanentSuspensionTime = time.Date(2099, 12, 31, 23, 59, 59, 0, time.UTC) + // User user type User struct { ID string `xorm:"not null pk autoincr BIGINT(20) id"` CreatedAt time.Time `xorm:"created TIMESTAMP created_at"` UpdatedAt time.Time `xorm:"updated TIMESTAMP updated_at"` SuspendedAt time.Time `xorm:"TIMESTAMP suspended_at"` + SuspendedUntil time.Time `xorm:"DATETIME suspended_until"` DeletedAt time.Time `xorm:"TIMESTAMP deleted_at"` LastLoginDate time.Time `xorm:"TIMESTAMP last_login_date"` Username string `xorm:"not null default '' VARCHAR(50) UNIQUE username"` diff --git a/internal/migrations/init.go b/internal/migrations/init.go index 610e24d8b..fde27cdde 100644 --- a/internal/migrations/init.go +++ b/internal/migrations/init.go @@ -180,8 +180,10 @@ func (m *Mentor) initSiteInfoInterface() { } interfaceData := map[string]string{ - "language": m.userData.Language, - "time_zone": localTimezone, + "language": m.userData.Language, + "time_zone": localTimezone, + "default_avatar": "gravatar", + "gravatar_base_url": "https://www.gravatar.com/avatar/", } interfaceDataBytes, _ := json.Marshal(interfaceData) _, m.err = m.engine.Context(m.ctx).Insert(&entity.SiteInfo{ diff --git a/internal/migrations/migrations.go b/internal/migrations/migrations.go index 212bf14bb..4b5335b06 100644 --- a/internal/migrations/migrations.go +++ b/internal/migrations/migrations.go @@ -102,6 +102,7 @@ var migrations = []Migration{ NewMigration("v1.4.2", "add the number of question links", addQuestionLinkedCount, true), NewMigration("v1.4.5", "add file record", addFileRecord, true), NewMigration("v1.5.1", "add plugin kv storage", addPluginKVStorage, true), + NewMigration("v1.6.0", "move user config to interface", moveUserConfigToInterface, true), } func GetMigrations() []Migration { diff --git a/internal/migrations/v27.go b/internal/migrations/v27.go new file mode 100644 index 000000000..a0c4ef8a1 --- /dev/null +++ b/internal/migrations/v27.go @@ -0,0 +1,83 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package migrations + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/apache/answer/internal/base/constant" + "github.com/apache/answer/internal/entity" + "github.com/apache/answer/internal/schema" + "xorm.io/xorm" +) + +func addSuspendedUntilToUser(ctx context.Context, x *xorm.Engine) error { + type User struct { + SuspendedUntil *time.Time `xorm:"DATETIME suspended_until"` + } + return x.Context(ctx).Sync(new(User)) +} + +func moveUserConfigToInterface(ctx context.Context, x *xorm.Engine) error { + if err := addSuspendedUntilToUser(ctx, x); err != nil { + return fmt.Errorf("add suspended_until to user failed: %w", err) + } + + // Get old interface config + interfaceSiteInfo := &entity.SiteInfo{Type: constant.SiteTypeInterface} + exist, err := x.Context(ctx).Get(interfaceSiteInfo) + if err != nil { + return fmt.Errorf("get config failed: %w", err) + } + if !exist { + return fmt.Errorf("interface site info not found") + } + + interfaceConfig := &schema.SiteInterfaceReq{} + _ = json.Unmarshal([]byte(interfaceSiteInfo.Content), interfaceConfig) + + // Get old user config + usersConfig := &entity.SiteInfo{Type: constant.SiteTypeUsers} + exist, err = x.Context(ctx).Get(usersConfig) + if err != nil { + return fmt.Errorf("get config failed: %w", err) + } + if !exist { + return fmt.Errorf("users site info not found") + } + + siteUsers := &schema.SiteUsersReq{} + _ = json.Unmarshal([]byte(usersConfig.Content), siteUsers) + + interfaceConfig.DefaultAvatar = siteUsers.DefaultAvatar + interfaceConfig.GravatarBaseURL = siteUsers.GravatarBaseURL + + interfaceConfigByte, _ := json.Marshal(interfaceConfig) + interfaceSiteInfo.Content = string(interfaceConfigByte) + + _, err = x.Context(ctx).ID(interfaceSiteInfo.ID).Update(interfaceSiteInfo) + if err != nil { + return fmt.Errorf("insert site info failed: %w", err) + } + return nil +} diff --git a/internal/repo/user/user_backyard_repo.go b/internal/repo/user/user_backyard_repo.go index f5987df9e..c93845e97 100644 --- a/internal/repo/user/user_backyard_repo.go +++ b/internal/repo/user/user_backyard_repo.go @@ -52,16 +52,20 @@ func NewUserAdminRepo(data *data.Data, authRepo auth.AuthRepo) user_admin.UserAd // UpdateUserStatus update user status func (ur *userAdminRepo) UpdateUserStatus(ctx context.Context, userID string, userStatus, mailStatus int, - email string, + email string, suspendedUntil time.Time, ) (err error) { cond := &entity.User{Status: userStatus, MailStatus: mailStatus, EMail: email} switch userStatus { case entity.UserStatusSuspended: cond.SuspendedAt = time.Now() + cond.SuspendedUntil = suspendedUntil case entity.UserStatusDeleted: cond.DeletedAt = time.Now() + case entity.UserStatusAvailable: + // When restoring user status, clear suspended until time to zero + cond.SuspendedUntil = time.Time{} } - _, err = ur.data.DB.Context(ctx).ID(userID).Update(cond) + _, err = ur.data.DB.Context(ctx).ID(userID).MustCols("status", "mail_status", "e_mail", "suspended_at", "suspended_until", "deleted_at").Update(cond) if err != nil { return errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() } @@ -184,3 +188,20 @@ func (ur *userAdminRepo) DeletePermanentlyUsers(ctx context.Context) (err error) } return } + +// GetExpiredSuspendedUsers gets all suspended users whose suspension has expired +func (ur *userAdminRepo) GetExpiredSuspendedUsers(ctx context.Context) (users []*entity.User, err error) { + users = make([]*entity.User, 0) + now := time.Now() + + err = ur.data.DB.Context(ctx). + Where("status = ?", entity.UserStatusSuspended). + Where("suspended_until < ?", now). + Find(&users) + + if err != nil { + return nil, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + + return users, nil +} diff --git a/internal/router/answer_api_router.go b/internal/router/answer_api_router.go index e01bd9a95..1191492b9 100644 --- a/internal/router/answer_api_router.go +++ b/internal/router/answer_api_router.go @@ -157,7 +157,7 @@ func (a *AnswerAPIRouter) RegisterUnAuthAnswerAPIRouter(r *gin.RouterGroup) { r.GET("/user/staff", a.userController.UserStaff) // answer - r.GET("/answer/info", a.answerController.Get) + r.GET("/answer/info", a.answerController.GetAnswerInfo) r.GET("/answer/page", a.answerController.AnswerList) r.GET("/personal/answer/page", a.questionController.PersonalAnswerPage) @@ -265,9 +265,9 @@ func (a *AnswerAPIRouter) RegisterAnswerAPIRouter(r *gin.RouterGroup) { r.POST("/question/recover", a.questionController.QuestionRecover) // answer - r.POST("/answer", a.answerController.Add) - r.PUT("/answer", a.answerController.Update) - r.POST("/answer/acceptance", a.answerController.Accepted) + r.POST("/answer", a.answerController.AddAnswer) + r.PUT("/answer", a.answerController.UpdateAnswer) + r.POST("/answer/acceptance", a.answerController.AcceptAnswer) r.DELETE("/answer", a.answerController.RemoveAnswer) r.POST("/answer/recover", a.answerController.RecoverAnswer) diff --git a/internal/schema/answer_schema.go b/internal/schema/answer_schema.go index 3a404ed1a..015e26ac5 100644 --- a/internal/schema/answer_schema.go +++ b/internal/schema/answer_schema.go @@ -71,6 +71,11 @@ func (req *AnswerAddReq) Check() (errFields []*validator.FormErrorField, err err return nil, nil } +type GetAnswerInfoResp struct { + Info *AnswerInfo `json:"info"` + Question *QuestionInfoResp `json:"question"` +} + type AnswerUpdateReq struct { ID string `json:"id"` QuestionID string `json:"question_id"` diff --git a/internal/schema/backyard_user_schema.go b/internal/schema/backyard_user_schema.go index 923a20ed9..6ec848721 100644 --- a/internal/schema/backyard_user_schema.go +++ b/internal/schema/backyard_user_schema.go @@ -21,19 +21,23 @@ package schema import ( "context" + "strings" + "time" + "github.com/apache/answer/internal/base/constant" "github.com/apache/answer/internal/base/handler" "github.com/apache/answer/internal/base/reason" "github.com/apache/answer/internal/base/translator" "github.com/apache/answer/internal/base/validator" + "github.com/apache/answer/internal/entity" "github.com/segmentfault/pacman/errors" - "strings" ) // UpdateUserStatusReq update user request type UpdateUserStatusReq struct { UserID string `validate:"required" json:"user_id"` Status string `validate:"required,oneof=normal suspended deleted inactive" json:"status" enums:"normal,suspended,deleted,inactive"` + SuspendDuration string `validate:"omitempty,oneof=24h 48h 72h 7d 14d 1m 2m 3m 6m 1y forever" json:"suspend_duration"` RemoveAllContent bool `validate:"omitempty" json:"remove_all_content"` LoginUserID string `json:"-"` } @@ -43,6 +47,39 @@ func (r *UpdateUserStatusReq) IsSuspended() bool { return r.Status == constant.U func (r *UpdateUserStatusReq) IsDeleted() bool { return r.Status == constant.UserDeleted } func (r *UpdateUserStatusReq) IsInactive() bool { return r.Status == constant.UserInactive } +// GetSuspendedUntil calculates the suspended until time based on duration +func (r *UpdateUserStatusReq) GetSuspendedUntil() time.Time { + if !r.IsSuspended() || r.SuspendDuration == "" || r.SuspendDuration == "forever" { + return entity.PermanentSuspensionTime // permanent suspension + } + + now := time.Now() + switch r.SuspendDuration { + case "24h": + return now.Add(24 * time.Hour) + case "48h": + return now.Add(48 * time.Hour) + case "72h": + return now.Add(72 * time.Hour) + case "7d": + return now.Add(7 * 24 * time.Hour) + case "14d": + return now.Add(14 * 24 * time.Hour) + case "1m": + return now.AddDate(0, 1, 0) + case "2m": + return now.AddDate(0, 2, 0) + case "3m": + return now.AddDate(0, 3, 0) + case "6m": + return now.AddDate(0, 6, 0) + case "1y": + return now.AddDate(1, 0, 0) + default: + return entity.PermanentSuspensionTime // fallback to permanent + } +} + // GetUserPageReq get user list page request type GetUserPageReq struct { // page @@ -71,6 +108,8 @@ type GetUserPageResp struct { DeletedAt int64 `json:"deleted_at"` // suspended time SuspendedAt int64 `json:"suspended_at"` + // suspended until time + SuspendedUntil int64 `json:"suspended_until"` // username Username string `json:"username"` // email @@ -96,6 +135,8 @@ type GetUserInfoReq struct { // GetUserInfoResp get user response type GetUserInfoResp struct { + // suspended until + SuspendedUntil time.Time `json:"suspended_until"` } // UpdateUserRoleReq update user role request diff --git a/internal/schema/siteinfo_schema.go b/internal/schema/siteinfo_schema.go index 7e0c408fd..19f01ffdb 100644 --- a/internal/schema/siteinfo_schema.go +++ b/internal/schema/siteinfo_schema.go @@ -59,8 +59,10 @@ func (r *SiteGeneralReq) FormatSiteUrl() { // SiteInterfaceReq site interface request type SiteInterfaceReq struct { - Language string `validate:"required,gt=1,lte=128" form:"language" json:"language"` - TimeZone string `validate:"required,gt=1,lte=128" form:"time_zone" json:"time_zone"` + Language string `validate:"required,gt=1,lte=128" form:"language" json:"language"` + TimeZone string `validate:"required,gt=1,lte=128" form:"time_zone" json:"time_zone"` + DefaultAvatar string `validate:"required,oneof=system gravatar" json:"default_avatar"` + GravatarBaseURL string `validate:"omitempty" json:"gravatar_base_url"` } // SiteBrandingReq site branding request diff --git a/internal/schema/user_schema.go b/internal/schema/user_schema.go index 7c0af1dc4..7ba8817a8 100644 --- a/internal/schema/user_schema.go +++ b/internal/schema/user_schema.go @@ -20,10 +20,13 @@ package schema import ( + "context" "encoding/json" + "github.com/apache/answer/internal/base/handler" "github.com/apache/answer/internal/base/reason" "github.com/apache/answer/internal/base/translator" + "github.com/apache/answer/pkg/day" "github.com/segmentfault/pacman/errors" "github.com/apache/answer/internal/base/constant" @@ -96,6 +99,8 @@ type UserLoginResp struct { HavePassword bool `json:"have_password"` // visit token VisitToken string `json:"visit_token"` + // suspended until timestamp + SuspendedUntil int64 `json:"suspended_until"` } func (r *UserLoginResp) ConvertFromUserEntity(userInfo *entity.User) { @@ -104,6 +109,9 @@ func (r *UserLoginResp) ConvertFromUserEntity(userInfo *entity.User) { r.LastLoginDate = userInfo.LastLoginDate.Unix() r.Status = constant.ConvertUserStatus(userInfo.Status, userInfo.MailStatus) r.HavePassword = len(userInfo.Pass) > 0 + if !userInfo.SuspendedUntil.IsZero() { + r.SuspendedUntil = userInfo.SuspendedUntil.Unix() + } } type GetCurrentLoginUserInfoResp struct { @@ -119,6 +127,9 @@ func (r *GetCurrentLoginUserInfoResp) ConvertFromUserEntity(userInfo *entity.Use if len(r.ColorScheme) == 0 { r.ColorScheme = constant.ColorSchemeDefault } + if !userInfo.SuspendedUntil.IsZero() { + r.SuspendedUntil = userInfo.SuspendedUntil.Unix() + } } // GetOtherUserInfoByUsernameResp get user response @@ -156,6 +167,8 @@ type GetOtherUserInfoByUsernameResp struct { Location string `json:"location"` Status string `json:"status"` StatusMsg string `json:"status_msg,omitempty"` + // suspended until timestamp + SuspendedUntil int64 `json:"suspended_until"` } func (r *GetOtherUserInfoByUsernameResp) ConvertFromUserEntity(userInfo *entity.User) { @@ -163,29 +176,37 @@ func (r *GetOtherUserInfoByUsernameResp) ConvertFromUserEntity(userInfo *entity. r.CreatedAt = userInfo.CreatedAt.Unix() r.LastLoginDate = userInfo.LastLoginDate.Unix() r.Status = constant.ConvertUserStatus(userInfo.Status, userInfo.MailStatus) - if userInfo.MailStatus == entity.EmailStatusToBeVerified { - statusMsgShow, ok := UserStatusShowMsg[11] - if ok { - r.StatusMsg = statusMsgShow - } - } else { - statusMsgShow, ok := UserStatusShowMsg[userInfo.Status] - if ok { - r.StatusMsg = statusMsgShow - } + if !userInfo.SuspendedUntil.IsZero() { + r.SuspendedUntil = userInfo.SuspendedUntil.Unix() } + r.StatusMsg = "" } -const ( - NoticeStatusOn = 1 - NoticeStatusOff = 2 -) +func (r *GetOtherUserInfoByUsernameResp) ConvertFromUserEntityWithLang(ctx context.Context, userInfo *entity.User) { + _ = copier.Copy(r, userInfo) + r.CreatedAt = userInfo.CreatedAt.Unix() + r.LastLoginDate = userInfo.LastLoginDate.Unix() + r.Status = constant.ConvertUserStatus(userInfo.Status, userInfo.MailStatus) -var UserStatusShowMsg = map[int]string{ - 1: "", - 9: "This user was suspended forever. This user doesn't meet a community guideline.", - 10: "This user was deleted.", - 11: "This user is inactive.", + lang := handler.GetLangByCtx(ctx) + if userInfo.MailStatus == entity.EmailStatusToBeVerified { + r.StatusMsg = translator.Tr(lang, reason.UserStatusInactive) + } + switch userInfo.Status { + case entity.UserStatusSuspended: + if userInfo.SuspendedUntil.IsZero() || userInfo.SuspendedUntil.Year() >= 2099 { + r.StatusMsg = translator.Tr(lang, reason.UserStatusSuspendedForever) + } else { + r.SuspendedUntil = userInfo.SuspendedUntil.Unix() + trans := translator.GlobalTrans.Tr(lang, "ui.dates.long_date_with_time") + suspendedUntilFormatted := day.Format(userInfo.SuspendedUntil.Unix(), trans, "UTC") + r.StatusMsg = translator.TrWithData(lang, reason.UserStatusSuspendedUntil, map[string]interface{}{ + "SuspendedUntil": suspendedUntilFormatted, + }) + } + case entity.UserStatusDeleted: + r.StatusMsg = translator.Tr(lang, reason.UserStatusDeleted) + } } // UserEmailLoginReq user email login request @@ -348,15 +369,16 @@ type ActionRecordResp struct { } type UserBasicInfo struct { - ID string `json:"id"` - Username string `json:"username"` - Rank int `json:"rank"` - DisplayName string `json:"display_name"` - Avatar string `json:"avatar"` - Website string `json:"website"` - Location string `json:"location"` - Language string `json:"language"` - Status string `json:"status"` + ID string `json:"id"` + Username string `json:"username"` + Rank int `json:"rank"` + DisplayName string `json:"display_name"` + Avatar string `json:"avatar"` + Website string `json:"website"` + Location string `json:"location"` + Language string `json:"language"` + Status string `json:"status"` + SuspendedUntil int64 `json:"suspended_until"` } type GetOtherUserInfoByUsernameReq struct { diff --git a/internal/service/content/user_service.go b/internal/service/content/user_service.go index ece3a86de..81f7ac824 100644 --- a/internal/service/content/user_service.go +++ b/internal/service/content/user_service.go @@ -141,7 +141,7 @@ func (us *UserService) GetOtherUserInfoByUsername(ctx context.Context, req *sche return nil, errors.NotFound(reason.UserNotFound) } resp = &schema.GetOtherUserInfoByUsernameResp{} - resp.ConvertFromUserEntity(userInfo) + resp.ConvertFromUserEntityWithLang(ctx, userInfo) resp.Avatar = us.siteInfoService.FormatAvatar(ctx, userInfo.Avatar, userInfo.EMail, userInfo.Status).GetURL() // Only the user himself and the administrator can see the hidden questions @@ -314,12 +314,8 @@ func (us *UserService) UserModifyPassword(ctx context.Context, req *schema.UserM // UpdateInfo update user info func (us *UserService) UpdateInfo(ctx context.Context, req *schema.UpdateInfoRequest) ( errFields []*validator.FormErrorField, err error) { - siteUsers, err := us.siteInfoService.GetSiteUsers(ctx) - if err != nil { - return nil, err - } - if siteUsers.AllowUpdateUsername && len(req.Username) > 0 { + if len(req.Username) > 0 { if checker.IsInvalidUsername(req.Username) { return append(errFields, &validator.FormErrorField{ ErrorField: "username", @@ -359,7 +355,7 @@ func (us *UserService) UpdateInfo(ctx context.Context, req *schema.UpdateInfoReq return nil, errors.BadRequest(reason.UserNotFound) } - cond := us.formatUserInfoForUpdateInfo(oldUserInfo, req, siteUsers) + cond := us.formatUserInfoForUpdateInfo(oldUserInfo, req) us.cleanUpRemovedAvatar(ctx, oldUserInfo.Avatar, cond.Avatar) @@ -407,7 +403,7 @@ func (us *UserService) cleanUpRemovedAvatar( } func (us *UserService) formatUserInfoForUpdateInfo( - oldUserInfo *entity.User, req *schema.UpdateInfoRequest, siteUsersConf *schema.SiteUsersResp) *entity.User { + oldUserInfo *entity.User, req *schema.UpdateInfoRequest) *entity.User { avatar, _ := json.Marshal(req.Avatar) userInfo := &entity.User{} @@ -420,25 +416,19 @@ func (us *UserService) formatUserInfoForUpdateInfo( userInfo.Location = oldUserInfo.Location userInfo.ID = req.UserID - if len(req.DisplayName) > 0 && siteUsersConf.AllowUpdateDisplayName { + if len(req.DisplayName) > 0 { userInfo.DisplayName = req.DisplayName } - if len(req.Username) > 0 && siteUsersConf.AllowUpdateUsername { + if len(req.Username) > 0 { userInfo.Username = req.Username } - if len(avatar) > 0 && siteUsersConf.AllowUpdateAvatar { + if len(avatar) > 0 { userInfo.Avatar = string(avatar) } - if siteUsersConf.AllowUpdateBio { - userInfo.Bio = req.Bio - userInfo.BioHTML = req.BioHTML - } - if siteUsersConf.AllowUpdateWebsite { - userInfo.Website = req.Website - } - if siteUsersConf.AllowUpdateLocation { - userInfo.Location = req.Location - } + userInfo.Bio = req.Bio + userInfo.BioHTML = req.BioHTML + userInfo.Website = req.Website + userInfo.Location = req.Location return userInfo } diff --git a/internal/service/mock/siteinfo_repo_mock.go b/internal/service/mock/siteinfo_repo_mock.go index 1b8412780..a98ceb68c 100644 --- a/internal/service/mock/siteinfo_repo_mock.go +++ b/internal/service/mock/siteinfo_repo_mock.go @@ -77,6 +77,21 @@ func (mr *MockSiteInfoRepoMockRecorder) GetByType(ctx, siteType any) *gomock.Cal return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetByType", reflect.TypeOf((*MockSiteInfoRepo)(nil).GetByType), ctx, siteType) } +// IsBrandingFileUsed mocks base method. +func (m *MockSiteInfoRepo) IsBrandingFileUsed(ctx context.Context, filePath string) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IsBrandingFileUsed", ctx, filePath) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// IsBrandingFileUsed indicates an expected call of IsBrandingFileUsed. +func (mr *MockSiteInfoRepoMockRecorder) IsBrandingFileUsed(ctx, filePath any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsBrandingFileUsed", reflect.TypeOf((*MockSiteInfoRepo)(nil).IsBrandingFileUsed), ctx, filePath) +} + // SaveByType mocks base method. func (m *MockSiteInfoRepo) SaveByType(ctx context.Context, siteType string, data *entity.SiteInfo) error { m.ctrl.T.Helper() @@ -306,3 +321,17 @@ func (mr *MockSiteInfoCommonServiceMockRecorder) GetSiteWrite(ctx any) *gomock.C mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSiteWrite", reflect.TypeOf((*MockSiteInfoCommonService)(nil).GetSiteWrite), ctx) } + +// IsBrandingFileUsed mocks base method. +func (m *MockSiteInfoCommonService) IsBrandingFileUsed(ctx context.Context, filePath string) bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IsBrandingFileUsed", ctx, filePath) + ret0, _ := ret[0].(bool) + return ret0 +} + +// IsBrandingFileUsed indicates an expected call of IsBrandingFileUsed. +func (mr *MockSiteInfoCommonServiceMockRecorder) IsBrandingFileUsed(ctx, filePath any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsBrandingFileUsed", reflect.TypeOf((*MockSiteInfoCommonService)(nil).IsBrandingFileUsed), ctx, filePath) +} diff --git a/internal/service/notification_common/notification.go b/internal/service/notification_common/notification.go index 5edb6652b..2bceb6298 100644 --- a/internal/service/notification_common/notification.go +++ b/internal/service/notification_common/notification.go @@ -132,6 +132,7 @@ func (ns *NotificationCommon) AddNotification(ctx context.Context, msg *schema.N objInfo, err = ns.objectInfoService.GetInfo(ctx, req.ObjectInfo.ObjectID) if err != nil { log.Error(err) + return err } else { req.ObjectInfo.Title = objInfo.Title questionID = objInfo.QuestionID @@ -354,6 +355,9 @@ func (ns *NotificationCommon) SendNotificationToAllFollower(ctx context.Context, func (ns *NotificationCommon) syncNotificationToPlugin(ctx context.Context, objInfo *schema.SimpleObjectInfo, msg *schema.NotificationMsg) { + if objInfo == nil { + return + } siteInfo, err := ns.siteInfoService.GetSiteGeneral(ctx) if err != nil { log.Errorf("get site general info failed: %v", err) diff --git a/internal/service/siteinfo_common/siteinfo_service.go b/internal/service/siteinfo_common/siteinfo_service.go index 0c896c2b0..ef4869cc4 100644 --- a/internal/service/siteinfo_common/siteinfo_service.go +++ b/internal/service/siteinfo_common/siteinfo_service.go @@ -123,7 +123,7 @@ func (s *siteInfoCommonService) FormatListAvatar(ctx context.Context, userList [ func (s *siteInfoCommonService) getAvatarDefaultConfig(ctx context.Context) (string, string) { gravatarBaseURL, defaultAvatar := constant.DefaultGravatarBaseURL, constant.DefaultAvatar - usersConfig, err := s.GetSiteUsers(ctx) + usersConfig, err := s.GetSiteInterface(ctx) if err != nil { log.Error(err) } diff --git a/internal/service/user_admin/user_backyard.go b/internal/service/user_admin/user_backyard.go index f7dc0eae6..0f6ec81ed 100644 --- a/internal/service/user_admin/user_backyard.go +++ b/internal/service/user_admin/user_backyard.go @@ -59,7 +59,7 @@ import ( // UserAdminRepo user repository type UserAdminRepo interface { - UpdateUserStatus(ctx context.Context, userID string, userStatus, mailStatus int, email string) (err error) + UpdateUserStatus(ctx context.Context, userID string, userStatus, mailStatus int, email string, suspendedUntil time.Time) (err error) GetUserInfo(ctx context.Context, userID string) (user *entity.User, exist bool, err error) GetUserInfoByEmail(ctx context.Context, email string) (user *entity.User, exist bool, err error) GetUserPage(ctx context.Context, page, pageSize int, user *entity.User, @@ -68,6 +68,7 @@ type UserAdminRepo interface { AddUsers(ctx context.Context, users []*entity.User) (err error) UpdateUserPassword(ctx context.Context, userID string, password string) (err error) DeletePermanentlyUsers(ctx context.Context) (err error) + GetExpiredSuspendedUsers(ctx context.Context) (users []*entity.User, err error) } // UserAdminService user service @@ -156,7 +157,8 @@ func (us *UserAdminService) UpdateUserStatus(ctx context.Context, req *schema.Up userInfo.MailStatus = entity.EmailStatusAvailable } - err = us.userRepo.UpdateUserStatus(ctx, userInfo.ID, userInfo.Status, userInfo.MailStatus, userInfo.EMail) + suspendedUntil := req.GetSuspendedUntil() + err = us.userRepo.UpdateUserStatus(ctx, userInfo.ID, userInfo.Status, userInfo.MailStatus, userInfo.EMail, suspendedUntil) if err != nil { return err } @@ -525,6 +527,9 @@ func (us *UserAdminService) GetUserPage(ctx context.Context, req *schema.GetUser } else if u.Status == entity.UserStatusSuspended { t.Status = constant.UserSuspended t.SuspendedAt = u.SuspendedAt.Unix() + if !u.SuspendedUntil.IsZero() { + t.SuspendedUntil = u.SuspendedUntil.Unix() + } } else if u.MailStatus == entity.EmailStatusToBeVerified { t.Status = constant.UserInactive } else { @@ -625,3 +630,35 @@ func (us *UserAdminService) DeletePermanently(ctx context.Context, req *schema.D return errors.BadRequest(reason.RequestFormatError) } + +// CheckAndUnsuspendExpiredUsers checks for users whose suspension has expired and restores them to normal status +func (us *UserAdminService) CheckAndUnsuspendExpiredUsers(ctx context.Context) error { + // Find all suspended users whose suspension time has expired + expiredUsers, err := us.userRepo.GetExpiredSuspendedUsers(ctx) + if err != nil { + return err + } + + now := time.Now() + for _, user := range expiredUsers { + // Check if suspension has expired (not permanent and time has passed) + if user.Status == entity.UserStatusSuspended && + !user.SuspendedUntil.IsZero() && + user.SuspendedUntil.Before(now) { + + log.Infof("Unsuspending user %s (ID: %s) - suspension expired at %v", + user.Username, user.ID, user.SuspendedUntil) + + // Update user status to normal + err = us.userRepo.UpdateUserStatus(ctx, user.ID, entity.UserStatusAvailable, + entity.EmailStatusAvailable, user.EMail, time.Time{}) + if err != nil { + log.Errorf("Failed to unsuspend user %s (ID: %s): %v", + user.Username, user.ID, err) + continue + } + } + } + + return nil +} diff --git a/internal/service/user_common/user.go b/internal/service/user_common/user.go index 3df99261d..124972a16 100644 --- a/internal/service/user_common/user.go +++ b/internal/service/user_common/user.go @@ -180,6 +180,9 @@ func (us *UserCommon) FormatUserBasicInfo(ctx context.Context, userInfo *entity. userBasicInfo.Location = userInfo.Location userBasicInfo.Language = userInfo.Language userBasicInfo.Status = constant.ConvertUserStatus(userInfo.Status, userInfo.MailStatus) + if !userInfo.SuspendedUntil.IsZero() { + userBasicInfo.SuspendedUntil = userInfo.SuspendedUntil.Unix() + } if userBasicInfo.Status == constant.UserDeleted { userBasicInfo.Avatar = "" userBasicInfo.DisplayName = "user" + converter.DeleteUserDisplay(userInfo.ID) diff --git a/ui/src/common/constants.ts b/ui/src/common/constants.ts index 163d4989a..18f251145 100644 --- a/ui/src/common/constants.ts +++ b/ui/src/common/constants.ts @@ -126,7 +126,6 @@ export const ADMIN_NAV_MENUS = [ { name: 'write' }, { name: 'seo' }, { name: 'login' }, - { name: 'users', path: 'settings-users' }, { name: 'privileges' }, ], }, @@ -660,3 +659,56 @@ export const SYSTEM_AVATAR_OPTIONS = [ export const TAG_SLUG_NAME_MAX_LENGTH = 35; export const DEFAULT_THEME_COLOR = '#0033ff'; + +export const SUSPENSE_USER_TIME = [ + { + label: 'hours', + time: '24', + value: '24h', + }, + { + label: 'hours', + time: '48', + value: '48h', + }, + { + label: 'hours', + time: '72', + value: '72h', + }, + { + label: 'days', + time: '7', + value: '7d', + }, + { + label: 'days', + time: '14', + value: '14d', + }, + { + label: 'months', + time: '1', + value: '1m', + }, + { + label: 'months', + time: '2', + value: '2m', + }, + { + label: 'months', + time: '3', + value: '3m', + }, + { + label: 'months', + time: '6', + value: '6m', + }, + { + label: 'year', + time: '1', + value: '1y', + }, +]; diff --git a/ui/src/common/interface.ts b/ui/src/common/interface.ts index 3935c4391..114b0e4ce 100644 --- a/ui/src/common/interface.ts +++ b/ui/src/common/interface.ts @@ -382,6 +382,8 @@ export interface HelmetUpdate extends Omit { export interface AdminSettingsInterface { language: string; time_zone?: string; + default_avatar: string; + gravatar_base_url: string; } export interface AdminSettingsSmtp { @@ -403,8 +405,6 @@ export interface AdminSettingsUsers { allow_update_location: boolean; allow_update_username: boolean; allow_update_website: boolean; - default_avatar: string; - gravatar_base_url: string; } export interface SiteSettings { diff --git a/ui/src/components/Header/components/NavItems/index.tsx b/ui/src/components/Header/components/NavItems/index.tsx index 3dbed4e4b..ce373867e 100644 --- a/ui/src/components/Header/components/NavItems/index.tsx +++ b/ui/src/components/Header/components/NavItems/index.tsx @@ -24,7 +24,7 @@ import { NavLink, useNavigate } from 'react-router-dom'; import type * as Type from '@/common/interface'; import { Avatar, Icon } from '@/components'; -import { floppyNavigation } from '@/utils'; +import { floppyNavigation, isDarkTheme } from '@/utils'; import { userCenterStore } from '@/stores'; import { REACT_BASE_PATH } from '@/router/alias'; @@ -80,7 +80,7 @@ const Index: FC = ({ redDot, userInfo, logOut }) => { - + { if (theme_config?.[theme]?.navbar_style) { // const color = theme_config[theme].navbar_style.startsWith('#') themeMode = isLight(theme_config[theme].navbar_style) ? 'light' : 'dark'; - console.log('isLightTheme', themeMode); navbarStyle = `theme-${themeMode}`; } diff --git a/ui/src/components/PluginRender/index.tsx b/ui/src/components/PluginRender/index.tsx index 002d5a585..057f49521 100644 --- a/ui/src/components/PluginRender/index.tsx +++ b/ui/src/components/PluginRender/index.tsx @@ -20,7 +20,6 @@ import React, { FC, ReactNode } from 'react'; import PluginKit, { Plugin, PluginType } from '@/utils/pluginKit'; -import { writeSettingStore } from '@/stores'; /** * Note:Please set at least either of the `slug_name` and `type` attributes, otherwise no plugins will be rendered. * @@ -48,9 +47,6 @@ const Index: FC = ({ }) => { const pluginSlice: Plugin[] = []; const plugins = PluginKit.getPlugins().filter((plugin) => plugin.activated); - const { authorized_attachment_extensions = [] } = writeSettingStore( - (state) => state.write, - ); plugins.forEach((plugin) => { if (type && slug_name) { @@ -80,10 +76,9 @@ const Index: FC = ({ } if (type === 'editor') { - const showAttachFile = authorized_attachment_extensions?.length > 0; - const pendIndex = showAttachFile ? 16 : 15; + // index 16 is the position of the toolbar in the editor for plugins const nodes = React.Children.map(children, (child, index) => { - if (index === pendIndex) { + if (index === 16) { return ( <> {child} diff --git a/ui/src/components/QuestionList/index.tsx b/ui/src/components/QuestionList/index.tsx index a0f8b129c..3d770dbe5 100644 --- a/ui/src/components/QuestionList/index.tsx +++ b/ui/src/components/QuestionList/index.tsx @@ -103,7 +103,9 @@ const QuestionList: FC = ({
{source === 'questions' ? t('all_questions') - : t('x_questions', { count })} + : source === 'linked' + ? t('x_posts', { count }) + : t('x_questions', { count })}
= ({ curOrder === 'active' ? li.operated_at : li.created_at } className="text-secondary ms-1 flex-shrink-0" - preFix={ - curOrder === 'active' - ? t(li.operation_type) - : t('asked') - } />
diff --git a/ui/src/index.scss b/ui/src/index.scss index 97aa7b1fd..fb1e06a79 100644 --- a/ui/src/index.scss +++ b/ui/src/index.scss @@ -208,6 +208,40 @@ img[src=''] { .fmt { width: 100%; + hr { + height: 3px; + border: none; + position: relative; + overflow: visible; + text-align: center; + opacity: 1; + flex: none; + margin: 1.5rem 0; + &::before { + content: ''; + position: absolute; + left: 50%; + top: 50%; + transform: translate(calc(-50% - 16px), -50%); + width: 3px; + height: 3px; + background-color: var(--bs-border-color); + border-radius: 50%; + } + + &::after { + content: ''; + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + width: 3px; + height: 3px; + background-color: var(--bs-border-color); + border-radius: 50%; + box-shadow: 16px 0 0 var(--bs-border-color); + } + } h1 { @extend .fs-3; margin-top: 2rem; diff --git a/ui/src/pages/Admin/Interface/index.tsx b/ui/src/pages/Admin/Interface/index.tsx index 9c6220489..865029e9c 100644 --- a/ui/src/pages/Admin/Interface/index.tsx +++ b/ui/src/pages/Admin/Interface/index.tsx @@ -28,7 +28,7 @@ import { } from '@/common/interface'; import { interfaceStore, loggedUserInfoStore } from '@/stores'; import { JSONSchema, SchemaForm, UISchema } from '@/components'; -import { DEFAULT_TIMEZONE } from '@/common/constants'; +import { DEFAULT_TIMEZONE, SYSTEM_AVATAR_OPTIONS } from '@/common/constants'; import { updateInterfaceSetting, useInterfaceSetting, @@ -59,7 +59,8 @@ const Interface: FC = () => { description: t('language.text'), enum: langs?.map((lang) => lang.value), enumNames: langs?.map((lang) => lang.label), - default: setting?.language || storeInterface.language, + default: + setting?.language || storeInterface.language || langs?.[0]?.value, }, time_zone: { type: 'string', @@ -67,12 +68,26 @@ const Interface: FC = () => { description: t('time_zone.text'), default: setting?.time_zone || DEFAULT_TIMEZONE, }, + default_avatar: { + type: 'string', + title: t('avatar.label'), + description: t('avatar.text'), + enum: SYSTEM_AVATAR_OPTIONS?.map((v) => v.value), + enumNames: SYSTEM_AVATAR_OPTIONS?.map((v) => v.label), + default: setting?.default_avatar || 'system', + }, + gravatar_base_url: { + type: 'string', + title: t('gravatar_base_url.label'), + description: t('gravatar_base_url.text'), + default: setting?.gravatar_base_url || '', + }, }, }; const [formData, setFormData] = useState({ language: { - value: setting?.language || storeInterface.language, + value: setting?.language || storeInterface.language || langs?.[0]?.value, isInvalid: false, errorMsg: '', }, @@ -81,6 +96,16 @@ const Interface: FC = () => { isInvalid: false, errorMsg: '', }, + default_avatar: { + value: setting?.default_avatar || 'system', + isInvalid: false, + errorMsg: '', + }, + gravatar_base_url: { + value: setting?.gravatar_base_url || '', + isInvalid: false, + errorMsg: '', + }, }); const uiSchema: UISchema = { @@ -90,6 +115,15 @@ const Interface: FC = () => { time_zone: { 'ui:widget': 'timezone', }, + default_avatar: { + 'ui:widget': 'select', + }, + gravatar_base_url: { + 'ui:widget': 'input', + 'ui:options': { + placeholder: 'https://www.gravatar.com/avatar/', + }, + }, }; const getLangs = async () => { const res: LangsType[] = await loadLanguageOptions(true); @@ -122,6 +156,8 @@ const Interface: FC = () => { const reqParams: AdminSettingsInterface = { language: formData.language.value, time_zone: formData.time_zone.value, + default_avatar: formData.default_avatar.value, + gravatar_base_url: formData.gravatar_base_url.value, }; updateInterfaceSetting(reqParams) @@ -151,7 +187,14 @@ const Interface: FC = () => { if (setting) { const formMeta = {}; Object.keys(setting).forEach((k) => { - formMeta[k] = { ...formData[k], value: setting[k] }; + let v = setting[k]; + if (k === 'default_avatar' && !v) { + v = 'system'; + } + if (k === 'gravatar_base_url' && !v) { + v = ''; + } + formMeta[k] = { ...formData[k], value: v }; }); setFormData({ ...formData, ...formMeta }); } diff --git a/ui/src/pages/Admin/SettingsUsers/index.tsx b/ui/src/pages/Admin/SettingsUsers/index.tsx deleted file mode 100644 index e09809806..000000000 --- a/ui/src/pages/Admin/SettingsUsers/index.tsx +++ /dev/null @@ -1,218 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { FC, FormEvent, useEffect, useState } from 'react'; -import { useTranslation } from 'react-i18next'; - -import { useToast } from '@/hooks'; -import { FormDataType } from '@/common/interface'; -import { JSONSchema, SchemaForm, UISchema, initFormData } from '@/components'; -import { SYSTEM_AVATAR_OPTIONS } from '@/common/constants'; -import { - getUsersSetting, - putUsersSetting, - AdminSettingsUsers, -} from '@/services'; -import { handleFormError, scrollToElementTop } from '@/utils'; -import * as Type from '@/common/interface'; -import { siteInfoStore } from '@/stores'; - -const Index: FC = () => { - const { t } = useTranslation('translation', { - keyPrefix: 'admin.settings_users', - }); - const Toast = useToast(); - const { updateUsers: updateUsersStore } = siteInfoStore(); - const schema: JSONSchema = { - title: t('title'), - properties: { - default_avatar: { - type: 'string', - title: t('avatar.label'), - description: t('avatar.text'), - enum: SYSTEM_AVATAR_OPTIONS?.map((v) => v.value), - enumNames: SYSTEM_AVATAR_OPTIONS?.map((v) => v.label), - default: 'system', - }, - gravatar_base_url: { - type: 'string', - title: t('gravatar_base_url.label'), - description: t('gravatar_base_url.text'), - }, - profile_editable: { - type: 'string', - title: t('profile_editable.title'), - }, - allow_update_display_name: { - type: 'boolean', - title: 'allow_update_display_name', - }, - allow_update_username: { - type: 'boolean', - title: 'allow_update_username', - }, - allow_update_avatar: { - type: 'boolean', - title: 'allow_update_avatar', - }, - allow_update_bio: { - type: 'boolean', - title: 'allow_update_bio', - }, - allow_update_website: { - type: 'boolean', - title: 'allow_update_website', - }, - allow_update_location: { - type: 'boolean', - title: 'allow_update_location', - }, - }, - }; - - const [formData, setFormData] = useState(initFormData(schema)); - - const uiSchema: UISchema = { - default_avatar: { - 'ui:widget': 'select', - }, - gravatar_base_url: { - 'ui:widget': 'input', - 'ui:options': { - placeholder: 'https://www.gravatar.com/avatar/', - }, - }, - profile_editable: { - 'ui:widget': 'legend', - }, - allow_update_display_name: { - 'ui:widget': 'switch', - 'ui:options': { - label: t('allow_update_display_name.label'), - simplify: true, - }, - }, - allow_update_username: { - 'ui:widget': 'switch', - 'ui:options': { - label: t('allow_update_username.label'), - simplify: true, - }, - }, - allow_update_avatar: { - 'ui:widget': 'switch', - 'ui:options': { - label: t('allow_update_avatar.label'), - simplify: true, - }, - }, - allow_update_bio: { - 'ui:widget': 'switch', - 'ui:options': { - label: t('allow_update_bio.label'), - simplify: true, - }, - }, - allow_update_website: { - 'ui:widget': 'switch', - 'ui:options': { - label: t('allow_update_website.label'), - simplify: true, - }, - }, - allow_update_location: { - 'ui:widget': 'switch', - 'ui:options': { - label: t('allow_update_location.label'), - field_class_name: 'mb-3', - simplify: true, - }, - }, - }; - - const onSubmit = (evt: FormEvent) => { - evt.preventDefault(); - evt.stopPropagation(); - const reqParams: AdminSettingsUsers = { - allow_update_avatar: formData.allow_update_avatar.value, - allow_update_bio: formData.allow_update_bio.value, - allow_update_display_name: formData.allow_update_display_name.value, - allow_update_location: formData.allow_update_location.value, - allow_update_username: formData.allow_update_username.value, - allow_update_website: formData.allow_update_website.value, - default_avatar: formData.default_avatar.value, - gravatar_base_url: formData.gravatar_base_url.value, - }; - putUsersSetting(reqParams) - .then(() => { - updateUsersStore(reqParams); - Toast.onShow({ - msg: t('update', { keyPrefix: 'toast' }), - variant: 'success', - }); - }) - .catch((err) => { - if (err.isError) { - const data = handleFormError(err, formData); - setFormData({ ...data }); - const ele = document.getElementById(err.list[0].error_field); - scrollToElementTop(ele); - } - }); - }; - - useEffect(() => { - getUsersSetting().then((resp) => { - if (!resp) { - return; - } - const formMeta: Type.FormDataType = {}; - Object.keys(formData).forEach((k) => { - let v = resp[k]; - if (k === 'default_avatar' && !v) { - v = 'system'; - } - if (k === 'gravatar_base_url' && !v) { - v = ''; - } - formMeta[k] = { ...formData[k], value: v }; - }); - setFormData({ ...formData, ...formMeta }); - }); - }, []); - - const handleOnChange = (data) => { - setFormData(data); - }; - - return ( - <> -

{t('title')}

- - - ); -}; - -export default Index; diff --git a/ui/src/pages/Admin/Users/components/Action/index.tsx b/ui/src/pages/Admin/Users/components/Action/index.tsx index 9f0847a21..b55971f49 100644 --- a/ui/src/pages/Admin/Users/components/Action/index.tsx +++ b/ui/src/pages/Admin/Users/components/Action/index.tsx @@ -42,6 +42,7 @@ interface Props { currentUser; refreshUsers: () => void; showDeleteModal: (val) => void; + showSuspenseModal: (val) => void; userData; } @@ -53,6 +54,7 @@ const UserOperation = ({ refreshUsers, showDeleteModal, userData, + showSuspenseModal, }: Props) => { const { t } = useTranslation('translation', { keyPrefix: 'admin.users' }); const Toast = useToast(); @@ -170,17 +172,9 @@ const UserOperation = ({ if (type === 'suspend') { // cons - Modal.confirm({ - title: t('suspend_user.title'), - content: t('suspend_user.content'), - cancelBtnVariant: 'link', - cancelText: t('cancel', { keyPrefix: 'btns' }), - confirmBtnVariant: 'danger', - confirmText: t('suspend', { keyPrefix: 'btns' }), - onConfirm: () => { - // active -> suspended - postUserStatus('suspended'); - }, + showSuspenseModal({ + show: true, + userId: userData.user_id, }); } diff --git a/ui/src/pages/Admin/Users/components/SuspenseUserModal/index.tsx b/ui/src/pages/Admin/Users/components/SuspenseUserModal/index.tsx new file mode 100644 index 000000000..dc59ff345 --- /dev/null +++ b/ui/src/pages/Admin/Users/components/SuspenseUserModal/index.tsx @@ -0,0 +1,90 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useState } from 'react'; +import { Modal, Button, Form } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; + +import { changeUserStatus } from '@/services'; +import { SUSPENSE_USER_TIME } from '@/common/constants'; +import { toastStore } from '@/stores'; + +const SuspenseUserModal = ({ show, userId, onClose, refreshUsers }) => { + const { t } = useTranslation('translation', { keyPrefix: 'admin.users' }); + const [checkVal, setCheckVal] = useState('forever'); + + const handleClose = () => { + onClose(); + setCheckVal('forever'); + }; + + const handleSubmit = (e) => { + e.preventDefault(); + changeUserStatus({ + user_id: userId, + status: 'suspended', + suspend_duration: checkVal, + }).then(() => { + toastStore.getState().show({ + msg: t('user_suspended', { keyPrefix: 'messages' }), + variant: 'success', + }); + refreshUsers?.(); + handleClose(); + }); + }; + + return ( + + + {t('suspend_user.title')} + + +

{t('suspend_user.content')}

+
+ + {t('suspend_user.label')} + setCheckVal(e.target.value)}> + + {SUSPENSE_USER_TIME.map((item) => { + return ( + + ); + })} + + +
+
+ + + + +
+ ); +}; + +export default SuspenseUserModal; diff --git a/ui/src/pages/Admin/Users/index.tsx b/ui/src/pages/Admin/Users/index.tsx index 2402a839c..7e8e4fd61 100644 --- a/ui/src/pages/Admin/Users/index.tsx +++ b/ui/src/pages/Admin/Users/index.tsx @@ -23,6 +23,7 @@ import { useSearchParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import classNames from 'classnames'; +import dayjs from 'dayjs'; import { Pagination, @@ -47,6 +48,7 @@ import { formatCount } from '@/utils'; import DeleteUserModal from './components/DeleteUserModal'; import Action from './components/Action'; +import SuspenseUserModal from './components/SuspenseUserModal'; const UserFilterKeys: Type.UserFilterBy[] = [ 'normal', @@ -70,6 +72,10 @@ const Users: FC = () => { show: false, userId: '', }); + const [suspenseUserModalState, setSuspenseUserModalState] = useState({ + show: false, + userId: '', + }); const [urlSearchParams, setUrlSearchParams] = useSearchParams(); const curFilter = urlSearchParams.get('filter') || UserFilterKeys[0]; const curPage = Number(urlSearchParams.get('page') || '1'); @@ -172,6 +178,13 @@ const Users: FC = () => { }); }; + const handleSuspenseUserModalState = (modalData: { + show: boolean; + userId: string; + }) => { + setSuspenseUserModalState(modalData); + }; + const showAddUser = !ucAgent?.enabled || (ucAgent?.enabled && adminUcAgent?.allow_create_user); const showActionPassword = @@ -231,17 +244,22 @@ const Users: FC = () => { {t('name')} {t('reputation')} - + {t('email')} - + {t('created_at')} {(curFilter === 'deleted' || curFilter === 'suspended') && ( - + {curFilter === 'deleted' ? t('delete_at') : t('suspend_at')} )} + {curFilter === 'suspended' && ( + + {t('suspend_until')} + + )} {t('status')} {curFilter !== 'suspended' && curFilter !== 'deleted' && ( @@ -275,9 +293,21 @@ const Users: FC = () => { {curFilter === 'suspended' && ( - - - + <> + + + + + {user.suspended_until <= 0 || + Number( + dayjs(user.suspended_until * 1000).format('YYYY'), + ) > 2099 + ? t('suspend_user.forever') + : dayjs(user.suspended_until * 1000).format( + t('long_date_with_time', { keyPrefix: 'dates' }), + )} + + )} {curFilter === 'deleted' && ( @@ -306,6 +336,7 @@ const Users: FC = () => { currentUser={currentUser} refreshUsers={refreshUsers} showDeleteModal={changeDeleteUserModalState} + showSuspenseModal={handleSuspenseUserModalState} /> ) : null} @@ -332,6 +363,17 @@ const Users: FC = () => { }} onDelete={(val) => handleDelete(val)} /> + { + handleSuspenseUserModalState({ + show: false, + userId: '', + }); + }} + refreshUsers={refreshUsers} + /> ); }; diff --git a/ui/src/pages/Search/components/SearchHead/index.tsx b/ui/src/pages/Search/components/SearchHead/index.tsx index a175d381b..1bbb6b019 100644 --- a/ui/src/pages/Search/components/SearchHead/index.tsx +++ b/ui/src/pages/Search/components/SearchHead/index.tsx @@ -35,7 +35,11 @@ const Index: FC = ({ sort, count = 0 }) => { return (
-
{t('counts', { count, keyPrefix: 'search' })}
+
+ {count === -1 + ? t('counts_loading', { keyPrefix: 'search' }) + : t('counts', { count, keyPrefix: 'search' })} +
= ({ data }) => { - + { - + {isSkeletonShow ? ( diff --git a/ui/src/pages/Users/Personal/components/Answers/index.tsx b/ui/src/pages/Users/Personal/components/Answers/index.tsx index 4590a3cb4..055d56353 100644 --- a/ui/src/pages/Users/Personal/components/Answers/index.tsx +++ b/ui/src/pages/Users/Personal/components/Answers/index.tsx @@ -19,7 +19,6 @@ import { FC, memo } from 'react'; import { ListGroup, ListGroupItem } from 'react-bootstrap'; -import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { FormatTime, Tag, Counts } from '@/components'; @@ -30,7 +29,6 @@ interface Props { data: any[]; } const Index: FC = ({ visible, data }) => { - const { t } = useTranslation('translation', { keyPrefix: 'personal' }); if (!visible || !data?.length) { return null; } @@ -53,11 +51,7 @@ const Index: FC = ({ visible, data }) => {
- + = ({ visible, tabName, data }) => { tabName === 'bookmarks' ? item.create_time : item.created_at } className="me-3" - preFix={t('asked')} /> { const toast = useToast(); const { user, update } = loggedUserInfoStore(); const { agent: ucAgent } = userCenterStore(); - const { users: usersSetting } = siteInfoStore(); + const { interface: interfaceSetting } = interfaceStore(); const [mailHash, setMailHash] = useState(''); const [count] = useState(0); const [profileAgent, setProfileAgent] = useState(); @@ -309,7 +309,6 @@ const Index: React.FC = () => { @@ -332,7 +331,6 @@ const Index: React.FC = () => { @@ -356,7 +354,6 @@ const Index: React.FC = () => {