diff --git a/.env.example b/.env.example index b08736cdf..b3db68bd1 100644 --- a/.env.example +++ b/.env.example @@ -30,7 +30,19 @@ SWAGGER_ADDRESS_PORT= # Server SITE_ADDR=0.0.0.0:3000 +# Comma-separated reverse proxy IPs/CIDRs. Use "none" when directly exposed. +TRUSTED_PROXIES=127.0.0.1,::1 # Logging LOG_LEVEL=INFO LOG_PATH= + +# Cache +CACHE_TYPE=redis +REDIS_HOST=127.0.0.1 +REDIS_PORT=6379 +REDIS_USERNAME= +REDIS_PASSWORD= +REDIS_DB=0 +REDIS_KEY_PREFIX=hnu-forum: +REDIS_POOL_SIZE=20 diff --git a/.github/workflows/build-production-image.yml b/.github/workflows/build-production-image.yml new file mode 100644 index 000000000..bc5fb4294 --- /dev/null +++ b/.github/workflows/build-production-image.yml @@ -0,0 +1,70 @@ +# 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. + +name: Build Production Image + +on: + workflow_dispatch: + push: + branches: + - main + +permissions: + contents: read + packages: write + +concurrency: + group: production-image + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 90 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + platforms: linux/amd64 + push: true + pull: true + tags: | + ghcr.io/irofahaxikk/hnu-forum:production + ghcr.io/irofahaxikk/hnu-forum:sha-${{ github.sha }} + labels: | + org.opencontainers.image.source=https://github.com/IroFahaxikk/hnu-forum + org.opencontainers.image.revision=${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + build-args: | + GOPROXY=https://proxy.golang.org,direct diff --git a/.gitignore b/.gitignore index ba66f51a0..52165d6c6 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,7 @@ dist/ .husky/ # Environment variables -.env \ No newline at end of file +.env +/deploy/production/.env + +AGENT.md diff --git a/cmd/main.go b/cmd/main.go index 1f8153001..d36c59626 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -95,6 +95,9 @@ func runApp() { } func newApplication(serverConf *conf.Server, server *gin.Engine, manager *cron.ScheduledTaskManager) *pacman.Application { + if err := server.SetTrustedProxies(serverConf.HTTP.TrustedProxies); err != nil { + panic(err) + } manager.Run() return pacman.NewApp( pacman.WithName(Name), diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 446f6cc0b..e96fa362b 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -51,6 +51,7 @@ import ( "github.com/apache/answer/internal/repo/config" "github.com/apache/answer/internal/repo/export" "github.com/apache/answer/internal/repo/file_record" + "github.com/apache/answer/internal/repo/forum_section" "github.com/apache/answer/internal/repo/limit" "github.com/apache/answer/internal/repo/meta" notification2 "github.com/apache/answer/internal/repo/notification" @@ -58,6 +59,7 @@ import ( "github.com/apache/answer/internal/repo/question" "github.com/apache/answer/internal/repo/rank" "github.com/apache/answer/internal/repo/reason" + "github.com/apache/answer/internal/repo/registration" "github.com/apache/answer/internal/repo/report" "github.com/apache/answer/internal/repo/review" "github.com/apache/answer/internal/repo/revision" @@ -93,6 +95,7 @@ import ( "github.com/apache/answer/internal/service/feature_toggle" file_record2 "github.com/apache/answer/internal/service/file_record" "github.com/apache/answer/internal/service/follow" + forum_section2 "github.com/apache/answer/internal/service/forum_section" "github.com/apache/answer/internal/service/importer" meta2 "github.com/apache/answer/internal/service/meta" "github.com/apache/answer/internal/service/meta_common" @@ -147,6 +150,8 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database, cleanup() return nil, nil, err } + forumSectionRepo := forum_section.NewForumSectionRepo(dataData) + forumSectionService := forum_section2.NewForumSectionService(forumSectionRepo) siteInfoRepo := site_info.NewSiteInfo(dataData) siteInfoCommonService := siteinfo_common.NewSiteInfoCommonService(siteInfoRepo) langController := controller.NewLangController(i18nTranslator, siteInfoCommonService) @@ -191,7 +196,8 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database, eventqueueService := eventqueue.NewService() fileRecordRepo := file_record.NewFileRecordRepo(dataData) fileRecordService := file_record2.NewFileRecordService(fileRecordRepo, revisionRepo, serviceConf, siteInfoCommonService, userCommon) - userService := content.NewUserService(userRepo, userActiveActivityRepo, activityRepo, emailService, authService, siteInfoCommonService, userRoleRelService, userCommon, userExternalLoginService, userNotificationConfigRepo, userNotificationConfigService, questionCommon, eventqueueService, fileRecordService) + registrationSecurityRepo := registration.NewRegistrationRepo(dataData) + userService := content.NewUserService(userRepo, userActiveActivityRepo, activityRepo, emailService, authService, siteInfoCommonService, userRoleRelService, userCommon, userExternalLoginService, userNotificationConfigRepo, userNotificationConfigService, questionCommon, eventqueueService, fileRecordService, registrationSecurityRepo) captchaRepo := captcha.NewCaptchaRepo(dataData) captchaService := action.NewCaptchaService(captchaRepo) userController := controller.NewUserController(authService, userService, captchaService, emailService, siteInfoCommonService, userNotificationConfigService) @@ -215,7 +221,7 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database, answerActivityRepo := activity.NewAnswerActivityRepo(dataData, activityRepo, userRankRepo, noticequeueService) answerActivityService := activity2.NewAnswerActivityService(answerActivityRepo, configService) externalNotificationService := notification.NewExternalNotificationService(dataData, userNotificationConfigRepo, followRepo, emailService, userRepo, externalService, userExternalLoginRepo, siteInfoCommonService) - questionService := content.NewQuestionService(activityRepo, questionRepo, answerRepo, tagCommonService, tagService, questionCommon, userCommon, userRepo, userRoleRelService, revisionService, metaCommonService, collectionCommon, answerActivityService, emailService, noticequeueService, externalService, service, siteInfoCommonService, externalNotificationService, reviewService, configService, eventqueueService, reviewRepo, vector_syncService) + questionService := content.NewQuestionService(activityRepo, questionRepo, answerRepo, tagCommonService, tagService, questionCommon, userCommon, userRepo, userRoleRelService, revisionService, metaCommonService, collectionCommon, answerActivityService, emailService, noticequeueService, externalService, service, siteInfoCommonService, externalNotificationService, reviewService, configService, eventqueueService, reviewRepo, vector_syncService, forumSectionService) answerService := content.NewAnswerService(answerRepo, questionRepo, questionCommon, userCommon, collectionCommon, userRepo, revisionService, answerActivityService, answerCommon, voteRepo, emailService, userRoleRelService, noticequeueService, externalService, service, reviewService, eventqueueService, vector_syncService) reportHandle := report_handle.NewReportHandle(questionService, answerService, commentService) reportService := report2.NewReportService(reportRepo, objService, userCommon, answerRepo, questionRepo, commentCommonRepo, reportHandle, configService, eventqueueService) @@ -231,6 +237,7 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database, collectionService := collection2.NewCollectionService(collectionRepo, collectionGroupRepo, questionCommon) collectionController := controller.NewCollectionController(collectionService) questionController := controller.NewQuestionController(questionService, answerService, rankService, siteInfoCommonService, captchaService, rateLimitMiddleware) + forumSectionController := controller.NewForumSectionController(forumSectionService) answerController := controller.NewAnswerController(answerService, rankService, captchaService, siteInfoCommonService, rateLimitMiddleware) searchParser := search_parser.NewSearchParser(tagCommonService, userCommon) searchRepo := search_common.NewSearchRepo(dataData, uniqueIDRepo, userCommon, tagCommonService) @@ -293,7 +300,7 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database, aiController := controller.NewAIController(searchService, siteInfoCommonService, tagCommonService, questionCommon, commentRepo, userCommon, answerRepo, mcpController, aiConversationService, featureToggleService) aiConversationController := controller.NewAIConversationController(aiConversationService, featureToggleService) aiConversationAdminController := controller_admin.NewAIConversationAdminController(aiConversationService, featureToggleService) - answerAPIRouter := router.NewAnswerAPIRouter(langController, userController, commentController, reportController, voteController, tagController, followController, collectionController, questionController, answerController, searchController, revisionController, rankController, userAdminController, reasonController, themeController, siteInfoController, controllerSiteInfoController, notificationController, dashboardController, uploadController, activityController, roleController, pluginController, permissionController, userPluginController, reviewController, metaController, badgeController, controller_adminBadgeController, adminAPIKeyController, aiController, aiConversationController, aiConversationAdminController, mcpController) + answerAPIRouter := router.NewAnswerAPIRouter(langController, userController, commentController, reportController, voteController, tagController, followController, collectionController, questionController, answerController, searchController, revisionController, rankController, userAdminController, reasonController, themeController, siteInfoController, controllerSiteInfoController, notificationController, dashboardController, uploadController, activityController, roleController, pluginController, permissionController, userPluginController, reviewController, metaController, badgeController, controller_adminBadgeController, adminAPIKeyController, aiController, aiConversationController, aiConversationAdminController, mcpController, forumSectionController) swaggerRouter := router.NewSwaggerRouter(swaggerConf) uiRouter := router.NewUIRouter(controllerSiteInfoController, siteInfoCommonService) authUserMiddleware := middleware.NewAuthUserMiddleware(authService, siteInfoCommonService) diff --git a/configs/config.yaml b/configs/config.yaml index d14072785..36c6fbafe 100644 --- a/configs/config.yaml +++ b/configs/config.yaml @@ -18,12 +18,25 @@ server: http: addr: 0.0.0.0:80 + # Trust only the reverse proxy that directly connects to Answer. + trusted_proxies: + - 127.0.0.1 + - ::1 data: database: driver: "sqlite3" connection: "/data/sqlite3/answer.db" cache: + type: "redis" file_path: "/data/cache/cache.db" + redis: + host: "127.0.0.1" + port: 6379 + username: "" + password: "" + db: 0 + key_prefix: "hnu-forum:" + pool_size: 20 i18n: bundle_dir: "/data/i18n" swaggerui: @@ -41,4 +54,3 @@ ui: api_url: '/' base_url: '' api_base_url: '' - diff --git a/deploy/production/.env.example b/deploy/production/.env.example new file mode 100644 index 000000000..55ae8f345 --- /dev/null +++ b/deploy/production/.env.example @@ -0,0 +1,13 @@ +DOMAIN=dongpolakeside.com +HNU_DATA_ROOT=/srv/hnu-forum +ANSWER_IMAGE=ghcr.io/irofahaxikk/hnu-forum +IMAGE_TAG=production + +GOPROXY=https://proxy.golang.org,direct + +MYSQL_DATABASE=answer +MYSQL_USER=answer +MYSQL_PASSWORD=replace_with_generated_secret +MYSQL_ROOT_PASSWORD=replace_with_generated_secret + +REDIS_PASSWORD=replace_with_generated_secret diff --git a/deploy/production/docker-compose.yml b/deploy/production/docker-compose.yml new file mode 100644 index 000000000..eea581cd9 --- /dev/null +++ b/deploy/production/docker-compose.yml @@ -0,0 +1,120 @@ +# 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. + +name: hnu-forum + +x-logging: &default-logging + driver: json-file + options: + max-size: "10m" + max-file: "3" + +services: + mysql: + image: mysql:8.4 + restart: unless-stopped + environment: + TZ: Asia/Hong_Kong + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?MYSQL_ROOT_PASSWORD is required} + MYSQL_DATABASE: ${MYSQL_DATABASE:-answer} + MYSQL_USER: ${MYSQL_USER:-answer} + MYSQL_PASSWORD: ${MYSQL_PASSWORD:?MYSQL_PASSWORD is required} + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + - --default-time-zone=+08:00 + - --innodb-buffer-pool-size=384M + - --max-connections=100 + volumes: + - ${HNU_DATA_ROOT:-/srv/hnu-forum}/mysql:/var/lib/mysql + healthcheck: + test: + - CMD-SHELL + - mysqladmin ping -h 127.0.0.1 -uroot -p"$${MYSQL_ROOT_PASSWORD}" --silent + interval: 10s + timeout: 5s + retries: 20 + networks: + - backend + mem_limit: 768m + logging: *default-logging + + redis: + image: redis:7.4-alpine + restart: unless-stopped + environment: + TZ: Asia/Hong_Kong + REDIS_PASSWORD: ${REDIS_PASSWORD:?REDIS_PASSWORD is required} + command: + - sh + - -c + - exec redis-server --appendonly yes --requirepass "$$REDIS_PASSWORD" --maxmemory 128mb --maxmemory-policy allkeys-lru + volumes: + - ${HNU_DATA_ROOT:-/srv/hnu-forum}/redis:/data + healthcheck: + test: + - CMD-SHELL + - redis-cli -a "$${REDIS_PASSWORD}" ping | grep -q PONG + interval: 10s + timeout: 5s + retries: 20 + networks: + - backend + mem_limit: 256m + logging: *default-logging + + answer: + image: ${ANSWER_IMAGE:-ghcr.io/irofahaxikk/hnu-forum}:${IMAGE_TAG:-production} + pull_policy: always + restart: unless-stopped + environment: + TZ: Asia/Hong_Kong + SITE_ADDR: 0.0.0.0:80 + CACHE_TYPE: redis + REDIS_HOST: redis + REDIS_PORT: 6379 + REDIS_PASSWORD: ${REDIS_PASSWORD:?REDIS_PASSWORD is required} + REDIS_DB: 0 + REDIS_KEY_PREFIX: "hnu-forum:" + REDIS_POOL_SIZE: 10 + TRUSTED_PROXIES: 172.16.0.0/12 + SWAGGER_HOST: ${DOMAIN:-dongpolakeside.com} + SWAGGER_ADDRESS_PORT: ":443" + ports: + - 127.0.0.1:9080:80 + volumes: + - ${HNU_DATA_ROOT:-/srv/hnu-forum}/answer:/data + depends_on: + mysql: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: + - CMD-SHELL + - curl -fsS http://127.0.0.1/ >/dev/null || exit 1 + interval: 15s + timeout: 5s + retries: 20 + networks: + - backend + mem_limit: 1g + logging: *default-logging + +networks: + backend: + driver: bridge diff --git a/docs/docs.go b/docs/docs.go index 4e48c88d0..95b82d8ec 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -4249,6 +4249,41 @@ const docTemplate = `{ } } }, + "/answer/api/v1/forum/sections": { + "get": { + "description": "returns parent sections and their child sections", + "produces": [ + "application/json" + ], + "tags": [ + "Forum Section" + ], + "summary": "list campus forum sections", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.ForumSectionResp" + } + } + } + } + ] + } + } + } + } + }, "/answer/api/v1/language/config": { "get": { "description": "get language config mapping", @@ -7999,6 +8034,58 @@ const docTemplate = `{ } } }, + "/answer/api/v1/user/register/email/code": { + "post": { + "description": "Sends a six-digit registration code after captcha and rate-limit checks", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "User" + ], + "summary": "Send registration email verification code", + "parameters": [ + { + "description": "UserRegisterEmailCodeReq", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.UserRegisterEmailCodeReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.RespBody" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/schema.RetryAfterResp" + } + } + } + ] + } + } + } + } + }, "/answer/api/v1/user/staff": { "get": { "description": "get user staff", @@ -9567,6 +9654,32 @@ const docTemplate = `{ } } }, + "schema.ForumSectionResp": { + "type": "object", + "properties": { + "admin_only": { + "type": "boolean" + }, + "children": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.ForumSectionResp" + } + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "parent_id": { + "type": "integer" + }, + "slug": { + "type": "string" + } + } + }, "schema.GetAIModelResp": { "type": "object", "properties": { @@ -10984,6 +11097,7 @@ const docTemplate = `{ "schema.QuestionAdd": { "type": "object", "required": [ + "section_id", "title" ], "properties": { @@ -11000,6 +11114,11 @@ const docTemplate = `{ "maxLength": 65535, "minLength": 0 }, + "section_id": { + "description": "campus forum section (leaf section only)", + "type": "integer", + "minimum": 1 + }, "tags": { "description": "tags", "type": "array", @@ -11019,6 +11138,7 @@ const docTemplate = `{ "type": "object", "required": [ "answer_content", + "section_id", "title" ], "properties": { @@ -11046,6 +11166,10 @@ const docTemplate = `{ "type": "string" } }, + "section_id": { + "type": "integer", + "minimum": 1 + }, "tags": { "description": "tags", "type": "array", @@ -11131,6 +11255,9 @@ const docTemplate = `{ "pin": { "type": "integer" }, + "section_id": { + "type": "integer" + }, "show": { "type": "integer" }, @@ -11199,6 +11326,10 @@ const docTemplate = `{ "type": "integer", "minimum": 1 }, + "section": { + "type": "string", + "maxLength": 50 + }, "tag": { "type": "string", "maxLength": 100 @@ -11251,6 +11382,9 @@ const docTemplate = `{ "description": "1: unpin, 2: pin", "type": "integer" }, + "section_id": { + "type": "integer" + }, "show": { "description": "0: show, 1: hide", "type": "integer" @@ -11529,6 +11663,14 @@ const docTemplate = `{ } } }, + "schema.RetryAfterResp": { + "type": "object", + "properties": { + "retry_after": { + "type": "integer" + } + } + }, "schema.ReviewReportReq": { "type": "object", "required": [ @@ -13375,12 +13517,10 @@ const docTemplate = `{ } } }, - "schema.UserRegisterReq": { + "schema.UserRegisterEmailCodeReq": { "type": "object", "required": [ - "e_mail", - "name", - "pass" + "e_mail" ], "properties": { "captcha_code": { @@ -13392,6 +13532,25 @@ const docTemplate = `{ "e_mail": { "type": "string", "maxLength": 500 + } + } + }, + "schema.UserRegisterReq": { + "type": "object", + "required": [ + "e_mail", + "email_code", + "name", + "pass", + "pass_confirm" + ], + "properties": { + "e_mail": { + "type": "string", + "maxLength": 500 + }, + "email_code": { + "type": "string" }, "name": { "type": "string", @@ -13402,6 +13561,11 @@ const docTemplate = `{ "type": "string", "maxLength": 32, "minLength": 8 + }, + "pass_confirm": { + "type": "string", + "maxLength": 32, + "minLength": 8 } } }, diff --git a/docs/swagger.json b/docs/swagger.json index a075dfe45..e0cebe5ad 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -4222,6 +4222,41 @@ } } }, + "/answer/api/v1/forum/sections": { + "get": { + "description": "returns parent sections and their child sections", + "produces": [ + "application/json" + ], + "tags": [ + "Forum Section" + ], + "summary": "list campus forum sections", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.ForumSectionResp" + } + } + } + } + ] + } + } + } + } + }, "/answer/api/v1/language/config": { "get": { "description": "get language config mapping", @@ -7972,6 +8007,58 @@ } } }, + "/answer/api/v1/user/register/email/code": { + "post": { + "description": "Sends a six-digit registration code after captcha and rate-limit checks", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "User" + ], + "summary": "Send registration email verification code", + "parameters": [ + { + "description": "UserRegisterEmailCodeReq", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.UserRegisterEmailCodeReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.RespBody" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/schema.RetryAfterResp" + } + } + } + ] + } + } + } + } + }, "/answer/api/v1/user/staff": { "get": { "description": "get user staff", @@ -9540,6 +9627,32 @@ } } }, + "schema.ForumSectionResp": { + "type": "object", + "properties": { + "admin_only": { + "type": "boolean" + }, + "children": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.ForumSectionResp" + } + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "parent_id": { + "type": "integer" + }, + "slug": { + "type": "string" + } + } + }, "schema.GetAIModelResp": { "type": "object", "properties": { @@ -10957,6 +11070,7 @@ "schema.QuestionAdd": { "type": "object", "required": [ + "section_id", "title" ], "properties": { @@ -10973,6 +11087,11 @@ "maxLength": 65535, "minLength": 0 }, + "section_id": { + "description": "campus forum section (leaf section only)", + "type": "integer", + "minimum": 1 + }, "tags": { "description": "tags", "type": "array", @@ -10992,6 +11111,7 @@ "type": "object", "required": [ "answer_content", + "section_id", "title" ], "properties": { @@ -11019,6 +11139,10 @@ "type": "string" } }, + "section_id": { + "type": "integer", + "minimum": 1 + }, "tags": { "description": "tags", "type": "array", @@ -11104,6 +11228,9 @@ "pin": { "type": "integer" }, + "section_id": { + "type": "integer" + }, "show": { "type": "integer" }, @@ -11172,6 +11299,10 @@ "type": "integer", "minimum": 1 }, + "section": { + "type": "string", + "maxLength": 50 + }, "tag": { "type": "string", "maxLength": 100 @@ -11224,6 +11355,9 @@ "description": "1: unpin, 2: pin", "type": "integer" }, + "section_id": { + "type": "integer" + }, "show": { "description": "0: show, 1: hide", "type": "integer" @@ -11502,6 +11636,14 @@ } } }, + "schema.RetryAfterResp": { + "type": "object", + "properties": { + "retry_after": { + "type": "integer" + } + } + }, "schema.ReviewReportReq": { "type": "object", "required": [ @@ -13348,12 +13490,10 @@ } } }, - "schema.UserRegisterReq": { + "schema.UserRegisterEmailCodeReq": { "type": "object", "required": [ - "e_mail", - "name", - "pass" + "e_mail" ], "properties": { "captcha_code": { @@ -13365,6 +13505,25 @@ "e_mail": { "type": "string", "maxLength": 500 + } + } + }, + "schema.UserRegisterReq": { + "type": "object", + "required": [ + "e_mail", + "email_code", + "name", + "pass", + "pass_confirm" + ], + "properties": { + "e_mail": { + "type": "string", + "maxLength": 500 + }, + "email_code": { + "type": "string" }, "name": { "type": "string", @@ -13375,6 +13534,11 @@ "type": "string", "maxLength": 32, "minLength": 8 + }, + "pass_confirm": { + "type": "string", + "maxLength": 32, + "minLength": 8 } } }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index b3416a10e..3c7a7490c 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -766,6 +766,23 @@ definitions: description: if user is followed object will be true,otherwise false type: boolean type: object + schema.ForumSectionResp: + properties: + admin_only: + type: boolean + children: + items: + $ref: '#/definitions/schema.ForumSectionResp' + type: array + id: + type: integer + name: + type: string + parent_id: + type: integer + slug: + type: string + type: object schema.GetAIModelResp: properties: created: @@ -1761,6 +1778,10 @@ definitions: maxLength: 65535 minLength: 0 type: string + section_id: + description: campus forum section (leaf section only) + minimum: 1 + type: integer tags: description: tags items: @@ -1772,6 +1793,7 @@ definitions: minLength: 6 type: string required: + - section_id - title type: object schema.QuestionAddByAnswer: @@ -1794,6 +1816,9 @@ definitions: items: type: string type: array + section_id: + minimum: 1 + type: integer tags: description: tags items: @@ -1806,6 +1831,7 @@ definitions: type: string required: - answer_content + - section_id - title type: object schema.QuestionInfoResp: @@ -1855,6 +1881,8 @@ definitions: $ref: '#/definitions/schema.Operation' pin: type: integer + section_id: + type: integer show: type: integer status: @@ -1903,6 +1931,9 @@ definitions: page_size: minimum: 1 type: integer + section: + maxLength: 50 + type: string tag: maxLength: 100 type: string @@ -1939,6 +1970,8 @@ definitions: pin: description: '1: unpin, 2: pin' type: integer + section_id: + type: integer show: description: '0: show, 1: hide' type: integer @@ -2127,6 +2160,11 @@ definitions: question_id: type: string type: object + schema.RetryAfterResp: + properties: + retry_after: + type: integer + type: object schema.ReviewReportReq: properties: close_msg: @@ -3401,7 +3439,7 @@ definitions: - code - pass type: object - schema.UserRegisterReq: + schema.UserRegisterEmailCodeReq: properties: captcha_code: type: string @@ -3410,6 +3448,16 @@ definitions: e_mail: maxLength: 500 type: string + required: + - e_mail + type: object + schema.UserRegisterReq: + properties: + e_mail: + maxLength: 500 + type: string + email_code: + type: string name: maxLength: 30 minLength: 2 @@ -3418,10 +3466,16 @@ definitions: maxLength: 32 minLength: 8 type: string + pass_confirm: + maxLength: 32 + minLength: 8 + type: string required: - e_mail + - email_code - name - pass + - pass_confirm type: object schema.UserRetrievePassWordRequest: properties: @@ -5983,6 +6037,26 @@ paths: summary: update user follow tags tags: - Activity + /answer/api/v1/forum/sections: + get: + description: returns parent sections and their child sections + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/handler.RespBody' + - properties: + data: + items: + $ref: '#/definitions/schema.ForumSectionResp' + type: array + type: object + summary: list campus forum sections + tags: + - Forum Section /answer/api/v1/language/config: get: description: get language config mapping @@ -8260,6 +8334,38 @@ paths: summary: UserRegisterByEmail tags: - User + /answer/api/v1/user/register/email/code: + post: + consumes: + - application/json + description: Sends a six-digit registration code after captcha and rate-limit + checks + parameters: + - description: UserRegisterEmailCodeReq + in: body + name: data + required: true + schema: + $ref: '#/definitions/schema.UserRegisterEmailCodeReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.RespBody' + "429": + description: Too Many Requests + schema: + allOf: + - $ref: '#/definitions/handler.RespBody' + - properties: + data: + $ref: '#/definitions/schema.RetryAfterResp' + type: object + summary: Send registration email verification code + tags: + - User /answer/api/v1/user/staff: get: consumes: diff --git a/go.mod b/go.mod index 5787c8b18..a10f92505 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ go 1.25.0 require ( github.com/Machiel/slugify v1.0.1 github.com/Masterminds/semver/v3 v3.3.0 + github.com/alicebob/miniredis/v2 v2.38.0 github.com/anargu/gin-brotli v0.0.0-20220116052358-12bf532d5267 github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 github.com/bwmarrin/snowflake v0.3.0 @@ -45,6 +46,7 @@ require ( github.com/mozillazg/go-pinyin v0.20.0 github.com/mozillazg/go-unidecode v0.2.0 github.com/ory/dockertest/v3 v3.11.0 + github.com/redis/go-redis/v9 v9.21.0 github.com/robfig/cron/v3 v3.0.1 github.com/sashabaranov/go-openai v1.41.2 github.com/scottleedavis/go-exif-remove v0.0.0-20230314195146-7e059d593405 @@ -88,6 +90,7 @@ require ( github.com/bytedance/sonic v1.12.2 // indirect github.com/bytedance/sonic/loader v0.2.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect github.com/containerd/continuity v0.4.3 // indirect @@ -126,7 +129,7 @@ require ( github.com/invopop/jsonschema v0.13.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/cpuid/v2 v2.2.8 // indirect + github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/lestrrat-go/file-rotatelogs v2.4.0+incompatible // indirect github.com/lestrrat-go/strftime v1.1.0 // indirect @@ -166,6 +169,8 @@ require ( github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xeipuuv/gojsonschema v1.2.0 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + github.com/yuin/gopher-lua v1.1.1 // indirect + go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect golang.org/x/arch v0.10.0 // indirect diff --git a/go.sum b/go.sum index 1001f1da0..c94fc21bc 100644 --- a/go.sum +++ b/go.sum @@ -36,6 +36,8 @@ github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuy github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alicebob/miniredis/v2 v2.38.0 h1:nZAzCR+Lj+Vxk4ZXzm2NuKq2O33RXj1XxJ2e2uP9jiw= +github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= github.com/anargu/gin-brotli v0.0.0-20220116052358-12bf532d5267 h1:vDHsaEcs/Q0dwetADENtwus6W1ccaZ9h3KBTm0d2X0g= github.com/anargu/gin-brotli v0.0.0-20220116052358-12bf532d5267/go.mod h1:Yj3yPP/vi87JjwylUTCMyd6FrOfGqP1AHk0305hDm2o= github.com/andybalholm/brotli v1.0.1/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y= @@ -60,6 +62,10 @@ github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24 github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0= @@ -75,6 +81,8 @@ github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK3 github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/clbanning/x2j v0.0.0-20191024224557-825249438eec/go.mod h1:jMjuTZXRI4dUb/I5gc9Hdhagfvm9+RyrPryS/auMzxE= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= @@ -382,8 +390,8 @@ github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvW github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM= -github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= @@ -535,6 +543,8 @@ github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= +github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E= +github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= @@ -661,6 +671,10 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.7.4 h1:BDXOHExt+A7gwPCJgPIIq7ENvceR7we7rOS9TNoLZeg= github.com/yuin/goldmark v1.7.4/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0= go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= @@ -672,6 +686,8 @@ go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= diff --git a/i18n/en_US.yaml b/i18n/en_US.yaml index 5d1faa3e0..58c45acfc 100644 --- a/i18n/en_US.yaml +++ b/i18n/en_US.yaml @@ -155,6 +155,8 @@ backend: password: space_invalid: other: Password cannot contain spaces. + confirmation_mismatch: + other: The two passwords do not match. admin: cannot_update_their_password: other: You cannot modify your password. @@ -187,6 +189,10 @@ backend: email: duplicate: other: Email already exists. + verification_code_invalid: + other: The email verification code is invalid or has expired. + send_too_frequent: + other: Email verification codes are being sent too frequently. Please try again later. need_to_be_verified: other: Email should be verified. verify_url_expired: @@ -237,6 +243,11 @@ backend: other: Content cannot be empty. content_less_than_minimum: other: Not enough content entered. + forum_section: + invalid: + other: Please select a valid campus section. + admin_only: + other: Only administrators can publish site announcements. rank: fail_to_meet_the_condition: other: Reputation rank fail to meet the condition. @@ -518,6 +529,11 @@ backend: other: "[{{.SiteName}}] Confirm your new account" body: other: "Welcome to {{.SiteName}}!

\n\nClick the following link to confirm and activate your new account:
\n{{.RegisterUrl}}

\n\nIf the above link is not clickable, try copying and pasting it into the address bar of your web browser.\n

\n\n--
\nNote: This is an automatic system email, please do not reply to this message as your response will not be seen." + register_code: + title: + other: "[{{.SiteName}}] Registration verification code" + body: + other: "Welcome to {{.SiteName}}!

\n\nYour registration verification code is:
\n{{.Code}}

\n\nThis code expires in {{.ExpiresMinutes}} minutes. If you did not request it, please ignore this email." test: title: other: "[{{.SiteName}}] Test Email" @@ -1107,7 +1123,7 @@ ui: react_emoji: react with {{ emoji }} unreact_emoji: unreact with {{ emoji }} comment: - btn_add_comment: Add comment + btn_add_comment: Add reply reply_to: Reply to btn_reply: Reply btn_edit: Edit @@ -1115,24 +1131,23 @@ ui: btn_flag: Flag btn_save_edits: Save edits btn_cancel: Cancel - show_more: "{{count}} more comments" + show_more: "{{count}} more replies" tip_question: >- - Use comments to ask for more information or suggest improvements. Avoid - answering questions in comments. + Use replies to add information or suggest improvements. tip_answer: >- - Use comments to reply to other users or notify them of changes. If you are - adding new information, edit your post instead of commenting. + Use replies to join the discussion. Edit the original comment to improve + its main content. tip_vote: It adds something useful to the post edit_answer: - title: Edit Answer - default_reason: Edit answer - default_first_reason: Add answer + title: Edit Comment + default_reason: Edit comment + default_first_reason: Add comment form: fields: revision: label: Revision answer: - label: Answer + label: Comment feedback: characters: content must be at least 6 characters in length. edit_summary: @@ -1155,6 +1170,11 @@ ui: no_desc: The tag has no description. more: More wiki: Wiki + campus_forum: + sections: Campus sections + all_sections: All posts + admin_only: Admin only + last_30_days: Last 30 days ask: title: Create Question edit_title: Edit Question @@ -1163,11 +1183,17 @@ ui: similar_questions: Similar questions form: fields: + section: + label: Campus section + placeholder: Select a section + hint: Your post will appear in the selected campus section. + msg: + empty: Please select a campus section. revision: label: Revision title: label: Title - placeholder: What's your topic? Be specific. + placeholder: Enter a post title msg: empty: Title cannot be empty. range: Title up to 150 characters @@ -1179,7 +1205,8 @@ ui: optional_body: Describe what the question is about. minimum_characters: "Describe what the question is about, at least {{min_content_length}} characters are required." tags: - label: Tags + label: Tags (optional) + hint: Add relevant tags when possible to help others discover and browse your post. msg: empty: Tags cannot be empty. answer: @@ -1191,10 +1218,10 @@ ui: placeholder: >- Briefly explain your changes (corrected spelling, fixed grammar, improved formatting) - btn_post_question: Post your question + btn_post_question: Publish post btn_save_edits: Save edits answer_question: Answer your own question - post_question&answer: Post your question and answer + post_question&answer: Publish post and answer tag_selector: add_btn: Add tag create_btn: Create new tag @@ -1203,10 +1230,10 @@ ui: hint_zero_tags: Describe what your content is about. hint_more_than_one_tag: "Describe what your content is about, at least {{min_tags_number}} tags are required." no_result: No tags matched - tag_required_text: Required tag (at least one) + tag_required_text: Recommended tags header: nav: - question: Questions + question: Home tag: Tags user: Users badges: Badges @@ -1260,11 +1287,29 @@ ui: label: Email msg: empty: Email cannot be empty. + invalid: Please enter a valid email address. + domain: Only hainanu.edu.cn and alumni.hainanu.edu.cn email addresses are allowed. password: label: Password msg: empty: Password cannot be empty. + range: Password must be between 8 and 32 characters. different: The passwords entered on both sides are inconsistent + password_confirm: + label: Confirm password + msg: + empty: Please enter the password again. + different: The two passwords do not match. + verification_code: + label: Email verification code + placeholder: 6-digit code + send: Send code + sending: Sending... + resend: Resend in {{seconds}}s + sent: Verification code sent. Please check your email. + msg: + empty: Verification code cannot be empty. + invalid: Please enter the 6-digit verification code. account_forgot: page_title: Forgot Your Password btn_name: Send me recovery email @@ -1356,7 +1401,8 @@ ui: label: Location placeholder: "City, Country" notification: - heading: Email Notifications + heading: Notifications + email_disabled: Replies, comments, mentions, and invitations are shown only as in-app notifications and are not sent by email. turn_on: Turn on inbox: label: Inbox notifications @@ -1413,8 +1459,8 @@ ui: review: Your revision will show after review. sent_success: Sent successfully related_question: - title: Related - answers: answers + title: Related Posts + answers: comments linked_question: title: Linked description: Posts linked to @@ -1428,50 +1474,50 @@ ui: question_detail: action: Action created: Created - Asked: Asked - asked: asked + Asked: Posted + asked: posted update: Modified Edited: Edited edit: edited commented: commented Views: Viewed - Follow: Follow + Follow: Follow this post Following: Following - follow_tip: Follow this question to receive notifications - answered: answered + follow_tip: Follow this post to receive notifications + answered: commented closed_in: Closed in - show_exist: Show existing question. + show_exist: Show related posts. useful: Useful - question_useful: It is useful and clear - question_un_useful: It is unclear or not useful - question_bookmark: Bookmark this question - answer_useful: It is useful - answer_un_useful: It is not useful + question_useful: This post is useful and clear + question_un_useful: This post is unclear or not useful + question_bookmark: Bookmark this post + answer_useful: This comment is useful + answer_un_useful: This comment is not useful answers: - title: Answers + title: Comments score: Score newest: Newest oldest: Oldest btn_accept: Accept btn_accepted: Accepted write_answer: - title: Your Answer - edit_answer: Edit my existing answer - btn_name: Post your answer - add_another_answer: Add another answer - confirm_title: Continue to answer + title: Add a Comment + edit_answer: Edit my comment + btn_name: Post Comment + add_another_answer: Add another comment + confirm_title: Continue commenting continue: Continue confirm_info: >- -

Are you sure you want to add another answer?

