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);
+ }}>
+ {t('all_sections')}
+ {data.map((parent) => (
+
+ {parent.children.map((child) => (
+
+ {child.name}
+
+ ))}
+
+ ))}
+
+ );
+ }
+
+ 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 (
-
-
- isActive || pathname === '/' ? 'nav-link active' : 'nav-link'
- }>
-
- {t('header.nav.question')}
-
-
- {ai_enabled && (
+
+
- pathname === '/ai-assistant' ? 'nav-link active' : 'nav-link'
+ pathname === '/' ||
+ (pathname === '/questions' && !searchParams.has('section'))
+ ? 'nav-link active'
+ : 'nav-link'
}>
-
- {t('ai_assistant', { keyPrefix: 'page_title' })}
+
+ {t('header.nav.question')}
- )}
-
-
- pathname === '/tags' ? 'nav-link active' : 'nav-link'
- }>
-
- {t('header.nav.tag')}
-
+
-
-
- {t('header.nav.user')}
-
+
-
-
- {t('header.nav.badges')}
-
+
+ {ai_enabled && (
+
+ pathname === '/ai-assistant' ? 'nav-link active' : 'nav-link'
+ }>
+
+ {t('ai_assistant', { keyPrefix: 'page_title' })}
+
+ )}
-
+
+
{can_revision || userInfo?.role_id === 2 ? (
- <>
-
+
+
{t('header.nav.moderation')}
{can_revision && (
{t('header.nav.review')}
-
- {revision > 99 ? '99+' : revision > 0 ? revision : ''}
-
+ {revision > 0 ? (
+
+ {revision > 99 ? '99+' : revision}
+
+ ) : null}
)}
@@ -106,9 +107,9 @@ const Index: FC = () => {
{t('header.nav.admin')}
) : null}
- >
+
) : null}
-
+
);
};
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')}
+
+
+ {t('form.fields.section.placeholder')}
+
+ {forumSections.map((parent) => (
+
+ {parent.children
+ .filter(
+ (child) =>
+ !child.admin_only || loggedUser.role_id === 2,
+ )
+ .map((child) => (
+
+ {child.name}
+
+ ))}
+
+ ))}
+
+
+ {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 && (
-
-
-
- {data.accepted === 2
- ? t('answers.btn_accepted')
- : t('answers.btn_accept')}
-
-
- )}
= ({ 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')}
-
-
- }}
- />
-
-
-
- >
+ {
+ 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 (
+ 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')}
-
- )}
);
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: '',
+ },
+ })
+ }
+ />
+ 0}
+ className="text-nowrap"
+ onClick={handleSendEmailCode}>
+ {emailCodeSending
+ ? t('verification_code.sending')
+ : emailCodeCountdown > 0
+ ? t('verification_code.resend', {
+ seconds: emailCodeCountdown,
+ })
+ : t('verification_code.send')}
+
+
+ {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}
+
+
+
{t('signup', { keyPrefix: 'btns' })}
diff --git a/ui/src/pages/Users/Settings/Notification/index.tsx b/ui/src/pages/Users/Settings/Notification/index.tsx
index c0aa4061b..c4feec069 100644
--- a/ui/src/pages/Users/Settings/Notification/index.tsx
+++ b/ui/src/pages/Users/Settings/Notification/index.tsx
@@ -17,110 +17,18 @@
* under the License.
*/
-import React, { useState, FormEvent, useEffect } from 'react';
+import React from 'react';
+import { Alert } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
-import type { FormDataType, NotificationConfig } from '@/common/interface';
-import { useToast } from '@/hooks';
-import { useGetNotificationConfig, putNotificationConfig } from '@/services';
-import { SchemaForm, JSONSchema, UISchema, initFormData } from '@/components';
-
const Index = () => {
- const toast = useToast();
const { t } = useTranslation('translation', {
keyPrefix: 'settings.notification',
});
- const { data: configData } = useGetNotificationConfig();
-
- const schema: JSONSchema = {
- title: t('heading'),
- properties: {
- inbox: {
- type: 'boolean',
- title: t('inbox.label'),
- description: t('inbox.description'),
- default: configData?.inbox.enable,
- },
- all_new_question: {
- type: 'boolean',
- title: t('all_new_question.label'),
- description: t('all_new_question.description'),
- default: configData?.all_new_question.enable,
- },
- all_new_question_for_following_tags: {
- type: 'boolean',
- title: t('all_new_question_for_following_tags.label'),
- description: t('all_new_question_for_following_tags.description'),
- default: configData?.all_new_question_for_following_tags.enable,
- },
- },
- };
- const uiSchema: UISchema = {
- inbox: {
- 'ui:widget': 'switch',
- 'ui:options': {
- label: t('turn_on'),
- },
- },
- all_new_question: {
- 'ui:widget': 'switch',
- 'ui:options': {
- label: t('turn_on'),
- },
- },
- all_new_question_for_following_tags: {
- 'ui:widget': 'switch',
- 'ui:options': {
- label: t('turn_on'),
- text: t('all_new_question_for_following_tags.description'),
- },
- },
- };
- const [formData, setFormData] = useState(initFormData(schema));
-
- useEffect(() => {
- setFormData(initFormData(schema));
- }, [configData]);
-
- const handleSubmit = (event: FormEvent) => {
- event.preventDefault();
- event.stopPropagation();
- const params = {
- inbox: {
- enable: formData.inbox.value,
- key: configData?.inbox.key,
- },
- all_new_question: {
- enable: formData.all_new_question.value,
- key: configData?.all_new_question.key,
- },
- all_new_question_for_following_tags: {
- enable: formData.all_new_question_for_following_tags.value,
- key: configData?.all_new_question_for_following_tags.key,
- },
- } as NotificationConfig;
-
- putNotificationConfig(params).then(() => {
- toast.onShow({
- msg: t('update', { keyPrefix: 'toast' }),
- variant: 'success',
- });
- });
- };
-
- const handleChange = (ud) => {
- setFormData(ud);
- };
return (
<>
{t('heading')}
-
+ {t('email_disabled')}
>
);
};
diff --git a/ui/src/pages/Users/index.tsx b/ui/src/pages/Users/index.tsx
index 9382af7a0..e00d110ed 100644
--- a/ui/src/pages/Users/index.tsx
+++ b/ui/src/pages/Users/index.tsx
@@ -39,7 +39,9 @@ const Users = () => {
return null;
}
- const keys = Object.keys(users);
+ const keys = Object.keys(users).filter(
+ (key) => key !== 'users_with_the_most_reputation',
+ );
return (
@@ -84,9 +86,7 @@ const Users = () => {
{user.display_name}
- {key === 'users_with_the_most_vote'
- ? `${user.vote_count} ${t('votes')}`
- : `${user.rank} ${t('reputation')}`}
+ {`${user.vote_count} ${t('votes')}`}
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;