You could use the - edit link to refine and improve your existing answer, instead.

- empty: Answer cannot be empty. +

Are you sure you want to add another comment?

You could also + edit and improve your existing comment.

+ empty: Comment cannot be empty. characters: content must be at least 6 characters in length. tips: - header_1: Thanks for your answer - li1_1: Please be sure to answer the question. Provide details and share your research. + header_1: Thanks for your comment + li1_1: Keep your comment relevant to the post and provide helpful information. li1_2: Back up any statements you make with references or personal experience. header_2: But avoid ... - li2_1: Asking for help, seeking clarification, or responding to other answers. + li2_1: Posting off-topic content or duplicate comments. reopen: confirm_btn: Reopen title: Reopen this post @@ -1617,18 +1663,18 @@ ui: save: Save follow_tag_tip: Follow tags to curate your list of questions. hot_questions: Hot Questions - all_questions: All Questions + all_questions: Campus posts x_questions: "{{ count }} Questions" x_answers: "{{ count }} answers" x_posts: "{{ count }} Posts" questions: Questions answers: Answers - newest: Newest - active: Active + newest: New posts + active: Latest replies hot: Hot frequent: Frequent recommend: Recommend - score: Score + score: Most liked unanswered: Unanswered modified: modified answered: answered @@ -1642,9 +1688,14 @@ ui: answer: answer questions: Questions question: question + posts: Posts + post: post bookmarks: Bookmarks reputation: Reputation comments: Comments + comment: comment + replies: Replies + reply: reply votes: Votes badges: Badges newest: Newest @@ -1659,6 +1710,8 @@ ui: about_me_empty: "// Hello, World !" top_answers: Top Answers top_questions: Top Questions + top_comments: Top Comments + top_posts: Top Posts stats: Stats list_empty: No posts found.
Perhaps you'd like to select a different tab? content_empty: No posts found. @@ -1672,6 +1725,8 @@ ui: x_votes: votes received x_answers: answers x_questions: questions + x_comments: comments + x_posts: posts recent_badges: Recent Badges install: title: Installation @@ -1793,6 +1848,7 @@ ui: views: views votes: votes answers: answers + comments: comments accepted: Accepted page_error: http_error: HTTP Error {{ code }} @@ -1999,6 +2055,12 @@ ui: edit_profile: Edit profile change_status: Change status change_role: Change role + make_admin: + action: Make forum administrator + title: Make forum administrator + content: Are you sure you want to make this user a forum administrator? They will receive full access to the administration area and will need to sign in again. + confirm: Make administrator + success: The user is now a forum administrator. show_logs: Show logs add_user: Add user deactivate_user: @@ -2488,4 +2550,3 @@ ui: copy: Copy to clipboard copied: Copied external_content_warning: External images/media are not displayed. - diff --git a/i18n/zh_CN.yaml b/i18n/zh_CN.yaml index f16ed9fad..69635348f 100644 --- a/i18n/zh_CN.yaml +++ b/i18n/zh_CN.yaml @@ -154,6 +154,8 @@ backend: password: space_invalid: other: 密码不得含有空格。 + confirmation_mismatch: + other: 两次输入的密码不一致。 admin: cannot_update_their_password: other: 你无法修改自己的密码。 @@ -186,6 +188,10 @@ backend: email: duplicate: other: 邮箱已存在。 + verification_code_invalid: + other: 邮箱验证码错误或已过期。 + send_too_frequent: + other: 邮箱验证码发送过于频繁,请稍后再试。 need_to_be_verified: other: 邮箱需要验证。 verify_url_expired: @@ -236,6 +242,11 @@ backend: other: 内容不能为空。 content_less_than_minimum: other: 输入的内容不足。 + forum_section: + invalid: + other: 请选择有效的校园板块。 + admin_only: + other: 站务公告仅允许管理员发布。 rank: fail_to_meet_the_condition: other: 声望值未达到要求。 @@ -517,6 +528,11 @@ backend: other: "[{{.SiteName}}] 确认你的新账户" body: other: "欢迎加入 {{.SiteName}}!

\n\n请点击以下链接确认并激活你的新账户:
\n{{.RegisterUrl}}

\n\n如果上面的链接不能点击,请将其复制并粘贴到你的浏览器地址栏中。\n

\n\n--
\n这是系统自动发送的电子邮件,请勿回复,因为您的回复将不会被看到" + register_code: + title: + other: "[{{.SiteName}}] 注册验证码" + body: + other: "欢迎加入 {{.SiteName}}!

\n\n你的注册验证码是:
\n{{.Code}}

\n\n验证码在 {{.ExpiresMinutes}} 分钟内有效。如果不是你本人操作,请忽略此邮件。" test: title: other: "[{{.SiteName}}] 测试邮件" @@ -1091,7 +1107,7 @@ ui: react_emoji: 用 {{ emoji }} 回应 unreact_emoji: 撤销 {{ emoji }} comment: - btn_add_comment: 添加评论 + btn_add_comment: 添加回复 reply_to: 回复 btn_reply: 回复 btn_edit: 编辑 @@ -1099,22 +1115,22 @@ ui: btn_flag: 举报 btn_save_edits: 保存更改 btn_cancel: 取消 - show_more: "{{count}} 条剩余评论" + show_more: "{{count}} 条剩余回复" tip_question: >- - 使用评论提问更多信息或者提出改进意见。避免在评论里回答问题。 + 使用回复补充信息或提出改进建议。 tip_answer: >- - 使用评论对回答者进行回复,或者通知回答者你已更新了问题的内容。如果要补充或者完善问题的内容,请在原问题中更改。 + 使用回复参与讨论;如果要完善主要内容,请编辑原评论。 tip_vote: 它给帖子添加了一些有用的内容 edit_answer: - title: 编辑回答 - default_reason: 编辑回答 - default_first_reason: 添加答案 + title: 编辑评论 + default_reason: 编辑评论 + default_first_reason: 添加评论 form: fields: revision: label: 编辑历史 answer: - label: 回答内容 + label: 评论内容 feedback: characters: 内容长度至少 6 个字符 edit_summary: @@ -1136,6 +1152,11 @@ ui: no_desc: 此标签无描述。 more: 更多 wiki: 维基 + campus_forum: + sections: 校园板块 + all_sections: 全部帖子 + admin_only: 管理员发布 + last_30_days: 最近 30 天 ask: title: 创建问题 edit_title: 编辑问题 @@ -1144,11 +1165,17 @@ ui: similar_questions: 相似问题 form: fields: + section: + label: 校园板块 + placeholder: 请选择发帖板块 + hint: 帖子发布后会展示在所选板块中。 + msg: + empty: 请选择一个校园板块 revision: label: 修订版本 title: label: 标题 - placeholder: 你的主题是什么?请具体说明。 + placeholder: 请输入帖子标题 msg: empty: 标题不能为空。 range: 标题最多 150 个字符 @@ -1160,7 +1187,8 @@ ui: optional_body: 描述这个问题是什么。 minimum_characters: "详细描述这个问题,至少需要 {{min_content_length}} 字符。" tags: - label: 标签 + label: 标签(选填) + hint: 建议尽量添加相关标签,方便其他用户发现和浏览帖子。 msg: empty: 必须选择一个标签 answer: @@ -1171,10 +1199,10 @@ ui: label: 编辑备注 placeholder: >- 简单描述更改原因(更正拼写、修复语法、改进格式) - btn_post_question: 提交问题 + btn_post_question: 发布帖子 btn_save_edits: 保存更改 answer_question: 回答自己的问题 - post_question&answer: 提交问题和回答 + post_question&answer: 发布帖子和回答 tag_selector: add_btn: 添加标签 create_btn: 创建新标签 @@ -1183,10 +1211,10 @@ ui: hint_zero_tags: 描述您的内容与什么有关。 hint_more_than_one_tag: "描述您的内容是关于什么,至少需要{{min_tags_number}}个标签。" no_result: 没有匹配的标签 - tag_required_text: 必选标签(至少一个) + tag_required_text: 推荐标签 header: nav: - question: 问题 + question: 首页 tag: 标签 user: 用户 badges: 徽章 @@ -1238,11 +1266,29 @@ ui: label: 邮箱 msg: empty: 邮箱不能为空 + invalid: 请输入有效的邮箱地址 + domain: 仅支持 hainanu.edu.cn 学生邮箱和 alumni.hainanu.edu.cn 校友邮箱 password: label: 密码 msg: empty: 密码不能为空 + range: 密码长度必须在 8 至 32 个字符之间 different: 两次输入密码不一致 + password_confirm: + label: 确认密码 + msg: + empty: 请再次输入密码 + different: 两次输入密码不一致 + verification_code: + label: 邮箱验证码 + placeholder: 请输入 6 位验证码 + send: 发送验证码 + sending: 发送中... + resend: "{{seconds}} 秒后重发" + sent: 验证码已发送,请检查邮箱 + msg: + empty: 邮箱验证码不能为空 + invalid: 请输入 6 位数字验证码 account_forgot: page_title: 忘记密码 btn_name: 发送恢复邮件 @@ -1330,7 +1376,8 @@ ui: label: 位置 placeholder: "城市,国家" notification: - heading: 邮件通知 + heading: 消息通知 + email_disabled: 回复、评论、提及和邀请等消息仅通过站内通知展示,不会发送邮件。 turn_on: 开启 inbox: label: 收件箱通知 @@ -1386,8 +1433,8 @@ ui: review: 您的修订将在审阅通过后显示。 sent_success: 发送成功 related_question: - title: 相似 - answers: 个回答 + title: 相关帖子 + answers: 条评论 linked_question: title: 关联 description: 帖子关联到 @@ -1401,49 +1448,49 @@ ui: question_detail: action: 操作 created: 创建于 - Asked: 提问于 - asked: 提问于 + Asked: 发布于 + asked: 发布于 update: 修改于 Edited: 编辑于 edit: 编辑于 commented: 评论 Views: 阅读次数 - Follow: 关注此问题 + Follow: 关注此帖子 Following: 已关注 - follow_tip: 关注此问题以接收通知 - answered: 回答于 + follow_tip: 关注此帖子以接收通知 + answered: 评论于 closed_in: 关闭于 - show_exist: 查看类似问题。 + show_exist: 查看相关帖子。 useful: 有用的 - question_useful: 它是有用和明确的 - question_un_useful: 它不明确或没用的 - question_bookmark: 收藏该问题 - answer_useful: 这是有用的 - answer_un_useful: 它是没有用的 + question_useful: 这个帖子有用且内容明确 + question_un_useful: 这个帖子内容不明确或没有帮助 + question_bookmark: 收藏该帖子 + answer_useful: 这条评论有用 + answer_un_useful: 这条评论没有用 answers: - title: 个回答 + title: 条评论 score: 评分 newest: 最新 oldest: 最旧 btn_accept: 采纳 btn_accepted: 已被采纳 write_answer: - title: 你的回答 - edit_answer: 编辑我的回答 - btn_name: 提交你的回答 - add_another_answer: 添加另一个回答 - confirm_title: 继续回答 + title: 发表评论 + edit_answer: 编辑我的评论 + btn_name: 提交评论 + add_another_answer: 添加另一条评论 + confirm_title: 继续发表评论 continue: 继续 confirm_info: >- -

你确定要提交一个新的回答吗?

作为替代,你可以通过编辑来完善和改进之前的回答。

- empty: 回答内容不能为空。 +

你确定要提交一条新评论吗?

你也可以编辑并完善之前发表的评论。

+ empty: 评论内容不能为空。 characters: 内容长度至少 6 个字符。 tips: - header_1: 感谢你的回答 - li1_1: 请务必确定在 回答问题。提供详细信息并分享你的研究。 + header_1: 感谢你的评论 + li1_1: 请围绕帖子主题发表评论,并提供有帮助的信息。 li1_2: 用参考资料或个人经历来支持你所做的任何陈述。 header_2: 但是 请避免... - li2_1: 请求帮助,寻求澄清,或答复其他答案。 + li2_1: 发布与帖子主题无关的内容或重复评论。 reopen: confirm_btn: 重新打开 title: 重新打开这个帖子 @@ -1582,18 +1629,18 @@ ui: save: 保存 follow_tag_tip: 关注标签来筛选你的问题列表。 hot_questions: 热门问题 - all_questions: 全部问题 + all_questions: 校园帖子 x_questions: "{{ count }} 个问题" x_answers: "{{ count }} 个回答" x_posts: "{{ count }} 个帖子" questions: 问题 answers: 回答 - newest: 最新 - active: 活跃 + newest: 最新发表 + active: 最新回复 hot: 热门 frequent: 频繁的 recommend: 推荐 - score: 评分 + score: 点赞热门 unanswered: 未回答 modified: 更新于 answered: 回答于 @@ -1607,9 +1654,14 @@ ui: answer: 回答 questions: 问题 question: 问题 + posts: 帖子 + post: 帖子 bookmarks: 收藏 reputation: 声望 comments: 评论 + comment: 评论 + replies: 回复 + reply: 回复 votes: 得票 badges: 徽章 newest: 最新 @@ -1624,6 +1676,8 @@ ui: about_me_empty: "// Hello, World!" top_answers: 高分回答 top_questions: 高分问题 + top_comments: 热门评论 + top_posts: 热门帖子 stats: 状态 list_empty: 没有找到相关的内容。
试试看其他选项卡? content_empty: 未找到帖子。 @@ -1637,6 +1691,8 @@ ui: x_votes: 得票 x_answers: 个回答 x_questions: 个问题 + x_comments: 条评论 + x_posts: 篇帖子 recent_badges: 最近的徽章 install: title: 安装 @@ -1753,6 +1809,7 @@ ui: views: 次浏览 votes: 个点赞 answers: 个回答 + comments: 条评论 accepted: 已被采纳 page_error: http_error: HTTP 错误 {{ code }} @@ -1958,6 +2015,12 @@ ui: edit_profile: 编辑资料 change_status: 更改状态 change_role: 更改角色 + make_admin: + action: 设为论坛管理员 + title: 设为论坛管理员 + content: 确定要将该用户设为论坛管理员吗?该用户将获得完整的后台管理权限,并且需要重新登录。 + confirm: 确认设为管理员 + success: 已将该用户设为论坛管理员。 show_logs: 显示日志 add_user: 添加用户 deactivate_user: @@ -2353,7 +2416,7 @@ ui: proposed: 提案 question_edit: 问题编辑 answer_edit: 回答编辑 - tag_edit: '标签管理: 编辑标签' + tag_edit: "标签管理: 编辑标签" edit_summary: 编辑备注 edit_question: 编辑问题 edit_answer: 编辑回答 @@ -2382,7 +2445,7 @@ ui: upvote: 点赞 accept: 采纳 cancelled: 已取消 - commented: '评论:' + commented: "评论:" rollback: 回滚 edited: 最后编辑于 answered: 回答于 @@ -2446,5 +2509,3 @@ ui: copy: 复制到剪贴板 copied: 已复制 external_content_warning: 外部图像/媒体未显示。 - - diff --git a/internal/base/conf/conf.go b/internal/base/conf/conf.go index 04e3a19ba..ac269822f 100644 --- a/internal/base/conf/conf.go +++ b/internal/base/conf/conf.go @@ -21,8 +21,11 @@ package conf import ( "bytes" + "fmt" "os" "path/filepath" + "strconv" + "strings" "github.com/apache/answer/internal/base/data" "github.com/apache/answer/internal/base/path" @@ -50,6 +53,15 @@ type envConfigOverrides struct { SwaggerHost string SwaggerAddressPort string SiteAddr string + TrustedProxies string + CacheType string + RedisHost string + RedisPort string + RedisUsername string + RedisPassword string + RedisDB string + RedisKeyPrefix string + RedisPoolSize string } func loadEnvs() (envOverrides *envConfigOverrides) { @@ -57,6 +69,15 @@ func loadEnvs() (envOverrides *envConfigOverrides) { SwaggerHost: os.Getenv("SWAGGER_HOST"), SwaggerAddressPort: os.Getenv("SWAGGER_ADDRESS_PORT"), SiteAddr: os.Getenv("SITE_ADDR"), + TrustedProxies: os.Getenv("TRUSTED_PROXIES"), + CacheType: os.Getenv("CACHE_TYPE"), + RedisHost: os.Getenv("REDIS_HOST"), + RedisPort: os.Getenv("REDIS_PORT"), + RedisUsername: os.Getenv("REDIS_USERNAME"), + RedisPassword: os.Getenv("REDIS_PASSWORD"), + RedisDB: os.Getenv("REDIS_DB"), + RedisKeyPrefix: os.Getenv("REDIS_KEY_PREFIX"), + RedisPoolSize: os.Getenv("REDIS_POOL_SIZE"), } } @@ -77,22 +98,107 @@ type Data struct { // SetDefault set default config func (c *AllConfig) SetDefault() { + if c.Server == nil { + c.Server = &Server{} + } + if c.Server.HTTP == nil { + c.Server.HTTP = &server.HTTP{} + } + if c.Server.HTTP.TrustedProxies == nil { + c.Server.HTTP.TrustedProxies = []string{"127.0.0.1", "::1"} + } if c.UI == nil { c.UI = &server.UI{} } + if c.Data == nil { + c.Data = &Data{} + } + if c.Data.Cache == nil { + c.Data.Cache = &data.CacheConf{} + } + + // Application configuration defaults to Redis. + // Direct NewCache(&CacheConf{}) calls still use Memory for tests. + if c.Data.Cache.Type == "" { + c.Data.Cache.Type = data.CacheTypeRedis + } + if c.Data.Cache.Redis.Host == "" { + c.Data.Cache.Redis.Host = data.DefaultRedisHost + } + if c.Data.Cache.Redis.Port == 0 { + c.Data.Cache.Redis.Port = data.DefaultRedisPort + } + if c.Data.Cache.Redis.KeyPrefix == "" { + c.Data.Cache.Redis.KeyPrefix = data.DefaultRedisKeyPrefix + } + if c.Data.Cache.Redis.PoolSize == 0 { + c.Data.Cache.Redis.PoolSize = data.DefaultRedisPoolSize + } } -func (c *AllConfig) SetEnvironmentOverrides() { +func (c *AllConfig) SetEnvironmentOverrides() error { envs := loadEnvs() + if envs.SiteAddr != "" { c.Server.HTTP.Addr = envs.SiteAddr } + if envs.TrustedProxies != "" { + c.Server.HTTP.TrustedProxies = c.Server.HTTP.TrustedProxies[:0] + if !strings.EqualFold(strings.TrimSpace(envs.TrustedProxies), "none") { + for proxy := range strings.SplitSeq(envs.TrustedProxies, ",") { + if proxy = strings.TrimSpace(proxy); proxy != "" { + c.Server.HTTP.TrustedProxies = append(c.Server.HTTP.TrustedProxies, proxy) + } + } + } + } if envs.SwaggerHost != "" { c.Swaggerui.Host = envs.SwaggerHost } if envs.SwaggerAddressPort != "" { c.Swaggerui.Address = envs.SwaggerAddressPort } + if envs.CacheType != "" { + c.Data.Cache.Type = envs.CacheType + } + if envs.RedisHost != "" { + c.Data.Cache.Redis.Host = envs.RedisHost + } + if envs.RedisPort != "" { + port, err := strconv.Atoi(envs.RedisPort) + if err != nil || port < 1 || port > 65535 { + return fmt.Errorf("REDIS_PORT must be an integer between 1 and 65535, got %q", envs.RedisPort) + } + c.Data.Cache.Redis.Port = port + } + if envs.RedisUsername != "" { + c.Data.Cache.Redis.Username = envs.RedisUsername + } + if envs.RedisPassword != "" { + c.Data.Cache.Redis.Password = envs.RedisPassword + } + if envs.RedisDB != "" { + db, err := strconv.Atoi(envs.RedisDB) + if err != nil || db < 0 { + return fmt.Errorf("REDIS_DB must be a non-negative integer, got %q", envs.RedisDB) + } + c.Data.Cache.Redis.DB = db + } + if envs.RedisKeyPrefix != "" { + c.Data.Cache.Redis.KeyPrefix = envs.RedisKeyPrefix + } + if envs.RedisPoolSize != "" { + poolSize, err := strconv.Atoi(envs.RedisPoolSize) + if err != nil || poolSize <= 0 { + return fmt.Errorf( + "REDIS_POOL_SIZE must be a positive integer, got %q", + envs.RedisPoolSize, + ) + } + c.Data.Cache.Redis.PoolSize = poolSize + } + + return nil } // ReadConfig read config @@ -109,7 +215,9 @@ func ReadConfig(configFilePath string) (c *AllConfig, err error) { return nil, err } c.SetDefault() - c.SetEnvironmentOverrides() + if err = c.SetEnvironmentOverrides(); err != nil { + return nil, err + } return c, nil } diff --git a/internal/base/conf/conf_test.go b/internal/base/conf/conf_test.go new file mode 100644 index 000000000..f64e5e9fd --- /dev/null +++ b/internal/base/conf/conf_test.go @@ -0,0 +1,97 @@ +/* + * 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 conf + +import ( + "testing" + + "github.com/apache/answer/internal/base/data" + "github.com/apache/answer/internal/base/server" + "github.com/apache/answer/internal/router" + "github.com/stretchr/testify/require" +) + +func newTestConfig() *AllConfig { + return &AllConfig{ + Server: &Server{HTTP: &server.HTTP{}}, + Data: &Data{Cache: &data.CacheConf{}}, + Swaggerui: &router.SwaggerConfig{}, + } +} + +func TestRedisConfigDefaults(t *testing.T) { + config := newTestConfig() + config.SetDefault() + + require.Equal(t, data.CacheTypeRedis, config.Data.Cache.Type) + require.Equal(t, data.DefaultRedisHost, config.Data.Cache.Redis.Host) + require.Equal(t, data.DefaultRedisPort, config.Data.Cache.Redis.Port) + require.Equal(t, data.DefaultRedisKeyPrefix, config.Data.Cache.Redis.KeyPrefix) + require.Equal(t, data.DefaultRedisPoolSize, config.Data.Cache.Redis.PoolSize) + require.Equal(t, []string{"127.0.0.1", "::1"}, config.Server.HTTP.TrustedProxies) +} + +func TestRedisEnvironmentOverrides(t *testing.T) { + t.Setenv("CACHE_TYPE", "redis") + t.Setenv("REDIS_HOST", "redis.internal") + t.Setenv("REDIS_PORT", "6380") + t.Setenv("REDIS_USERNAME", "answer") + t.Setenv("REDIS_PASSWORD", "test-password") + t.Setenv("REDIS_DB", "3") + t.Setenv("REDIS_KEY_PREFIX", "hnu-forum:test:") + t.Setenv("REDIS_POOL_SIZE", "40") + + config := newTestConfig() + config.SetDefault() + require.NoError(t, config.SetEnvironmentOverrides()) + + require.Equal(t, "redis", config.Data.Cache.Type) + require.Equal(t, "redis.internal", config.Data.Cache.Redis.Host) + require.Equal(t, 6380, config.Data.Cache.Redis.Port) + require.Equal(t, "answer", config.Data.Cache.Redis.Username) + require.Equal(t, "test-password", config.Data.Cache.Redis.Password) + require.Equal(t, 3, config.Data.Cache.Redis.DB) + require.Equal(t, "hnu-forum:test:", config.Data.Cache.Redis.KeyPrefix) + require.Equal(t, 40, config.Data.Cache.Redis.PoolSize) +} + +func TestTrustedProxyEnvironmentOverride(t *testing.T) { + t.Setenv("TRUSTED_PROXIES", "10.0.0.2, 10.0.0.0/24") + config := newTestConfig() + config.SetDefault() + require.NoError(t, config.SetEnvironmentOverrides()) + require.Equal(t, []string{"10.0.0.2", "10.0.0.0/24"}, config.Server.HTTP.TrustedProxies) +} + +func TestTrustedProxyEnvironmentCanDisableProxyTrust(t *testing.T) { + t.Setenv("TRUSTED_PROXIES", "none") + config := newTestConfig() + config.SetDefault() + require.NoError(t, config.SetEnvironmentOverrides()) + require.Empty(t, config.Server.HTTP.TrustedProxies) +} + +func TestInvalidRedisEnvironment(t *testing.T) { + t.Setenv("REDIS_PORT", "invalid") + + config := newTestConfig() + config.SetDefault() + require.Error(t, config.SetEnvironmentOverrides()) +} diff --git a/internal/base/constant/email_tpl_key.go b/internal/base/constant/email_tpl_key.go index 2a06783f7..407416e80 100644 --- a/internal/base/constant/email_tpl_key.go +++ b/internal/base/constant/email_tpl_key.go @@ -32,8 +32,10 @@ const ( EmailTplKeyPassResetTitle = "email_tpl.pass_reset.title" EmailTplKeyPassResetBody = "email_tpl.pass_reset.body" - EmailTplKeyRegisterTitle = "email_tpl.register.title" - EmailTplKeyRegisterBody = "email_tpl.register.body" + EmailTplKeyRegisterTitle = "email_tpl.register.title" + EmailTplKeyRegisterBody = "email_tpl.register.body" + EmailTplKeyRegisterCodeTitle = "email_tpl.register_code.title" + EmailTplKeyRegisterCodeBody = "email_tpl.register_code.body" EmailTplKeyTestTitle = "email_tpl.test.title" EmailTplKeyTestBody = "email_tpl.test.body" diff --git a/internal/base/data/atomic_cache.go b/internal/base/data/atomic_cache.go new file mode 100644 index 000000000..e762b58e0 --- /dev/null +++ b/internal/base/data/atomic_cache.go @@ -0,0 +1,43 @@ +/* + * 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 data + +import ( + "context" + "time" +) + +// SlidingWindowRule describes one reusable Redis sliding-window limit. +type SlidingWindowRule struct { + Key string + Limit int64 + Window time.Duration +} + +// AtomicCache exposes cache operations that must remain atomic across app instances. +type AtomicCache interface { + SetIfAbsent(ctx context.Context, key, value string, ttl time.Duration) (stored bool, err error) + CheckAndRecordSlidingWindows( + ctx context.Context, + member string, + rules []SlidingWindowRule, + ) (retryAfter time.Duration, err error) + CompareAndDelete(ctx context.Context, key, expected string) (matched bool, err error) +} diff --git a/internal/base/data/config.go b/internal/base/data/config.go index 17a62c9c2..693229d2d 100644 --- a/internal/base/data/config.go +++ b/internal/base/data/config.go @@ -19,6 +19,16 @@ package data +const ( + CacheTypeMemory = "memory" + CacheTypeRedis = "redis" + + DefaultRedisHost = "127.0.0.1" + DefaultRedisPort = 6379 + DefaultRedisKeyPrefix = "hnu-forum:" + DefaultRedisPoolSize = 20 +) + // Database database config type Database struct { Driver string `json:"driver" mapstructure:"driver" yaml:"driver"` @@ -30,5 +40,18 @@ type Database struct { // CacheConf cache type CacheConf struct { - FilePath string `json:"file_path" mapstructure:"file_path" yaml:"file_path"` + Type string `json:"type" mapstructure:"type" yaml:"type"` + FilePath string `json:"file_path" mapstructure:"file_path" yaml:"file_path,omitempty"` + Redis RedisCacheConf `json:"redis" mapstructure:"redis" yaml:"redis"` +} + +// RedisCacheConf configures Redis as the shared application cache. +type RedisCacheConf struct { + Host string `json:"host" mapstructure:"host" yaml:"host"` + Port int `json:"port" mapstructure:"port" yaml:"port"` + Username string `json:"username" mapstructure:"username" yaml:"username,omitempty"` + Password string `json:"password" mapstructure:"password" yaml:"password,omitempty"` + DB int `json:"db" mapstructure:"db" yaml:"db"` + KeyPrefix string `json:"key_prefix" mapstructure:"key_prefix" yaml:"key_prefix"` + PoolSize int `json:"pool_size" mapstructure:"pool_size" yaml:"pool_size,omitempty"` } diff --git a/internal/base/data/data.go b/internal/base/data/data.go index 7696d8f56..d4c655e64 100644 --- a/internal/base/data/data.go +++ b/internal/base/data/data.go @@ -20,7 +20,10 @@ package data import ( + "errors" + "fmt" "path/filepath" + "strings" "time" "github.com/apache/answer/pkg/dir" @@ -96,6 +99,9 @@ func NewDB(debug bool, dataConf *Database) (*xorm.Engine, error) { // NewCache new cache instance func NewCache(c *CacheConf) (cache.Cache, func(), error) { + if c == nil { + return nil, func() {}, errors.New("cache config is required") + } var pluginCache plugin.Cache _ = plugin.CallCache(func(fn plugin.Cache) error { pluginCache = fn @@ -104,21 +110,43 @@ func NewCache(c *CacheConf) (cache.Cache, func(), error) { if pluginCache != nil { return pluginCache, func() {}, nil } + cacheType := strings.ToLower(strings.TrimSpace(c.Type)) + switch cacheType { + case CacheTypeRedis: + redisCache, err := NewRedisCache(c.Redis) + if err != nil { + return nil, func() {}, err + } + log.Infof("using redis cache with key prefix %s", c.Redis.KeyPrefix) + cleanup := func() { + if err := redisCache.Close(); err != nil { + log.Warn(err) + } + } + return redisCache, cleanup, nil + case "", CacheTypeMemory: + return newMemoryCache(c) + default: + return nil, func() {}, fmt.Errorf("unsupported cache type %q", c.Type) + } +} - // TODO What cache type should be initialized according to the configuration file +func newMemoryCache(c *CacheConf) (cache.Cache, func(), error) { memCache := memory.NewCache() if len(c.FilePath) > 0 { cacheFileDir := filepath.Dir(c.FilePath) log.Debugf("try to create cache directory %s", cacheFileDir) - err := dir.CreateDirIfNotExist(cacheFileDir) - if err != nil { + + if err := dir.CreateDirIfNotExist(cacheFileDir); err != nil { log.Errorf("create cache dir failed: %s", err) } + log.Infof("try to load cache file from %s", c.FilePath) if err := memory.Load(memCache, c.FilePath); err != nil { log.Warn(err) } + go func() { ticker := time.Tick(time.Minute) for range ticker { @@ -128,11 +156,16 @@ func NewCache(c *CacheConf) (cache.Cache, func(), error) { } }() } + cleanup := func() { + if c.FilePath == "" { + return + } log.Infof("try to save cache file to %s", c.FilePath) if err := memory.Save(memCache, c.FilePath); err != nil { log.Warn(err) } } + return memCache, cleanup, nil } diff --git a/internal/base/data/redis_cache.go b/internal/base/data/redis_cache.go new file mode 100644 index 000000000..dd06f9659 --- /dev/null +++ b/internal/base/data/redis_cache.go @@ -0,0 +1,296 @@ +/* + * 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 data + +import ( + "context" + "errors" + "fmt" + "net" + "strconv" + "strings" + "time" + + "github.com/redis/go-redis/v9" + "github.com/segmentfault/pacman/cache" +) + +const ( + redisPingTimeout = 5 * time.Second + redisScanSize = int64(500) +) + +var _ cache.Cache = (*RedisCache)(nil) +var _ AtomicCache = (*RedisCache)(nil) + +var changeInt64Script = redis.NewScript(` + if redis.call("EXISTS", KEYS[1]) == 0 then + return redis.error_reply("cache key does not exist") + end + return redis.call("INCRBY", KEYS[1], ARGV[1]) + `) + +var slidingWindowScript = redis.NewScript(` + local now = tonumber(ARGV[1]) + local member = ARGV[2] + local max_retry_after = 0 + + for index, key in ipairs(KEYS) do + local limit = tonumber(ARGV[2 + index * 2 - 1]) + local window = tonumber(ARGV[2 + index * 2]) + redis.call("ZREMRANGEBYSCORE", key, "-inf", now - window) + local count = redis.call("ZCARD", key) + if count >= limit then + local oldest = redis.call("ZRANGE", key, 0, 0, "WITHSCORES") + if #oldest > 0 then + local retry_after = tonumber(oldest[2]) + window - now + if retry_after > max_retry_after then + max_retry_after = retry_after + end + end + end + end + + if max_retry_after > 0 then + return max_retry_after + end + + for index, key in ipairs(KEYS) do + local window = tonumber(ARGV[2 + index * 2]) + redis.call("ZADD", key, now, member) + redis.call("PEXPIRE", key, window) + end + return 0 + `) + +var compareAndDeleteScript = redis.NewScript(` + local value = redis.call("GET", KEYS[1]) + if value and value == ARGV[1] then + redis.call("DEL", KEYS[1]) + return 1 + end + return 0 + `) + +type RedisCache struct { + client *redis.Client + keyPrefix string +} + +func NewRedisCache(conf RedisCacheConf) (*RedisCache, error) { + host := strings.TrimSpace(conf.Host) + keyPrefix := strings.TrimSpace(conf.KeyPrefix) + + if host == "" { + return nil, errors.New("redis cache host is required") + } + if conf.Port < 1 || conf.Port > 65535 { + return nil, fmt.Errorf("redis cache port must be between 1 and 65535, got %d", conf.Port) + } + if conf.DB < 0 { + return nil, fmt.Errorf("redis cache db must be non-negative, got %d", conf.DB) + } + if conf.PoolSize < 0 { + return nil, fmt.Errorf("redis cache pool size must be non-negative, got %d", conf.PoolSize) + } + if err := validateRedisKeyPrefix(keyPrefix); err != nil { + return nil, err + } + + poolSize := conf.PoolSize + if poolSize <= 0 { + poolSize = DefaultRedisPoolSize + } + options := &redis.Options{ + Addr: net.JoinHostPort(host, strconv.Itoa(conf.Port)), + Username: conf.Username, + Password: conf.Password, + DB: conf.DB, + PoolSize: poolSize, + DialTimeout: redisPingTimeout, + ReadTimeout: redisPingTimeout, + WriteTimeout: redisPingTimeout, + } + client := redis.NewClient(options) + ctx, cancel := context.WithTimeout(context.Background(), redisPingTimeout) + defer cancel() + if err := client.Ping(ctx).Err(); err != nil { + _ = client.Close() + return nil, fmt.Errorf("connect to redis at %s: %w", options.Addr, err) + } + return &RedisCache{ + client: client, + keyPrefix: keyPrefix, + }, nil +} + +// validateRedisKeyPrefix prevents one deployment from clearing another +// deployment's keys when Flush scans the shared Redis database. +func validateRedisKeyPrefix(prefix string) error { + if prefix == "" { + return errors.New("redis cache key prefix is required") + } + if !strings.HasSuffix(prefix, ":") { + return errors.New("redis cache key prefix must end with ':'") + } + if strings.ContainsAny(prefix, "*?[]\\") { + return errors.New("redis cache key prefix contains unsupported pattern characters") + } + return nil +} + +func (c *RedisCache) key(key string) string { + return c.keyPrefix + key +} + +// GetString returns a cached string. +func (c *RedisCache) GetString(ctx context.Context, key string) (string, bool, error) { + value, err := c.client.Get(ctx, c.key(key)).Result() + if errors.Is(err, redis.Nil) { + return "", false, nil + } + if err != nil { + return "", false, err + } + return value, true, nil +} + +// SetString stores a string with an optional TTL. A zero TTL does not expire, +// matching the existing memory cache behavior. +func (c *RedisCache) SetString(ctx context.Context, key string, value string, ttl time.Duration) error { + return c.client.Set(ctx, c.key(key), value, ttl).Err() +} + +// SetIfAbsent stores a value only when the key does not already exist. +func (c *RedisCache) SetIfAbsent(ctx context.Context, key, value string, ttl time.Duration) (bool, error) { + return c.client.SetNX(ctx, c.key(key), value, ttl).Result() +} + +// GetInt64 returns a cached int64. +func (c *RedisCache) GetInt64(ctx context.Context, key string) (int64, bool, error) { + value, exist, err := c.GetString(ctx, key) + if err != nil || !exist { + return 0, exist, err + } + + result, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return 0, true, fmt.Errorf("parse cached int64: %w", err) + } + + return result, true, nil +} + +// SetInt64 stores an int64 with an optional TTL. +func (c *RedisCache) SetInt64(ctx context.Context, key string, value int64, ttl time.Duration) error { + return c.client.Set(ctx, c.key(key), value, ttl).Err() +} + +// Increase atomically increments an existing int64 and preserves its TTL. +func (c *RedisCache) Increase(ctx context.Context, key string, value int64) (int64, error) { + return c.changeExistingInt64(ctx, key, value) +} + +// Decrease atomically decrements an existing int64 and preserves its TTL. +func (c *RedisCache) Decrease(ctx context.Context, key string, value int64) (int64, error) { + return c.changeExistingInt64(ctx, key, -value) +} + +func (c *RedisCache) changeExistingInt64(ctx context.Context, key string, delta int64) (int64, error) { + result, err := changeInt64Script.Run(ctx, c.client, []string{c.key(key)}, delta).Int64() + if err != nil { + return 0, err + } + return result, nil +} + +// Del deletes one cache key. +func (c *RedisCache) Del(ctx context.Context, key string) error { + return c.client.Del(ctx, c.key(key)).Err() +} + +// CheckAndRecordSlidingWindows checks all rules and records the request only +// when every rule allows it. The Lua script keeps this atomic across instances. +func (c *RedisCache) CheckAndRecordSlidingWindows( + ctx context.Context, + member string, + rules []SlidingWindowRule, +) (time.Duration, error) { + if len(rules) == 0 { + return 0, nil + } + if member == "" { + return 0, errors.New("sliding window member is required") + } + + keys := make([]string, 0, len(rules)) + args := make([]any, 0, 2+len(rules)*2) + args = append(args, time.Now().UnixMilli(), member) + for _, rule := range rules { + if rule.Key == "" || rule.Limit <= 0 || rule.Window <= 0 { + return 0, errors.New("invalid sliding window rule") + } + keys = append(keys, c.key(rule.Key)) + args = append(args, rule.Limit, rule.Window.Milliseconds()) + } + + retryAfterMilliseconds, err := slidingWindowScript.Run(ctx, c.client, keys, args...).Int64() + if err != nil { + return 0, err + } + return time.Duration(retryAfterMilliseconds) * time.Millisecond, nil +} + +// CompareAndDelete consumes a value only when it matches the expected value. +func (c *RedisCache) CompareAndDelete(ctx context.Context, key, expected string) (bool, error) { + result, err := compareAndDeleteScript.Run(ctx, c.client, []string{c.key(key)}, expected).Int64() + if err != nil { + return false, err + } + return result == 1, nil +} + +// Flush deletes only keys in this deployment's namespace. It never issues +// FLUSHDB or FLUSHALL because the Redis database may be shared. +func (c *RedisCache) Flush(ctx context.Context) error { + var cursor uint64 + pattern := c.keyPrefix + "*" + + for { + keys, nextCursor, err := c.client.Scan(ctx, cursor, pattern, redisScanSize).Result() + if err != nil { + return err + } + if len(keys) > 0 { + if err := c.client.Del(ctx, keys...).Err(); err != nil { + return err + } + } + cursor = nextCursor + if cursor == 0 { + return nil + } + } +} + +// Close releases the Redis connection pool. +func (c *RedisCache) Close() error { + return c.client.Close() +} diff --git a/internal/base/data/redis_cache_test.go b/internal/base/data/redis_cache_test.go new file mode 100644 index 000000000..7f94456df --- /dev/null +++ b/internal/base/data/redis_cache_test.go @@ -0,0 +1,340 @@ +/* + * 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 data + +import ( + "context" + "strconv" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/stretchr/testify/require" +) + +func newTestRedisCache( + t *testing.T, +) (*RedisCache, *miniredis.Miniredis) { + t.Helper() + + server := miniredis.RunT(t) + + redisCache, err := NewRedisCache(RedisCacheConf{ + Host: server.Host(), + Port: mustRedisPort(t, server.Port()), + KeyPrefix: "hnu-forum:test:", + PoolSize: 2, + }) + require.NoError(t, err) + + t.Cleanup(func() { + require.NoError(t, redisCache.Close()) + }) + + return redisCache, server +} + +func mustRedisPort(t *testing.T, port string) int { + t.Helper() + value, err := strconv.Atoi(port) + require.NoError(t, err) + return value +} + +func TestRedisCacheString(t *testing.T) { + redisCache, _ := newTestRedisCache(t) + ctx := context.Background() + + value, exist, err := redisCache.GetString(ctx, "missing") + require.NoError(t, err) + require.False(t, exist) + require.Empty(t, value) + + require.NoError( + t, + redisCache.SetString(ctx, "name", "answer", time.Minute), + ) + + value, exist, err = redisCache.GetString(ctx, "name") + require.NoError(t, err) + require.True(t, exist) + require.Equal(t, "answer", value) + + require.NoError(t, redisCache.Del(ctx, "name")) + + _, exist, err = redisCache.GetString(ctx, "name") + require.NoError(t, err) + require.False(t, exist) +} + +func TestRedisCacheTTL(t *testing.T) { + redisCache, server := newTestRedisCache(t) + ctx := context.Background() + + require.NoError( + t, + redisCache.SetString(ctx, "temporary", "value", time.Minute), + ) + + server.FastForward(time.Minute + time.Second) + + _, exist, err := redisCache.GetString(ctx, "temporary") + require.NoError(t, err) + require.False(t, exist) +} + +func TestRedisCacheSetIfAbsent(t *testing.T) { + redisCache, server := newTestRedisCache(t) + ctx := context.Background() + + stored, err := redisCache.SetIfAbsent(ctx, "lock", "first", time.Minute) + require.NoError(t, err) + require.True(t, stored) + + stored, err = redisCache.SetIfAbsent(ctx, "lock", "second", time.Minute) + require.NoError(t, err) + require.False(t, stored) + + value, exists, err := redisCache.GetString(ctx, "lock") + require.NoError(t, err) + require.True(t, exists) + require.Equal(t, "first", value) + + server.FastForward(time.Minute + time.Second) + stored, err = redisCache.SetIfAbsent(ctx, "lock", "third", time.Minute) + require.NoError(t, err) + require.True(t, stored) +} + +func TestRedisCacheAuthentication(t *testing.T) { + server := miniredis.RunT(t) + server.RequireAuth("test-password") + + redisCache, err := NewRedisCache(RedisCacheConf{ + Host: server.Host(), + Port: mustRedisPort(t, server.Port()), + Password: "test-password", + KeyPrefix: "hnu-forum:test:", + }) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, redisCache.Close()) + }) + + require.NoError(t, redisCache.SetString(context.Background(), "authenticated", "yes", time.Minute)) +} + +func TestRedisCacheInt64(t *testing.T) { + redisCache, server := newTestRedisCache(t) + ctx := context.Background() + + require.NoError( + t, + redisCache.SetInt64(ctx, "counter", 10, time.Minute), + ) + + ttlBefore := server.TTL(redisCache.key("counter")) + + value, err := redisCache.Increase(ctx, "counter", 5) + require.NoError(t, err) + require.Equal(t, int64(15), value) + + value, err = redisCache.Decrease(ctx, "counter", 3) + require.NoError(t, err) + require.Equal(t, int64(12), value) + + storedValue, exist, err := redisCache.GetInt64(ctx, "counter") + require.NoError(t, err) + require.True(t, exist) + require.Equal(t, int64(12), storedValue) + + ttlAfter := server.TTL(redisCache.key("counter")) + require.Equal(t, ttlBefore, ttlAfter) +} + +func TestRedisCacheIncreaseMissingKey(t *testing.T) { + redisCache, _ := newTestRedisCache(t) + + _, err := redisCache.Increase( + context.Background(), + "missing-counter", + 1, + ) + require.Error(t, err) +} + +func TestRedisCacheSlidingWindows(t *testing.T) { + redisCache, _ := newTestRedisCache(t) + ctx := context.Background() + rules := []SlidingWindowRule{ + {Key: "rate:email:minute", Limit: 1, Window: time.Minute}, + {Key: "rate:email:hour", Limit: 5, Window: time.Hour}, + {Key: "rate:ip:hour", Limit: 100, Window: time.Hour}, + } + + retryAfter, err := redisCache.CheckAndRecordSlidingWindows(ctx, "request-1", rules) + require.NoError(t, err) + require.Zero(t, retryAfter) + + retryAfter, err = redisCache.CheckAndRecordSlidingWindows(ctx, "request-2", rules) + require.NoError(t, err) + require.Greater(t, retryAfter, time.Duration(0)) + require.LessOrEqual(t, retryAfter, time.Minute) +} + +func TestRedisCacheSlidingWindowsDoNotPartiallyRecordRejectedRequest(t *testing.T) { + redisCache, _ := newTestRedisCache(t) + ctx := context.Background() + + _, err := redisCache.CheckAndRecordSlidingWindows(ctx, "request-1", []SlidingWindowRule{ + {Key: "rate:strict", Limit: 1, Window: time.Hour}, + {Key: "rate:loose", Limit: 10, Window: time.Hour}, + }) + require.NoError(t, err) + + retryAfter, err := redisCache.CheckAndRecordSlidingWindows(ctx, "request-2", []SlidingWindowRule{ + {Key: "rate:strict", Limit: 1, Window: time.Hour}, + {Key: "rate:loose", Limit: 10, Window: time.Hour}, + }) + require.NoError(t, err) + require.Greater(t, retryAfter, time.Duration(0)) + require.Equal(t, int64(1), redisCache.client.ZCard(ctx, redisCache.key("rate:loose")).Val()) +} + +func TestRedisCacheCompareAndDelete(t *testing.T) { + redisCache, _ := newTestRedisCache(t) + ctx := context.Background() + require.NoError(t, redisCache.SetString(ctx, "one-time-code", "digest", time.Minute)) + + matched, err := redisCache.CompareAndDelete(ctx, "one-time-code", "wrong") + require.NoError(t, err) + require.False(t, matched) + + matched, err = redisCache.CompareAndDelete(ctx, "one-time-code", "digest") + require.NoError(t, err) + require.True(t, matched) + + matched, err = redisCache.CompareAndDelete(ctx, "one-time-code", "digest") + require.NoError(t, err) + require.False(t, matched) +} + +func TestRedisCacheFlushOnlyOwnNamespace(t *testing.T) { + redisCache, _ := newTestRedisCache(t) + ctx := context.Background() + + require.NoError( + t, + redisCache.SetString(ctx, "owned", "value", time.Hour), + ) + require.NoError( + t, + redisCache.client.Set( + ctx, + "another-project:key", + "value", + time.Hour, + ).Err(), + ) + + require.NoError(t, redisCache.Flush(ctx)) + + _, exist, err := redisCache.GetString(ctx, "owned") + require.NoError(t, err) + require.False(t, exist) + + externalValue, err := redisCache.client.Get( + ctx, + "another-project:key", + ).Result() + require.NoError(t, err) + require.Equal(t, "value", externalValue) +} + +func TestRedisCacheConfigValidation(t *testing.T) { + testCases := []struct { + name string + conf RedisCacheConf + }{ + { + name: "missing host", + conf: RedisCacheConf{ + Port: 6379, + KeyPrefix: "hnu-forum:test:", + }, + }, + { + name: "invalid port", + conf: RedisCacheConf{ + Host: "127.0.0.1", + Port: 70000, + KeyPrefix: "hnu-forum:test:", + }, + }, + { + name: "invalid database", + conf: RedisCacheConf{ + Host: "127.0.0.1", + Port: 6379, + DB: -1, + KeyPrefix: "hnu-forum:test:", + }, + }, + { + name: "invalid pool size", + conf: RedisCacheConf{ + Host: "127.0.0.1", + Port: 6379, + KeyPrefix: "hnu-forum:test:", + PoolSize: -1, + }, + }, + { + name: "missing prefix", + conf: RedisCacheConf{ + Host: "127.0.0.1", + Port: 6379, + }, + }, + { + name: "prefix without separator", + conf: RedisCacheConf{ + Host: "127.0.0.1", + Port: 6379, + KeyPrefix: "hnu-forum", + }, + }, + { + name: "prefix with pattern", + conf: RedisCacheConf{ + Host: "127.0.0.1", + Port: 6379, + KeyPrefix: "hnu-forum:*:", + }, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + _, err := NewRedisCache(testCase.conf) + require.Error(t, err) + }) + } +} diff --git a/internal/base/reason/reason.go b/internal/base/reason/reason.go index b4c569a03..b34f68fff 100644 --- a/internal/base/reason/reason.go +++ b/internal/base/reason/reason.go @@ -48,6 +48,8 @@ const ( QuestionUnderReview = "error.question.under_review" QuestionContentCannotEmpty = "error.question.content_cannot_empty" QuestionContentLessThanMinimum = "error.question.content_less_than_minimum" + ForumSectionInvalid = "error.forum_section.invalid" + ForumSectionAdminOnly = "error.forum_section.admin_only" AnswerNotFound = "error.answer.not_found" AnswerCannotDeleted = "error.answer.cannot_deleted" AnswerCannotUpdate = "error.answer.cannot_update" @@ -71,6 +73,9 @@ const ( EmailVerifyURLExpired = "error.email.verify_url_expired" EmailNeedToBeVerified = "error.email.need_to_be_verified" EmailIllegalDomainError = "error.email.illegal_email_domain_error" + EmailVerificationCodeInvalid = "error.email.verification_code_invalid" + EmailSendTooFrequent = "error.email.send_too_frequent" + PasswordConfirmationMismatch = "error.password.confirmation_mismatch" UserSuspended = "error.user.suspended" ObjectNotFound = "error.object.not_found" TagNotFound = "error.tag.not_found" diff --git a/internal/base/server/config.go b/internal/base/server/config.go index 32b1a040d..b909b1eb0 100644 --- a/internal/base/server/config.go +++ b/internal/base/server/config.go @@ -21,7 +21,8 @@ package server // HTTP http config type HTTP struct { - Addr string `json:"addr" mapstructure:"addr"` + Addr string `json:"addr" mapstructure:"addr" yaml:"addr"` + TrustedProxies []string `json:"trusted_proxies" mapstructure:"trusted_proxies" yaml:"trusted_proxies"` } // UI ui config diff --git a/internal/cli/config.go b/internal/cli/config.go index e2445c590..0ebfe31a2 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -48,7 +48,7 @@ func SetDefaultConfig(dbConf *data.Database, cacheConf *data.CacheConf, field *C cache, cacheCleanup, err := data.NewCache(cacheConf) if err != nil { - fmt.Println("new cache failed") + return fmt.Errorf("initialize cache: %w", err) } defer func() { if cache != nil { diff --git a/internal/controller/answer_controller.go b/internal/controller/answer_controller.go index 6e16c6a85..f7d644bf9 100644 --- a/internal/controller/answer_controller.go +++ b/internal/controller/answer_controller.go @@ -106,7 +106,7 @@ func (ac *AnswerController) RemoveAnswer(ctx *gin.Context) { } req.CanDelete = canList[0] || objectOwner if !req.CanDelete { - handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil) + handler.HandleResponse(ctx, errors.Forbidden(reason.ForbiddenError), nil) return } @@ -143,7 +143,7 @@ func (ac *AnswerController) RecoverAnswer(ctx *gin.Context) { return } if !canList[0] { - handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil) + handler.HandleResponse(ctx, errors.Forbidden(reason.ForbiddenError), nil) return } @@ -239,7 +239,7 @@ func (ac *AnswerController) AddAnswer(ctx *gin.Context) { return } if !can { - handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil) + handler.HandleResponse(ctx, errors.Forbidden(reason.ForbiddenError), nil) return } @@ -337,7 +337,7 @@ func (ac *AnswerController) UpdateAnswer(ctx *gin.Context) { req.CanEdit = canList[0] || objectOwner req.NoNeedReview = canList[1] || objectOwner if !req.CanEdit { - handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil) + handler.HandleResponse(ctx, errors.Forbidden(reason.ForbiddenError), nil) return } @@ -428,7 +428,7 @@ func (ac *AnswerController) AcceptAnswer(ctx *gin.Context) { return } if !can { - handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil) + handler.HandleResponse(ctx, errors.Forbidden(reason.ForbiddenError), nil) return } diff --git a/internal/controller/comment_controller.go b/internal/controller/comment_controller.go index b9beead94..e44273c5b 100644 --- a/internal/controller/comment_controller.go +++ b/internal/controller/comment_controller.go @@ -117,7 +117,7 @@ func (cc *CommentController) AddComment(ctx *gin.Context) { req.CanEdit = canList[1] req.CanDelete = canList[2] if !req.CanAdd { - handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil) + handler.HandleResponse(ctx, errors.Forbidden(reason.ForbiddenError), nil) return } @@ -166,7 +166,7 @@ func (cc *CommentController) RemoveComment(ctx *gin.Context) { return } if !can { - handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil) + handler.HandleResponse(ctx, errors.Forbidden(reason.ForbiddenError), nil) return } @@ -206,7 +206,7 @@ func (cc *CommentController) UpdateComment(ctx *gin.Context) { req.CanEdit = canList[0] || cc.rankService.CheckOperationObjectOwner(ctx, req.UserID, req.CommentID) linkUrlLimitUser := canList[1] if !req.CanEdit { - handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil) + handler.HandleResponse(ctx, errors.Forbidden(reason.ForbiddenError), nil) return } diff --git a/internal/controller/controller.go b/internal/controller/controller.go index c31763bea..bf1eae105 100644 --- a/internal/controller/controller.go +++ b/internal/controller/controller.go @@ -32,6 +32,7 @@ var ProviderSetController = wire.NewSet( NewCollectionController, NewUserController, NewQuestionController, + NewForumSectionController, NewAnswerController, NewSearchController, NewRevisionController, diff --git a/internal/controller/forum_section_controller.go b/internal/controller/forum_section_controller.go new file mode 100644 index 000000000..96fcd6434 --- /dev/null +++ b/internal/controller/forum_section_controller.go @@ -0,0 +1,46 @@ +/* + * 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 controller + +import ( + "github.com/apache/answer/internal/base/handler" + forumsectionservice "github.com/apache/answer/internal/service/forum_section" + "github.com/gin-gonic/gin" +) + +type ForumSectionController struct { + service *forumsectionservice.Service +} + +func NewForumSectionController(service *forumsectionservice.Service) *ForumSectionController { + return &ForumSectionController{service: service} +} + +// List returns the campus section tree. +// @Summary list campus forum sections +// @Description returns parent sections and their child sections +// @Tags Forum Section +// @Produce json +// @Success 200 {object} handler.RespBody{data=[]schema.ForumSectionResp} +// @Router /answer/api/v1/forum/sections [get] +func (c *ForumSectionController) List(ctx *gin.Context) { + sections, err := c.service.ListTree(ctx) + handler.HandleResponse(ctx, err, sections) +} diff --git a/internal/controller/question_controller.go b/internal/controller/question_controller.go index 05ad319ab..30f302953 100644 --- a/internal/controller/question_controller.go +++ b/internal/controller/question_controller.go @@ -107,7 +107,7 @@ func (qc *QuestionController) RemoveQuestion(ctx *gin.Context) { return } if !can { - handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil) + handler.HandleResponse(ctx, errors.Forbidden(reason.ForbiddenError), nil) return } err = qc.questionService.RemoveQuestion(ctx, req) @@ -147,11 +147,11 @@ func (qc *QuestionController) OperationQuestion(ctx *gin.Context) { req.CanPin = canList[0] req.CanList = canList[1] if (req.Operation == schema.QuestionOperationPin || req.Operation == schema.QuestionOperationUnPin) && !req.CanPin { - handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil) + handler.HandleResponse(ctx, errors.Forbidden(reason.ForbiddenError), nil) return } if (req.Operation == schema.QuestionOperationHide || req.Operation == schema.QuestionOperationShow) && !req.CanList { - handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil) + handler.HandleResponse(ctx, errors.Forbidden(reason.ForbiddenError), nil) return } err = qc.questionService.OperationQuestion(ctx, req) @@ -181,7 +181,7 @@ func (qc *QuestionController) CloseQuestion(ctx *gin.Context) { return } if !can { - handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil) + handler.HandleResponse(ctx, errors.Forbidden(reason.ForbiddenError), nil) return } @@ -212,7 +212,7 @@ func (qc *QuestionController) ReopenQuestion(ctx *gin.Context) { return } if !can { - handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil) + handler.HandleResponse(ctx, errors.Forbidden(reason.ForbiddenError), nil) return } @@ -399,7 +399,7 @@ func (qc *QuestionController) AddQuestion(ctx *gin.Context) { }() req.UserID = middleware.GetLoginUserIDFromContext(ctx) - canList, requireRanks, err := qc.rankService.CheckOperationPermissionsForRanks(ctx, req.UserID, []string{ + canList, _, err := qc.rankService.CheckOperationPermissionsForRanks(ctx, req.UserID, []string{ permission.QuestionAdd, permission.QuestionEdit, permission.QuestionDelete, @@ -415,6 +415,7 @@ func (qc *QuestionController) AddQuestion(ctx *gin.Context) { } linkUrlLimitUser := canList[7] isAdmin := middleware.GetUserIsAdminModerator(ctx) + req.IsAdmin = middleware.GetIsAdminFromContext(ctx) if !isAdmin || !linkUrlLimitUser { captchaPass := qc.actionService.ActionRecordVerifyCaptcha(ctx, entity.CaptchaActionQuestion, req.UserID, req.CaptchaID, req.CaptchaCode) if !captchaPass { @@ -435,7 +436,7 @@ func (qc *QuestionController) AddQuestion(ctx *gin.Context) { req.CanUseReservedTag = canList[5] req.CanAddTag = canList[6] if !req.CanAdd { - handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil) + handler.HandleResponse(ctx, errors.Forbidden(reason.ForbiddenError), nil) return } @@ -446,9 +447,7 @@ func (qc *QuestionController) AddQuestion(ctx *gin.Context) { return } if !req.CanAddTag && hasNewTag { - lang := handler.GetLangByCtx(ctx) - msg := translator.TrWithData(lang, reason.NoEnoughRankToOperate, &schema.PermissionTrTplData{Rank: requireRanks[6]}) - handler.HandleResponse(ctx, errors.Forbidden(reason.NoEnoughRankToOperate).WithMsg(msg), nil) + handler.HandleResponse(ctx, errors.Forbidden(reason.ForbiddenError), nil) return } @@ -520,6 +519,7 @@ func (qc *QuestionController) AddQuestionByAnswer(ctx *gin.Context) { linkUrlLimitUser := canList[6] isAdmin := middleware.GetUserIsAdminModerator(ctx) + req.IsAdmin = middleware.GetIsAdminFromContext(ctx) if !isAdmin || !linkUrlLimitUser { captchaPass := qc.actionService.ActionRecordVerifyCaptcha(ctx, entity.CaptchaActionQuestion, req.UserID, req.CaptchaID, req.CaptchaCode) if !captchaPass { @@ -538,7 +538,7 @@ func (qc *QuestionController) AddQuestionByAnswer(ctx *gin.Context) { req.CanReopen = canList[4] req.CanUseReservedTag = canList[5] if !req.CanAdd { - handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil) + handler.HandleResponse(ctx, errors.Forbidden(reason.ForbiddenError), nil) return } questionReq := new(schema.QuestionAdd) @@ -628,7 +628,7 @@ func (qc *QuestionController) UpdateQuestion(ctx *gin.Context) { } req.ID = uid.DeShortID(req.ID) req.UserID = middleware.GetLoginUserIDFromContext(ctx) - canList, requireRanks, err := qc.rankService.CheckOperationPermissionsForRanks(ctx, req.UserID, []string{ + canList, _, err := qc.rankService.CheckOperationPermissionsForRanks(ctx, req.UserID, []string{ permission.QuestionEdit, permission.QuestionDelete, permission.QuestionEditWithoutReview, @@ -661,7 +661,7 @@ func (qc *QuestionController) UpdateQuestion(ctx *gin.Context) { req.CanUseReservedTag = canList[3] req.CanAddTag = canList[4] if !req.CanEdit { - handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil) + handler.HandleResponse(ctx, errors.Forbidden(reason.ForbiddenError), nil) return } @@ -682,9 +682,7 @@ func (qc *QuestionController) UpdateQuestion(ctx *gin.Context) { return } if !req.CanAddTag && hasNewTag { - lang := handler.GetLangByCtx(ctx) - msg := translator.TrWithData(lang, reason.NoEnoughRankToOperate, &schema.PermissionTrTplData{Rank: requireRanks[4]}) - handler.HandleResponse(ctx, errors.Forbidden(reason.NoEnoughRankToOperate).WithMsg(msg), nil) + handler.HandleResponse(ctx, errors.Forbidden(reason.ForbiddenError), nil) return } @@ -730,7 +728,7 @@ func (qc *QuestionController) QuestionRecover(ctx *gin.Context) { return } if !canList[0] { - handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil) + handler.HandleResponse(ctx, errors.Forbidden(reason.ForbiddenError), nil) return } @@ -783,7 +781,7 @@ func (qc *QuestionController) UpdateQuestionInviteUser(ctx *gin.Context) { req.CanInviteOtherToAnswer = canList[0] if !req.CanInviteOtherToAnswer { - handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil) + handler.HandleResponse(ctx, errors.Forbidden(reason.ForbiddenError), nil) return } err = qc.questionService.UpdateQuestionInviteUser(ctx, req) @@ -832,6 +830,26 @@ func (qc *QuestionController) UserTop(ctx *gin.Context) { }) } +// UserContentTop lists a user's top forum posts and top-level comments. +// It is a forum-oriented compatibility API over the question/answer model. +// @Summary UserContentTop +// @Description List a user's top forum posts and comments +// @Tags Personal +// @Accept json +// @Produce json +// @Param username query string true "username" +// @Success 200 {object} handler.RespBody +// @Router /answer/api/v1/personal/content/top [get] +func (qc *QuestionController) UserContentTop(ctx *gin.Context) { + userName := ctx.Query("username") + userID := middleware.GetLoginUserIDFromContext(ctx) + postList, commentList, err := qc.questionService.SearchUserTopList(ctx, userName, userID) + handler.HandleResponse(ctx, err, gin.H{ + "posts": postList, + "comments": commentList, + }) +} + // PersonalQuestionPage list personal questions // @Summary list personal questions // @Description list personal questions diff --git a/internal/controller/report_controller.go b/internal/controller/report_controller.go index 13b4c0953..2a981fcca 100644 --- a/internal/controller/report_controller.go +++ b/internal/controller/report_controller.go @@ -92,7 +92,7 @@ func (rc *ReportController) AddReport(ctx *gin.Context) { return } if !can { - handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil) + handler.HandleResponse(ctx, errors.Forbidden(reason.ForbiddenError), nil) return } diff --git a/internal/controller/revision_controller.go b/internal/controller/revision_controller.go index 57574375a..86801a846 100644 --- a/internal/controller/revision_controller.go +++ b/internal/controller/revision_controller.go @@ -186,7 +186,7 @@ func (rc *RevisionController) CheckCanUpdateRevision(ctx *gin.Context) { return } if !can { - handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil) + handler.HandleResponse(ctx, errors.Forbidden(reason.ForbiddenError), nil) return } diff --git a/internal/controller/tag_controller.go b/internal/controller/tag_controller.go index 56555cd88..5dffa342d 100644 --- a/internal/controller/tag_controller.go +++ b/internal/controller/tag_controller.go @@ -108,7 +108,7 @@ func (tc *TagController) RemoveTag(ctx *gin.Context) { return } if !can { - handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil) + handler.HandleResponse(ctx, errors.Forbidden(reason.ForbiddenError), nil) return } err = tc.tagService.RemoveTag(ctx, req) @@ -140,7 +140,7 @@ func (tc *TagController) AddTag(ctx *gin.Context) { return } if !canList[0] { - handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil) + handler.HandleResponse(ctx, errors.Forbidden(reason.ForbiddenError), nil) return } @@ -174,7 +174,7 @@ func (tc *TagController) UpdateTag(ctx *gin.Context) { return } if !canList[0] { - handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil) + handler.HandleResponse(ctx, errors.Forbidden(reason.ForbiddenError), nil) return } req.NoNeedReview = canList[1] @@ -212,7 +212,7 @@ func (tc *TagController) RecoverTag(ctx *gin.Context) { return } if !canList[0] { - handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil) + handler.HandleResponse(ctx, errors.Forbidden(reason.ForbiddenError), nil) return } @@ -349,7 +349,7 @@ func (tc *TagController) UpdateTagSynonym(ctx *gin.Context) { return } if !can { - handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil) + handler.HandleResponse(ctx, errors.Forbidden(reason.ForbiddenError), nil) return } @@ -375,7 +375,7 @@ func (tc *TagController) MergeTag(ctx *gin.Context) { isAdminModerator := middleware.GetUserIsAdminModerator(ctx) if !isAdminModerator { - handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil) + handler.HandleResponse(ctx, errors.Forbidden(reason.ForbiddenError), nil) return } diff --git a/internal/controller/user_controller.go b/internal/controller/user_controller.go index 531d88b45..dfe3472fe 100644 --- a/internal/controller/user_controller.go +++ b/internal/controller/user_controller.go @@ -20,6 +20,7 @@ package controller import ( + "net/http" "net/url" "github.com/apache/answer/internal/base/constant" @@ -42,6 +43,11 @@ import ( "github.com/segmentfault/pacman/log" ) +var registrationEmailDomains = []string{ + "hainanu.edu.cn", + "alumni.hainanu.edu.cn", +} + // UserController user controller type UserController struct { userService *content.UserService @@ -275,24 +281,18 @@ func (uc *UserController) UserRegisterByEmail(ctx *gin.Context) { if handler.BindAndCheck(ctx, req) { return } - if !checker.EmailInAllowEmailDomain(req.Email, siteInfo.AllowEmailDomains) { - handler.HandleResponse(ctx, errors.BadRequest(reason.EmailIllegalDomainError), nil) + if !checker.EmailInAllowEmailDomain(req.Email, registrationEmailDomains) { + errFields := []*validator.FormErrorField{{ + ErrorField: "e_mail", + ErrorMsg: translator.Tr( + handler.GetLangByCtx(ctx), + reason.EmailIllegalDomainError, + ), + }} + handler.HandleResponse(ctx, errors.BadRequest(reason.EmailIllegalDomainError), errFields) return } - req.RequireEmailVerification = siteInfo.RequireEmailVerification req.IP = ctx.ClientIP() - isAdmin := middleware.GetUserIsAdminModerator(ctx) - if !isAdmin { - captchaPass := uc.actionService.ActionRecordVerifyCaptcha(ctx, entity.CaptchaActionEmail, req.IP, req.CaptchaID, req.CaptchaCode) - if !captchaPass { - errFields := append([]*validator.FormErrorField{}, &validator.FormErrorField{ - ErrorField: "captcha_code", - ErrorMsg: translator.Tr(handler.GetLangByCtx(ctx), reason.CaptchaVerificationFailed), - }) - handler.HandleResponse(ctx, errors.BadRequest(reason.CaptchaVerificationFailed), errFields) - return - } - } resp, errFields, err := uc.userService.UserRegisterByEmail(ctx, req) if len(errFields) > 0 { @@ -306,6 +306,81 @@ func (uc *UserController) UserRegisterByEmail(ctx *gin.Context) { } } +// UserRegisterEmailCodeSend godoc +// @Summary Send registration email verification code +// @Description Sends a six-digit registration code after captcha and rate-limit checks +// @Tags User +// @Accept json +// @Produce json +// @Param data body schema.UserRegisterEmailCodeReq true "UserRegisterEmailCodeReq" +// @Success 200 {object} handler.RespBody +// @Failure 429 {object} handler.RespBody{data=schema.RetryAfterResp} +// @Router /answer/api/v1/user/register/email/code [post] +func (uc *UserController) UserRegisterEmailCodeSend(ctx *gin.Context) { + siteInfo, err := uc.siteInfoCommonService.GetSiteLogin(ctx) + if err != nil { + handler.HandleResponse(ctx, err, nil) + return + } + if !siteInfo.AllowNewRegistrations || !siteInfo.AllowEmailRegistrations { + handler.HandleResponse(ctx, errors.BadRequest(reason.NotAllowedRegistration), nil) + return + } + + req := &schema.UserRegisterEmailCodeReq{} + if handler.BindAndCheck(ctx, req) { + return + } + if !checker.EmailInAllowEmailDomain(req.Email, registrationEmailDomains) { + errFields := []*validator.FormErrorField{{ + ErrorField: "e_mail", + ErrorMsg: translator.Tr( + handler.GetLangByCtx(ctx), + reason.EmailIllegalDomainError, + ), + }} + handler.HandleResponse(ctx, errors.BadRequest(reason.EmailIllegalDomainError), errFields) + return + } + + req.IP = ctx.ClientIP() + if !uc.actionService.ActionRecordVerifyCaptcha( + ctx, + entity.CaptchaActionEmail, + req.IP, + req.CaptchaID, + req.CaptchaCode, + ) { + errFields := []*validator.FormErrorField{{ + ErrorField: "captcha_code", + ErrorMsg: translator.Tr( + handler.GetLangByCtx(ctx), + reason.CaptchaVerificationFailed, + ), + }} + handler.HandleResponse(ctx, errors.BadRequest(reason.CaptchaVerificationFailed), errFields) + return + } + + retryAfter, errFields, err := uc.userService.UserRegisterEmailCodeSend(ctx, req) + if len(errFields) > 0 { + for _, field := range errFields { + field.ErrorMsg = translator.Tr(handler.GetLangByCtx(ctx), field.ErrorMsg) + } + handler.HandleResponse(ctx, err, errFields) + return + } + if retryAfter > 0 { + handler.HandleResponse( + ctx, + errors.New(http.StatusTooManyRequests, reason.EmailSendTooFrequent), + &schema.RetryAfterResp{RetryAfter: retryAfter}, + ) + return + } + handler.HandleResponse(ctx, err, nil) +} + // UserVerifyEmail godoc // @Summary UserVerifyEmail // @Description UserVerifyEmail diff --git a/internal/controller/vote_controller.go b/internal/controller/vote_controller.go index 302796677..30727fb67 100644 --- a/internal/controller/vote_controller.go +++ b/internal/controller/vote_controller.go @@ -73,15 +73,13 @@ func (vc *VoteController) VoteUp(ctx *gin.Context) { req.ObjectID = uid.DeShortID(req.ObjectID) req.UserID = middleware.GetLoginUserIDFromContext(ctx) - can, needRank, err := vc.rankService.CheckVotePermission(ctx, req.UserID, req.ObjectID, true) + can, _, err := vc.rankService.CheckVotePermission(ctx, req.UserID, req.ObjectID, true) if err != nil { handler.HandleResponse(ctx, err, nil) return } if !can { - lang := handler.GetLangByCtx(ctx) - msg := translator.TrWithData(lang, reason.NoEnoughRankToOperate, &schema.PermissionTrTplData{Rank: needRank}) - handler.HandleResponse(ctx, errors.Forbidden(reason.NoEnoughRankToOperate).WithMsg(msg), nil) + handler.HandleResponse(ctx, errors.Forbidden(reason.ForbiddenError), nil) return } @@ -128,15 +126,13 @@ func (vc *VoteController) VoteDown(ctx *gin.Context) { req.UserID = middleware.GetLoginUserIDFromContext(ctx) isAdmin := middleware.GetUserIsAdminModerator(ctx) - can, needRank, err := vc.rankService.CheckVotePermission(ctx, req.UserID, req.ObjectID, false) + can, _, err := vc.rankService.CheckVotePermission(ctx, req.UserID, req.ObjectID, false) if err != nil { handler.HandleResponse(ctx, err, nil) return } if !can { - lang := handler.GetLangByCtx(ctx) - msg := translator.TrWithData(lang, reason.NoEnoughRankToOperate, &schema.PermissionTrTplData{Rank: needRank}) - handler.HandleResponse(ctx, errors.Forbidden(reason.NoEnoughRankToOperate).WithMsg(msg), nil) + handler.HandleResponse(ctx, errors.Forbidden(reason.ForbiddenError), nil) return } diff --git a/internal/entity/forum_section_entity.go b/internal/entity/forum_section_entity.go new file mode 100644 index 000000000..65dae79ca --- /dev/null +++ b/internal/entity/forum_section_entity.go @@ -0,0 +1,41 @@ +/* + * 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 entity + +import "time" + +const ForumSectionStatusAvailable = 1 + +// ForumSection is a fixed campus forum category. Only leaf sections accept posts. +type ForumSection struct { + ID int64 `xorm:"not null pk BIGINT(20) id"` + CreatedAt time.Time `xorm:"created TIMESTAMP created_at"` + UpdatedAt time.Time `xorm:"updated TIMESTAMP updated_at"` + ParentID int64 `xorm:"not null default 0 BIGINT(20) INDEX parent_id"` + Slug string `xorm:"not null unique VARCHAR(50) slug"` + Name string `xorm:"not null VARCHAR(50) name"` + Sort int `xorm:"not null default 0 INT(11) sort"` + AdminOnly bool `xorm:"not null default false BOOL admin_only"` + Status int `xorm:"not null default 1 INT(11) status"` +} + +func (ForumSection) TableName() string { + return "forum_section" +} diff --git a/internal/entity/question_entity.go b/internal/entity/question_entity.go index 9e5dcd112..4cdf5b7b4 100644 --- a/internal/entity/question_entity.go +++ b/internal/entity/question_entity.go @@ -54,6 +54,7 @@ type Question struct { CreatedAt time.Time `xorm:"not null default CURRENT_TIMESTAMP TIMESTAMP created_at"` UpdatedAt time.Time `xorm:"updated_at TIMESTAMP"` UserID string `xorm:"not null default 0 BIGINT(20) INDEX user_id"` + SectionID int64 `xorm:"not null default 0 BIGINT(20) INDEX section_id"` InviteUserID string `xorm:"TEXT invite_user_id"` LastEditUserID string `xorm:"not null default 0 BIGINT(20) last_edit_user_id"` Title string `xorm:"not null default '' VARCHAR(150) title"` diff --git a/internal/migrations/migrations.go b/internal/migrations/migrations.go index 59fbb7bea..e0e6199c2 100644 --- a/internal/migrations/migrations.go +++ b/internal/migrations/migrations.go @@ -110,6 +110,7 @@ var migrations = []Migration{ NewMigration("v2.0.1", "change avatar type to text", updateAvatarType, false), NewMigration("v2.0.2", "add reasoning content to ai conversation record", addAIConversationReasoningContent, false), NewMigration("v2.0.3", "add require email verification login setting", addRequireEmailVerification, true), + NewMigration("v2.0.4", "add campus forum sections", addCampusForumSections, true), } func GetMigrations() []Migration { @@ -146,8 +147,9 @@ func ExpectedVersion() int64 { func Migrate(debug bool, dbConf *data.Database, cacheConf *data.CacheConf, upgradeToSpecificVersion string) error { cache, cacheCleanup, err := data.NewCache(cacheConf) if err != nil { - fmt.Println("new cache failed:", err.Error()) + return fmt.Errorf("new cache failed: %w", err) } + defer cacheCleanup() engine, err := data.NewDB(debug, dbConf) if err != nil { fmt.Println("new database failed: ", err.Error()) @@ -193,8 +195,5 @@ func Migrate(debug bool, dbConf *data.Database, cacheConf *data.CacheConf, upgra } currentDBVersion++ } - if cache != nil { - cacheCleanup() - } return nil } diff --git a/internal/migrations/v35.go b/internal/migrations/v35.go new file mode 100644 index 000000000..85556f0bf --- /dev/null +++ b/internal/migrations/v35.go @@ -0,0 +1,72 @@ +/* + * 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" + "fmt" + + "github.com/apache/answer/internal/entity" + "xorm.io/xorm" +) + +func addCampusForumSections(ctx context.Context, x *xorm.Engine) error { + if err := x.Context(ctx).Sync(new(entity.ForumSection), new(entity.Question)); err != nil { + return fmt.Errorf("sync campus forum sections failed: %w", err) + } + + sections := []*entity.ForumSection{ + {ID: 1, Slug: "career-future", Name: "前程似锦", Sort: 10, Status: 1}, + {ID: 2, Slug: "hainanu-campus", Name: "海大校园", Sort: 20, Status: 1}, + {ID: 3, Slug: "technology-life", Name: "科技生活", Sort: 30, Status: 1}, + {ID: 4, Slug: "life-information", Name: "生活信息", Sort: 40, Status: 1}, + {ID: 5, Slug: "site-management", Name: "站务管理", Sort: 50, Status: 1}, + {ID: 101, ParentID: 1, Slug: "employment-startup", Name: "就业创业", Sort: 101, Status: 1}, + {ID: 102, ParentID: 1, Slug: "study-abroad", Name: "出国留学", Sort: 102, Status: 1}, + {ID: 103, ParentID: 1, Slug: "civil-service", Name: "公考选调", Sort: 103, Status: 1}, + {ID: 104, ParentID: 1, Slug: "postgraduate", Name: "保研考研", Sort: 104, Status: 1}, + {ID: 201, ParentID: 2, Slug: "freshmen", Name: "新生专区", Sort: 201, Status: 1}, + {ID: 202, ParentID: 2, Slug: "graduation", Name: "毕业感言", Sort: 202, Status: 1}, + {ID: 203, ParentID: 2, Slug: "food-entertainment", Name: "吃喝玩乐", Sort: 203, Status: 1}, + {ID: 204, ParentID: 2, Slug: "campus-matchmaking", Name: "海大鹊桥", Sort: 204, Status: 1}, + {ID: 205, ParentID: 2, Slug: "campus-hotspot", Name: "校园热点", Sort: 205, Status: 1}, + {ID: 301, ParentID: 3, Slug: "exams", Name: "考试专区", Sort: 301, Status: 1}, + {ID: 302, ParentID: 3, Slug: "academic-exchange", Name: "学术交流", Sort: 302, Status: 1}, + {ID: 303, ParentID: 3, Slug: "programming", Name: "程序之家", Sort: 303, Status: 1}, + {ID: 304, ParentID: 3, Slug: "competitions", Name: "竞赛专区", Sort: 304, Status: 1}, + {ID: 401, ParentID: 4, Slug: "second-hand", Name: "二手专区", Sort: 401, Status: 1}, + {ID: 402, ParentID: 4, Slug: "lost-found", Name: "失物招领", Sort: 402, Status: 1}, + {ID: 403, ParentID: 4, Slug: "carpool", Name: "拼车同行", Sort: 403, Status: 1}, + {ID: 404, ParentID: 4, Slug: "part-time-jobs", Name: "兼职信息中心", Sort: 404, Status: 1}, + {ID: 501, ParentID: 5, Slug: "site-announcements", Name: "站务公告", Sort: 501, AdminOnly: true, Status: 1}, + } + for _, section := range sections { + exists, err := x.Context(ctx).ID(section.ID).Exist(new(entity.ForumSection)) + if err != nil { + return err + } + if !exists { + if _, err = x.Context(ctx).Insert(section); err != nil { + return fmt.Errorf("insert forum section %s: %w", section.Slug, err) + } + } + } + return nil +} diff --git a/internal/repo/activity/vote_repo.go b/internal/repo/activity/vote_repo.go index 389ae18d8..38355e91a 100644 --- a/internal/repo/activity/vote_repo.go +++ b/internal/repo/activity/vote_repo.go @@ -198,7 +198,22 @@ func (vr *VoteRepo) ListUserVotes(ctx context.Context, userID string, builder.In("activity_type", activityTypes), ) - session.Where(cond).Desc("updated_at") + session.Where(cond) + session.And(`( + EXISTS ( + SELECT 1 FROM question + WHERE question.id = activity.object_id + AND question.status != ? + ) + OR EXISTS ( + SELECT 1 FROM answer + INNER JOIN question ON question.id = answer.question_id + WHERE answer.id = activity.object_id + AND answer.status != ? + AND question.status != ? + ) + )`, entity.QuestionStatusDeleted, entity.AnswerStatusDeleted, entity.QuestionStatusDeleted) + session.Desc("updated_at") total, err = pager.Help(page, pageSize, &voteList, &entity.Activity{}, session) if err != nil { diff --git a/internal/repo/answer/answer_repo.go b/internal/repo/answer/answer_repo.go index 42e3494a8..c103b4a4c 100644 --- a/internal/repo/answer/answer_repo.go +++ b/internal/repo/answer/answer_repo.go @@ -394,6 +394,11 @@ func (ar *answerRepo) GetPersonalAnswerPage(ctx context.Context, req *entity.Per UserID: req.UserID, } session := ar.data.DB.Context(ctx) + session.And(`EXISTS ( + SELECT 1 FROM question + WHERE question.id = answer.question_id + AND question.status != ? + )`, entity.QuestionStatusDeleted) switch req.Order { case entity.AnswerSearchOrderByTime: session = session.OrderBy("created_at desc") diff --git a/internal/repo/collection/collection_repo.go b/internal/repo/collection/collection_repo.go index 482cb075d..99bd30637 100644 --- a/internal/repo/collection/collection_repo.go +++ b/internal/repo/collection/collection_repo.go @@ -207,6 +207,11 @@ func (cr *collectionRepo) SearchList(ctx context.Context, search *entity.Collect } else { return rows, count, nil } + session = session.And(`EXISTS ( + SELECT 1 FROM question + WHERE question.id = collection.object_id + AND question.status != ? + )`, entity.QuestionStatusDeleted) session = session.Limit(search.PageSize, offset) count, err = session.OrderBy("updated_at desc").FindAndCount(&rows) if err != nil { diff --git a/internal/repo/comment/comment_repo.go b/internal/repo/comment/comment_repo.go index a0d091053..541d58e19 100644 --- a/internal/repo/comment/comment_repo.go +++ b/internal/repo/comment/comment_repo.go @@ -150,6 +150,18 @@ func (cr *commentRepo) GetCommentPage(ctx context.Context, commentQuery *comment session := cr.data.DB.Context(ctx) session.OrderBy(commentQuery.GetOrderBy()) session.Where("status = ?", entity.CommentStatusAvailable) + if commentQuery.ExcludeDeletedContent { + session.And(`EXISTS ( + SELECT 1 FROM question + WHERE question.id = comment.question_id + AND question.status != ? + )`, entity.QuestionStatusDeleted) + session.And(`NOT EXISTS ( + SELECT 1 FROM answer + WHERE answer.id = comment.object_id + AND answer.status = ? + )`, entity.AnswerStatusDeleted) + } cond := &entity.Comment{ObjectID: commentQuery.ObjectID, UserID: commentQuery.UserID} total, err = pager.Help(commentQuery.Page, commentQuery.PageSize, &commentList, cond, session) diff --git a/internal/repo/forum_section/forum_section_repo.go b/internal/repo/forum_section/forum_section_repo.go new file mode 100644 index 000000000..2dbba5363 --- /dev/null +++ b/internal/repo/forum_section/forum_section_repo.go @@ -0,0 +1,65 @@ +/* + * 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 forum_section + +import ( + "context" + "strings" + + "github.com/apache/answer/internal/base/data" + "github.com/apache/answer/internal/base/reason" + "github.com/apache/answer/internal/entity" + forumsectionservice "github.com/apache/answer/internal/service/forum_section" + "github.com/segmentfault/pacman/errors" +) + +type repo struct{ data *data.Data } + +func NewForumSectionRepo(dataSource *data.Data) forumsectionservice.Repo { + return &repo{data: dataSource} +} + +func (r *repo) List(ctx context.Context) ([]*entity.ForumSection, error) { + list := make([]*entity.ForumSection, 0) + err := r.data.DB.Context(ctx).Where("status = ?", entity.ForumSectionStatusAvailable). + Asc("sort", "id").Find(&list) + if err != nil { + return nil, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return list, nil +} + +func (r *repo) GetByID(ctx context.Context, id int64) (*entity.ForumSection, bool, error) { + section := &entity.ForumSection{ID: id} + exists, err := r.data.DB.Context(ctx).Get(section) + if err != nil { + return nil, false, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return section, exists, nil +} + +func (r *repo) GetBySlug(ctx context.Context, slug string) (*entity.ForumSection, bool, error) { + section := &entity.ForumSection{} + exists, err := r.data.DB.Context(ctx).Where("slug = ?", strings.ToLower(strings.TrimSpace(slug))).Get(section) + if err != nil { + return nil, false, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return section, exists, nil +} diff --git a/internal/repo/provider.go b/internal/repo/provider.go index 510a94aaa..ead2a24fb 100644 --- a/internal/repo/provider.go +++ b/internal/repo/provider.go @@ -36,6 +36,7 @@ import ( "github.com/apache/answer/internal/repo/config" "github.com/apache/answer/internal/repo/export" "github.com/apache/answer/internal/repo/file_record" + "github.com/apache/answer/internal/repo/forum_section" "github.com/apache/answer/internal/repo/limit" "github.com/apache/answer/internal/repo/meta" "github.com/apache/answer/internal/repo/notification" @@ -43,6 +44,7 @@ import ( "github.com/apache/answer/internal/repo/question" "github.com/apache/answer/internal/repo/rank" "github.com/apache/answer/internal/repo/reason" + "github.com/apache/answer/internal/repo/registration" "github.com/apache/answer/internal/repo/report" "github.com/apache/answer/internal/repo/review" "github.com/apache/answer/internal/repo/revision" @@ -93,6 +95,7 @@ var ProviderSetRepo = wire.NewSet( search_common.NewSearchRepo, meta.NewMetaRepo, export.NewEmailRepo, + registration.NewRegistrationRepo, reason.NewReasonRepo, site_info.NewSiteInfo, notification.NewNotificationRepo, @@ -111,6 +114,7 @@ var ProviderSetRepo = wire.NewSet( badge_group.NewBadgeGroupRepo, badge_award.NewBadgeAwardRepo, file_record.NewFileRecordRepo, + forum_section.NewForumSectionRepo, api_key.NewAPIKeyRepo, ai_conversation.NewAIConversationRepo, ) diff --git a/internal/repo/question/question_repo.go b/internal/repo/question/question_repo.go index 3449efb8b..a6c3f8152 100644 --- a/internal/repo/question/question_repo.go +++ b/internal/repo/question/question_repo.go @@ -393,7 +393,7 @@ func (qr *questionRepo) SitemapQuestions(ctx context.Context, page, pageSize int // GetQuestionPage query question page func (qr *questionRepo) GetQuestionPage(ctx context.Context, page, pageSize int, - tagIDs []string, userID, orderCond string, inDays int, showHidden, showPending bool) ( + tagIDs []string, sectionIDs []int64, userID, orderCond string, inDays int, showHidden, showPending bool) ( questionList []*entity.Question, total int64, err error) { questionList = make([]*entity.Question, 0) session := qr.data.DB.Context(ctx) @@ -411,6 +411,9 @@ func (qr *questionRepo) GetQuestionPage(ctx context.Context, page, pageSize int, session.In("tag_rel.tag_id", tagIDs) session.And("tag_rel.status = ?", entity.TagRelStatusAvailable) } + if len(sectionIDs) > 0 { + session.In("question.section_id", sectionIDs) + } if len(userID) > 0 { session.And("question.user_id = ?", userID) if !showHidden { @@ -420,7 +423,11 @@ func (qr *questionRepo) GetQuestionPage(ctx context.Context, page, pageSize int, session.And("question.show = ?", entity.QuestionShow) } if inDays > 0 { - session.And("question.created_at > ?", time.Now().AddDate(0, 0, -inDays)) + if orderCond == schema.QuestionOrderCondActive { + session.And("question.post_update_time > ?", time.Now().AddDate(0, 0, -inDays)) + } else { + session.And("question.created_at > ?", time.Now().AddDate(0, 0, -inDays)) + } } switch orderCond { diff --git a/internal/repo/registration/registration_repo.go b/internal/repo/registration/registration_repo.go new file mode 100644 index 000000000..c8da510b1 --- /dev/null +++ b/internal/repo/registration/registration_repo.go @@ -0,0 +1,183 @@ +/* + * 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 registration + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" + "time" + + "github.com/apache/answer/internal/base/data" + "github.com/apache/answer/internal/base/reason" + registrationservice "github.com/apache/answer/internal/service/registration" + "github.com/apache/answer/pkg/token" + "github.com/segmentfault/pacman/errors" +) + +const ( + emailCodeKeyPrefix = "answer:register:email-code:" + emailCodeLockKeyPrefix = "answer:register:email-code-lock:" + emailMinuteRateKeyPrefix = "answer:register:rate:email-minute:" + emailHourRateKeyPrefix = "answer:register:rate:email-hour:" + ipHourRateKeyPrefix = "answer:register:rate:ip-hour:" +) + +type registrationRepo struct { + data *data.Data +} + +// NewRegistrationRepo creates the Redis-backed registration security repository. +func NewRegistrationRepo(dataSource *data.Data) registrationservice.SecurityRepo { + return ®istrationRepo{data: dataSource} +} + +func (r *registrationRepo) CheckAndRecordSendLimit( + ctx context.Context, + email, ip string, +) (time.Duration, error) { + atomicCache, ok := r.data.Cache.(data.AtomicCache) + if !ok { + return 0, errors.InternalServer(reason.DatabaseError). + WithError(fmt.Errorf("registration rate limiting requires an atomic cache")). + WithStack() + } + + emailHash := hashValue(normalizeEmail(email)) + ipHash := hashValue(strings.TrimSpace(ip)) + rules := []data.SlidingWindowRule{ + {Key: emailMinuteRateKeyPrefix + emailHash, Limit: 1, Window: registrationservice.EmailCooldown}, + {Key: emailHourRateKeyPrefix + emailHash, Limit: registrationservice.EmailHourlyLimit, Window: registrationservice.EmailHourlyWindow}, + {Key: ipHourRateKeyPrefix + ipHash, Limit: registrationservice.IPHourlyLimit, Window: registrationservice.IPHourlyWindow}, + } + + retryAfter, err := atomicCache.CheckAndRecordSlidingWindows(ctx, token.GenerateToken(), rules) + if err != nil { + return 0, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return retryAfter, nil +} + +func (r *registrationRepo) SaveEmailCode( + ctx context.Context, + email, code string, + ttl time.Duration, +) error { + err := r.data.Cache.SetString(ctx, emailCodeKey(email), codeDigest(email, code), ttl) + if err != nil { + return errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return nil +} + +func (r *registrationRepo) VerifyAndLockEmailCode( + ctx context.Context, + email, code string, +) (string, bool, error) { + atomicCache, ok := r.data.Cache.(data.AtomicCache) + if !ok { + return "", false, errors.InternalServer(reason.DatabaseError). + WithError(fmt.Errorf("registration code verification requires an atomic cache")). + WithStack() + } + + verificationToken := token.GenerateToken() + locked, err := atomicCache.SetIfAbsent( + ctx, + emailCodeLockKey(email), + verificationToken, + registrationservice.EmailCodeVerificationLockTTL, + ) + if err != nil { + return "", false, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + if !locked { + return "", false, nil + } + + storedDigest, exists, err := r.data.Cache.GetString(ctx, emailCodeKey(email)) + if err != nil { + _ = r.ReleaseEmailCodeLock(ctx, email, verificationToken) + return "", false, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + if !exists || storedDigest != codeDigest(email, code) { + if err = r.ReleaseEmailCodeLock(ctx, email, verificationToken); err != nil { + return "", false, err + } + return "", false, nil + } + return verificationToken, true, nil +} + +func (r *registrationRepo) DeleteEmailCodeIfMatches(ctx context.Context, email, code string) (bool, error) { + atomicCache, ok := r.data.Cache.(data.AtomicCache) + if !ok { + return false, errors.InternalServer(reason.DatabaseError). + WithError(fmt.Errorf("registration code deletion requires an atomic cache")). + WithStack() + } + + matched, err := atomicCache.CompareAndDelete(ctx, emailCodeKey(email), codeDigest(email, code)) + if err != nil { + return false, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return matched, nil +} + +func (r *registrationRepo) ReleaseEmailCodeLock( + ctx context.Context, + email, verificationToken string, +) error { + atomicCache, ok := r.data.Cache.(data.AtomicCache) + if !ok { + return errors.InternalServer(reason.DatabaseError). + WithError(fmt.Errorf("registration code lock release requires an atomic cache")). + WithStack() + } + + _, err := atomicCache.CompareAndDelete(ctx, emailCodeLockKey(email), verificationToken) + if err != nil { + return errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return nil +} + +func emailCodeKey(email string) string { + return emailCodeKeyPrefix + hashValue(normalizeEmail(email)) +} + +func emailCodeLockKey(email string) string { + return emailCodeLockKeyPrefix + hashValue(normalizeEmail(email)) +} + +func codeDigest(email, code string) string { + return hashValue(normalizeEmail(email) + "\x00" + code) +} + +func hashValue(value string) string { + digest := sha256.Sum256([]byte(value)) + return hex.EncodeToString(digest[:]) +} + +func normalizeEmail(email string) string { + return strings.ToLower(strings.TrimSpace(email)) +} diff --git a/internal/repo/registration/registration_repo_test.go b/internal/repo/registration/registration_repo_test.go new file mode 100644 index 000000000..ec6b2c588 --- /dev/null +++ b/internal/repo/registration/registration_repo_test.go @@ -0,0 +1,154 @@ +/* + * 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 registration + +import ( + "context" + "strconv" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/apache/answer/internal/base/data" + "github.com/stretchr/testify/require" +) + +func newRegistrationTestRepo(t *testing.T) (*registrationRepo, *miniredis.Miniredis) { + t.Helper() + server := miniredis.RunT(t) + port, err := strconv.Atoi(server.Port()) + require.NoError(t, err) + redisCache, err := data.NewRedisCache(data.RedisCacheConf{ + Host: server.Host(), Port: port, KeyPrefix: "hnu-forum:test:", + }) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, redisCache.Close()) }) + return ®istrationRepo{data: &data.Data{Cache: redisCache}}, server +} + +func TestRegistrationEmailCodeIsKeptUntilDeletedAfterSuccessfulRegistration(t *testing.T) { + repo, _ := newRegistrationTestRepo(t) + ctx := context.Background() + require.NoError(t, repo.SaveEmailCode(ctx, "Student@HAINANU.EDU.CN", "012345", time.Minute)) + + verificationToken, matched, err := repo.VerifyAndLockEmailCode(ctx, "student@hainanu.edu.cn", "999999") + require.NoError(t, err) + require.False(t, matched) + require.Empty(t, verificationToken) + + verificationToken, matched, err = repo.VerifyAndLockEmailCode(ctx, "student@hainanu.edu.cn", "012345") + require.NoError(t, err) + require.True(t, matched) + require.NotEmpty(t, verificationToken) + + // Simulate a database insertion failure: releasing the lock must retain the code. + require.NoError(t, repo.ReleaseEmailCodeLock(ctx, "student@hainanu.edu.cn", verificationToken)) + + verificationToken, matched, err = repo.VerifyAndLockEmailCode(ctx, "student@hainanu.edu.cn", "012345") + require.NoError(t, err) + require.True(t, matched) + + deleted, err := repo.DeleteEmailCodeIfMatches(ctx, "student@hainanu.edu.cn", "012345") + require.NoError(t, err) + require.True(t, deleted) + require.NoError(t, repo.ReleaseEmailCodeLock(ctx, "student@hainanu.edu.cn", verificationToken)) + + _, matched, err = repo.VerifyAndLockEmailCode(ctx, "student@hainanu.edu.cn", "012345") + require.NoError(t, err) + require.False(t, matched) +} + +func TestRegistrationEmailCodeVerificationIsLockedPerEmail(t *testing.T) { + repo, _ := newRegistrationTestRepo(t) + ctx := context.Background() + require.NoError(t, repo.SaveEmailCode(ctx, "student@hainanu.edu.cn", "012345", time.Minute)) + + verificationToken, matched, err := repo.VerifyAndLockEmailCode(ctx, "student@hainanu.edu.cn", "012345") + require.NoError(t, err) + require.True(t, matched) + + _, matched, err = repo.VerifyAndLockEmailCode(ctx, "student@hainanu.edu.cn", "012345") + require.NoError(t, err) + require.False(t, matched) + + require.NoError(t, repo.ReleaseEmailCodeLock(ctx, "student@hainanu.edu.cn", verificationToken)) +} + +func TestDeletingVerifiedCodeDoesNotDeleteNewerCode(t *testing.T) { + repo, _ := newRegistrationTestRepo(t) + ctx := context.Background() + require.NoError(t, repo.SaveEmailCode(ctx, "student@hainanu.edu.cn", "012345", time.Minute)) + + verificationToken, matched, err := repo.VerifyAndLockEmailCode(ctx, "student@hainanu.edu.cn", "012345") + require.NoError(t, err) + require.True(t, matched) + + require.NoError(t, repo.SaveEmailCode(ctx, "student@hainanu.edu.cn", "654321", time.Minute)) + deleted, err := repo.DeleteEmailCodeIfMatches(ctx, "student@hainanu.edu.cn", "012345") + require.NoError(t, err) + require.False(t, deleted) + require.NoError(t, repo.ReleaseEmailCodeLock(ctx, "student@hainanu.edu.cn", verificationToken)) + + newToken, matched, err := repo.VerifyAndLockEmailCode(ctx, "student@hainanu.edu.cn", "654321") + require.NoError(t, err) + require.True(t, matched) + require.NoError(t, repo.ReleaseEmailCodeLock(ctx, "student@hainanu.edu.cn", newToken)) +} + +func TestRegistrationEmailCodeExpires(t *testing.T) { + repo, server := newRegistrationTestRepo(t) + ctx := context.Background() + require.NoError(t, repo.SaveEmailCode(ctx, "student@hainanu.edu.cn", "012345", time.Minute)) + server.FastForward(time.Minute + time.Second) + + _, matched, err := repo.VerifyAndLockEmailCode(ctx, "student@hainanu.edu.cn", "012345") + require.NoError(t, err) + require.False(t, matched) +} + +func TestRegistrationSendLimitsUseEmailStrictlyAndIPAsFallback(t *testing.T) { + repo, server := newRegistrationTestRepo(t) + ctx := context.Background() + + retryAfter, err := repo.CheckAndRecordSendLimit(ctx, "first@hainanu.edu.cn", "203.0.113.10") + require.NoError(t, err) + require.Zero(t, retryAfter) + + retryAfter, err = repo.CheckAndRecordSendLimit(ctx, "first@hainanu.edu.cn", "203.0.113.10") + require.NoError(t, err) + require.Greater(t, retryAfter, time.Duration(0)) + + // Another email sharing the same campus NAT remains allowed. + retryAfter, err = repo.CheckAndRecordSendLimit(ctx, "second@hainanu.edu.cn", "203.0.113.10") + require.NoError(t, err) + require.Zero(t, retryAfter) + + // Five sends per rolling hour are allowed when the minute cooldown has elapsed. + for send := 2; send <= 5; send++ { + server.FastForward(time.Minute + time.Second) + retryAfter, err = repo.CheckAndRecordSendLimit(ctx, "first@hainanu.edu.cn", "203.0.113.10") + require.NoError(t, err) + require.Zero(t, retryAfter) + } + server.FastForward(time.Minute + time.Second) + retryAfter, err = repo.CheckAndRecordSendLimit(ctx, "first@hainanu.edu.cn", "203.0.113.10") + require.NoError(t, err) + require.Greater(t, retryAfter, time.Duration(0)) +} diff --git a/internal/repo/repo_test/personal_deleted_content_test.go b/internal/repo/repo_test/personal_deleted_content_test.go new file mode 100644 index 000000000..877e6208c --- /dev/null +++ b/internal/repo/repo_test/personal_deleted_content_test.go @@ -0,0 +1,139 @@ +/* + * 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 repo_test + +import ( + "context" + "testing" + + "github.com/apache/answer/internal/base/pager" + "github.com/apache/answer/internal/entity" + activityrepo "github.com/apache/answer/internal/repo/activity" + answerrepo "github.com/apache/answer/internal/repo/answer" + collectionrepo "github.com/apache/answer/internal/repo/collection" + commentrepo "github.com/apache/answer/internal/repo/comment" + commentservice "github.com/apache/answer/internal/service/comment" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPersonalRepositoriesExcludeDeletedContentBeforePagination(t *testing.T) { + ctx := context.Background() + const ( + questionID = "990000001" + answerID = "990000002" + commentID = "990000003" + collectionID = "990000004" + activityID = "990000005" + userID = "990000006" + activityType = 990001 + ) + + question := &entity.Question{ + ID: questionID, + UserID: userID, + Title: "visible question", + Status: entity.QuestionStatusAvailable, + } + answer := &entity.Answer{ + ID: answerID, + QuestionID: questionID, + UserID: userID, + OriginalText: "visible answer", + ParsedText: "visible answer", + Status: entity.AnswerStatusAvailable, + } + comment := &entity.Comment{ + ID: commentID, + ObjectID: answerID, + QuestionID: questionID, + UserID: userID, + OriginalText: "visible comment", + ParsedText: "visible comment", + Status: entity.CommentStatusAvailable, + } + collection := &entity.Collection{ID: collectionID, ObjectID: questionID, UserID: userID} + activity := &entity.Activity{ + ID: activityID, + ObjectID: answerID, + UserID: userID, + ActivityType: activityType, + } + + _, err := testDataSource.DB.Insert(question, answer, comment, collection, activity) + require.NoError(t, err) + t.Cleanup(func() { + _, _ = testDataSource.DB.ID(activityID).Delete(&entity.Activity{}) + _, _ = testDataSource.DB.ID(collectionID).Delete(&entity.Collection{}) + _, _ = testDataSource.DB.ID(commentID).Delete(&entity.Comment{}) + _, _ = testDataSource.DB.ID(answerID).Delete(&entity.Answer{}) + _, _ = testDataSource.DB.ID(questionID).Delete(&entity.Question{}) + }) + + answerRepo := answerrepo.NewAnswerRepo(testDataSource, nil, nil, nil) + collectionRepo := collectionrepo.NewCollectionRepo(testDataSource, nil) + commentRepo := commentrepo.NewCommentRepo(testDataSource, nil) + voteRepo := activityrepo.NewVoteRepo(testDataSource, nil, nil, nil) + + personalCounts := func() (answers, collections, comments, votes int64) { + _, answers, err = answerRepo.GetPersonalAnswerPage(ctx, &entity.PersonalAnswerPageQueryCond{ + Page: 1, PageSize: 10, UserID: userID, + }) + require.NoError(t, err) + + _, collections, err = collectionRepo.SearchList(ctx, &entity.CollectionSearch{ + Collection: entity.Collection{UserID: userID}, Page: 1, PageSize: 10, + }) + require.NoError(t, err) + + _, comments, err = commentRepo.GetCommentPage(ctx, &commentservice.CommentQuery{ + PageCond: pager.PageCond{Page: 1, PageSize: 10}, UserID: userID, ExcludeDeletedContent: true, + }) + require.NoError(t, err) + + _, votes, err = voteRepo.ListUserVotes(ctx, userID, 1, 10, []int{activityType}) + require.NoError(t, err) + return + } + + answers, collections, comments, votes := personalCounts() + assert.Equal(t, int64(1), answers) + assert.Equal(t, int64(1), collections) + assert.Equal(t, int64(1), comments) + assert.Equal(t, int64(1), votes) + + _, err = testDataSource.DB.ID(answerID).Cols("status").Update(&entity.Answer{Status: entity.AnswerStatusDeleted}) + require.NoError(t, err) + answers, collections, comments, votes = personalCounts() + assert.Equal(t, int64(0), answers) + assert.Equal(t, int64(1), collections) + assert.Equal(t, int64(0), comments) + assert.Equal(t, int64(0), votes) + + _, err = testDataSource.DB.ID(answerID).Cols("status").Update(&entity.Answer{Status: entity.AnswerStatusAvailable}) + require.NoError(t, err) + _, err = testDataSource.DB.ID(questionID).Cols("status").Update(&entity.Question{Status: entity.QuestionStatusDeleted}) + require.NoError(t, err) + answers, collections, comments, votes = personalCounts() + assert.Equal(t, int64(0), answers) + assert.Equal(t, int64(0), collections) + assert.Equal(t, int64(0), comments) + assert.Equal(t, int64(0), votes) +} diff --git a/internal/repo/user/user_repo.go b/internal/repo/user/user_repo.go index 1533cc5e8..6b6f11201 100644 --- a/internal/repo/user/user_repo.go +++ b/internal/repo/user/user_repo.go @@ -239,7 +239,7 @@ func (ur *userRepo) GetByUsernames(ctx context.Context, usernames []string) ([]* // GetByEmail get user by email func (ur *userRepo) GetByEmail(ctx context.Context, email string) (userInfo *entity.User, exist bool, err error) { userInfo = &entity.User{} - exist, err = ur.data.DB.Context(ctx).Where("e_mail = ?", email). + exist, err = ur.data.DB.Context(ctx).Where("LOWER(e_mail) = ?", strings.ToLower(strings.TrimSpace(email))). Where("status != ?", entity.UserStatusDeleted).Get(userInfo) if err != nil { err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() diff --git a/internal/router/answer_api_router.go b/internal/router/answer_api_router.go index 84b8b4e1c..ccf1bba41 100644 --- a/internal/router/answer_api_router.go +++ b/internal/router/answer_api_router.go @@ -62,6 +62,7 @@ type AnswerAPIRouter struct { aiConversationController *controller.AIConversationController aiConversationAdminController *controller_admin.AIConversationAdminController mcpController *controller.MCPController + forumSectionController *controller.ForumSectionController } func NewAnswerAPIRouter( @@ -100,6 +101,7 @@ func NewAnswerAPIRouter( aiConversationController *controller.AIConversationController, aiConversationAdminController *controller_admin.AIConversationAdminController, mcpController *controller.MCPController, + forumSectionController *controller.ForumSectionController, ) *AnswerAPIRouter { return &AnswerAPIRouter{ langController: langController, @@ -137,6 +139,7 @@ func NewAnswerAPIRouter( aiConversationController: aiConversationController, aiConversationAdminController: aiConversationAdminController, mcpController: mcpController, + forumSectionController: forumSectionController, } } @@ -155,6 +158,7 @@ func (a *AnswerAPIRouter) RegisterMustUnAuthAnswerAPIRouter(authUserMiddleware * routerGroup := r.Group("", middleware.BanAPIForUserCenter) routerGroup.POST("/user/login/email", a.userController.UserEmailLogin) routerGroup.POST("/user/register/email", a.userController.UserRegisterByEmail) + routerGroup.POST("/user/register/email/code", a.userController.UserRegisterEmailCodeSend) routerGroup.POST("/user/email/verification", a.userController.UserVerifyEmail) routerGroup.PUT("/user/email", a.userController.UserChangeEmailVerify) routerGroup.POST("/user/password/reset", a.userController.RetrievePassWord) @@ -166,6 +170,9 @@ func (a *AnswerAPIRouter) RegisterMustUnAuthAnswerAPIRouter(authUserMiddleware * } func (a *AnswerAPIRouter) RegisterUnAuthAnswerAPIRouter(r *gin.RouterGroup) { + // campus forum sections + r.GET("/forum/sections", a.forumSectionController.List) + // user r.GET("/personal/user/info", a.userController.GetOtherUserInfoByUsername) r.GET("/user/ranking", a.userController.UserRanking) @@ -174,7 +181,11 @@ func (a *AnswerAPIRouter) RegisterUnAuthAnswerAPIRouter(r *gin.RouterGroup) { // answer r.GET("/answer/info", a.answerController.GetAnswerInfo) r.GET("/answer/page", a.answerController.AnswerList) + // Forum-oriented aliases: answers are top-level comments on posts. + r.GET("/post/comment/info", a.answerController.GetAnswerInfo) + r.GET("/post/comment/page", a.answerController.AnswerList) r.GET("/personal/answer/page", a.questionController.PersonalAnswerPage) + r.GET("/personal/post/comment/page", a.questionController.PersonalAnswerPage) // question r.GET("/question/info", a.questionController.GetQuestion) @@ -182,13 +193,19 @@ func (a *AnswerAPIRouter) RegisterUnAuthAnswerAPIRouter(r *gin.RouterGroup) { r.GET("/question/page", a.questionController.QuestionPage) r.GET("/question/recommend/page", a.questionController.QuestionRecommendPage) r.GET("/question/similar/tag", a.questionController.SimilarQuestion) + // Forum-oriented alias: related posts currently use the first tag. + r.GET("/post/related", a.questionController.SimilarQuestion) r.GET("/personal/qa/top", a.questionController.UserTop) r.GET("/personal/question/page", a.questionController.PersonalQuestionPage) + r.GET("/personal/content/top", a.questionController.UserContentTop) + r.GET("/personal/post/page", a.questionController.PersonalQuestionPage) r.GET("/question/link", a.questionController.GetQuestionLink) // comment r.GET("/comment/page", a.commentController.GetCommentWithPage) r.GET("/personal/comment/page", a.commentController.GetCommentPersonalWithPage) + // Forum-oriented alias: low-level comments are replies. + r.GET("/personal/reply/page", a.commentController.GetCommentPersonalWithPage) r.GET("/comment", a.commentController.GetComment) // tag @@ -207,13 +224,6 @@ func (a *AnswerAPIRouter) RegisterUnAuthAnswerAPIRouter(r *gin.RouterGroup) { // reaction r.GET("/meta/reaction", a.metaController.GetReaction) - - // badges - r.GET("/badge", a.badgeController.GetBadgeInfo) - r.GET("/badge/awards/page", a.badgeController.GetBadgeAwardList) - r.GET("/badge/user/awards/recent", a.badgeController.GetRecentBadgeAwardListByUsername) - r.GET("/badge/user/awards", a.badgeController.GetAllBadgeAwardListByUsername) - r.GET("/badges", a.badgeController.GetBadgeList) } func (a *AnswerAPIRouter) RegisterAuthUserWithAnyStatusAnswerAPIRouter(r *gin.RouterGroup) { @@ -283,6 +293,11 @@ func (a *AnswerAPIRouter) RegisterAnswerAPIRouter(r *gin.RouterGroup) { r.POST("/answer/acceptance", a.answerController.AcceptAnswer) r.DELETE("/answer", a.answerController.RemoveAnswer) r.POST("/answer/recover", a.answerController.RecoverAnswer) + // Forum-oriented aliases for top-level post comments. + r.POST("/post/comment", a.answerController.AddAnswer) + r.PUT("/post/comment", a.answerController.UpdateAnswer) + r.DELETE("/post/comment", a.answerController.RemoveAnswer) + r.POST("/post/comment/recover", a.answerController.RecoverAnswer) // user r.PUT("/user/password", middleware.BanAPIForUserCenter, a.userController.UserModifyPassWord) @@ -412,10 +427,6 @@ func (a *AnswerAPIRouter) RegisterAnswerAdminAPIRouter(r *gin.RouterGroup) { r.GET("/plugin/config", a.pluginController.GetPluginConfig) r.PUT("/plugin/config", a.pluginController.UpdatePluginConfig) - // badge - r.GET("/badges", a.adminBadgeController.GetBadgeList) - r.PUT("/badge/status", a.adminBadgeController.UpdateBadgeStatus) - // api key r.GET("/api-key/all", a.apiKeyController.GetAllAPIKeys) r.POST("/api-key", a.apiKeyController.AddAPIKey) diff --git a/internal/schema/email_template.go b/internal/schema/email_template.go index d7e4b929a..0113ce046 100644 --- a/internal/schema/email_template.go +++ b/internal/schema/email_template.go @@ -61,6 +61,12 @@ type RegisterTemplateData struct { RegisterUrl string } +type RegisterCodeTemplateData struct { + SiteName string + Code string + ExpiresMinutes int +} + type PassResetTemplateData struct { SiteName string PassResetUrl string diff --git a/internal/schema/forum_section_schema.go b/internal/schema/forum_section_schema.go new file mode 100644 index 000000000..6d506e18e --- /dev/null +++ b/internal/schema/forum_section_schema.go @@ -0,0 +1,29 @@ +/* + * 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 schema + +type ForumSectionResp struct { + ID int64 `json:"id"` + ParentID int64 `json:"parent_id"` + Slug string `json:"slug"` + Name string `json:"name"` + AdminOnly bool `json:"admin_only"` + Children []*ForumSectionResp `json:"children"` +} diff --git a/internal/schema/permission.go b/internal/schema/permission.go index 106709d12..3c2ce5956 100644 --- a/internal/schema/permission.go +++ b/internal/schema/permission.go @@ -64,10 +64,5 @@ func (r *GetPermissionResp) TrTip(lang i18n.Language, requireRank int) { if r.HasPermission { return } - if requireRank <= 0 { - r.NoPermissionTip = translator.Tr(lang, reason.RankFailToMeetTheCondition) - } else { - r.NoPermissionTip = translator.TrWithData( - lang, reason.NoEnoughRankToOperate, &PermissionTrTplData{Rank: requireRank}) - } + r.NoPermissionTip = translator.Tr(lang, reason.ForbiddenError) } diff --git a/internal/schema/question_schema.go b/internal/schema/question_schema.go index 4c2313852..fd4a01a8f 100644 --- a/internal/schema/question_schema.go +++ b/internal/schema/question_schema.go @@ -84,6 +84,8 @@ type QuestionAdd struct { HTML string `json:"-"` // tags Tags []*TagItem `validate:"dive" json:"tags"` + // campus forum section (leaf section only) + SectionID int64 `validate:"required,min=1" json:"section_id"` // user id UserID string `json:"-"` QuestionPermission @@ -113,7 +115,8 @@ type QuestionAddByAnswer struct { AnswerContent string `validate:"required,notblank,gte=6,lte=65535" json:"answer_content"` AnswerHTML string `json:"-"` // tags - Tags []*TagItem `validate:"dive" json:"tags"` + Tags []*TagItem `validate:"dive" json:"tags"` + SectionID int64 `validate:"required,min=1" json:"section_id"` // user id UserID string `json:"-"` MentionUsernameList []string `validate:"omitempty" json:"mention_username_list"` @@ -144,6 +147,7 @@ func (req *QuestionAddByAnswer) Check() (errFields []*validator.FormErrorField, type QuestionPermission struct { IsAdminModerator bool `json:"-"` + IsAdmin bool `json:"-"` // whether user can add it CanAdd bool `json:"-"` // whether user can edit it @@ -237,6 +241,7 @@ type QuestionInfoResp struct { HTML string `json:"html"` Description string `json:"description"` Tags []*TagResp `json:"tags"` + SectionID int64 `json:"section_id"` ViewCount int `json:"view_count"` UniqueViewCount int `json:"unique_view_count"` VoteCount int `json:"vote_count"` @@ -372,6 +377,7 @@ type QuestionPageReq struct { Tag string `validate:"omitempty,gt=0,lte=100" form:"tag"` Username string `validate:"omitempty,gt=0,lte=100" form:"username"` InDays int `validate:"omitempty,min=1" form:"in_days"` + Section string `validate:"omitempty,lte=50" form:"section"` LoginUserID string `json:"-"` UserIDBeSearched string `json:"-"` @@ -395,6 +401,7 @@ type QuestionPageResp struct { Show int `json:"show"` // 0: show, 1: hide Status int `json:"status"` Tags []*TagResp `json:"tags"` + SectionID int64 `json:"section_id"` // question statistical information ViewCount int `json:"view_count"` diff --git a/internal/schema/simple_obj_info_schema.go b/internal/schema/simple_obj_info_schema.go index eebdd8980..64e11d0d6 100644 --- a/internal/schema/simple_obj_info_schema.go +++ b/internal/schema/simple_obj_info_schema.go @@ -60,6 +60,16 @@ func (s *SimpleObjectInfo) IsDeleted() bool { return false } +// IsDeletedOrParentDeleted reports whether an object, or the question that +// contains it, has been deleted. Personal activity pages must not expose links +// to either kind of deleted content. +func (s *SimpleObjectInfo) IsDeletedOrParentDeleted() bool { + if s == nil { + return true + } + return s.IsDeleted() || s.QuestionStatus == entity.QuestionStatusDeleted +} + func (s *SimpleObjectInfo) CheckVisibility(userID string, isAdminModerator bool) error { if s == nil { return errors.NotFound(reason.ObjectNotFound) diff --git a/internal/schema/simple_obj_info_schema_test.go b/internal/schema/simple_obj_info_schema_test.go new file mode 100644 index 000000000..d1dfbb680 --- /dev/null +++ b/internal/schema/simple_obj_info_schema_test.go @@ -0,0 +1,103 @@ +/* + * 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 schema + +import ( + "testing" + + "github.com/apache/answer/internal/base/constant" + "github.com/apache/answer/internal/entity" + "github.com/stretchr/testify/assert" +) + +func TestSimpleObjectInfo_IsDeletedOrParentDeleted(t *testing.T) { + tests := []struct { + name string + info *SimpleObjectInfo + want bool + }{ + {name: "missing object", info: nil, want: true}, + { + name: "available question", + info: &SimpleObjectInfo{ObjectType: constant.QuestionObjectType, QuestionStatus: entity.QuestionStatusAvailable}, + want: false, + }, + { + name: "deleted question", + info: &SimpleObjectInfo{ObjectType: constant.QuestionObjectType, QuestionStatus: entity.QuestionStatusDeleted}, + want: true, + }, + { + name: "available answer on available question", + info: &SimpleObjectInfo{ + ObjectType: constant.AnswerObjectType, + AnswerStatus: entity.AnswerStatusAvailable, + QuestionStatus: entity.QuestionStatusAvailable, + }, + want: false, + }, + { + name: "deleted answer", + info: &SimpleObjectInfo{ + ObjectType: constant.AnswerObjectType, + AnswerStatus: entity.AnswerStatusDeleted, + QuestionStatus: entity.QuestionStatusAvailable, + }, + want: true, + }, + { + name: "answer on deleted question", + info: &SimpleObjectInfo{ + ObjectType: constant.AnswerObjectType, + AnswerStatus: entity.AnswerStatusAvailable, + QuestionStatus: entity.QuestionStatusDeleted, + }, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, tt.info.IsDeletedOrParentDeleted()) + }) + } +} + +func TestUserResponsesExposeForumCountAliases(t *testing.T) { + userInfo := &entity.User{ + AnswerCount: 7, + QuestionCount: 3, + } + + loginResp := &UserLoginResp{} + loginResp.ConvertFromUserEntity(userInfo) + assert.Equal(t, 7, loginResp.CommentCount) + assert.Equal(t, 3, loginResp.PostCount) + + currentResp := &GetCurrentLoginUserInfoResp{} + currentResp.ConvertFromUserEntity(userInfo) + assert.Equal(t, 7, currentResp.CommentCount) + assert.Equal(t, 3, currentResp.PostCount) + + otherResp := &GetOtherUserInfoByUsernameResp{} + otherResp.ConvertFromUserEntity(userInfo) + assert.Equal(t, 7, otherResp.CommentCount) + assert.Equal(t, 3, otherResp.PostCount) +} diff --git a/internal/schema/user_schema.go b/internal/schema/user_schema.go index 0683a5aff..4afaceb45 100644 --- a/internal/schema/user_schema.go +++ b/internal/schema/user_schema.go @@ -67,6 +67,10 @@ type UserLoginResp struct { AnswerCount int `json:"answer_count"` // question count QuestionCount int `json:"question_count"` + // forum comment count (compatibility alias of answer_count) + CommentCount int `json:"comment_count"` + // forum post count (compatibility alias of question_count) + PostCount int `json:"post_count"` // rank Rank int `json:"rank"` // authority group @@ -105,6 +109,8 @@ type UserLoginResp struct { func (r *UserLoginResp) ConvertFromUserEntity(userInfo *entity.User) { _ = copier.Copy(r, userInfo) + r.CommentCount = userInfo.AnswerCount + r.PostCount = userInfo.QuestionCount r.CreatedAt = userInfo.CreatedAt.Unix() r.LastLoginDate = userInfo.LastLoginDate.Unix() r.Status = constant.ConvertUserStatus(userInfo.Status, userInfo.MailStatus) @@ -121,6 +127,8 @@ type GetCurrentLoginUserInfoResp struct { func (r *GetCurrentLoginUserInfoResp) ConvertFromUserEntity(userInfo *entity.User) { _ = copier.Copy(r, userInfo) + r.CommentCount = userInfo.AnswerCount + r.PostCount = userInfo.QuestionCount r.CreatedAt = userInfo.CreatedAt.Unix() r.LastLoginDate = userInfo.LastLoginDate.Unix() r.Status = constant.ConvertUserStatus(userInfo.Status, userInfo.MailStatus) @@ -149,6 +157,10 @@ type GetOtherUserInfoByUsernameResp struct { AnswerCount int `json:"answer_count"` // question count QuestionCount int `json:"question_count"` + // forum comment count (compatibility alias of answer_count) + CommentCount int `json:"comment_count"` + // forum post count (compatibility alias of question_count) + PostCount int `json:"post_count"` // rank Rank int `json:"rank"` // display name @@ -173,6 +185,8 @@ type GetOtherUserInfoByUsernameResp struct { func (r *GetOtherUserInfoByUsernameResp) ConvertFromUserEntity(userInfo *entity.User) { _ = copier.Copy(r, userInfo) + r.CommentCount = userInfo.AnswerCount + r.PostCount = userInfo.QuestionCount r.CreatedAt = userInfo.CreatedAt.Unix() r.LastLoginDate = userInfo.LastLoginDate.Unix() r.Status = constant.ConvertUserStatus(userInfo.Status, userInfo.MailStatus) @@ -219,13 +233,12 @@ type UserEmailLoginReq struct { // UserRegisterReq user register request type UserRegisterReq struct { - Name string `validate:"required,gte=2,lte=30" json:"name"` - Email string `validate:"required,email,gt=0,lte=500" json:"e_mail" ` - Pass string `validate:"required,gte=8,lte=32" json:"pass"` - CaptchaID string `json:"captcha_id"` - CaptchaCode string `json:"captcha_code"` - IP string `json:"-" ` - RequireEmailVerification bool `json:"-"` + Name string `validate:"required,gte=2,lte=30" json:"name"` + Email string `validate:"required,email,gt=0,lte=500" json:"e_mail"` + Pass string `validate:"required,gte=8,lte=32" json:"pass"` + PassConfirm string `validate:"required,gte=8,lte=32" json:"pass_confirm"` + EmailCode string `validate:"required,len=6,numeric" json:"email_code"` + IP string `json:"-"` } func (u *UserRegisterReq) Check() (errFields []*validator.FormErrorField, err error) { @@ -236,9 +249,27 @@ func (u *UserRegisterReq) Check() (errFields []*validator.FormErrorField, err er }) return errFields, err } + if u.Pass != u.PassConfirm { + errFields = append(errFields, &validator.FormErrorField{ + ErrorField: "pass_confirm", + ErrorMsg: reason.PasswordConfirmationMismatch, + }) + return errFields, errors.BadRequest(reason.PasswordConfirmationMismatch) + } return nil, nil } +type UserRegisterEmailCodeReq struct { + Email string `validate:"required,email,gt=0,lte=500" json:"e_mail"` + CaptchaID string `json:"captcha_id"` + CaptchaCode string `json:"captcha_code"` + IP string `json:"-"` +} + +type RetryAfterResp struct { + RetryAfter int64 `json:"retry_after"` +} + type UserModifyPasswordReq struct { OldPass string `validate:"omitempty,gte=8,lte=32" json:"old_pass"` Pass string `validate:"required,gte=8,lte=32" json:"pass"` diff --git a/internal/service/badge/badge_event_handler.go b/internal/service/badge/badge_event_handler.go index 0a9a84c0f..c531a5e07 100644 --- a/internal/service/badge/badge_event_handler.go +++ b/internal/service/badge/badge_event_handler.go @@ -57,7 +57,6 @@ func NewBadgeEventService( eventRuleRepo: eventRuleRepo, badgeAwardService: badgeAwardService, } - eventQueueService.RegisterHandler(n.Handler) return n } diff --git a/internal/service/comment/comment_service.go b/internal/service/comment/comment_service.go index 0decd5847..51b499c6c 100644 --- a/internal/service/comment/comment_service.go +++ b/internal/service/comment/comment_service.go @@ -68,6 +68,8 @@ type CommentQuery struct { QueryCond string // user id UserID string + // exclude comments whose object or parent question has been deleted + ExcludeDeletedContent bool } func (c *CommentQuery) GetOrderBy() string { @@ -529,9 +531,10 @@ func (cs *CommentService) GetCommentPersonalWithPage(ctx context.Context, req *s } dto := &CommentQuery{ - PageCond: pager.PageCond{Page: req.Page, PageSize: req.PageSize}, - UserID: req.UserID, - QueryCond: "created_at", + PageCond: pager.PageCond{Page: req.Page, PageSize: req.PageSize}, + UserID: req.UserID, + QueryCond: "created_at", + ExcludeDeletedContent: true, } commentList, total, err := cs.commentRepo.GetCommentPage(ctx, dto) if err != nil { @@ -549,16 +552,16 @@ func (cs *CommentService) GetCommentPersonalWithPage(ctx context.Context, req *s objInfo, err := cs.objectInfoService.GetInfo(ctx, comment.ObjectID) if err != nil { log.Error(err) - } else { - commentResp.ObjectType = objInfo.ObjectType - commentResp.Title = objInfo.Title - commentResp.UrlTitle = htmltext.UrlTitle(objInfo.Title) - commentResp.QuestionID = objInfo.QuestionID - commentResp.AnswerID = objInfo.AnswerID - if objInfo.QuestionStatus == entity.QuestionStatusDeleted { - commentResp.Title = "Deleted question" - } + continue + } + if objInfo.IsDeletedOrParentDeleted() { + continue } + commentResp.ObjectType = objInfo.ObjectType + commentResp.Title = objInfo.Title + commentResp.UrlTitle = htmltext.UrlTitle(objInfo.Title) + commentResp.QuestionID = objInfo.QuestionID + commentResp.AnswerID = objInfo.AnswerID } resp = append(resp, commentResp) } diff --git a/internal/service/content/question_hottest_service.go b/internal/service/content/question_hottest_service.go index b73e7a30d..5b6bc20fc 100644 --- a/internal/service/content/question_hottest_service.go +++ b/internal/service/content/question_hottest_service.go @@ -40,6 +40,7 @@ func (q *QuestionService) RefreshHottestCron(ctx context.Context) { ctx, page, pageSize, []string{}, + []int64{}, "", "newest", schema.HotInDays, false, false) diff --git a/internal/service/content/question_service.go b/internal/service/content/question_service.go index 73f66a4c1..24a6eaf07 100644 --- a/internal/service/content/question_service.go +++ b/internal/service/content/question_service.go @@ -44,6 +44,7 @@ import ( collectioncommon "github.com/apache/answer/internal/service/collection_common" "github.com/apache/answer/internal/service/config" "github.com/apache/answer/internal/service/export" + forumsectionservice "github.com/apache/answer/internal/service/forum_section" metacommon "github.com/apache/answer/internal/service/meta_common" "github.com/apache/answer/internal/service/noticequeue" "github.com/apache/answer/internal/service/notification" @@ -95,6 +96,7 @@ type QuestionService struct { eventQueueService eventqueue.Service reviewRepo review.ReviewRepo vectorSyncService vector_sync.Service + forumSectionService *forumsectionservice.Service } func NewQuestionService( @@ -122,6 +124,7 @@ func NewQuestionService( eventQueueService eventqueue.Service, reviewRepo review.ReviewRepo, vectorSyncService vector_sync.Service, + forumSectionService *forumsectionservice.Service, ) *QuestionService { return &QuestionService{ activityRepo: activityRepo, @@ -148,6 +151,7 @@ func NewQuestionService( eventQueueService: eventQueueService, reviewRepo: reviewRepo, vectorSyncService: vectorSyncService, + forumSectionService: forumSectionService, } } @@ -237,7 +241,7 @@ func (qs *QuestionService) CheckAddQuestion(ctx context.Context, req *schema.Que if err != nil { return } - if len(req.Tags) < minimumTags { + if len(req.Tags) > 0 && len(req.Tags) < minimumTags { errorlist := make([]*validator.FormErrorField, 0) errorlist = append(errorlist, &validator.FormErrorField{ ErrorField: "tags", @@ -305,11 +309,15 @@ func (qs *QuestionService) HasNewTag(ctx context.Context, tags []*schema.TagItem // AddQuestion add question func (qs *QuestionService) AddQuestion(ctx context.Context, req *schema.QuestionAdd) (questionInfo any, err error) { + _, err = qs.forumSectionService.ValidatePostingSection(ctx, req.SectionID, req.IsAdmin) + if err != nil { + return nil, err + } minimumTags, err := qs.tagCommon.GetMinimumTags(ctx) if err != nil { return } - if len(req.Tags) < minimumTags { + if len(req.Tags) > 0 && len(req.Tags) < minimumTags { errorlist := make([]*validator.FormErrorField, 0) errorlist = append(errorlist, &validator.FormErrorField{ ErrorField: "tags", @@ -372,6 +380,7 @@ func (qs *QuestionService) AddQuestion(ctx context.Context, req *schema.Question question := &entity.Question{} now := time.Now() question.UserID = req.UserID + question.SectionID = req.SectionID question.Title = req.Title question.OriginalText = req.Content question.ParsedText = req.HTML @@ -1244,19 +1253,15 @@ func (qs *QuestionService) PersonalAnswerPage(ctx context.Context, req *schema.P } for _, item := range answerlist { - _, ok := questionMaps[item.QuestionID] - if ok { - item.QuestionInfo = questionMaps[item.QuestionID] - } else { + questionInfo, ok := questionMaps[item.QuestionID] + if !ok || questionInfo.Status == entity.QuestionStatusDeleted { continue } + item.QuestionInfo = questionInfo info := &schema.UserAnswerInfo{} _ = copier.Copy(info, item) info.AnswerID = item.ID info.QuestionID = item.QuestionID - if item.QuestionInfo.Status == entity.QuestionStatusDeleted { - info.QuestionInfo.Title = "Deleted question" - } userAnswerlist = append(userAnswerlist, info) } @@ -1288,16 +1293,13 @@ func (qs *QuestionService) PersonalCollectionPage(ctx context.Context, req *sche if handler.GetEnableShortID(ctx) { id = uid.EnShortID(id) } - _, ok := questionMaps[id] - if ok { - questionMaps[id].LastAnsweredUserInfo = nil - questionMaps[id].UpdateUserInfo = nil - questionMaps[id].Content = "" - questionMaps[id].HTML = "" - if questionMaps[id].Status == entity.QuestionStatusDeleted { - questionMaps[id].Title = "Deleted question" - } - list = append(list, questionMaps[id]) + questionInfo, ok := questionMaps[id] + if ok && questionInfo.Status != entity.QuestionStatusDeleted { + questionInfo.LastAnsweredUserInfo = nil + questionInfo.UpdateUserInfo = nil + questionInfo.Content = "" + questionInfo.HTML = "" + list = append(list, questionInfo) } } @@ -1327,12 +1329,14 @@ func (qs *QuestionService) SearchUserTopList(ctx context.Context, userName strin if err != nil { return userQuestionlist, userAnswerlist, err } - answersearch := &entity.AnswerSearch{} - answersearch.UserID = userinfo.ID - answersearch.PageSize = 5 - answersearch.Order = entity.AnswerSearchOrderByVote + answersearch := &entity.PersonalAnswerPageQueryCond{ + Page: 1, + PageSize: 5, + UserID: userinfo.ID, + Order: entity.AnswerSearchOrderByVote, + } questionIDs := make([]string, 0) - answerList, _, err := qs.questioncommon.AnswerCommon.Search(ctx, answersearch) + answerList, _, err := qs.questioncommon.AnswerCommon.PersonalAnswerPage(ctx, answersearch) if err != nil { return userQuestionlist, userAnswerlist, err } @@ -1346,9 +1350,9 @@ func (qs *QuestionService) SearchUserTopList(ctx context.Context, userName strin return userQuestionlist, userAnswerlist, err } for _, item := range answerlist { - _, ok := questionMaps[item.QuestionID] - if ok { - item.QuestionInfo = questionMaps[item.QuestionID] + questionInfo, ok := questionMaps[item.QuestionID] + if ok && questionInfo.Status != entity.QuestionStatusDeleted { + item.QuestionInfo = questionInfo } } @@ -1360,6 +1364,9 @@ func (qs *QuestionService) SearchUserTopList(ctx context.Context, userName strin } for _, item := range answerlist { + if item.QuestionInfo == nil || item.QuestionInfo.Status == entity.QuestionStatusDeleted { + continue + } info := &schema.UserAnswerInfo{} _ = copier.Copy(info, item) info.AnswerID = item.ID @@ -1484,6 +1491,17 @@ func (qs *QuestionService) GetQuestionPage(ctx context.Context, req *schema.Ques } // query by tag condition var tagIDs = make([]string, 0) + var sectionIDs = make([]int64, 0) + if req.Section != "" { + var exists bool + sectionIDs, exists, err = qs.forumSectionService.ResolveSectionIDs(ctx, req.Section) + if err != nil { + return nil, 0, err + } + if !exists { + return questions, 0, nil + } + } if len(req.Tag) > 0 { tagInfo, exist, err := qs.tagCommon.GetTagBySlugName(ctx, strings.ToLower(req.Tag)) if err != nil { @@ -1518,7 +1536,7 @@ func (qs *QuestionService) GetQuestionPage(ctx context.Context, req *schema.Ques } questionList, total, err := qs.questionRepo.GetQuestionPage(ctx, req.Page, req.PageSize, - tagIDs, req.UserIDBeSearched, req.OrderCond, req.InDays, showHidden, req.ShowPending) + tagIDs, sectionIDs, req.UserIDBeSearched, req.OrderCond, req.InDays, showHidden, req.ShowPending) if err != nil { return nil, 0, err } diff --git a/internal/service/content/user_service.go b/internal/service/content/user_service.go index c1f800ff9..ce7d3c9d8 100644 --- a/internal/service/content/user_service.go +++ b/internal/service/content/user_service.go @@ -21,8 +21,11 @@ package content import ( "context" + "crypto/rand" "encoding/json" "fmt" + "math/big" + "strings" "time" "github.com/apache/answer/internal/service/eventqueue" @@ -43,6 +46,7 @@ import ( "github.com/apache/answer/internal/service/auth" "github.com/apache/answer/internal/service/export" "github.com/apache/answer/internal/service/file_record" + "github.com/apache/answer/internal/service/registration" "github.com/apache/answer/internal/service/role" "github.com/apache/answer/internal/service/siteinfo_common" usercommon "github.com/apache/answer/internal/service/user_common" @@ -70,6 +74,7 @@ type UserService struct { questionService *questioncommon.QuestionCommon eventQueueService eventqueue.Service fileRecordService *file_record.FileRecordService + registrationSecurityRepo registration.SecurityRepo } func NewUserService(userRepo usercommon.UserRepo, @@ -86,6 +91,7 @@ func NewUserService(userRepo usercommon.UserRepo, questionService *questioncommon.QuestionCommon, eventQueueService eventqueue.Service, fileRecordService *file_record.FileRecordService, + registrationSecurityRepo registration.SecurityRepo, ) *UserService { return &UserService{ userCommonService: userCommonService, @@ -102,6 +108,7 @@ func NewUserService(userRepo usercommon.UserRepo, questionService: questionService, eventQueueService: eventQueueService, fileRecordService: fileRecordService, + registrationSecurityRepo: registrationSecurityRepo, } } @@ -150,6 +157,12 @@ func (us *UserService) GetOtherUserInfoByUsername(ctx context.Context, req *sche return nil, err } resp.QuestionCount = int(questionCount) + resp.PostCount = resp.QuestionCount + commentCount, err := us.questionService.GetPersonalUserCommentCount(ctx, userInfo.ID) + if err != nil { + return nil, err + } + resp.CommentCount = int(commentCount) return resp, nil } @@ -487,6 +500,7 @@ func (us *UserService) UserUpdateInterface(ctx context.Context, req *schema.Upda func (us *UserService) UserRegisterByEmail(ctx context.Context, registerUserInfo *schema.UserRegisterReq) ( resp *schema.UserLoginResp, errFields []*validator.FormErrorField, err error, ) { + registerUserInfo.Email = normalizeRegistrationEmail(registerUserInfo.Email) _, has, err := us.userRepo.GetByEmail(ctx, registerUserInfo.Email) if err != nil { return nil, nil, err @@ -498,14 +512,9 @@ func (us *UserService) UserRegisterByEmail(ctx context.Context, registerUserInfo }) return nil, errFields, errors.BadRequest(reason.EmailDuplicate) } - userInfo := &entity.User{} userInfo.EMail = registerUserInfo.Email userInfo.DisplayName = registerUserInfo.Name - userInfo.Pass, err = us.encryptPassword(ctx, registerUserInfo.Pass) - if err != nil { - return nil, nil, err - } userInfo.Username, err = us.userCommonService.MakeUsername(ctx, registerUserInfo.Name) if err != nil { errFields = append(errFields, &validator.FormErrorField{ @@ -514,31 +523,61 @@ func (us *UserService) UserRegisterByEmail(ctx context.Context, registerUserInfo }) return nil, errFields, err } + userInfo.Pass, err = us.encryptPassword(ctx, registerUserInfo.Pass) + if err != nil { + return nil, nil, err + } + + verificationToken, validCode, err := us.registrationSecurityRepo.VerifyAndLockEmailCode( + ctx, + registerUserInfo.Email, + registerUserInfo.EmailCode, + ) + if err != nil { + return nil, nil, err + } + if !validCode { + errFields = append(errFields, &validator.FormErrorField{ + ErrorField: "email_code", + ErrorMsg: reason.EmailVerificationCodeInvalid, + }) + return nil, errFields, errors.BadRequest(reason.EmailVerificationCodeInvalid) + } + defer func() { + if releaseErr := us.registrationSecurityRepo.ReleaseEmailCodeLock( + ctx, + registerUserInfo.Email, + verificationToken, + ); releaseErr != nil { + log.Errorf("release registration email code lock failed: %v", releaseErr) + } + }() + userInfo.IPInfo = registerUserInfo.IP - userInfo.MailStatus = entity.EmailStatusToBeVerified + userInfo.MailStatus = entity.EmailStatusAvailable userInfo.Status = entity.UserStatusAvailable userInfo.LastLoginDate = time.Now() err = us.userRepo.AddUser(ctx, userInfo) if err != nil { return nil, nil, err } + if _, deleteErr := us.registrationSecurityRepo.DeleteEmailCodeIfMatches( + ctx, + registerUserInfo.Email, + registerUserInfo.EmailCode, + ); deleteErr != nil { + // The account already exists at this point. Keep registration successful; + // the duplicate-email check prevents the retained code from being reused. + log.Errorf("delete registration email code after user creation failed: %v", deleteErr) + } if err := us.userNotificationConfigService.SetDefaultUserNotificationConfig(ctx, []string{userInfo.ID}); err != nil { log.Errorf("set default user notification config failed, err: %v", err) } - err = applyRegistrationVerification(userInfo, registerUserInfo.RequireEmailVerification, registrationVerificationActions{ - sendActivationEmail: func() error { - return us.sendRegistrationActivationEmail(ctx, userInfo) - }, - activateUser: func() error { - return us.userActivity.UserActive(ctx, userInfo.ID) - }, - markEmailAvailable: func() error { - return us.userRepo.UpdateEmailStatus(ctx, userInfo.ID, entity.EmailStatusAvailable) - }, - }) - if err != nil { - return nil, nil, err + if err = us.userActivity.UserActive(ctx, userInfo.ID); err != nil { + // UserActive awards registration activity/rank; the verified account is + // already valid and should not become unusable when that auxiliary step fails. + log.Errorf("record registration activity for verified user failed: %v", err) } roleID, err := us.userRoleService.GetUserRole(ctx, userInfo.ID) @@ -570,6 +609,75 @@ func (us *UserService) UserRegisterByEmail(ctx context.Context, registerUserInfo return resp, nil, nil } +// UserRegisterEmailCodeSend validates the account and sends a one-time email code. +func (us *UserService) UserRegisterEmailCodeSend( + ctx context.Context, + req *schema.UserRegisterEmailCodeReq, +) (retryAfter int64, errFields []*validator.FormErrorField, err error) { + req.Email = normalizeRegistrationEmail(req.Email) + _, exists, err := us.userRepo.GetByEmail(ctx, req.Email) + if err != nil { + return 0, nil, err + } + if exists { + errFields = append(errFields, &validator.FormErrorField{ + ErrorField: "e_mail", + ErrorMsg: reason.EmailDuplicate, + }) + return 0, errFields, errors.BadRequest(reason.EmailDuplicate) + } + + retry, err := us.registrationSecurityRepo.CheckAndRecordSendLimit(ctx, req.Email, req.IP) + if err != nil { + return 0, nil, err + } + if retry > 0 { + seconds := int64((retry + time.Second - 1) / time.Second) + return seconds, nil, errors.New(429, reason.EmailSendTooFrequent) + } + + code, err := generateRegistrationEmailCode() + if err != nil { + return 0, nil, errors.InternalServer(reason.UnknownError).WithError(err).WithStack() + } + if err = us.registrationSecurityRepo.SaveEmailCode( + ctx, + req.Email, + code, + registration.EmailCodeTTL, + ); err != nil { + return 0, nil, err + } + + title, body, err := us.emailService.RegisterCodeTemplate( + ctx, + code, + int(registration.EmailCodeTTL/time.Minute), + ) + if err == nil { + err = us.emailService.SendWithResult(ctx, req.Email, title, body) + } + if err != nil { + if _, deleteErr := us.registrationSecurityRepo.DeleteEmailCodeIfMatches(ctx, req.Email, code); deleteErr != nil { + log.Errorf("delete registration code after email failure: %v", deleteErr) + } + return 0, nil, errors.InternalServer(reason.UnknownError).WithError(err).WithStack() + } + return 0, nil, nil +} + +func generateRegistrationEmailCode() (string, error) { + value, err := rand.Int(rand.Reader, big.NewInt(1000000)) + if err != nil { + return "", err + } + return fmt.Sprintf("%06d", value.Int64()), nil +} + +func normalizeRegistrationEmail(email string) string { + return strings.ToLower(strings.TrimSpace(email)) +} + type registrationVerificationActions struct { sendActivationEmail func() error activateUser func() error @@ -596,21 +704,6 @@ func applyRegistrationVerification( return nil } -func (us *UserService) sendRegistrationActivationEmail(ctx context.Context, userInfo *entity.User) error { - data := &schema.EmailCodeContent{ - Email: userInfo.EMail, - UserID: userInfo.ID, - } - code := token.GenerateToken() - verifyEmailURL := fmt.Sprintf("%s/users/account-activation?code=%s", us.getSiteUrl(ctx), code) - title, body, err := us.emailService.RegisterTemplate(ctx, verifyEmailURL) - if err != nil { - return err - } - go us.emailService.SendAndSaveCode(ctx, userInfo.ID, userInfo.EMail, title, body, code, data.ToJSONString()) - return nil -} - func (us *UserService) UserVerifyEmailSend(ctx context.Context, userID string) error { userInfo, has, err := us.userRepo.GetByUserID(ctx, userID) if err != nil { diff --git a/internal/service/content/vote_service.go b/internal/service/content/vote_service.go index 1f74769f5..ff58abf15 100644 --- a/internal/service/content/vote_service.go +++ b/internal/service/content/vote_service.go @@ -219,6 +219,9 @@ func (vs *VoteService) ListUserVotes(ctx context.Context, req schema.GetVoteWith log.Error(err) continue } + if objInfo.IsDeletedOrParentDeleted() { + continue + } item := &schema.GetVoteWithPageResp{ CreatedAt: voteInfo.CreatedAt.Unix(), @@ -232,9 +235,6 @@ func (vs *VoteService) ListUserVotes(ctx context.Context, req schema.GetVoteWith } item.VoteType = translator.Tr(lang, activity_type.ActivityTypeFlagMapping[activityTypeMapping[voteInfo.ActivityType]]) - if objInfo.QuestionStatus == entity.QuestionStatusDeleted { - item.Title = translator.Tr(lang, constant.DeletedQuestionTitleTrKey) - } votes = append(votes, item) } return pager.NewPageModel(total, votes), err diff --git a/internal/service/export/email_service.go b/internal/service/export/email_service.go index 5b649354c..03e1650f5 100644 --- a/internal/service/export/email_service.go +++ b/internal/service/export/email_service.go @@ -121,15 +121,20 @@ func (es *EmailService) SendAndSaveCodeWithTime( // Send email send func (es *EmailService) Send(ctx context.Context, toEmailAddr, subject, body string) { + if err := es.SendWithResult(ctx, toEmailAddr, subject, body); err != nil { + log.Errorf("send email to %s failed: %s", toEmailAddr, err) + } +} + +// SendWithResult sends an email and reports configuration or SMTP failures. +func (es *EmailService) SendWithResult(ctx context.Context, toEmailAddr, subject, body string) error { log.Infof("try to send email to %s", toEmailAddr) ec, err := es.GetEmailConfig(ctx) if err != nil { - log.Errorf("get email config failed: %s", err) - return + return fmt.Errorf("get email config: %w", err) } if len(ec.SMTPHost) == 0 { - log.Warnf("smtp host is empty, skip send email") - return + return fmt.Errorf("smtp host is empty") } m := gomail.NewMessage() @@ -150,10 +155,10 @@ func (es *EmailService) Send(ctx context.Context, toEmailAddr, subject, body str d.TLSConfig = &tls.Config{ServerName: d.Host, InsecureSkipVerify: true} } if err := d.DialAndSend(m); err != nil { - log.Errorf("send email to %s failed: %s", toEmailAddr, err) - } else { - log.Infof("send email to %s success", toEmailAddr) + return fmt.Errorf("send email to %s: %w", toEmailAddr, err) } + log.Infof("send email to %s success", toEmailAddr) + return nil } // VerifyUrlExpired email send @@ -181,6 +186,27 @@ func (es *EmailService) RegisterTemplate(ctx context.Context, registerUrl string return title, body, nil } +func (es *EmailService) RegisterCodeTemplate( + ctx context.Context, + code string, + expiresMinutes int, +) (title, body string, err error) { + siteInfo, err := es.siteInfoService.GetSiteGeneral(ctx) + if err != nil { + return "", "", err + } + templateData := &schema.RegisterCodeTemplateData{ + SiteName: siteInfo.Name, + Code: code, + ExpiresMinutes: expiresMinutes, + } + + lang := handler.GetLangByCtx(ctx) + title = translator.TrWithData(lang, constant.EmailTplKeyRegisterCodeTitle, templateData) + body = translator.TrWithData(lang, constant.EmailTplKeyRegisterCodeBody, templateData) + return title, body, nil +} + func (es *EmailService) PassResetTemplate(ctx context.Context, passResetUrl string) (title, body string, err error) { siteInfo, err := es.siteInfoService.GetSiteGeneral(ctx) if err != nil { diff --git a/internal/service/forum_section/forum_section_service.go b/internal/service/forum_section/forum_section_service.go new file mode 100644 index 000000000..e8f7db865 --- /dev/null +++ b/internal/service/forum_section/forum_section_service.go @@ -0,0 +1,110 @@ +/* + * 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 forum_section + +import ( + "context" + + "github.com/apache/answer/internal/base/reason" + "github.com/apache/answer/internal/entity" + "github.com/apache/answer/internal/schema" + "github.com/segmentfault/pacman/errors" +) + +type Repo interface { + List(ctx context.Context) ([]*entity.ForumSection, error) + GetByID(ctx context.Context, id int64) (*entity.ForumSection, bool, error) + GetBySlug(ctx context.Context, slug string) (*entity.ForumSection, bool, error) +} + +type Service struct { + repo Repo +} + +func NewForumSectionService(repo Repo) *Service { + return &Service{repo: repo} +} + +func (s *Service) ListTree(ctx context.Context) ([]*schema.ForumSectionResp, error) { + sections, err := s.repo.List(ctx) + if err != nil { + return nil, err + } + parents := make([]*schema.ForumSectionResp, 0) + parentMap := make(map[int64]*schema.ForumSectionResp) + for _, section := range sections { + item := toResp(section) + if section.ParentID == 0 { + parents = append(parents, item) + parentMap[section.ID] = item + } + } + for _, section := range sections { + if section.ParentID == 0 { + continue + } + if parent := parentMap[section.ParentID]; parent != nil { + parent.Children = append(parent.Children, toResp(section)) + } + } + return parents, nil +} + +func (s *Service) ValidatePostingSection(ctx context.Context, id int64, isAdmin bool) (*entity.ForumSection, error) { + section, exists, err := s.repo.GetByID(ctx, id) + if err != nil { + return nil, err + } + if !exists || section.Status != entity.ForumSectionStatusAvailable || section.ParentID == 0 { + return nil, errors.BadRequest(reason.ForumSectionInvalid) + } + if section.AdminOnly && !isAdmin { + return nil, errors.Forbidden(reason.ForumSectionAdminOnly) + } + return section, nil +} + +func (s *Service) ResolveSectionIDs(ctx context.Context, slug string) ([]int64, bool, error) { + section, exists, err := s.repo.GetBySlug(ctx, slug) + if err != nil || !exists { + return nil, exists, err + } + if section.ParentID != 0 { + return []int64{section.ID}, true, nil + } + sections, err := s.repo.List(ctx) + if err != nil { + return nil, false, err + } + ids := make([]int64, 0) + for _, child := range sections { + if child.ParentID == section.ID { + ids = append(ids, child.ID) + } + } + return ids, true, nil +} + +func toResp(section *entity.ForumSection) *schema.ForumSectionResp { + return &schema.ForumSectionResp{ + ID: section.ID, ParentID: section.ParentID, Slug: section.Slug, + Name: section.Name, AdminOnly: section.AdminOnly, Children: make([]*schema.ForumSectionResp, 0), + } +} diff --git a/internal/service/forum_section/forum_section_service_test.go b/internal/service/forum_section/forum_section_service_test.go new file mode 100644 index 000000000..db2ad3638 --- /dev/null +++ b/internal/service/forum_section/forum_section_service_test.go @@ -0,0 +1,93 @@ +/* + * 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 forum_section + +import ( + "context" + "testing" + + "github.com/apache/answer/internal/entity" + "github.com/stretchr/testify/require" +) + +type testRepo struct { + sections []*entity.ForumSection +} + +func (r *testRepo) List(context.Context) ([]*entity.ForumSection, error) { + return r.sections, nil +} + +func (r *testRepo) GetByID(_ context.Context, id int64) (*entity.ForumSection, bool, error) { + for _, section := range r.sections { + if section.ID == id { + return section, true, nil + } + } + return &entity.ForumSection{}, false, nil +} + +func (r *testRepo) GetBySlug(_ context.Context, slug string) (*entity.ForumSection, bool, error) { + for _, section := range r.sections { + if section.Slug == slug { + return section, true, nil + } + } + return &entity.ForumSection{}, false, nil +} + +func testSections() []*entity.ForumSection { + return []*entity.ForumSection{ + {ID: 1, Slug: "site-management", Name: "站务管理", Status: 1}, + {ID: 101, ParentID: 1, Slug: "site-announcements", Name: "站务公告", AdminOnly: true, Status: 1}, + {ID: 102, ParentID: 1, Slug: "part-time-jobs", Name: "兼职信息中心", Status: 1}, + } +} + +func TestListTreeAndResolveParentSection(t *testing.T) { + service := NewForumSectionService(&testRepo{sections: testSections()}) + + tree, err := service.ListTree(context.Background()) + require.NoError(t, err) + require.Len(t, tree, 1) + require.Len(t, tree[0].Children, 2) + + ids, exists, err := service.ResolveSectionIDs(context.Background(), "site-management") + require.NoError(t, err) + require.True(t, exists) + require.ElementsMatch(t, []int64{101, 102}, ids) +} + +func TestValidatePostingSectionPermissions(t *testing.T) { + service := NewForumSectionService(&testRepo{sections: testSections()}) + ctx := context.Background() + + _, err := service.ValidatePostingSection(ctx, 101, false) + require.Error(t, err) + + _, err = service.ValidatePostingSection(ctx, 101, true) + require.NoError(t, err) + + _, err = service.ValidatePostingSection(ctx, 102, false) + require.NoError(t, err) + + _, err = service.ValidatePostingSection(ctx, 1, true) + require.Error(t, err) +} diff --git a/internal/service/importer/importer_service.go b/internal/service/importer/importer_service.go index 9d12bf07b..a29b11331 100644 --- a/internal/service/importer/importer_service.go +++ b/internal/service/importer/importer_service.go @@ -23,9 +23,7 @@ import ( "context" "fmt" - "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/schema" "github.com/apache/answer/internal/service/content" @@ -104,7 +102,7 @@ func (ip *ImporterService) ImportQuestion(ctx context.Context, questionInfo plug DisplayName: tag, } } - canList, requireRanks, err := ip.rankService.CheckOperationPermissionsForRanks(ctx, req.UserID, []string{ + canList, _, err := ip.rankService.CheckOperationPermissionsForRanks(ctx, req.UserID, []string{ permission.QuestionAdd, permission.QuestionEdit, permission.QuestionDelete, @@ -135,10 +133,7 @@ func (ip *ImporterService) ImportQuestion(ctx context.Context, questionInfo plug return err } if !req.CanAddTag && hasNewTag { - lang := handler.GetLangByCtx(ctx.(*gin.Context)) - msg := translator.TrWithData(lang, reason.NoEnoughRankToOperate, &schema.PermissionTrTplData{Rank: requireRanks[6]}) - log.Errorf("error: %v", msg) - return errors.BadRequest(msg) + return errors.BadRequest(reason.ForbiddenError) } errList, err := ip.questionService.CheckAddQuestion(ctx, req) diff --git a/internal/service/notification/external_notification.go b/internal/service/notification/external_notification.go index 5282cab0f..cc0679472 100644 --- a/internal/service/notification/external_notification.go +++ b/internal/service/notification/external_notification.go @@ -23,7 +23,6 @@ import ( "context" "github.com/apache/answer/internal/base/data" - "github.com/apache/answer/internal/base/translator" "github.com/apache/answer/internal/entity" "github.com/apache/answer/internal/schema" "github.com/apache/answer/internal/service/activity_common" @@ -77,25 +76,18 @@ func NewExternalNotificationService( } func (ns *ExternalNotificationService) Handler(ctx context.Context, msg *schema.ExternalNotificationMsg) error { - log.Debugf("try to send external notification %+v", msg) + log.Debugf("content email notifications are disabled, handling external notification %+v", msg) - // If receiver not set language, use site default language. - if len(msg.ReceiverLang) == 0 || msg.ReceiverLang == translator.DefaultLangOption { - if interfaceInfo, _ := ns.siteInfoService.GetSiteInterface(ctx); interfaceInfo != nil { - msg.ReceiverLang = interfaceInfo.Language - } - } + // Content activity is delivered through the in-app notification queue. + // Keep plugin notifications for new questions, but never send content emails. if msg.NewQuestionTemplateRawData != nil { - return ns.handleNewQuestionNotification(ctx, msg) - } - if msg.NewCommentTemplateRawData != nil { - return ns.handleNewCommentNotification(ctx, msg) - } - if msg.NewAnswerTemplateRawData != nil { - return ns.handleNewAnswerNotification(ctx, msg) + ns.syncNewQuestionNotificationToPlugin(ctx, msg) + return nil } - if msg.NewInviteAnswerTemplateRawData != nil { - return ns.handleInviteAnswerNotification(ctx, msg) + if msg.NewCommentTemplateRawData != nil || + msg.NewAnswerTemplateRawData != nil || + msg.NewInviteAnswerTemplateRawData != nil { + return nil } log.Errorf("unknown notification message: %+v", msg) return nil diff --git a/internal/service/notification/external_notification_test.go b/internal/service/notification/external_notification_test.go new file mode 100644 index 000000000..9e36d666a --- /dev/null +++ b/internal/service/notification/external_notification_test.go @@ -0,0 +1,68 @@ +/* + * 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 notification + +import ( + "context" + "testing" + + "github.com/apache/answer/internal/schema" +) + +func TestExternalNotificationHandlerSkipsContentEmails(t *testing.T) { + service := &ExternalNotificationService{} + tests := []struct { + name string + msg *schema.ExternalNotificationMsg + }{ + { + name: "new question", + msg: &schema.ExternalNotificationMsg{ + NewQuestionTemplateRawData: &schema.NewQuestionTemplateRawData{}, + }, + }, + { + name: "new answer", + msg: &schema.ExternalNotificationMsg{ + NewAnswerTemplateRawData: &schema.NewAnswerTemplateRawData{}, + }, + }, + { + name: "new comment", + msg: &schema.ExternalNotificationMsg{ + NewCommentTemplateRawData: &schema.NewCommentTemplateRawData{}, + }, + }, + { + name: "invite to answer", + msg: &schema.ExternalNotificationMsg{ + NewInviteAnswerTemplateRawData: &schema.NewInviteAnswerTemplateRawData{}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := service.Handler(context.Background(), tt.msg); err != nil { + t.Fatalf("Handler() error = %v", err) + } + }) + } +} diff --git a/internal/service/notification/invite_answer_notification.go b/internal/service/notification/invite_answer_notification.go deleted file mode 100644 index 6b0407f9f..000000000 --- a/internal/service/notification/invite_answer_notification.go +++ /dev/null @@ -1,82 +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. - */ - -package notification - -import ( - "context" - "time" - - "github.com/apache/answer/internal/base/constant" - "github.com/apache/answer/internal/schema" - "github.com/segmentfault/pacman/i18n" - "github.com/segmentfault/pacman/log" -) - -func (ns *ExternalNotificationService) handleInviteAnswerNotification(ctx context.Context, - msg *schema.ExternalNotificationMsg) error { - log.Debugf("try to send invite answer notification %+v", msg) - - notificationConfig, exist, err := ns.userNotificationConfigRepo.GetByUserIDAndSource(ctx, msg.ReceiverUserID, constant.InboxSource) - if err != nil { - return err - } - if !exist { - return nil - } - channels := schema.NewNotificationChannelsFormJson(notificationConfig.Channels) - for _, channel := range channels { - if !channel.Enable { - continue - } - if channel.Key == constant.EmailChannel { - ns.sendInviteAnswerNotificationEmail(ctx, msg.ReceiverUserID, msg.ReceiverEmail, msg.ReceiverLang, msg.NewInviteAnswerTemplateRawData) - } - } - return nil -} - -func (ns *ExternalNotificationService) sendInviteAnswerNotificationEmail(ctx context.Context, - userID, email, lang string, rawData *schema.NewInviteAnswerTemplateRawData) { - if unavailable := ns.checkUserStatusBeforeNotification(ctx, userID); unavailable { - return - } - codeContent := &schema.EmailCodeContent{ - SourceType: schema.UnsubscribeSourceType, - NotificationSources: []constant.NotificationSource{ - constant.InboxSource, - }, - Email: email, - UserID: userID, - SkipValidationLatestCode: true, - } - - // If receiver has set language, use it to send email. - if len(lang) > 0 { - ctx = context.WithValue(ctx, constant.AcceptLanguageContextKey, i18n.Language(lang)) - } - title, body, err := ns.emailService.NewInviteAnswerTemplate(ctx, rawData) - if err != nil { - log.Error(err) - return - } - - ns.emailService.SendAndSaveCodeWithTime( - ctx, userID, email, title, body, rawData.UnsubscribeCode, codeContent.ToJSONString(), 1*24*time.Hour) -} diff --git a/internal/service/notification/new_answer_notification.go b/internal/service/notification/new_answer_notification.go deleted file mode 100644 index 4ae9ca9ea..000000000 --- a/internal/service/notification/new_answer_notification.go +++ /dev/null @@ -1,82 +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. - */ - -package notification - -import ( - "context" - "time" - - "github.com/apache/answer/internal/base/constant" - "github.com/apache/answer/internal/schema" - "github.com/segmentfault/pacman/i18n" - "github.com/segmentfault/pacman/log" -) - -func (ns *ExternalNotificationService) handleNewAnswerNotification(ctx context.Context, - msg *schema.ExternalNotificationMsg) error { - log.Debugf("try to send new comment notification %+v", msg) - - notificationConfig, exist, err := ns.userNotificationConfigRepo.GetByUserIDAndSource(ctx, msg.ReceiverUserID, constant.InboxSource) - if err != nil { - return err - } - if !exist { - return nil - } - channels := schema.NewNotificationChannelsFormJson(notificationConfig.Channels) - for _, channel := range channels { - if !channel.Enable { - continue - } - if channel.Key == constant.EmailChannel { - ns.sendNewAnswerNotificationEmail(ctx, msg.ReceiverUserID, msg.ReceiverEmail, msg.ReceiverLang, msg.NewAnswerTemplateRawData) - } - } - return nil -} - -func (ns *ExternalNotificationService) sendNewAnswerNotificationEmail(ctx context.Context, - userID, email, lang string, rawData *schema.NewAnswerTemplateRawData) { - if unavailable := ns.checkUserStatusBeforeNotification(ctx, userID); unavailable { - return - } - codeContent := &schema.EmailCodeContent{ - SourceType: schema.UnsubscribeSourceType, - NotificationSources: []constant.NotificationSource{ - constant.InboxSource, - }, - Email: email, - UserID: userID, - SkipValidationLatestCode: true, - } - - // If receiver has set language, use it to send email. - if len(lang) > 0 { - ctx = context.WithValue(ctx, constant.AcceptLanguageContextKey, i18n.Language(lang)) - } - title, body, err := ns.emailService.NewAnswerTemplate(ctx, rawData) - if err != nil { - log.Error(err) - return - } - - ns.emailService.SendAndSaveCodeWithTime( - ctx, userID, email, title, body, rawData.UnsubscribeCode, codeContent.ToJSONString(), 1*24*time.Hour) -} diff --git a/internal/service/notification/new_comment_notification.go b/internal/service/notification/new_comment_notification.go deleted file mode 100644 index 9734e54e5..000000000 --- a/internal/service/notification/new_comment_notification.go +++ /dev/null @@ -1,81 +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. - */ - -package notification - -import ( - "context" - "time" - - "github.com/apache/answer/internal/base/constant" - "github.com/apache/answer/internal/schema" - "github.com/segmentfault/pacman/i18n" - "github.com/segmentfault/pacman/log" -) - -func (ns *ExternalNotificationService) handleNewCommentNotification(ctx context.Context, - msg *schema.ExternalNotificationMsg) error { - log.Debugf("try to send new comment notification %+v", msg) - - notificationConfig, exist, err := ns.userNotificationConfigRepo.GetByUserIDAndSource(ctx, msg.ReceiverUserID, constant.InboxSource) - if err != nil { - return err - } - if !exist { - return nil - } - channels := schema.NewNotificationChannelsFormJson(notificationConfig.Channels) - for _, channel := range channels { - if !channel.Enable { - continue - } - if channel.Key == constant.EmailChannel { - ns.sendNewCommentNotificationEmail(ctx, msg.ReceiverUserID, msg.ReceiverEmail, msg.ReceiverLang, msg.NewCommentTemplateRawData) - } - } - return nil -} - -func (ns *ExternalNotificationService) sendNewCommentNotificationEmail(ctx context.Context, - userID, email, lang string, rawData *schema.NewCommentTemplateRawData) { - if unavailable := ns.checkUserStatusBeforeNotification(ctx, userID); unavailable { - return - } - codeContent := &schema.EmailCodeContent{ - SourceType: schema.UnsubscribeSourceType, - NotificationSources: []constant.NotificationSource{ - constant.InboxSource, - }, - Email: email, - UserID: userID, - SkipValidationLatestCode: true, - } - // If receiver has set language, use it to send email. - if len(lang) > 0 { - ctx = context.WithValue(ctx, constant.AcceptLanguageContextKey, i18n.Language(lang)) - } - title, body, err := ns.emailService.NewCommentTemplate(ctx, rawData) - if err != nil { - log.Error(err) - return - } - - ns.emailService.SendAndSaveCodeWithTime( - ctx, userID, email, title, body, rawData.UnsubscribeCode, codeContent.ToJSONString(), 1*24*time.Hour) -} diff --git a/internal/service/notification/notification_service.go b/internal/service/notification/notification_service.go index 6a69cbaef..031b64f14 100644 --- a/internal/service/notification/notification_service.go +++ b/internal/service/notification/notification_service.go @@ -91,42 +91,9 @@ func (ns *NotificationService) GetRedDot(ctx context.Context, req *schema.GetRed redBot.Revision = ns.countAllReviewAmount(ctx, req) } - // get badge award - redBot.BadgeAward = ns.getBadgeAward(ctx, req.UserID) return redBot, nil } -func (ns *NotificationService) getBadgeAward(ctx context.Context, userID string) (badgeAward *schema.RedDotBadgeAward) { - key := fmt.Sprintf(constant.RedDotCacheKey, constant.NotificationTypeBadgeAchievement, userID) - cacheData, exist, err := ns.data.Cache.GetString(ctx, key) - if err != nil { - log.Errorf("get badge award failed: %v", err) - return nil - } - if !exist { - return nil - } - - c := schema.NewRedDotBadgeAwardCache() - c.FromJSON(cacheData) - award := c.GetBadgeAward() - if award == nil { - return nil - } - badgeInfo, exists, err := ns.badgeRepo.GetByID(ctx, award.BadgeID) - if err != nil { - log.Errorf("get badge info failed: %v", err) - return nil - } - if !exists { - return nil - } - award.Name = translator.Tr(handler.GetLangByCtx(ctx), badgeInfo.Name) - award.Icon = badgeInfo.Icon - award.Level = badgeInfo.Level - return award -} - func (ns *NotificationService) countAllReviewAmount(ctx context.Context, req *schema.GetRedDot) (amount int64) { // get queue amount if req.IsAdmin { diff --git a/internal/service/provider.go b/internal/service/provider.go index d848272f7..aa1ed8746 100644 --- a/internal/service/provider.go +++ b/internal/service/provider.go @@ -42,6 +42,7 @@ import ( "github.com/apache/answer/internal/service/feature_toggle" "github.com/apache/answer/internal/service/file_record" "github.com/apache/answer/internal/service/follow" + forumsectionservice "github.com/apache/answer/internal/service/forum_section" "github.com/apache/answer/internal/service/importer" "github.com/apache/answer/internal/service/meta" metacommon "github.com/apache/answer/internal/service/meta_common" @@ -80,6 +81,7 @@ var ProviderSetService = wire.NewSet( content.NewVoteService, tag.NewTagService, follow.NewFollowService, + forumsectionservice.NewForumSectionService, collection.NewCollectionGroupService, collection.NewCollectionService, action.NewCaptchaService, diff --git a/internal/service/question_common/question.go b/internal/service/question_common/question.go index 3a7306342..88d8e7282 100644 --- a/internal/service/question_common/question.go +++ b/internal/service/question_common/question.go @@ -59,7 +59,7 @@ type QuestionRepo interface { UpdateQuestion(ctx context.Context, question *entity.Question, Cols []string) (err error) GetQuestion(ctx context.Context, id string) (question *entity.Question, exist bool, err error) GetQuestionList(ctx context.Context, question *entity.Question) (questions []*entity.Question, err error) - GetQuestionPage(ctx context.Context, page, pageSize int, tagIDs []string, userID, orderCond string, inDays int, showHidden, showPending bool) ( + GetQuestionPage(ctx context.Context, page, pageSize int, tagIDs []string, sectionIDs []int64, userID, orderCond string, inDays int, showHidden, showPending bool) ( questionList []*entity.Question, total int64, err error) GetRecommendQuestionPageByTags(ctx context.Context, userID string, tagIDs, followedQuestionIDs []string, page, pageSize int) (questionList []*entity.Question, total int64, err error) UpdateQuestionStatus(ctx context.Context, questionID string, status int) (err error) @@ -154,6 +154,18 @@ func (qs *QuestionCommon) GetPersonalUserQuestionCount(ctx context.Context, logi return qs.questionRepo.GetUserQuestionCount(ctx, userID, show) } +// GetPersonalUserCommentCount returns the number of visible top-level forum +// comments created by a user. Answers are the underlying storage model for +// these comments, and deleted parent posts are excluded by the repository. +func (qs *QuestionCommon) GetPersonalUserCommentCount(ctx context.Context, userID string) (count int64, err error) { + _, count, err = qs.AnswerCommon.PersonalAnswerPage(ctx, &entity.PersonalAnswerPageQueryCond{ + Page: 1, + PageSize: 1, + UserID: userID, + }) + return count, err +} + func (qs *QuestionCommon) UpdatePv(ctx context.Context, questionID string) error { return qs.questionRepo.UpdatePvCount(ctx, questionID) } @@ -385,6 +397,7 @@ func (qs *QuestionCommon) FormatQuestionsPage( LastAnswerID: questionInfo.LastAnswerID, Pin: questionInfo.Pin, Show: questionInfo.Show, + SectionID: questionInfo.SectionID, Operator: &schema.QuestionPageRespOperator{ID: questionInfo.UserID}, } @@ -683,6 +696,7 @@ func (qs *QuestionCommon) ShowFormat(ctx context.Context, data *entity.Question) } } info.Tags = make([]*schema.TagResp, 0) + info.SectionID = data.SectionID return &info } func (qs *QuestionCommon) ShowFormatWithTag(ctx context.Context, data *entity.QuestionWithTagsRevision) *schema.QuestionInfoResp { diff --git a/internal/service/rank/rank_service.go b/internal/service/rank/rank_service.go index 6651091a1..346ce0a20 100644 --- a/internal/service/rank/rank_service.go +++ b/internal/service/rank/rank_service.go @@ -43,9 +43,29 @@ import ( "xorm.io/xorm" ) -const ( - PermissionPrefix = "rank." -) +// isMemberPermission reports whether an available signed-in user can perform +// the action without relying on reputation. Ownership and staff permissions +// are checked separately. +func isMemberPermission(action string) bool { + switch action { + case permission.QuestionAdd, + permission.QuestionVoteUp, + permission.QuestionVoteDown, + permission.AnswerAdd, + permission.AnswerVoteUp, + permission.AnswerVoteDown, + permission.AnswerInviteSomeoneToAnswer, + permission.CommentAdd, + permission.CommentVoteUp, + permission.CommentVoteDown, + permission.ReportAdd, + permission.TagAdd, + permission.VoteDetail: + return true + default: + return false + } +} type UserRankRepo interface { GetMaxDailyRank(ctx context.Context) (maxDailyRank int, err error) @@ -92,7 +112,7 @@ func (rs *RankService) CheckOperationPermission(ctx context.Context, userID stri } // get the rank of the current user - userInfo, exist, err := rs.userCommon.GetUserBasicInfoByID(ctx, userID) + _, exist, err := rs.userCommon.GetUserBasicInfoByID(ctx, userID) if err != nil { return false, err } @@ -115,9 +135,7 @@ func (rs *RankService) CheckOperationPermission(ctx context.Context, userID stri return true, nil } } - - can, _ = rs.checkUserRank(ctx, userInfo.ID, userInfo.Rank, PermissionPrefix+action) - return can, nil + return isMemberPermission(action), nil } // CheckOperationPermissionsForRanks verify that the user has permission @@ -130,7 +148,7 @@ func (rs *RankService) CheckOperationPermissionsForRanks(ctx context.Context, us } // get the rank of the current user - userInfo, exist, err := rs.userCommon.GetUserBasicInfoByID(ctx, userID) + _, exist, err := rs.userCommon.GetUserBasicInfoByID(ctx, userID) if err != nil { return can, requireRanks, err } @@ -144,9 +162,7 @@ func (rs *RankService) CheckOperationPermissionsForRanks(ctx context.Context, us can[idx] = true continue } - meetRank, requireRank := rs.checkUserRank(ctx, userInfo.ID, userInfo.Rank, PermissionPrefix+action) - can[idx] = meetRank - requireRanks[idx] = requireRank + can[idx] = isMemberPermission(action) } return can, requireRanks, nil } @@ -175,14 +191,14 @@ func (rs *RankService) CheckOperationObjectOwner(ctx context.Context, userID, ob } // CheckVotePermission verify that the user has vote permission -func (rs *RankService) CheckVotePermission(ctx context.Context, userID, objectID string, voteUp bool) ( +func (rs *RankService) CheckVotePermission(ctx context.Context, userID, objectID string, _ bool) ( can bool, needRank int, err error) { if len(userID) == 0 || len(objectID) == 0 { return false, 0, nil } // get the rank of the current user - userInfo, exist, err := rs.userCommon.GetUserBasicInfoByID(ctx, userID) + _, exist, err := rs.userCommon.GetUserBasicInfoByID(ctx, userID) if err != nil { return can, 0, err } @@ -193,33 +209,16 @@ func (rs *RankService) CheckVotePermission(ctx context.Context, userID, objectID if err != nil { return can, 0, err } - action := "" switch objectInfo.ObjectType { case constant.QuestionObjectType: - if voteUp { - action = permission.QuestionVoteUp - } else { - action = permission.QuestionVoteDown - } + return true, 0, nil case constant.AnswerObjectType: - if voteUp { - action = permission.AnswerVoteUp - } else { - action = permission.AnswerVoteDown - } + return true, 0, nil case constant.CommentObjectType: - if voteUp { - action = permission.CommentVoteUp - } else { - action = permission.CommentVoteDown - } - } - powerMapping := rs.getUserPowerMapping(ctx, userID) - if powerMapping[action] { return true, 0, nil + default: + return false, 0, nil } - can, needRank = rs.checkUserRank(ctx, userInfo.ID, userInfo.Rank, PermissionPrefix+action) - return can, needRank, nil } // getUserPowerMapping get user power mapping @@ -242,23 +241,6 @@ func (rs *RankService) getUserPowerMapping(ctx context.Context, userID string) ( return powerMapping } -// checkUserRank verify that the user meets the prestige criteria -func (rs *RankService) checkUserRank(ctx context.Context, userID string, userRank int, action string) ( - can bool, rank int) { - // get the amount of rank required for the current operation - requireRank, err := rs.configService.GetIntValue(ctx, action) - if err != nil { - log.Error(err) - return false, requireRank - } - if userRank < requireRank || requireRank < 0 { - log.Debugf("user %s want to do action %s, but rank %d < %d", - userID, action, userRank, requireRank) - return false, requireRank - } - return true, requireRank -} - // GetRankPersonalPage get personal comment list page func (rs *RankService) GetRankPersonalPage(ctx context.Context, req *schema.GetRankPersonalWithPageReq) ( pageModel *pager.PageModel, err error) { diff --git a/internal/service/rank/rank_service_test.go b/internal/service/rank/rank_service_test.go new file mode 100644 index 000000000..0805113d8 --- /dev/null +++ b/internal/service/rank/rank_service_test.go @@ -0,0 +1,56 @@ +/* + * 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 rank + +import ( + "testing" + + "github.com/apache/answer/internal/service/permission" + "github.com/stretchr/testify/require" +) + +func TestIsMemberPermission(t *testing.T) { + tests := map[string]bool{ + permission.QuestionAdd: true, + permission.QuestionVoteUp: true, + permission.QuestionVoteDown: true, + permission.AnswerAdd: true, + permission.AnswerVoteUp: true, + permission.AnswerVoteDown: true, + permission.AnswerInviteSomeoneToAnswer: true, + permission.CommentAdd: true, + permission.CommentVoteUp: true, + permission.CommentVoteDown: true, + permission.ReportAdd: true, + permission.TagAdd: true, + permission.VoteDetail: true, + permission.QuestionEdit: false, + permission.QuestionDelete: false, + permission.QuestionAudit: false, + permission.QuestionPin: false, + permission.TagEdit: false, + } + + for action, expected := range tests { + t.Run(action, func(t *testing.T) { + require.Equal(t, expected, isMemberPermission(action)) + }) + } +} diff --git a/internal/service/registration/security.go b/internal/service/registration/security.go new file mode 100644 index 000000000..110f3e57e --- /dev/null +++ b/internal/service/registration/security.go @@ -0,0 +1,44 @@ +/* + * 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 registration + +import ( + "context" + "time" +) + +const ( + EmailCodeTTL = 10 * time.Minute + EmailCodeVerificationLockTTL = time.Minute + EmailCooldown = time.Minute + EmailHourlyWindow = time.Hour + EmailHourlyLimit = int64(5) + IPHourlyWindow = time.Hour + IPHourlyLimit = int64(100) +) + +// SecurityRepo stores one-time registration codes and applies shared limits. +type SecurityRepo interface { + CheckAndRecordSendLimit(ctx context.Context, email, ip string) (retryAfter time.Duration, err error) + SaveEmailCode(ctx context.Context, email, code string, ttl time.Duration) error + VerifyAndLockEmailCode(ctx context.Context, email, code string) (verificationToken string, matched bool, err error) + DeleteEmailCodeIfMatches(ctx context.Context, email, code string) (bool, error) + ReleaseEmailCodeLock(ctx context.Context, email, verificationToken string) error +} diff --git a/internal/service/tag_common/tag_common.go b/internal/service/tag_common/tag_common.go index 87b53f396..3ab4726d1 100644 --- a/internal/service/tag_common/tag_common.go +++ b/internal/service/tag_common/tag_common.go @@ -270,6 +270,11 @@ func (ts *TagCommonService) GetTagListByNames(ctx context.Context, tagNames []st } func (ts *TagCommonService) ExistRecommend(ctx context.Context, tags []*schema.TagItem) (bool, error) { + // Tags are optional. Only enforce recommended-tag rules after a user adds a tag. + if len(tags) == 0 { + return true, nil + } + taginfo, err := ts.siteInfoService.GetSiteTag(ctx) if err != nil { return false, err @@ -660,7 +665,7 @@ func (ts *TagCommonService) CheckChangeReservedTag(ctx context.Context, oldobjec // ObjectChangeTag change object tag list func (ts *TagCommonService) ObjectChangeTag(ctx context.Context, objectTagData *schema.TagChange, minimumTags int) (errorlist []*validator.FormErrorField, err error) { // checks if the tags sent in the put req are less than the minimum, if so, tag changes are not applied - if len(objectTagData.Tags) < minimumTags { + if len(objectTagData.Tags) > 0 && len(objectTagData.Tags) < minimumTags { errorlist := make([]*validator.FormErrorField, 0) errorlist = append(errorlist, &validator.FormErrorField{ ErrorField: "tags", diff --git a/internal/service/user_notification_config/user_notification_config_service.go b/internal/service/user_notification_config/user_notification_config_service.go index 8ab72fa94..346a1a540 100644 --- a/internal/service/user_notification_config/user_notification_config_service.go +++ b/internal/service/user_notification_config/user_notification_config_service.go @@ -93,7 +93,7 @@ func (us *UserNotificationConfigService) UpdateUserNotificationConfig( func (us *UserNotificationConfigService) SetDefaultUserNotificationConfig(ctx context.Context, userIDs []string) ( err error) { return us.userNotificationConfigRepo.Add(ctx, userIDs, - string(constant.InboxSource), `[{"key":"email","enable":true}]`) + string(constant.InboxSource), `[{"key":"email","enable":false}]`) } func (us *UserNotificationConfigService) convertToEntity(_ context.Context, userID string, diff --git a/licenserc.toml b/licenserc.toml index 4e1b4b7e5..04dce5b62 100644 --- a/licenserc.toml +++ b/licenserc.toml @@ -30,6 +30,8 @@ excludes = [ "ui/.browserslistrc", "ui/.npmrc", "ui/.env.*", + ".env.example", + "deploy/production/.env.example", "script/plugin_list", "charts/templates/_helpers.tpl", "charts/.helmignore", diff --git a/pkg/checker/email.go b/pkg/checker/email.go index a9732fdf0..12239d712 100644 --- a/pkg/checker/email.go +++ b/pkg/checker/email.go @@ -26,8 +26,16 @@ func EmailInAllowEmailDomain(email string, allowEmailDomains []string) bool { return true } + email = strings.TrimSpace(email) + at := strings.LastIndex(email, "@") + if at <= 0 || at != strings.Index(email, "@") || at == len(email)-1 { + return false + } + emailDomain := email[at+1:] + for _, domain := range allowEmailDomains { - if strings.HasSuffix(email, domain) { + domain = strings.TrimSpace(strings.TrimPrefix(domain, "@")) + if domain != "" && strings.EqualFold(emailDomain, domain) { return true } } diff --git a/pkg/checker/email_test.go b/pkg/checker/email_test.go new file mode 100644 index 000000000..ad15628b1 --- /dev/null +++ b/pkg/checker/email_test.go @@ -0,0 +1,56 @@ +/* + * 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 checker + +import "testing" + +func TestEmailInAllowEmailDomain(t *testing.T) { + allowed := []string{"hainanu.edu.cn", "@alumni.hainanu.edu.cn"} + tests := []struct { + name string + email string + want bool + }{ + {name: "student email", email: "student@hainanu.edu.cn", want: true}, + {name: "alumni email", email: "alumni@alumni.hainanu.edu.cn", want: true}, + {name: "case insensitive", email: "student@HAINANU.EDU.CN", want: true}, + {name: "surrounding spaces", email: " student@hainanu.edu.cn ", want: true}, + {name: "subdomain is not exact", email: "student@mail.hainanu.edu.cn", want: false}, + {name: "prefixed domain", email: "student@evil-hainanu.edu.cn", want: false}, + {name: "suffixed domain", email: "student@hainanu.edu.cn.evil.com", want: false}, + {name: "multiple at signs", email: "student@evil@hainanu.edu.cn", want: false}, + {name: "missing local part", email: "@hainanu.edu.cn", want: false}, + {name: "missing domain", email: "student@", want: false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := EmailInAllowEmailDomain(test.email, allowed); got != test.want { + t.Fatalf("EmailInAllowEmailDomain(%q) = %v, want %v", test.email, got, test.want) + } + }) + } +} + +func TestEmailInAllowEmailDomainAllowsAllWhenUnconfigured(t *testing.T) { + if !EmailInAllowEmailDomain("any@example.com", nil) { + t.Fatal("an empty allow list should preserve the existing allow-all behavior") + } +} diff --git a/ui/.env.development b/ui/.env.development index a634cee2e..cadfdb014 100644 --- a/ui/.env.development +++ b/ui/.env.development @@ -1,2 +1,4 @@ -PUBLIC_URL -REACT_APP_API_URL = http://10.0.20.84:8080/ +PUBLIC_URL=/ + REACT_APP_API_URL=http://127.0.0.1:9080/ + REACT_APP_BASE_URL= + REACT_APP_API_BASE_URL= diff --git a/ui/public/manifest.json b/ui/public/manifest.json index a92240fba..5abf40f8c 100644 --- a/ui/public/manifest.json +++ b/ui/public/manifest.json @@ -1,6 +1,6 @@ { - "short_name": "Answer", - "name": "Apache Answer", + "short_name": "Dongpolakeside", + "name": "Dongpolakeside", "icons": [ { "src": "favicon.ico", diff --git a/ui/src/common/constants.ts b/ui/src/common/constants.ts index 862072817..c8569826e 100644 --- a/ui/src/common/constants.ts +++ b/ui/src/common/constants.ts @@ -17,7 +17,7 @@ * under the License. */ -export const DEFAULT_SITE_NAME = 'Answer'; +export const DEFAULT_SITE_NAME = 'Dongpolakeside'; export const DEFAULT_LANG = 'en_US'; export const CURRENT_LANG_STORAGE_KEY = '_a_lang_'; export const LANG_RESOURCE_STORAGE_KEY = '_a_lang_r_'; @@ -116,8 +116,7 @@ export const ADMIN_NAV_MENUS = [ icon: 'people-fill', children: [ { name: 'users', pathPrefix: 'users/' }, - { name: 'badges' }, - { name: 'rules', path: 'rules/privileges', pathPrefix: 'rules/' }, + { name: 'rules', path: 'rules/policies', pathPrefix: 'rules/' }, ], }, { @@ -179,7 +178,6 @@ export const ADMIN_USERS_NAV_MENUS = [ ]; export const ADMIN_RULES_NAV_MENUS = [ - { name: 'privileges', path: '/admin/rules/privileges' }, { name: 'policies', path: '/admin/rules/policies' }, ]; diff --git a/ui/src/common/interface.ts b/ui/src/common/interface.ts index 8ab714230..a036028f5 100644 --- a/ui/src/common/interface.ts +++ b/ui/src/common/interface.ts @@ -83,6 +83,16 @@ export interface QuestionParams extends ImgCodeReq { url_title?: string; content: string; tags: Tag[]; + section_id?: number; +} + +export interface ForumSection { + id: number; + parent_id: number; + slug: string; + name: string; + admin_only: boolean; + children: ForumSection[]; } export interface QuestionWithAnswer extends QuestionParams { @@ -112,6 +122,12 @@ export interface LoginReqParams { export interface RegisterReqParams extends LoginReqParams { name: string; + pass_confirm: string; + email_code: string; +} + +export interface RegisterEmailCodeReq extends ImgCodeReq { + e_mail: string; } export interface ModifyPasswordReq { @@ -130,7 +146,7 @@ export interface ModifyUserReq { website: string; } -enum RoleId { +export enum RoleId { User = 1, Admin = 2, Moderator = 3, @@ -161,6 +177,10 @@ export interface UserInfoBase { export interface UserInfoRes extends UserInfoBase { bio: string; bio_html: string; + post_count?: number; + comment_count?: number; + question_count?: number; + answer_count?: number; create_time?: string; /** * value = 1 active; @@ -308,6 +328,7 @@ export interface QueryQuestionsReq extends Paging { order: QuestionOrderBy; tag?: string; in_days?: number; + section?: string; } export type AdminQuestionStatus = diff --git a/ui/src/components/BaseUserCard/index.tsx b/ui/src/components/BaseUserCard/index.tsx index f171bc2ca..858ddf830 100644 --- a/ui/src/components/BaseUserCard/index.tsx +++ b/ui/src/components/BaseUserCard/index.tsx @@ -21,13 +21,11 @@ import { memo, FC } from 'react'; import { Link } from 'react-router-dom'; import { Avatar } from '@/components'; -import { formatCount } from '@/utils'; interface Props { data: any; showAvatar?: boolean; avatarSize?: string; - showReputation?: boolean; avatarSearchStr?: string; className?: string; avatarClass?: string; @@ -41,7 +39,6 @@ const Index: FC = ({ avatarSize = '24px', className = 'small', avatarSearchStr = 's=48', - showReputation = true, nameMaxWidth = '300px', }) => { return ( @@ -82,12 +79,6 @@ const Index: FC = ({ {data?.display_name} )} - - {showReputation && ( - - {formatCount(data?.rank)} - - )} ); }; diff --git a/ui/src/components/CampusSectionNav/index.scss b/ui/src/components/CampusSectionNav/index.scss new file mode 100644 index 000000000..84689878e --- /dev/null +++ b/ui/src/components/CampusSectionNav/index.scss @@ -0,0 +1,94 @@ +/* + * 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. + */ + +.campus-section-nav { + width: 100%; + + .accordion { + --bs-accordion-border-width: 0; + --bs-accordion-bg: transparent; + --bs-accordion-btn-bg: transparent; + --bs-accordion-active-bg: transparent; + --bs-accordion-active-color: var(--an-side-nav-link); + --bs-accordion-btn-focus-box-shadow: none; + } + + .campus-section-group { + border: 0; + background: transparent; + } + + .accordion-button { + padding: 0.5rem 1rem; + border-radius: var(--bs-border-radius); + color: var(--an-side-nav-link); + font-size: 1rem; + box-shadow: none; + } + + .accordion-button, + .accordion-button:not(.collapsed), + .accordion-button:focus, + .accordion-button:focus-visible, + .campus-section-link, + .campus-section-link:focus, + .campus-section-link:focus-visible, + .campus-section-link.active { + box-shadow: none !important; + } + + .accordion-button:hover { + color: var(--an-side-nav-link-hover-color); + background-color: var(--bs-tertiary-bg); + } + + .accordion-button::after { + width: 0.8rem; + height: 0.8rem; + background-size: 0.8rem; + } + + .accordion-body { + padding: 0 0 0.35rem 2.5rem; + } + + .campus-section-link { + padding: 0.35rem 0.75rem; + border-radius: var(--bs-border-radius); + color: var(--an-side-nav-link); + text-decoration: none; + font-size: 0.875rem; + } + + .campus-section-link:hover { + color: var(--an-side-nav-link-hover-color); + background-color: var(--bs-tertiary-bg); + } + + .campus-section-link.active { + color: var(--an-side-nav-link-hover-color); + background-color: var(--bs-secondary-bg); + font-weight: 600; + } + + .campus-section-note { + color: var(--bs-secondary-color); + font-size: 0.75rem; + } +} diff --git a/ui/src/components/CampusSectionNav/index.tsx b/ui/src/components/CampusSectionNav/index.tsx new file mode 100644 index 000000000..75ee8e988 --- /dev/null +++ b/ui/src/components/CampusSectionNav/index.tsx @@ -0,0 +1,138 @@ +/* + * 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 } from 'react'; +import { Accordion, Form, Placeholder } from 'react-bootstrap'; +import { Link, useLocation, useSearchParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; + +import { useForumSections } from '@/services'; +import Icon from '@/components/Icon'; + +import './index.scss'; + +interface Props { + mobile?: boolean; +} + +const SECTION_ICONS: Record = { + 'career-future': 'briefcase-fill', + 'hainanu-campus': 'building-fill', + 'technology-life': 'laptop-fill', + 'life-information': 'basket-fill', + 'site-management': 'megaphone-fill', +}; + +const CampusSectionNav: FC = ({ mobile = false }) => { + const { t } = useTranslation('translation', { keyPrefix: 'campus_forum' }); + const { data, isLoading } = useForumSections(); + const { pathname } = useLocation(); + const [searchParams, setSearchParams] = useSearchParams(); + const current = searchParams.get('section') || ''; + + const sectionHref = (slug = '') => { + const params = new URLSearchParams(searchParams); + params.delete('page'); + if (slug) params.set('section', slug); + else params.delete('section'); + return `/questions?${params.toString()}`; + }; + + if (mobile) { + return ( + { + const params = new URLSearchParams(searchParams); + params.delete('page'); + if (event.target.value) params.set('section', event.target.value); + else params.delete('section'); + setSearchParams(params); + }}> + + {data.map((parent) => ( + + {parent.children.map((child) => ( + + ))} + + ))} + + ); + } + + return ( +
+
+ {t('sections')} +
+ {isLoading ? ( + + + + ) : ( + String(item.id))}> + {data.map((parent) => ( + + + + {parent.name} + + + {parent.children.map((child) => ( + + {child.name} + {child.admin_only ? ( + + · {t('admin_only')} + + ) : null} + + ))} + + + ))} + + )} +
+ ); +}; + +export default CampusSectionNav; diff --git a/ui/src/components/Counts/index.tsx b/ui/src/components/Counts/index.tsx index ef1723772..fba03e3db 100644 --- a/ui/src/components/Counts/index.tsx +++ b/ui/src/components/Counts/index.tsx @@ -36,6 +36,7 @@ interface Props { showViews?: boolean; showAccepted?: boolean; isAccepted?: boolean; + answersLabel?: 'answers' | 'comments'; className?: string; } const Index: FC = ({ @@ -45,6 +46,7 @@ const Index: FC = ({ showViews = true, isAccepted = false, showAccepted = false, + answersLabel = 'answers', className = '', }) => { const { t } = useTranslation('translation', { keyPrefix: 'counts' }); @@ -77,7 +79,7 @@ const Index: FC = ({ )} {data.answers} - {t('answers')} + {t(answersLabel)} )} {showViews && ( diff --git a/ui/src/components/Header/components/NavItems/index.tsx b/ui/src/components/Header/components/NavItems/index.tsx index 61733695d..dc0d3433f 100644 --- a/ui/src/components/Header/components/NavItems/index.tsx +++ b/ui/src/components/Header/components/NavItems/index.tsx @@ -34,6 +34,8 @@ interface Props { logOut: (e) => void; } +const formatUnreadCount = (count = 0) => (count > 99 ? '99+' : count); + const Index: FC = ({ redDot, userInfo, logOut }) => { const { t } = useTranslation(); const navigate = useNavigate(); @@ -57,11 +59,12 @@ const Index: FC = ({ redDot, userInfo, logOut }) => { className="icon-link nav-link d-flex align-items-center justify-content-center p-0 me-2 position-relative"> {(redDot?.inbox || 0) > 0 && ( -
+ + {formatUnreadCount(redDot?.inbox)} {t('new_alerts', { keyPrefix: 'notifications' })} -
+ )} @@ -71,11 +74,12 @@ const Index: FC = ({ redDot, userInfo, logOut }) => { className="icon-link nav-link d-flex align-items-center justify-content-center p-0 me-2 position-relative"> {(redDot?.achievement || 0) > 0 && ( -
+ + {formatUnreadCount(redDot?.achievement)} {t('new_alerts', { keyPrefix: 'notifications' })} -
+ )} diff --git a/ui/src/components/Header/index.tsx b/ui/src/components/Header/index.tsx index 22aad5aa1..6254f5cb3 100644 --- a/ui/src/components/Header/index.tsx +++ b/ui/src/components/Header/index.tsx @@ -35,6 +35,9 @@ import { } from '@/stores'; import { logout, useQueryNotificationStatus } from '@/services'; import { Icon, MobileSideNav } from '@/components'; +import Storage from '@/utils/storage'; +import { RouteAlias, BASE_ORIGIN } from '@/router/alias'; +import { REDIRECT_PATH_STORAGE_KEY } from '@/common/constants'; import NavItems from './components/NavItems'; import SearchInput from './components/SearchInput'; @@ -73,7 +76,8 @@ const Header: FC = () => { evt.preventDefault(); await logout(); clearUserStore(); - window.location.replace(window.location.href); + Storage.remove(REDIRECT_PATH_STORAGE_KEY); + window.location.replace(`${BASE_ORIGIN}${RouteAlias.home}`); }; useEffect(() => { diff --git a/ui/src/components/PinList/index.tsx b/ui/src/components/PinList/index.tsx index be60b9a92..984dcef09 100644 --- a/ui/src/components/PinList/index.tsx +++ b/ui/src/components/PinList/index.tsx @@ -59,12 +59,12 @@ const PinList: FC = ({ data }) => { = 1} showViews={false} className="mt-2 mt-md-0 small text-secondary" /> diff --git a/ui/src/components/QuestionList/index.tsx b/ui/src/components/QuestionList/index.tsx index 3d770dbe5..e766bff8f 100644 --- a/ui/src/components/QuestionList/index.tsx +++ b/ui/src/components/QuestionList/index.tsx @@ -17,7 +17,7 @@ * under the License. */ -import { FC, useEffect, useState } from 'react'; +import { FC, useEffect, useMemo, useState } from 'react'; import { ListGroup, Dropdown } from 'react-bootstrap'; import { NavLink, useSearchParams, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; @@ -39,6 +39,7 @@ import * as Type from '@/common/interface'; import { useSkeletonControl } from '@/hooks'; import Storage from '@/utils/storage'; import { LIST_VIEW_STORAGE_KEY } from '@/common/constants'; +import { useForumSections } from '@/services'; export const QUESTION_ORDER_KEYS: Type.QuestionOrderBy[] = [ 'newest', @@ -82,6 +83,14 @@ const QuestionList: FC = ({ ); const [viewType, setViewType] = useState('card'); + const { data: forumSections } = useForumSections(); + const sectionMap = useMemo(() => { + const mapping = new Map(); + forumSections.forEach((parent) => { + parent.children.forEach((child) => mapping.set(child.id, child)); + }); + return mapping; + }, [forumSections]); const handleViewMode = (key) => { Storage.set(LIST_VIEW_STORAGE_KEY, key); @@ -188,6 +197,14 @@ const QuestionList: FC = ({ )}
+ {sectionMap.has(li.section_id) ? ( + e.stopPropagation()}> + {sectionMap.get(li.section_id)?.name} + + ) : null} {Array.isArray(li.tags) ? li.tags.map((tag, index) => { return ( @@ -204,12 +221,12 @@ const QuestionList: FC = ({
= 1} className="mt-2 mt-md-0" />
diff --git a/ui/src/components/Share/index.tsx b/ui/src/components/Share/index.tsx index d63147b19..284b678e2 100644 --- a/ui/src/components/Share/index.tsx +++ b/ui/src/components/Share/index.tsx @@ -26,7 +26,7 @@ import copy from 'copy-to-clipboard'; import classNames from 'classnames'; import { BASE_ORIGIN } from '@/router/alias'; -import { loggedUserInfoStore } from '@/stores'; +import { loggedUserInfoStore, siteInfoStore } from '@/stores'; interface IProps { type: 'answer' | 'question'; @@ -40,6 +40,7 @@ interface IProps { const Index: FC = ({ type, qid, aid, title, className, mode }) => { const user = loggedUserInfoStore((state) => state.user); + const siteName = siteInfoStore((state) => state.siteInfo.name); const [show, setShow] = useState(false); const [showTip, setShowTip] = useState(false); const [canSystemShare, setSystemShareState] = useState(false); @@ -72,7 +73,7 @@ const Index: FC = ({ type, qid, aid, title, className, mode }) => { const systemShare = () => { navigator.share({ title, - text: `${title} - Answer:`, + text: `${title} - ${siteName}:`, url: baseUrl, }); }; diff --git a/ui/src/components/SideNav/index.scss b/ui/src/components/SideNav/index.scss index 27894d77c..ba9cb1d43 100644 --- a/ui/src/components/SideNav/index.scss +++ b/ui/src/components/SideNav/index.scss @@ -27,21 +27,30 @@ } #sideNav { + min-height: 100%; max-width: 208px; + .nav-link { color: var(--an-side-nav-link); } + .nav-link:focus-visible { box-shadow: none; } + .nav-link:hover { color: var(--an-side-nav-link-hover-color); background-color: var(--bs-gray-100); } + .nav-link.active { color: var(--an-side-nav-link-hover-color); background-color: var(--bs-gray-200); } + + .side-nav-admin { + border-top: 1px solid var(--bs-border-color); + } } @media screen and (max-width: 991.9px) { diff --git a/ui/src/components/SideNav/index.tsx b/ui/src/components/SideNav/index.tsx index 54d2f6a47..fbba31e30 100644 --- a/ui/src/components/SideNav/index.tsx +++ b/ui/src/components/SideNav/index.tsx @@ -19,11 +19,16 @@ import { FC } from 'react'; import { Nav } from 'react-bootstrap'; -import { NavLink, useLocation, useNavigate } from 'react-router-dom'; +import { + NavLink, + useLocation, + useNavigate, + useSearchParams, +} from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { loggedUserInfoStore, sideNavStore, aiControlStore } from '@/stores'; -import { Icon, PluginRender } from '@/components'; +import { Icon, PluginRender, CampusSectionNav } from '@/components'; import { PluginType } from '@/utils/pluginKit'; import request from '@/utils/request'; @@ -32,71 +37,67 @@ import './index.scss'; const Index: FC = () => { const { t } = useTranslation(); const { pathname } = useLocation(); + const [searchParams] = useSearchParams(); const { user: userInfo } = loggedUserInfoStore(); const { can_revision, revision } = sideNavStore(); const { ai_enabled } = aiControlStore(); const navigate = useNavigate(); return ( - + ); }; diff --git a/ui/src/components/UserCard/index.tsx b/ui/src/components/UserCard/index.tsx index cc7e883c6..f97e19015 100644 --- a/ui/src/components/UserCard/index.tsx +++ b/ui/src/components/UserCard/index.tsx @@ -23,7 +23,6 @@ import { Link } from 'react-router-dom'; import classnames from 'classnames'; import { Avatar, FormatTime } from '@/components'; -import { formatCount } from '@/utils'; interface Props { data: any; @@ -97,9 +96,6 @@ const Index: FC = ({ ) : ( {data?.display_name} )} - - {formatCount(data?.rank)} - {time && (isLogged ? ( diff --git a/ui/src/components/index.ts b/ui/src/components/index.ts index 5c81739bb..d24b9a794 100644 --- a/ui/src/components/index.ts +++ b/ui/src/components/index.ts @@ -68,6 +68,7 @@ import BubbleAi from './BubbleAi'; import BubbleUser from './BubbleUser'; import Sender from './Sender'; import TabNav from './TabNav'; +import CampusSectionNav from './CampusSectionNav'; export { Avatar, @@ -123,5 +124,6 @@ export { BubbleUser, Sender, TabNav, + CampusSectionNav, }; export type { EditorRef, JSONSchema, UISchema }; diff --git a/ui/src/index.scss b/ui/src/index.scss index 78f54216c..3f9688976 100644 --- a/ui/src/index.scss +++ b/ui/src/index.scss @@ -80,14 +80,17 @@ img[src=''] { } } -.unread-dot { - width: 18px; +.unread-count { + min-width: 18px; height: 18px; - border-radius: 50%; position: absolute; - left: 15px; - top: 0; + left: 17px; + top: -4px; + padding: 0 5px; border: 1px solid #fff; + font-size: 0.625rem; + line-height: 16px; + text-align: center; } .badge-tag { diff --git a/ui/src/pages/Admin/Users/components/Action/index.tsx b/ui/src/pages/Admin/Users/components/Action/index.tsx index b55971f49..2733038c7 100644 --- a/ui/src/pages/Admin/Users/components/Action/index.tsx +++ b/ui/src/pages/Admin/Users/components/Action/index.tsx @@ -31,9 +31,11 @@ import { import { updateUserPassword, changeUserStatus, + changeUserRole, updateUserProfile, } from '@/services'; import { toastStore } from '@/stores'; +import { RoleId } from '@/common/interface'; interface Props { showActionPassword?: boolean; @@ -142,6 +144,28 @@ const UserOperation = ({ }); } + if (type === 'make_admin') { + Modal.confirm({ + title: t('make_admin.title'), + content: t('make_admin.content'), + cancelBtnVariant: 'link', + confirmBtnVariant: 'primary', + confirmText: t('make_admin.confirm'), + onConfirm: () => { + changeUserRole({ + user_id, + role_id: RoleId.Admin, + }).then(() => { + Toast.onShow({ + msg: t('make_admin.success'), + variant: 'success', + }); + refreshUsers?.(); + }); + }, + }); + } + if (type === 'password') { changePasswordModal.onShow(user_id); } @@ -207,9 +231,17 @@ const UserOperation = ({ {t('edit_profile')} {showActionRole ? ( - handleAction('role')}> - {t('change_role')} - + <> + {userData.role_id !== RoleId.Admin && ( + handleAction('make_admin')}> + + {t('make_admin.action')} + + )} + handleAction('role')}> + {t('change_role')} + + ) : null} {userData.status === 'inactive' ? ( handleAction('activation')}> diff --git a/ui/src/pages/Admin/Users/index.tsx b/ui/src/pages/Admin/Users/index.tsx index 200aacf38..f35313b28 100644 --- a/ui/src/pages/Admin/Users/index.tsx +++ b/ui/src/pages/Admin/Users/index.tsx @@ -45,7 +45,6 @@ import { changeUserStatus, deletePermanently, } from '@/services'; -import { formatCount } from '@/utils'; import { ADMIN_USERS_NAV_MENUS } from '@/common/constants'; import DeleteUserModal from './components/DeleteUserModal'; @@ -251,7 +250,6 @@ const Users: FC = () => { {t('name')} - {t('reputation')} {t('email')} @@ -291,11 +289,9 @@ const Users: FC = () => { avatarSize="32px" avatarSearchStr="s=48" avatarClass="me-2" - showReputation={false} nameMaxWidth="160px" /> - {formatCount(user.rank)} {user.e_mail} diff --git a/ui/src/pages/Badges/Detail/components/UserCard/index.tsx b/ui/src/pages/Badges/Detail/components/UserCard/index.tsx index 116a81b33..c0613050a 100644 --- a/ui/src/pages/Badges/Detail/components/UserCard/index.tsx +++ b/ui/src/pages/Badges/Detail/components/UserCard/index.tsx @@ -19,17 +19,14 @@ import { memo, FC } from 'react'; import { Link } from 'react-router-dom'; -import { useTranslation } from 'react-i18next'; import { Avatar } from '@/components'; -import { formatCount } from '@/utils'; interface Props { data: any; } const Index: FC = ({ data }) => { - const { t } = useTranslation('translation', { keyPrefix: 'badges' }); return (
{data?.status !== 'deleted' ? ( @@ -80,10 +77,6 @@ const Index: FC = ({ data }) => { ) : ( {data?.display_name} )} -
- {formatCount(data?.rank)}{' '} - {t('x_reputation', { keyPrefix: 'personal' })} -
); diff --git a/ui/src/pages/Layout/index.tsx b/ui/src/pages/Layout/index.tsx index 048ca812e..bc9811d86 100644 --- a/ui/src/pages/Layout/index.tsx +++ b/ui/src/pages/Layout/index.tsx @@ -39,9 +39,8 @@ import { PageTags, HttpErrorContent, } from '@/components'; -import { LoginToContinueModal, BadgeModal } from '@/components/Modal'; +import { LoginToContinueModal } from '@/components/Modal'; import { changeTheme, Storage, scrollToElementTop } from '@/utils'; -import { useQueryNotificationStatus } from '@/services'; import { useExternalToast } from '@/hooks'; import { EXTERNAL_CONTENT_DISPLAY_MODE } from '@/common/constants'; @@ -57,7 +56,6 @@ const Layout: FC = () => { }; const { code: httpStatusCode, reset: httpStatusReset } = errorCodeStore(); const { show: showLoginToContinueModal } = loginToContinueStore(); - const { data: notificationData } = useQueryNotificationStatus(); const layout = themeSettingStore((state) => state.layout); useEffect(() => { // handle footnote links @@ -225,10 +223,6 @@ const Layout: FC = () => { - diff --git a/ui/src/pages/Questions/Ask/index.tsx b/ui/src/pages/Questions/Ask/index.tsx index ab680c495..ff66bf7c6 100644 --- a/ui/src/pages/Questions/Ask/index.tsx +++ b/ui/src/pages/Questions/Ask/index.tsx @@ -28,7 +28,7 @@ import isEqual from 'lodash/isEqual'; import debounce from 'lodash/debounce'; import fm from 'front-matter'; -import { writeSettingStore } from '@/stores'; +import { writeSettingStore, loggedUserInfoStore } from '@/stores'; import { usePageTags, usePromptWithUnload } from '@/hooks'; import { Editor, EditorRef, TagSelector } from '@/components'; import type * as Type from '@/common/interface'; @@ -41,6 +41,7 @@ import { queryQuestionByTitle, getTagsBySlugName, saveQuestionWithAnswer, + useForumSections, } from '@/services'; import { handleFormError, @@ -54,6 +55,7 @@ import { useCaptchaPlugin } from '@/utils/pluginKit'; import SearchQuestion from './components/SearchQuestion'; interface FormDataItem { + section_id: Type.FormValue; title: Type.FormValue; tags: Type.FormValue; content: Type.FormValue; @@ -65,6 +67,11 @@ const saveDraft = new SaveDraft({ type: 'question' }); const Ask = () => { const initFormData = { + section_id: { + value: 0, + isInvalid: false, + errorMsg: '', + }, title: { value: '', isInvalid: false, @@ -122,6 +129,8 @@ const Ask = () => { }); }; const writeInfo = writeSettingStore((state) => state.write); + const { user: loggedUser } = loggedUserInfoStore(); + const { data: forumSections } = useForumSections(); const isEdit = qid !== undefined; @@ -161,6 +170,7 @@ const Ask = () => { formData.title.value = draft.title; formData.content.value = draft.content; formData.tags.value = draft.tags; + formData.section_id.value = draft.section_id || 0; formData.answer_content.value = draft.answer_content; setCheckState(Boolean(draft.answer_content)); setHasDraft(true); @@ -177,7 +187,7 @@ const Ask = () => { }, [qid]); useEffect(() => { - const { title, tags, content, answer_content } = formData; + const { title, tags, content, answer_content, section_id } = formData; const { title: editTitle, tags: editTags, content: editContent } = immData; // edited @@ -199,6 +209,7 @@ const Ask = () => { // write if ( title.value || + section_id.value > 0 || tags.value.length > 0 || content.value || answer_content.value @@ -207,6 +218,7 @@ const Ask = () => { saveDraft.save({ params: { title: title.value, + section_id: section_id.value, tags: tags.value, content: content.value, answer_content: answer_content.value, @@ -240,6 +252,7 @@ const Ask = () => { original_text: '', }; }); + formData.section_id.value = res.section_id || 0; setImmData({ ...formData }); setFormData({ ...formData }); }); @@ -278,6 +291,16 @@ const Ask = () => { tags: { value, errorMsg: '', isInvalid: false }, }); + const handleSectionChange = (event: React.ChangeEvent) => + setFormData({ + ...formData, + section_id: { + value: Number(event.currentTarget.value), + isInvalid: false, + errorMsg: '', + }, + }); + const handleAnswerChange = (value: string) => setFormData((prev) => ({ ...prev, @@ -383,11 +406,24 @@ const Ask = () => { const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); event.stopPropagation(); + if (!isEdit && formData.section_id.value <= 0) { + setFormData({ + ...formData, + section_id: { + ...formData.section_id, + isInvalid: true, + errorMsg: t('form.fields.section.msg.empty'), + }, + }); + scrollToElementTop(document.getElementById('section_id')); + return; + } const params: Type.QuestionParams = { title: formData.title.value, content: formData.content.value, tags: formData.tags.value, + section_id: formData.section_id.value, }; if (isEdit) { @@ -468,6 +504,37 @@ const Ask = () => { )} + {!isEdit && ( + + {t('form.fields.section.label')} + + + {forumSections.map((parent) => ( + + {parent.children + .filter( + (child) => + !child.admin_only || loggedUser.role_id === 2, + ) + .map((child) => ( + + ))} + + ))} + + + {formData.section_id.errorMsg} + + {t('form.fields.section.hint')} + + )} {t('form.fields.title.label')} { showRequiredTag maxTagLength={5} isInvalid={formData.tags.isInvalid} + formText={t('form.fields.tags.hint')} errMsg={formData.tags.errorMsg} /> diff --git a/ui/src/pages/Questions/Detail/components/Answer/index.tsx b/ui/src/pages/Questions/Detail/components/Answer/index.tsx index bd0ecbb5b..ba055e543 100644 --- a/ui/src/pages/Questions/Detail/components/Answer/index.tsx +++ b/ui/src/pages/Questions/Detail/components/Answer/index.tsx @@ -18,7 +18,7 @@ */ import { memo, FC, useEffect, useRef } from 'react'; -import { Button, Alert, Badge } from 'react-bootstrap'; +import { Alert } from 'react-bootstrap'; import { useTranslation } from 'react-i18next'; import { useSearchParams } from 'react-router-dom'; @@ -26,21 +26,18 @@ import { Actions, Operate, UserCard, - Icon, Comment, htmlRender, ImgViewer, } from '@/components'; import { scrollToElementTop, bgFadeOut } from '@/utils'; import { AnswerItem } from '@/common/interface'; -import { acceptanceAnswer } from '@/services'; import { useRenderHtmlPlugin } from '@/utils/pluginKit'; interface Props { data: AnswerItem; /** router answer id */ aid?: string; - canAccept: boolean; questionTitle: string; isLogged: boolean; callback: (type: string) => void; @@ -51,7 +48,6 @@ const Index: FC = ({ isLogged, questionTitle = '', callback, - canAccept = false, }) => { const { t } = useTranslation('translation', { keyPrefix: 'question_detail', @@ -61,15 +57,6 @@ const Index: FC = ({ useRenderHtmlPlugin(answerRef.current?.querySelector('.fmt') as HTMLElement); - const acceptAnswer = () => { - acceptanceAnswer({ - question_id: data.question_id, - answer_id: data.accepted === 2 ? '0' : data.id, - }).then(() => { - callback?.(''); - }); - }; - useEffect(() => { if (!answerRef?.current) { return; @@ -120,15 +107,6 @@ const Index: FC = ({ timelinePath={`/posts/${data.question_id}/${data.id}/timeline`} /> - - {data?.accepted === 2 && ( -
- - - Best answer - -
- )}
= ({ username: data?.user_info?.username, }} /> - - {canAccept && ( - - )} = ({ id }) => { to={pathFactory.questionLanding(item.id, item.url_title)}>
{item.title}
{item.answer_count > 0 && ( -
0 - ? 'link-success' - : 'link-secondary' - }`}> - 0 - ? 'check-circle-fill' - : 'chat-square-text-fill' - } - className="me-1" - /> +
+ {item.answer_count} {t2('answers')} diff --git a/ui/src/pages/Questions/Detail/components/RelatedQuestions/index.tsx b/ui/src/pages/Questions/Detail/components/RelatedQuestions/index.tsx index 19bf269dc..efde66c16 100644 --- a/ui/src/pages/Questions/Detail/components/RelatedQuestions/index.tsx +++ b/ui/src/pages/Questions/Detail/components/RelatedQuestions/index.tsx @@ -60,21 +60,8 @@ const Index: FC = ({ id }) => { to={pathFactory.questionLanding(item.id, item.url_title)}>
{item.title}
{item.answer_count > 0 && ( -
0 - ? 'link-success' - : 'link-secondary' - }`}> - 0 - ? 'check-circle-fill' - : 'chat-square-text-fill' - } - className="me-1" - /> - +
+ {item.answer_count} {t('answers')} diff --git a/ui/src/pages/Questions/Detail/components/WriteAnswer/index.tsx b/ui/src/pages/Questions/Detail/components/WriteAnswer/index.tsx index 5627231dc..8b8164d3a 100644 --- a/ui/src/pages/Questions/Detail/components/WriteAnswer/index.tsx +++ b/ui/src/pages/Questions/Detail/components/WriteAnswer/index.tsx @@ -18,8 +18,8 @@ */ import { memo, useState, FC, useEffect } from 'react'; -import { Form, Button, Alert } from 'react-bootstrap'; -import { useTranslation, Trans } from 'react-i18next'; +import { Form, Button } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { marked } from 'marked'; @@ -40,7 +40,6 @@ interface Props { /** question id */ qid: string; answered?: boolean; - loggedUserRank: number; first_answer_id?: string; }; callback?: (obj) => void; @@ -63,7 +62,6 @@ const Index: FC = ({ visible = false, data, callback }) => { const [focusType, setFocusType] = useState(''); const [editorFocusState, setEditorFocusState] = useState(false); const [hasDraft, setHasDraft] = useState(false); - const [showTips, setShowTips] = useState(data.loggedUserRank < 100); const aCaptcha = useCaptchaPlugin('answer'); const writeInfo = writeSettingStore((state) => state.write); const [editorCanSave, setEditorCanSave] = useState(false); @@ -261,61 +259,32 @@ const Index: FC = ({ visible = false, data, callback }) => {
)} {showEditor && ( - <> - { - if (editorCanSave) { - setFormData({ - content: { - value: val, - isInvalid: false, - errorMsg: '', - }, - }); - } - }} - onFocus={() => { - setFocusType('answer'); - }} - onBlur={() => { - setFocusType(''); - }} - /> - - setShowTips(false)} - dismissible - className="mt-3"> -

{t('tips.header_1')}

-
    -
  • - }} - /> -
  • -
  • {t('tips.li1_2')}
  • -
-

- }} - /> -

-
    -
  • {t('tips.li2_1')}
  • -
-
- + { + if (editorCanSave) { + setFormData({ + content: { + value: val, + isInvalid: false, + errorMsg: '', + }, + }); + } + }} + onFocus={() => { + setFocusType('answer'); + }} + onBlur={() => { + setFocusType(''); + }} + /> )} diff --git a/ui/src/pages/Questions/Detail/components/index.tsx b/ui/src/pages/Questions/Detail/components/index.tsx index ee6657908..484b741a0 100644 --- a/ui/src/pages/Questions/Detail/components/index.tsx +++ b/ui/src/pages/Questions/Detail/components/index.tsx @@ -24,7 +24,6 @@ import RelatedQuestions from './RelatedQuestions'; import WriteAnswer from './WriteAnswer'; import Alert from './Alert'; import ContentLoader from './ContentLoader'; -import InviteToAnswer from './InviteToAnswer'; import LinkedQuestions from './LinkedQuestions'; export { @@ -35,6 +34,5 @@ export { WriteAnswer, Alert, ContentLoader, - InviteToAnswer, LinkedQuestions, }; diff --git a/ui/src/pages/Questions/Detail/index.tsx b/ui/src/pages/Questions/Detail/index.tsx index 8c8921b88..dee21f561 100644 --- a/ui/src/pages/Questions/Detail/index.tsx +++ b/ui/src/pages/Questions/Detail/index.tsx @@ -46,7 +46,6 @@ import { WriteAnswer, Alert, ContentLoader, - InviteToAnswer, LinkedQuestions, } from './components'; @@ -76,11 +75,8 @@ const Index = () => { }); const { setUsers } = usePageUsers(); const userInfo = loggedUserInfoStore((state) => state.user); - const isAuthor = userInfo?.username === question?.user_info?.username; const isAdmin = userInfo?.role_id === 2; - const isModerator = userInfo?.role_id === 3; const isLogged = Boolean(userInfo?.access_token); - const loggedUserRank = userInfo?.rank; const location = useLocation(); useEffect(() => { @@ -230,17 +226,7 @@ const Index = () => { keywords: question?.tags.map((_) => _.slug_name).join(','), }); - const showInviteToAnswer = question?.id; const showLinkedQuestions = question?.id && question.id !== ''; - let canInvitePeople = false; - if (showInviteToAnswer && Array.isArray(question.extends_actions)) { - const inviteAct = question.extends_actions.find((op) => { - return op.action === 'invite_other_to_answer'; - }); - if (inviteAct) { - canInvitePeople = true; - } - } return ( @@ -266,7 +252,6 @@ const Index = () => { key={item?.id} data={item} questionTitle={question?.title || ''} - canAccept={isAuthor || isAdmin || isModerator} callback={initPage} isLogged={isLogged} /> @@ -292,7 +277,6 @@ const Index = () => { data={{ qid, answered: question?.answered, - loggedUserRank, first_answer_id: question?.first_answer_id, }} callback={writeAnswerCallback} @@ -301,12 +285,6 @@ const Index = () => { - {showInviteToAnswer ? ( - - ) : null} {showLinkedQuestions ? : null} diff --git a/ui/src/pages/Questions/index.tsx b/ui/src/pages/Questions/index.tsx index 7239feb66..c7a5d4cdd 100644 --- a/ui/src/pages/Questions/index.tsx +++ b/ui/src/pages/Questions/index.tsx @@ -28,34 +28,44 @@ import { QuestionList, HotQuestions, CustomSidebar, + CampusSectionNav, } from '@/components'; import { siteInfoStore, loggedUserInfoStore, loginSettingStore, } from '@/stores'; -import { useQuestionList, useQuestionRecommendList } from '@/services'; +import { useQuestionList } from '@/services'; import * as Type from '@/common/interface'; import { userCenter, floppyNavigation } from '@/utils'; -import { QUESTION_ORDER_KEYS } from '@/components/QuestionList'; + +const CAMPUS_QUESTION_ORDER_KEYS: Type.QuestionOrderBy[] = [ + 'active', + 'newest', + 'score', +]; const Questions: FC = () => { const { t } = useTranslation('translation', { keyPrefix: 'question' }); const { t: t2 } = useTranslation('translation'); + const { t: tCampus } = useTranslation('translation', { + keyPrefix: 'campus_forum', + }); const { user: loggedUser } = loggedUserInfoStore((_) => _); const [urlSearchParams] = useSearchParams(); const curPage = Number(urlSearchParams.get('page')) || 1; - const curOrder = (urlSearchParams.get('order') || - QUESTION_ORDER_KEYS[0]) as Type.QuestionOrderBy; + const requestedOrder = urlSearchParams.get('order') as Type.QuestionOrderBy; + const curOrder = CAMPUS_QUESTION_ORDER_KEYS.includes(requestedOrder) + ? requestedOrder + : CAMPUS_QUESTION_ORDER_KEYS[0]; const reqParams: Type.QueryQuestionsReq = { page_size: 20, page: curPage, order: curOrder as Type.QuestionOrderBy, + in_days: 30, + section: urlSearchParams.get('section') || undefined, }; - const { data: listData, isLoading: listLoading } = - curOrder === 'recommend' - ? useQuestionRecommendList(reqParams) - : useQuestionList(reqParams); + const { data: listData, isLoading: listLoading } = useQuestionList(reqParams); const isIndexPage = useMatch('/'); let pageTitle = t('questions', { keyPrefix: 'page_title' }); let slogan = ''; @@ -70,15 +80,15 @@ const Questions: FC = () => { return ( + +
+ {tCampus('last_30_days')} +
key !== 'recommend') - } + orderList={CAMPUS_QUESTION_ORDER_KEYS} isLoading={listLoading} /> diff --git a/ui/src/pages/Search/components/SearchItem/index.tsx b/ui/src/pages/Search/components/SearchItem/index.tsx index 163917716..fdff250fa 100644 --- a/ui/src/pages/Search/components/SearchItem/index.tsx +++ b/ui/src/pages/Search/components/SearchItem/index.tsx @@ -84,6 +84,7 @@ const Index: FC = ({ data }) => { = ({ data, isAdmin, objectInfo, revisionList }) => { className="fs-normal" data={data?.user_info} showAvatar={false} - showReputation={false} /> )} diff --git a/ui/src/pages/Users/Logout/index.tsx b/ui/src/pages/Users/Logout/index.tsx index 5561c16f8..3e7b580ec 100644 --- a/ui/src/pages/Users/Logout/index.tsx +++ b/ui/src/pages/Users/Logout/index.tsx @@ -40,10 +40,8 @@ const Index = () => { if (loggedUserInfo.username) { logout().then(() => { clearUserStore(); - const redirect = - Storage.get(REDIRECT_PATH_STORAGE_KEY) || RouteAlias.home; Storage.remove(REDIRECT_PATH_STORAGE_KEY); - window.location.replace(`${BASE_ORIGIN}${redirect}`); + window.location.replace(`${BASE_ORIGIN}${RouteAlias.home}`); }); } // auto height of container diff --git a/ui/src/pages/Users/Notifications/components/Achievements/index.tsx b/ui/src/pages/Users/Notifications/components/Achievements/index.tsx index 824bb1788..59f23cb24 100644 --- a/ui/src/pages/Users/Notifications/components/Achievements/index.tsx +++ b/ui/src/pages/Users/Notifications/components/Achievements/index.tsx @@ -70,19 +70,7 @@ const Achievements = ({ data, handleReadNotification }) => { )}> {item.object_info.object_type === 'badge_award' ? (
👏
- ) : ( - <> - {item.rank > 0 && ( -
{`+${item.rank}`}
- )} - {item.rank === 0 && ( -
{item.rank}
- )} - {item.rank < 0 && ( -
{`${item.rank}`}
- )} - - )} + ) : null}
handleReadNotification(item.id)}> diff --git a/ui/src/pages/Users/Personal/components/Answers/index.tsx b/ui/src/pages/Users/Personal/components/Answers/index.tsx index 055d56353..2a1cb07c1 100644 --- a/ui/src/pages/Users/Personal/components/Answers/index.tsx +++ b/ui/src/pages/Users/Personal/components/Answers/index.tsx @@ -32,9 +32,12 @@ const Index: FC = ({ visible, data }) => { if (!visible || !data?.length) { return null; } + const visibleData = data.filter( + (item) => item.answer_id && item.question_id && item.question_info?.title, + ); return ( - {data.map((item) => { + {visibleData.map((item) => { return ( = ({ visible, data }) => { data={{ votes: item?.vote_count, views: 0, answers: 0 }} showAnswers={false} showViews={false} - showAccepted={item.accepted === 2} />
diff --git a/ui/src/pages/Users/Personal/components/Comments/index.tsx b/ui/src/pages/Users/Personal/components/Comments/index.tsx index 504535521..c2d47089e 100644 --- a/ui/src/pages/Users/Personal/components/Comments/index.tsx +++ b/ui/src/pages/Users/Personal/components/Comments/index.tsx @@ -33,9 +33,16 @@ const Index: FC = ({ visible, data }) => { if (!visible || !data?.length) { return null; } + const visibleData = data.filter( + (item) => + item.title && + item.question_id && + (item.object_type === 'question' || + (item.object_type === 'answer' && item.answer_id)), + ); return ( - {data.map((item) => { + {visibleData.map((item) => { return ( = ({ visible, tabName, data }) => { if (!visible) { return null; } + const visibleData = data.filter((item) => { + const id = tabName === 'posts' ? item.question_id : item.id; + return id && item.title; + }); return ( - {data.map((item) => { + {visibleData.map((item) => { return ( + key={tabName === 'posts' ? item.question_id : item.id}>
{item.title} - {tabName === 'questions' && item.status === 'closed' + {tabName === 'posts' && item.status === 'closed' ? ` [${t('closed', { keyPrefix: 'question' })}]` : null} @@ -73,7 +77,7 @@ const Index: FC = ({ visible, tabName, data }) => { /> 0} + answersLabel="comments" data={{ votes: item.vote_count, answers: item.answer_count, diff --git a/ui/src/pages/Users/Personal/components/ListHead/index.tsx b/ui/src/pages/Users/Personal/components/ListHead/index.tsx index 8a8a6174e..bf2a0c70c 100644 --- a/ui/src/pages/Users/Personal/components/ListHead/index.tsx +++ b/ui/src/pages/Users/Personal/components/ListHead/index.tsx @@ -31,7 +31,7 @@ interface Props { visible: boolean; } const Index: FC = ({ - tabName = 'answers', + tabName = 'comments', visible, sort, count = 0, @@ -47,7 +47,7 @@ const Index: FC = ({
{count} {t(tabName)}
- {(tabName === 'answers' || tabName === 'questions') && ( + {(tabName === 'comments' || tabName === 'posts') && ( = ({ slug, tabName = 'overview', isSelf }) => { const { t } = useTranslation('translation', { keyPrefix: 'personal' }); diff --git a/ui/src/pages/Users/Personal/components/Overview/index.tsx b/ui/src/pages/Users/Personal/components/Overview/index.tsx index 60d502e9e..1e96995e2 100644 --- a/ui/src/pages/Users/Personal/components/Overview/index.tsx +++ b/ui/src/pages/Users/Personal/components/Overview/index.tsx @@ -21,22 +21,15 @@ import { FC, memo } from 'react'; import { useTranslation } from 'react-i18next'; import { Row, Col } from 'react-bootstrap'; -// import * as Type from '@/common/interface'; -import { CardBadge } from '@/components'; -import { useGetRecentAwardBadges } from '@/services'; import TopList from '../TopList'; interface Props { - username: string; visible: boolean; introduction: string; data; } -const Index: FC = ({ visible, introduction, data, username }) => { +const Index: FC = ({ visible, introduction, data }) => { const { t } = useTranslation('translation', { keyPrefix: 'personal' }); - const { data: recentBadges } = useGetRecentAwardBadges( - visible ? username : null, - ); if (!visible) { return null; } @@ -54,43 +47,22 @@ const Index: FC = ({ visible, introduction, data, username }) => { -
{t('top_answers')}
- {data?.answer?.length > 0 ? ( - +
{t('top_comments')}
+ {data?.comments?.length > 0 ? ( + ) : (
{t('content_empty')}
)} -
{t('top_questions')}
- {data?.question?.length > 0 ? ( - +
{t('top_posts')}
+ {data?.posts?.length > 0 ? ( + ) : (
{t('content_empty')}
)}
- -
-
{t('recent_badges')}
- {Number(recentBadges?.count) > 0 ? ( - - {recentBadges?.list?.map((item) => { - return ( - - - - ); - })} - - ) : ( -
{t('content_empty')}
- )} -
); }; diff --git a/ui/src/pages/Users/Personal/components/TopList/index.tsx b/ui/src/pages/Users/Personal/components/TopList/index.tsx index aa896caf1..eda6ecf10 100644 --- a/ui/src/pages/Users/Personal/components/TopList/index.tsx +++ b/ui/src/pages/Users/Personal/components/TopList/index.tsx @@ -26,21 +26,26 @@ import { Icon } from '@/components'; interface Props { data: any[]; - type: 'answer' | 'question'; + type: 'comment' | 'post'; } const Index: FC = ({ data, type }) => { const { t } = useTranslation('translation', { keyPrefix: 'personal' }); + const visibleData = data.filter((item) => + type === 'comment' + ? item.answer_id && item.question_id && item.question_info?.title + : item.question_id && item.title, + ); return (
    - {data?.map((item, index) => { + {visibleData.map((item, index) => { return (
  1. + className={`${index === visibleData.length - 1 ? '' : 'mb-2'}`} + key={type === 'comment' ? item.answer_id : item.question_id}> = ({ data, type }) => { item.url_title, ) }> - {type === 'answer' ? item.question_info.title : item.title} + {type === 'comment' ? item.question_info.title : item.title}
    @@ -60,30 +65,16 @@ const Index: FC = ({ data, type }) => { {item.vote_count} {t('votes', { keyPrefix: 'counts' })} - {type === 'question' && ( -
    0 ? 'text-success' : '' - }`}> - {Number(item.accepted_answer_id) > 0 ? ( - - ) : ( - - )} + {type === 'post' && ( +
    + {' '} - {item.answer_count} {t('answers', { keyPrefix: 'counts' })} + {item.answer_count} {t('comments')}
    )} - - {type === 'answer' && item.accepted === 2 && ( -
    - - {t('accepted')} -
    - )}
  2. ); diff --git a/ui/src/pages/Users/Personal/components/UserInfo/index.tsx b/ui/src/pages/Users/Personal/components/UserInfo/index.tsx index 07cc20b0e..7d22d6de7 100644 --- a/ui/src/pages/Users/Personal/components/UserInfo/index.tsx +++ b/ui/src/pages/Users/Personal/components/UserInfo/index.tsx @@ -100,16 +100,16 @@ const Index: FC = ({ data }) => {
    - {data.rank || 0} - {t('x_reputation')} -
    -
    - {data.answer_count || 0} - {t('x_answers')} + + {data.comment_count ?? data.answer_count ?? 0} + + {t('x_comments')}
    - {data?.question_count || 0} - {t('x_questions')} + + {data.post_count ?? data.question_count ?? 0} + + {t('x_posts')}
    diff --git a/ui/src/pages/Users/Personal/components/Votes/index.tsx b/ui/src/pages/Users/Personal/components/Votes/index.tsx index 581cc39f6..65c7e4449 100644 --- a/ui/src/pages/Users/Personal/components/Votes/index.tsx +++ b/ui/src/pages/Users/Personal/components/Votes/index.tsx @@ -20,6 +20,7 @@ import { FC, memo } from 'react'; import { ListGroup, ListGroupItem } from 'react-bootstrap'; import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; import { pathFactory } from '@/router/pathFactory'; import { FormatTime } from '@/components'; @@ -30,13 +31,21 @@ interface Props { } const Index: FC = ({ visible, data }) => { + const { t } = useTranslation('translation', { keyPrefix: 'personal' }); if (!visible || !data?.length) { return null; } + const visibleData = data.filter( + (item) => + item.title && + item.question_id && + (item.object_type === 'question' || + (item.object_type === 'answer' && item.answer_id)), + ); return ( - {data.map((item) => { + {visibleData.map((item) => { return ( = ({ visible, data }) => { {item.title}
    - {item.object_type} + + {t(item.object_type === 'question' ? 'post' : 'comment')} + diff --git a/ui/src/pages/Users/Personal/components/index.ts b/ui/src/pages/Users/Personal/components/index.ts index 0a9baab1f..902430c0f 100644 --- a/ui/src/pages/Users/Personal/components/index.ts +++ b/ui/src/pages/Users/Personal/components/index.ts @@ -24,11 +24,9 @@ import TopList from './TopList'; import Alert from './Alert'; import ListHead from './ListHead'; import DefaultList from './DefaultList'; -import Reputation from './Reputation'; -import Comments from './Comments'; +import Comments from './Answers'; +import Replies from './Comments'; import Votes from './Votes'; -import Answers from './Answers'; -import Badges from './Badges'; export { Alert, @@ -38,9 +36,7 @@ export { TopList, ListHead, DefaultList, - Reputation, Comments, + Replies, Votes, - Answers, - Badges, }; diff --git a/ui/src/pages/Users/Personal/index.tsx b/ui/src/pages/Users/Personal/index.tsx index e9516acee..ec3932423 100644 --- a/ui/src/pages/Users/Personal/index.tsx +++ b/ui/src/pages/Users/Personal/index.tsx @@ -20,7 +20,7 @@ import { FC } from 'react'; import { Row, Col } from 'react-bootstrap'; import { useTranslation } from 'react-i18next'; -import { useParams, useSearchParams, Link } from 'react-router-dom'; +import { useParams, useSearchParams, Link, Navigate } from 'react-router-dom'; import { usePageTags } from '@/hooks'; import { Pagination, FormatTime, Empty } from '@/components'; @@ -39,15 +39,21 @@ import { Alert, ListHead, DefaultList, - Reputation, Comments, - Answers, + Replies, Votes, - Badges, } from './components'; const Personal: FC = () => { - const { tabName = 'overview', username = '' } = useParams(); + const { tabName: routeTabName = 'overview', username = '' } = useParams(); + const legacyTabMap: Record = { + answers: 'comments', + questions: 'posts', + }; + const tabName = + routeTabName === 'reputation' || routeTabName === 'badges' + ? 'overview' + : legacyTabMap[routeTabName] || routeTabName; const [searchParams] = useSearchParams(); const page = searchParams.get('page') || 1; const order = searchParams.get('order') || 'newest'; @@ -77,6 +83,12 @@ const Personal: FC = () => { title: pageTitle, }); + if (legacyTabMap[routeTabName]) { + const query = searchParams.toString(); + const target = `/users/${username}/${legacyTabMap[routeTabName]}`; + return ; + } + return (
    @@ -102,29 +114,22 @@ const Personal: FC = () => { visible={tabName === 'overview'} introduction={userInfo?.bio_html || ''} data={topData} - username={username} /> - + - - + - {!list?.length && !isLoading && } {count > 0 && ( diff --git a/ui/src/pages/Users/Register/components/SignUpForm/index.tsx b/ui/src/pages/Users/Register/components/SignUpForm/index.tsx index 8dc30b965..6e00d28b1 100644 --- a/ui/src/pages/Users/Register/components/SignUpForm/index.tsx +++ b/ui/src/pages/Users/Register/components/SignUpForm/index.tsx @@ -17,27 +17,53 @@ * under the License. */ -import React, { FormEvent, useState } from 'react'; -import { Form, Button } from 'react-bootstrap'; +import React, { FormEvent, useEffect, useState } from 'react'; +import { Form, Button, InputGroup } from 'react-bootstrap'; import { Link } from 'react-router-dom'; import { Trans, useTranslation } from 'react-i18next'; import { useCaptchaPlugin } from '@/utils/pluginKit'; import type { FormDataType, + RegisterEmailCodeReq, RegisterReqParams, UserInfoRes, } from '@/common/interface'; -import { register } from '@/services'; +import { register, sendRegisterEmailCode } from '@/services'; import { handleFormError, scrollToElementTop } from '@/utils'; import { useLegalClick } from '@/behaviour/useLegalClick'; +import { useToast } from '@/hooks'; interface Props { callback: (user: UserInfoRes) => void; } +const EMAIL_CODE_COUNTDOWN_SECONDS = 60; +const ALLOWED_EMAIL_DOMAINS = new Set([ + 'hainanu.edu.cn', + 'alumni.hainanu.edu.cn', +]); + +const normalizeEmail = (email: string) => email.trim().toLowerCase(); + +const getEmailDomain = (email: string) => { + const normalizedEmail = normalizeEmail(email); + const atIndex = normalizedEmail.lastIndexOf('@'); + if (atIndex <= 0 || atIndex !== normalizedEmail.indexOf('@')) { + return ''; + } + return normalizedEmail.slice(atIndex + 1); +}; + +const isAllowedEmail = (email: string) => + ALLOWED_EMAIL_DOMAINS.has(getEmailDomain(email)); + const Index: React.FC = ({ callback }) => { const { t } = useTranslation('translation', { keyPrefix: 'login' }); + const Toast = useToast(); + const [emailCodeCountdown, setEmailCodeCountdown] = useState(0); + const [emailCodeSending, setEmailCodeSending] = useState(false); + const [emailCodeSentTo, setEmailCodeSentTo] = useState(''); const [formData, setFormData] = useState({ name: { value: '', @@ -54,18 +80,78 @@ const Index: React.FC = ({ callback }) => { isInvalid: false, errorMsg: '', }, + pass_confirm: { + value: '', + isInvalid: false, + errorMsg: '', + }, + email_code: { + value: '', + isInvalid: false, + errorMsg: '', + }, }); const emailCaptcha = useCaptchaPlugin('email'); const nameRegex = /^[\w.-\s]{2,30}$/; + useEffect(() => { + if (emailCodeCountdown <= 0) { + return undefined; + } + const timer = window.setTimeout(() => { + setEmailCodeCountdown((seconds) => Math.max(0, seconds - 1)); + }, 1000); + return () => window.clearTimeout(timer); + }, [emailCodeCountdown]); + const handleChange = (params: FormDataType) => { setFormData({ ...formData, ...params }); }; + const checkEmailValidated = (): boolean => { + const email = normalizeEmail(formData.e_mail.value); + if (!email) { + handleChange({ + e_mail: { + value: '', + isInvalid: true, + errorMsg: t('email.msg.empty'), + }, + }); + return false; + } + if (!/^[^\s@]+@[^\s@]+$/.test(email)) { + handleChange({ + e_mail: { + value: email, + isInvalid: true, + errorMsg: t('email.msg.invalid'), + }, + }); + return false; + } + if (!isAllowedEmail(email)) { + handleChange({ + e_mail: { + value: email, + isInvalid: true, + errorMsg: t('email.msg.domain'), + }, + }); + return false; + } + return true; + }; + const checkValidated = (): boolean => { let bol = true; - const { name, e_mail, pass } = formData; + const { + name, + pass, + pass_confirm: passConfirm, + email_code: emailCode, + } = formData; if (!name.value) { bol = false; @@ -90,13 +176,28 @@ const Index: React.FC = ({ callback }) => { }; } - if (!e_mail.value) { + const email = normalizeEmail(formData.e_mail.value); + if (!email) { bol = false; formData.e_mail = { value: '', isInvalid: true, errorMsg: t('email.msg.empty'), }; + } else if (!/^[^\s@]+@[^\s@]+$/.test(email)) { + bol = false; + formData.e_mail = { + value: email, + isInvalid: true, + errorMsg: t('email.msg.invalid'), + }; + } else if (!isAllowedEmail(email)) { + bol = false; + formData.e_mail = { + value: email, + isInvalid: true, + errorMsg: t('email.msg.domain'), + }; } if (!pass.value) { @@ -106,6 +207,45 @@ const Index: React.FC = ({ callback }) => { isInvalid: true, errorMsg: t('password.msg.empty'), }; + } else if (pass.value.length < 8 || pass.value.length > 32) { + bol = false; + formData.pass = { + value: pass.value, + isInvalid: true, + errorMsg: t('password.msg.range'), + }; + } + + if (!passConfirm.value) { + bol = false; + formData.pass_confirm = { + value: '', + isInvalid: true, + errorMsg: t('password_confirm.msg.empty'), + }; + } else if (pass.value !== passConfirm.value) { + bol = false; + formData.pass_confirm = { + value: passConfirm.value, + isInvalid: true, + errorMsg: t('password_confirm.msg.different'), + }; + } + + if (!emailCode.value) { + bol = false; + formData.email_code = { + value: '', + isInvalid: true, + errorMsg: t('verification_code.msg.empty'), + }; + } else if (!/^\d{6}$/.test(emailCode.value)) { + bol = false; + formData.email_code = { + value: emailCode.value, + isInvalid: true, + errorMsg: t('verification_code.msg.invalid'), + }; } setFormData({ ...formData, @@ -120,32 +260,87 @@ const Index: React.FC = ({ callback }) => { return bol; }; - const legalClick = useLegalClick(); - - const handleRegister = (event?: any) => { - if (event) { - event.preventDefault(); - } - const reqParams: RegisterReqParams = { - name: formData.name.value, - e_mail: formData.e_mail.value, - pass: formData.pass.value, + const sendEmailCode = () => { + const reqParams: RegisterEmailCodeReq = { + e_mail: normalizeEmail(formData.e_mail.value), }; - const captcha = emailCaptcha?.getCaptcha(); if (captcha?.verify) { reqParams.captcha_code = captcha.captcha_code; reqParams.captcha_id = captcha.captcha_id; } - register(reqParams) - .then(async (res) => { + setEmailCodeSending(true); + sendRegisterEmailCode(reqParams) + .then(async () => { await emailCaptcha?.close(); + setEmailCodeSentTo(reqParams.e_mail); + setEmailCodeCountdown(EMAIL_CODE_COUNTDOWN_SECONDS); + Toast.onShow({ + msg: t('verification_code.sent'), + variant: 'success', + }); + }) + .catch((err) => { + if (err?.code === 429 && err?.data?.retry_after) { + setEmailCodeCountdown(Number(err.data.retry_after)); + } + if (err?.isError) { + const captchaError = emailCaptcha?.handleCaptchaError(err.list); + const data = handleFormError(err, formData); + setFormData({ ...data }); + const firstFormError = err.list.find( + (item: { error_field: string }) => + item.error_field !== 'captcha_code', + ); + if (!captchaError || firstFormError) { + const ele = document.getElementById( + firstFormError?.error_field || err.list[0].error_field, + ); + scrollToElementTop(ele); + } + return; + } + if (err?.msg) { + Toast.onShow({ msg: err.msg, variant: 'danger' }); + } + }) + .finally(() => { + setEmailCodeSending(false); + }); + }; + + const handleSendEmailCode = () => { + if (emailCodeSending || emailCodeCountdown > 0) { + return; + } + if (!checkEmailValidated()) { + return; + } + if (!emailCaptcha) { + sendEmailCode(); + return; + } + emailCaptcha.check(sendEmailCode); + }; + + const legalClick = useLegalClick(); + + const handleRegister = () => { + const reqParams: RegisterReqParams = { + name: formData.name.value, + e_mail: normalizeEmail(formData.e_mail.value), + pass: formData.pass.value, + pass_confirm: formData.pass_confirm.value, + email_code: formData.email_code.value, + }; + + register(reqParams) + .then((res) => { callback(res); }) .catch((err) => { if (err.isError) { - emailCaptcha?.handleCaptchaError(err.list); const data = handleFormError(err, formData); setFormData({ ...data }); const ele = document.getElementById(err.list[0].error_field); @@ -154,19 +349,13 @@ const Index: React.FC = ({ callback }) => { }); }; - const handleSubmit = async (event: FormEvent) => { + const handleSubmit = (event: FormEvent) => { event.preventDefault(); event.stopPropagation(); if (!checkValidated()) { return; } - if (!emailCaptcha) { - handleRegister(); - return; - } - emailCaptcha.check(() => { - handleRegister(); - }); + handleRegister(); }; return ( @@ -194,33 +383,87 @@ const Index: React.FC = ({ callback }) => { {formData.name.errorMsg} - + {t('email.label')} + onChange={(e) => { + const email = e.target.value; + const changedAfterCodeSent = Boolean( + emailCodeSentTo && normalizeEmail(email) !== emailCodeSentTo, + ); + if (changedAfterCodeSent) { + setEmailCodeSentTo(''); + setEmailCodeCountdown(0); + } handleChange({ e_mail: { - value: e.target.value, + value: email, isInvalid: false, errorMsg: '', }, - }) - } + email_code: { + value: changedAfterCodeSent ? '' : formData.email_code.value, + isInvalid: false, + errorMsg: '', + }, + }); + }} /> {formData.e_mail.errorMsg} - + + {t('verification_code.label')} + + + handleChange({ + email_code: { + value: e.target.value.replace(/\D/g, '').slice(0, 6), + isInvalid: false, + errorMsg: '', + }, + }) + } + /> + + + {formData.email_code.errorMsg} + + + + + {t('password.label')} = ({ callback }) => { + + {t('password_confirm.label')} + + handleChange({ + pass_confirm: { + value: e.target.value, + isInvalid: false, + errorMsg: '', + }, + }) + } + /> + + {formData.pass_confirm.errorMsg} + + +
    diff --git a/ui/src/router/routes.ts b/ui/src/router/routes.ts index 8423fb7a3..e383d1930 100644 --- a/ui/src/router/routes.ts +++ b/ui/src/router/routes.ts @@ -238,14 +238,6 @@ const routes: RouteNode[] = [ path: 'review', page: 'pages/Review', }, - { - path: '/badges', - page: 'pages/Badges/index', - }, - { - path: '/badges/:badge_id', - page: 'pages/Badges/Detail/index', - }, ], }, { @@ -429,10 +421,6 @@ const routes: RouteNode[] = [ path: 'login', page: 'pages/Admin/Login', }, - { - path: 'rules/privileges', - page: 'pages/Admin/Privileges', - }, { path: 'installed-plugins', page: 'pages/Admin/Plugins/Installed', @@ -441,10 +429,6 @@ const routes: RouteNode[] = [ path: ':slug_name', page: 'pages/Admin/Plugins/Config', }, - { - path: 'badges', - page: 'pages/Admin/Badges', - }, { path: 'ai-assistant', page: 'pages/Admin/AiAssistant', diff --git a/ui/src/services/client/forumSection.ts b/ui/src/services/client/forumSection.ts new file mode 100644 index 000000000..88e371618 --- /dev/null +++ b/ui/src/services/client/forumSection.ts @@ -0,0 +1,31 @@ +/* + * 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 useSWR from 'swr'; + +import request from '@/utils/request'; +import type * as Type from '@/common/interface'; + +export const useForumSections = () => { + const { data, error } = useSWR( + '/answer/api/v1/forum/sections', + request.instance.get, + ); + return { data: data || [], isLoading: !data && !error, error }; +}; diff --git a/ui/src/services/client/index.ts b/ui/src/services/client/index.ts index 4d5c85c10..35e9e7db9 100644 --- a/ui/src/services/client/index.ts +++ b/ui/src/services/client/index.ts @@ -21,6 +21,7 @@ export * from './activity'; export * from './personal'; export * from './notification'; export * from './question'; +export * from './forumSection'; export * from './search'; export * from './tag'; export * from './settings'; diff --git a/ui/src/services/client/personal.ts b/ui/src/services/client/personal.ts index 3ccb0d46f..84a20b582 100644 --- a/ui/src/services/client/personal.ts +++ b/ui/src/services/client/personal.ts @@ -53,8 +53,8 @@ interface ListRes { } export const usePersonalTop = (username: string, tabName: string) => { - const apiUrl = '/answer/api/v1/personal/qa/top?username='; - const { data, error } = useSWR<{ answer: any[]; question: any[] }, Error>( + const apiUrl = '/answer/api/v1/personal/content/top?username='; + const { data, error } = useSWR<{ comments: any[]; posts: any[] }, Error>( tabName === 'overview' ? `${apiUrl}${username}` : null, request.instance.get, ); @@ -67,23 +67,19 @@ export const usePersonalTop = (username: string, tabName: string) => { export const usePersonalListByTabName = (params: ListReq, tabName: string) => { let apiUrl: string | null = ''; - if (tabName === 'answers') { - apiUrl = '/answer/api/v1/personal/answer/page'; + if (tabName === 'comments') { + apiUrl = '/answer/api/v1/personal/post/comment/page'; } - if (tabName === 'questions') { - apiUrl = '/answer/api/v1/personal/question/page'; + if (tabName === 'posts') { + apiUrl = '/answer/api/v1/personal/post/page'; } if (tabName === 'bookmarks') { delete params.order; apiUrl = '/answer/api/v1/personal/collection/page'; } - if (tabName === 'comments') { - delete params.order; - apiUrl = '/answer/api/v1/personal/comment/page'; - } - if (tabName === 'reputation') { + if (tabName === 'replies') { delete params.order; - apiUrl = '/answer/api/v1/personal/rank/page'; + apiUrl = '/answer/api/v1/personal/reply/page'; } if (tabName === 'votes') { delete params.username; diff --git a/ui/src/services/client/question.ts b/ui/src/services/client/question.ts index 169ccf3d3..d288f919f 100644 --- a/ui/src/services/client/question.ts +++ b/ui/src/services/client/question.ts @@ -74,7 +74,7 @@ export const useSimilarQuestion = (params: { question_id: string; page_size: number; }) => { - const apiUrl = `/answer/api/v1/question/similar/tag?${qs.stringify(params)}`; + const apiUrl = `/answer/api/v1/post/related?${qs.stringify(params)}`; const { data, error } = useSWR( params.question_id ? apiUrl : null, @@ -108,7 +108,7 @@ export const putInviteUser = ( }; export const unDeleteAnswer = (id) => { - return request.post('/answer/api/v1/answer/recover', { + return request.post('/answer/api/v1/post/comment/recover', { answer_id: id, }); }; diff --git a/ui/src/services/common.ts b/ui/src/services/common.ts index f612bac93..39692a1f0 100644 --- a/ui/src/services/common.ts +++ b/ui/src/services/common.ts @@ -108,7 +108,7 @@ export const useQueryAnswerInfo = (id: string) => { return useSWR<{ info; question; - }>(`/answer/api/v1/answer/info?id=${id}`, request.instance.get); + }>(`/answer/api/v1/post/comment/info?id=${id}`, request.instance.get); }; export const modifyQuestion = ( @@ -118,7 +118,7 @@ export const modifyQuestion = ( }; export const modifyAnswer = (params: Type.AnswerParams) => { - return request.put(`/answer/api/v1/answer`, params); + return request.put(`/answer/api/v1/post/comment`, params); }; export const login = (params: Type.LoginReqParams) => { @@ -135,6 +135,12 @@ export const register = (params: Type.RegisterReqParams) => { ); }; +export const sendRegisterEmailCode = (params: Type.RegisterEmailCodeReq) => { + return request.post('/answer/api/v1/user/register/email/code', params, { + timeout: 20000, + }); +}; + export const logout = () => { return request.get('/answer/api/v1/user/logout'); }; @@ -221,12 +227,12 @@ export const useQuestionLink = (params: { }; export const getAnswers = (params: Type.AnswersReq) => { - const apiUrl = `/answer/api/v1/answer/page?${qs.stringify(params)}`; + const apiUrl = `/answer/api/v1/post/comment/page?${qs.stringify(params)}`; return request.get>(apiUrl); }; export const postAnswer = (params: Type.PostAnswerReq) => { - return request.post('/answer/api/v1/answer', params); + return request.post('/answer/api/v1/post/comment', params); }; export const bookmark = (params: { @@ -297,7 +303,7 @@ export const deleteAnswer = (params: { captcha_code?: string; captcha_id?: string; }) => { - return request.delete('/answer/api/v1/answer', params); + return request.delete('/answer/api/v1/post/comment', params); }; export const closeQuestion = (params: { diff --git a/ui/src/utils/saveDraft.ts b/ui/src/utils/saveDraft.ts index 6d7f18145..94f252705 100644 --- a/ui/src/utils/saveDraft.ts +++ b/ui/src/utils/saveDraft.ts @@ -30,6 +30,7 @@ export type QuestionDraft = { title: string; content: string; tags: any[]; + section_id?: number; answer_content: string; }; callback?: () => void;