Merge remote-tracking branch 'origin/release/1.7.0'

This commit is contained in:
LinkinStars
2025-10-28 15:25:07 +08:00
130 changed files with 1741 additions and 582 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
.PHONY: build clean ui
VERSION=1.6.0
VERSION=1.7.0
BIN=answer
DIR_SRC=./cmd/answer
DOCKER_CMD=docker
+1 -1
View File
@@ -23,7 +23,7 @@ To learn more about the project, visit [answer.apache.org](https://answer.apache
### Running with docker
```bash
docker run -d -p 9080:80 -v answer-data:/data --name answer apache/answer:1.6.0
docker run -d -p 9080:80 -v answer-data:/data --name answer apache/answer:1.7.0
```
For more information, see [Installation](https://answer.apache.org/docs/installation).
+50 -15
View File
@@ -20,11 +20,13 @@
package answercmd
import (
"context"
"fmt"
"os"
"strings"
"github.com/apache/answer/internal/base/conf"
"github.com/apache/answer/internal/base/path"
"github.com/apache/answer/internal/cli"
"github.com/apache/answer/internal/install"
"github.com/apache/answer/internal/migrations"
@@ -53,6 +55,10 @@ var (
i18nSourcePath string
// i18nTargetPath i18n to path
i18nTargetPath string
// resetPasswordEmail user email for password reset
resetPasswordEmail string
// resetPasswordPassword new password for password reset
resetPasswordPassword string
)
func init() {
@@ -76,7 +82,10 @@ func init() {
i18nCmd.Flags().StringVarP(&i18nTargetPath, "target", "t", "", "i18n target path, eg: -t ./i18n/target")
for _, cmd := range []*cobra.Command{initCmd, checkCmd, runCmd, dumpCmd, upgradeCmd, buildCmd, pluginCmd, configCmd, i18nCmd} {
resetPasswordCmd.Flags().StringVarP(&resetPasswordEmail, "email", "e", "", "user email address")
resetPasswordCmd.Flags().StringVarP(&resetPasswordPassword, "password", "p", "", "new password (not recommended, will be recorded in shell history)")
for _, cmd := range []*cobra.Command{initCmd, checkCmd, runCmd, dumpCmd, upgradeCmd, buildCmd, pluginCmd, configCmd, i18nCmd, resetPasswordCmd} {
rootCmd.AddCommand(cmd)
}
}
@@ -96,8 +105,8 @@ To run answer, use:
Short: "Run Answer",
Long: `Start running Answer`,
Run: func(_ *cobra.Command, _ []string) {
cli.FormatAllPath(dataDirPath)
fmt.Println("config file path: ", cli.GetConfigFilePath())
path.FormatAllPath(dataDirPath)
fmt.Println("config file path: ", path.GetConfigFilePath())
fmt.Println("Answer is starting..........................")
runApp()
},
@@ -111,10 +120,10 @@ To run answer, use:
// check config file and database. if config file exists and database is already created, init done
cli.InstallAllInitialEnvironment(dataDirPath)
configFileExist := cli.CheckConfigFile(cli.GetConfigFilePath())
configFileExist := cli.CheckConfigFile(path.GetConfigFilePath())
if configFileExist {
fmt.Println("config file exists, try to read the config...")
c, err := conf.ReadConfig(cli.GetConfigFilePath())
c, err := conf.ReadConfig(path.GetConfigFilePath())
if err != nil {
fmt.Println("read config failed: ", err.Error())
return
@@ -128,7 +137,7 @@ To run answer, use:
}
// start installation server to install
install.Run(cli.GetConfigFilePath())
install.Run(path.GetConfigFilePath())
},
}
@@ -138,9 +147,9 @@ To run answer, use:
Long: `Upgrade Answer to the latest version`,
Run: func(_ *cobra.Command, _ []string) {
log.SetLogger(log.NewStdLogger(os.Stdout))
cli.FormatAllPath(dataDirPath)
path.FormatAllPath(dataDirPath)
cli.InstallI18nBundle(true)
c, err := conf.ReadConfig(cli.GetConfigFilePath())
c, err := conf.ReadConfig(path.GetConfigFilePath())
if err != nil {
fmt.Println("read config failed: ", err.Error())
return
@@ -159,8 +168,8 @@ To run answer, use:
Long: `Back up database into an SQL file`,
Run: func(_ *cobra.Command, _ []string) {
fmt.Println("Answer is backing up data")
cli.FormatAllPath(dataDirPath)
c, err := conf.ReadConfig(cli.GetConfigFilePath())
path.FormatAllPath(dataDirPath)
c, err := conf.ReadConfig(path.GetConfigFilePath())
if err != nil {
fmt.Println("read config failed: ", err.Error())
return
@@ -179,9 +188,9 @@ To run answer, use:
Short: "Check the required environment",
Long: `Check if the current environment meets the startup requirements`,
Run: func(_ *cobra.Command, _ []string) {
cli.FormatAllPath(dataDirPath)
path.FormatAllPath(dataDirPath)
fmt.Println("Start checking the required environment...")
if cli.CheckConfigFile(cli.GetConfigFilePath()) {
if cli.CheckConfigFile(path.GetConfigFilePath()) {
fmt.Println("config file exists [✔]")
} else {
fmt.Println("config file not exists [x]")
@@ -193,7 +202,7 @@ To run answer, use:
fmt.Println("upload directory not exists [x]")
}
c, err := conf.ReadConfig(cli.GetConfigFilePath())
c, err := conf.ReadConfig(path.GetConfigFilePath())
if err != nil {
fmt.Println("read config failed: ", err.Error())
return
@@ -246,9 +255,9 @@ To run answer, use:
Short: "Set some config to default value",
Long: `Set some config to default value`,
Run: func(_ *cobra.Command, _ []string) {
cli.FormatAllPath(dataDirPath)
path.FormatAllPath(dataDirPath)
c, err := conf.ReadConfig(cli.GetConfigFilePath())
c, err := conf.ReadConfig(path.GetConfigFilePath())
if err != nil {
fmt.Println("read config failed: ", err.Error())
return
@@ -297,6 +306,32 @@ To run answer, use:
}
},
}
resetPasswordCmd = &cobra.Command{
Use: "passwd",
Aliases: []string{"password", "reset-password"},
Short: "Reset user password",
Long: "Reset user password by email address.",
Example: ` # Interactive mode (recommended, safest)
answer passwd -C ./answer-data
# Specify email only (will prompt for password securely)
answer passwd -C ./answer-data --email user@example.com
answer passwd -C ./answer-data -e user@example.com
# Specify email and password (NOT recommended, will be recorded in shell history)
answer passwd -C ./answer-data -e user@example.com -p newpassword123`,
Run: func(cmd *cobra.Command, args []string) {
opts := &cli.ResetPasswordOptions{
Email: resetPasswordEmail,
Password: resetPasswordPassword,
}
if err := cli.ResetPassword(context.Background(), dataDirPath, opts); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
},
}
)
// Execute adds all child commands to the root command and sets flags appropriately.
+2 -2
View File
@@ -28,7 +28,7 @@ import (
"github.com/apache/answer/internal/base/conf"
"github.com/apache/answer/internal/base/constant"
"github.com/apache/answer/internal/base/cron"
"github.com/apache/answer/internal/cli"
"github.com/apache/answer/internal/base/path"
"github.com/apache/answer/internal/schema"
"github.com/gin-gonic/gin"
"github.com/segmentfault/pacman"
@@ -67,7 +67,7 @@ func Main() {
}
func runApp() {
c, err := conf.ReadConfig(cli.GetConfigFilePath())
c, err := conf.ReadConfig(path.GetConfigFilePath())
if err != nil {
panic(err)
}
+7 -26
View File
@@ -1,28 +1,8 @@
//go:build !wireinject
// +build !wireinject
/*
* 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.
*/
// Code generated by Wire. DO NOT EDIT.
//go:generate go run github.com/google/wire/cmd/wire
//go:build !wireinject
// +build !wireinject
package answercmd
@@ -192,22 +172,22 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database,
objService := object_info.NewObjService(answerRepo, questionRepo, commentCommonRepo, tagCommonRepo, tagCommonService)
notificationQueueService := notice_queue.NewNotificationQueueService()
externalNotificationQueueService := notice_queue.NewNewQuestionNotificationQueueService()
commentService := comment2.NewCommentService(commentRepo, commentCommonRepo, userCommon, objService, voteRepo, emailService, userRepo, notificationQueueService, externalNotificationQueueService, activityQueueService, eventQueueService)
rolePowerRelRepo := role.NewRolePowerRelRepo(dataData)
rolePowerRelService := role2.NewRolePowerRelService(rolePowerRelRepo, userRoleRelService)
rankService := rank2.NewRankService(userCommon, userRankRepo, objService, userRoleRelService, rolePowerRelService, configService)
limitRepo := limit.NewRateLimitRepo(dataData)
rateLimitMiddleware := middleware.NewRateLimitMiddleware(limitRepo)
commentController := controller.NewCommentController(commentService, rankService, captchaService, rateLimitMiddleware)
reportRepo := report.NewReportRepo(dataData, uniqueIDRepo)
tagService := tag2.NewTagService(tagRepo, tagCommonService, revisionService, followRepo, siteInfoCommonService, activityQueueService)
answerActivityRepo := activity.NewAnswerActivityRepo(dataData, activityRepo, userRankRepo, notificationQueueService)
answerActivityService := activity2.NewAnswerActivityService(answerActivityRepo, configService)
externalNotificationService := notification.NewExternalNotificationService(dataData, userNotificationConfigRepo, followRepo, emailService, userRepo, externalNotificationQueueService, userExternalLoginRepo, siteInfoCommonService)
reviewRepo := review.NewReviewRepo(dataData)
reviewService := review2.NewReviewService(reviewRepo, objService, userCommon, userRepo, questionRepo, answerRepo, userRoleRelService, externalNotificationQueueService, tagCommonService, questionCommon, notificationQueueService, siteInfoCommonService)
reviewService := review2.NewReviewService(reviewRepo, objService, userCommon, userRepo, questionRepo, answerRepo, userRoleRelService, externalNotificationQueueService, tagCommonService, questionCommon, notificationQueueService, siteInfoCommonService, commentCommonRepo)
questionService := content.NewQuestionService(activityRepo, questionRepo, answerRepo, tagCommonService, tagService, questionCommon, userCommon, userRepo, userRoleRelService, revisionService, metaCommonService, collectionCommon, answerActivityService, emailService, notificationQueueService, externalNotificationQueueService, activityQueueService, siteInfoCommonService, externalNotificationService, reviewService, configService, eventQueueService, reviewRepo)
answerService := content.NewAnswerService(answerRepo, questionRepo, questionCommon, userCommon, collectionCommon, userRepo, revisionService, answerActivityService, answerCommon, voteRepo, emailService, userRoleRelService, notificationQueueService, externalNotificationQueueService, activityQueueService, reviewService, eventQueueService)
commentService := comment2.NewCommentService(commentRepo, commentCommonRepo, userCommon, objService, voteRepo, emailService, userRepo, notificationQueueService, externalNotificationQueueService, activityQueueService, eventQueueService, reviewService)
commentController := controller.NewCommentController(commentService, rankService, captchaService, rateLimitMiddleware)
reportHandle := report_handle.NewReportHandle(questionService, answerService, commentService)
reportService := report2.NewReportService(reportRepo, objService, userCommon, answerRepo, questionRepo, commentCommonRepo, reportHandle, configService, eventQueueService)
reportController := controller.NewReportController(reportService, rankService, captchaService)
@@ -289,7 +269,8 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database,
captchaController := controller.NewCaptchaController()
embedController := controller.NewEmbedController()
renderController := controller.NewRenderController()
pluginAPIRouter := router.NewPluginAPIRouter(connectorController, userCenterController, captchaController, embedController, renderController)
sidebarController := controller.NewSidebarController()
pluginAPIRouter := router.NewPluginAPIRouter(connectorController, userCenterController, captchaController, embedController, renderController, sidebarController)
ginEngine := server.NewHTTPServer(debug, staticRouter, answerAPIRouter, swaggerRouter, uiRouter, authUserMiddleware, avatarMiddleware, shortIDMiddleware, templateRouter, pluginAPIRouter, uiConf)
scheduledTaskManager := cron.NewScheduledTaskManager(siteInfoCommonService, questionService, fileRecordService, userAdminService, serviceConf)
application := newApplication(serverConf, ginEngine, scheduledTaskManager)
-19
View File
@@ -1,22 +1,3 @@
/*
* 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 docs Code generated by swaggo/swag. DO NOT EDIT
package docs
-17
View File
@@ -1,20 +1,3 @@
# 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.
basePath: /
definitions:
constant.NotificationChannelKey:
+1
View File
@@ -60,6 +60,7 @@ require (
golang.org/x/crypto v0.36.0
golang.org/x/image v0.20.0
golang.org/x/net v0.38.0
golang.org/x/term v0.30.0
golang.org/x/text v0.23.0
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
gopkg.in/yaml.v3 v3.0.1
+2
View File
@@ -785,6 +785,8 @@ golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXR
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+1 -1
View File
@@ -684,7 +684,7 @@ ui:
caption: People can mention you as "@username".
msg: Username cannot be empty.
msg_range: Username must be 2-30 characters in length.
character: 'Must use the character set "a-z", "0-9", " - . _"'
character: 'Must use the character set "a-z", "0-9", "- . _"'
avatar:
label: Profile Image
gravatar: Gravatar
+1 -1
View File
@@ -684,7 +684,7 @@ ui:
caption: People can mention you as "@username".
msg: Username cannot be empty.
msg_range: Username must be 2-30 characters in length.
character: 'Must use the character set "a-z", "0-9", " - . _"'
character: 'Must use the character set "a-z", "0-9", "- . _"'
avatar:
label: Profile Image
gravatar: Gravatar
+1 -1
View File
@@ -677,7 +677,7 @@ ui:
caption: People can mention you as "@username".
msg: Username cannot be empty.
msg_range: Username up to 30 characters
character: 'Must use the character set "a-z", "0-9", " - . _"'
character: 'Must use the character set "a-z", "0-9", "- . _"'
avatar:
label: Profile Image
gravatar: Gravatar
+1 -1
View File
@@ -677,7 +677,7 @@ ui:
caption: People can mention you as "@username".
msg: Username cannot be empty.
msg_range: Username up to 30 characters
character: 'Must use the character set "a-z", "0-9", " - . _"'
character: 'Must use the character set "a-z", "0-9", "- . _"'
avatar:
label: Profile Image
gravatar: Gravatar
+1 -1
View File
@@ -677,7 +677,7 @@ ui:
caption: People can mention you as "@username".
msg: Username cannot be empty.
msg_range: Username up to 30 characters
character: 'Must use the character set "a-z", "0-9", " - . _"'
character: 'Must use the character set "a-z", "0-9", "- . _"'
avatar:
label: Profile Image
gravatar: Gravatar
+1 -1
View File
@@ -677,7 +677,7 @@ ui:
caption: People can mention you as "@username".
msg: Username cannot be empty.
msg_range: Username up to 30 characters
character: 'Must use the character set "a-z", "0-9", " - . _"'
character: 'Must use the character set "a-z", "0-9", "- . _"'
avatar:
label: Profile Image
gravatar: Gravatar
+1 -1
View File
@@ -677,7 +677,7 @@ ui:
caption: People can mention you as "@username".
msg: Username cannot be empty.
msg_range: Username up to 30 characters
character: 'Must use the character set "a-z", "0-9", " - . _"'
character: 'Must use the character set "a-z", "0-9", "- . _"'
avatar:
label: Profile Image
gravatar: Gravatar
+1 -1
View File
@@ -684,7 +684,7 @@ ui:
caption: People can mention you as "@username".
msg: Username cannot be empty.
msg_range: Username must be 2-30 characters in length.
character: 'Must use the character set "a-z", "0-9", " - . _"'
character: 'Must use the character set "a-z", "0-9", "- . _"'
avatar:
label: Profile Image
gravatar: Gravatar
+1 -1
View File
@@ -1292,7 +1292,7 @@ ui:
caption: People can mention you as "@username".
msg: Username cannot be empty.
msg_range: Username must be 2-30 characters in length.
character: 'Must use the character set "a-z", "0-9", " - . _"'
character: 'Must use the character set "a-z", "0-9", "- . _"'
avatar:
label: Profile image
gravatar: Gravatar
+1 -1
View File
@@ -1292,7 +1292,7 @@ ui:
caption: People can mention you as "@username".
msg: Username cannot be empty.
msg_range: Username must be 2-30 characters in length.
character: 'Must use the character set "a-z", "0-9", " - . _"'
character: 'Must use the character set "a-z", "0-9", "- . _"'
avatar:
label: Profile image
gravatar: Gravatar
+1 -1
View File
@@ -1292,7 +1292,7 @@ ui:
caption: Man kan nævne dig som "@username".
msg: Brugernavn skal udfyldes.
msg_range: Username must be 2-30 characters in length.
character: 'Skal bruge tegnsættet "a-z", "0-9", " - . _"'
character: 'Skal bruge tegnsættet "a-z", "0-9", "- . _"'
avatar:
label: Profilbillede
gravatar: Gravatar
+1 -1
View File
@@ -1292,7 +1292,7 @@ ui:
caption: Leute können dich als "@Benutzername" erwähnen.
msg: Benutzername darf nicht leer sein.
msg_range: Der Benutzername muss zwischen 2 und 30 Zeichen lang sein.
character: 'Muss den Zeichensatz "a-z", "0-9", " - . _" verwenden'
character: 'Muss den Zeichensatz "a-z", "0-9", "- . _" verwenden'
avatar:
label: Profilbild
gravatar: Gravatar
+1 -1
View File
@@ -684,7 +684,7 @@ ui:
caption: People can mention you as "@username".
msg: Username cannot be empty.
msg_range: Username must be 2-30 characters in length.
character: 'Must use the character set "a-z", "0-9", " - . _"'
character: 'Must use the character set "a-z", "0-9", "- . _"'
avatar:
label: Profile Image
gravatar: Gravatar
+22 -5
View File
@@ -235,6 +235,8 @@ backend:
other: No permission to update.
content_cannot_empty:
other: Content cannot be empty.
content_less_than_minumum:
other: Not enough content entered.
rank:
fail_to_meet_the_condition:
other: Reputation rank fail to meet the condition.
@@ -264,6 +266,8 @@ backend:
other: You cannot delete a tag that is in use.
cannot_set_synonym_as_itself:
other: You cannot set the synonym of the current tag as itself.
minimum_count:
other: Not enough tags were entered.
smtp:
config_from_name_cannot_be_email:
other: The from name cannot be a email address.
@@ -852,6 +856,7 @@ ui:
http_50X: HTTP Error 500
http_403: HTTP Error 403
logout: Log Out
posts: Posts
notifications:
title: Notifications
inbox: Inbox
@@ -1158,6 +1163,9 @@ ui:
label: Body
msg:
empty: Body cannot be empty.
hint:
optional_body: Share what the question is about.
minimum_characters: "Share what the question is about, at least {{min_content_length}} characters are required."
tags:
label: Tags
msg:
@@ -1179,7 +1187,9 @@ ui:
add_btn: Add tag
create_btn: Create new tag
search_tag: Search tag
hint: "Describe what your content is about, at least one tag is required."
hint: Describe what your content is about, at least one tag is required.
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)
header:
@@ -1235,7 +1245,7 @@ ui:
msg:
empty: Name cannot be empty.
range: Name must be between 2 to 30 characters in length.
character: 'Must use the character set "a-z", "A-Z", "0-9", " - . _"'
character: 'Must use the character set "a-z", "0-9", " - . _"'
email:
label: Email
msg:
@@ -1317,7 +1327,7 @@ ui:
caption: People can mention you as "@username".
msg: Username cannot be empty.
msg_range: Username must be 2-30 characters in length.
character: 'Must use the character set "a-z", "0-9", " - . _"'
character: 'Must use the character set "a-z", "0-9", "- . _"'
avatar:
label: Profile image
gravatar: Gravatar
@@ -1407,9 +1417,11 @@ ui:
search: Search people
question_detail:
action: Action
created: Created
Asked: Asked
asked: asked
update: Modified
Edited: Edited
edit: edited
commented: commented
Views: Viewed
@@ -1730,7 +1742,7 @@ ui:
admin_name:
label: Name
msg: Name cannot be empty.
character: 'Must use the character set "a-z", "A-Z", "0-9", " - . _"'
character: 'Must use the character set "a-z", "0-9", " - . _"'
msg_max_length: Name must be between 2 to 30 characters in length.
admin_password:
label: Password
@@ -2118,10 +2130,16 @@ ui:
ask_before_display: Ask before displaying external content
write:
page_title: Write
min_content:
label: Minimum question body length
text: Minimum allowed question body length in characters.
restrict_answer:
title: Answer write
label: Each user can only write one answer for the same question
text: "Turn off to allow users to write multiple answers to the same question, which may cause answers to be unfocused."
min_tags:
label: "Minimum tags per question"
text: "Minimum number of tags required in a question."
recommend_tags:
label: Recommend tags
text: "Recommend tags will show in the dropdown list by default."
@@ -2277,7 +2295,6 @@ ui:
btn_submit: Save
not_found_props: "Required property {{ key }} not found."
select: Select
page_review:
review: Review
proposed: proposed
+1 -1
View File
@@ -1292,7 +1292,7 @@ ui:
caption: La gente puede mencionarte con "@nombredeusuario".
msg: El nombre de usuario no puede estar vacío.
msg_range: Username must be 2-30 characters in length.
character: 'Debe usar el conjunto de caracteres "a-z", "0-9", " - . _"'
character: 'Debe usar el conjunto de caracteres "a-z", "0-9", "- . _"'
avatar:
label: Imagen de perfil
gravatar: Gravatar
+1 -1
View File
@@ -1292,7 +1292,7 @@ ui:
caption: دیگران میتوانند به شما به بصورت "@username" اشاره کنند.
msg: نام کاربری نمی تواند خالی باشد.
msg_range: Username must be 2-30 characters in length.
character: 'باید از حروف "a-z", "0-9", " - . _" استفاده شود'
character: 'باید از حروف "a-z", "0-9", "- . _" استفاده شود'
avatar:
label: عکس پروفایل
gravatar: Gravatar
+1 -1
View File
@@ -684,7 +684,7 @@ ui:
caption: People can mention you as "@username".
msg: Username cannot be empty.
msg_range: Username must be 2-30 characters in length.
character: 'Must use the character set "a-z", "0-9", " - . _"'
character: 'Must use the character set "a-z", "0-9", "- . _"'
avatar:
label: Profile Image
gravatar: Gravatar
+1 -1
View File
@@ -1292,7 +1292,7 @@ ui:
caption: Les gens peuvent vous mentionner avec "@username".
msg: Le nom d'utilisateur ne peut pas être vide.
msg_range: Le nom d'utilisateur doit contenir entre 2 et 30 caractères.
character: 'Doit utiliser seulement les caractères "a-z", "0-9", " - . _"'
character: 'Doit utiliser seulement les caractères "a-z", "0-9", "- . _"'
avatar:
label: Photo de profil
gravatar: Gravatar
+1 -1
View File
@@ -684,7 +684,7 @@ ui:
caption: People can mention you as "@username".
msg: Username cannot be empty.
msg_range: Username must be 2-30 characters in length.
character: 'Must use the character set "a-z", "0-9", " - . _"'
character: 'Must use the character set "a-z", "0-9", "- . _"'
avatar:
label: Profile Image
gravatar: Gravatar
+1 -1
View File
@@ -1292,7 +1292,7 @@ ui:
caption: People can mention you as "@username".
msg: Username cannot be empty.
msg_range: Username must be 2-30 characters in length.
character: 'Must use the character set "a-z", "0-9", " - . _"'
character: 'Must use the character set "a-z", "0-9", "- . _"'
avatar:
label: Profile image
gravatar: Gravatar
+1 -1
View File
@@ -684,7 +684,7 @@ ui:
caption: People can mention you as "@username".
msg: Username cannot be empty.
msg_range: Username must be 2-30 characters in length.
character: 'Must use the character set "a-z", "0-9", " - . _"'
character: 'Must use the character set "a-z", "0-9", "- . _"'
avatar:
label: Profile Image
gravatar: Gravatar
+1 -1
View File
@@ -677,7 +677,7 @@ ui:
caption: People can mention you as "@username".
msg: Username cannot be empty.
msg_range: Username up to 30 characters
character: 'Must use the character set "a-z", "0-9", " - . _"'
character: 'Must use the character set "a-z", "0-9", "- . _"'
avatar:
label: Profile Image
gravatar: Gravatar
+1 -1
View File
@@ -1292,7 +1292,7 @@ ui:
caption: People can mention you as "@username".
msg: Username cannot be empty.
msg_range: Username must be 2-30 characters in length.
character: 'Must use the character set "a-z", "0-9", " - . _"'
character: 'Must use the character set "a-z", "0-9", "- . _"'
avatar:
label: Profile image
gravatar: Gravatar
+1 -1
View File
@@ -1292,7 +1292,7 @@ ui:
caption: Gli altri utenti possono menzionarti con @{{username}}.
msg: Il nome utente non può essere vuoto.
msg_range: Username must be 2-30 characters in length.
character: 'È necessario utilizzare il set di caratteri "a-z", "0-9", " - . _"'
character: 'È necessario utilizzare il set di caratteri "a-z", "0-9", "- . _"'
avatar:
label: Immagine del profilo
gravatar: Gravatar
+2 -2
View File
@@ -1291,7 +1291,7 @@ ui:
caption: ユーザーは "@username" としてあなたをメンションできます。
msg: ユーザー名は空にできません。
msg_range: Username must be 2-30 characters in length.
character: '文字セット "a-z", "0-9", " - . _" を使用してください。'
character: '文字セット "a-z", "0-9", "- . _" を使用してください。'
avatar:
label: プロフィール画像
gravatar: Gravatar
@@ -1345,7 +1345,7 @@ ui:
new_pass:
label: 新しいパスワード
pass_confirm:
label: 新しいパスワードの確認
label: 新しいパスワードの確認
interface:
heading: 外観
lang:
+1 -1
View File
@@ -1292,7 +1292,7 @@ ui:
caption: 다른 사용자가 "@사용자이름"으로 멘션할 수 있습니다.
msg: 사용자 이름을 입력하세요.
msg_range: 유저 이름은 2-30 자 길이여야 합니다.
character: '문자 집합 "a-z", "0-9", " - . _"을 사용해야 합니다.'
character: '문자 집합 "a-z", "0-9", "- . _"을 사용해야 합니다.'
avatar:
label: 프로필 이미지
gravatar: Gravatar
+1 -1
View File
@@ -1292,7 +1292,7 @@ ui:
caption: People can mention you as "@username".
msg: Username cannot be empty.
msg_range: Username must be 2-30 characters in length.
character: 'Must use the character set "a-z", "0-9", " - . _"'
character: 'Must use the character set "a-z", "0-9", "- . _"'
avatar:
label: Profile image
gravatar: Gravatar
+1 -1
View File
@@ -684,7 +684,7 @@ ui:
caption: People can mention you as "@username".
msg: Username cannot be empty.
msg_range: Username must be 2-30 characters in length.
character: 'Must use the character set "a-z", "0-9", " - . _"'
character: 'Must use the character set "a-z", "0-9", "- . _"'
avatar:
label: Profile Image
gravatar: Gravatar
+1 -1
View File
@@ -684,7 +684,7 @@ ui:
caption: People can mention you as "@username".
msg: Username cannot be empty.
msg_range: Username must be 2-30 characters in length.
character: 'Must use the character set "a-z", "0-9", " - . _"'
character: 'Must use the character set "a-z", "0-9", "- . _"'
avatar:
label: Profile Image
gravatar: Gravatar
+1 -1
View File
@@ -1292,7 +1292,7 @@ ui:
caption: Ludzie mogą oznaczać Cię jako "@nazwa_użytkownika".
msg: Nazwa użytkownika nie może być pusta.
msg_range: Username must be 2-30 characters in length.
character: 'Należy używać zestawu znaków "a-z", "0-9", " - . _"'
character: 'Należy używać zestawu znaków "a-z", "0-9", "- . _"'
avatar:
label: Zdjęcie profilowe
gravatar: Gravatar
+1 -1
View File
@@ -683,7 +683,7 @@ ui:
caption: As pessoas poderão mensionar você com "@usuário".
msg: Nome de usuário não pode ser vazio.
msg_range: Nome de usuário até 30 caracteres.
character: 'Deve usar o conjunto de caracteres "a-z", "0-9", " - . _"'
character: 'Deve usar o conjunto de caracteres "a-z", "0-9", "- . _"'
avatar:
label: Perfil Imagem
gravatar: Gravatar
+1 -1
View File
@@ -1292,7 +1292,7 @@ ui:
caption: As pessoas poderão mensionar você com "@usuário".
msg: Nome de usuário não pode ser vazio.
msg_range: Username must be 2-30 characters in length.
character: 'Deve usar o conjunto de caracteres "a-z", "0-9", " - . _"'
character: 'Deve usar o conjunto de caracteres "a-z", "0-9", "- . _"'
avatar:
label: Perfil Imagem
gravatar: Gravatar
+1 -1
View File
@@ -1292,7 +1292,7 @@ ui:
caption: Oamenii te pot menționa ca "@utilizator".
msg: Numele de utilizator nu poate fi gol.
msg_range: Username must be 2-30 characters in length.
character: 'Trebuie să utilizați setul de caractere "a-z", "0-9", " - . _"'
character: 'Trebuie să utilizați setul de caractere "a-z", "0-9", "- . _"'
avatar:
label: Imaginea de profil
gravatar: Gravatar
+1 -1
View File
@@ -1292,7 +1292,7 @@ ui:
caption: Люди могут упоминать вас как "@username".
msg: Имя пользователя не может быть пустым.
msg_range: Username must be 2-30 characters in length.
character: 'Необходимо использовать набор символов "a-z", "0-9", " - . _"'
character: 'Необходимо использовать набор символов "a-z", "0-9", "- . _"'
avatar:
label: Изображение профиля
gravatar: Gravatar
+1 -1
View File
@@ -677,7 +677,7 @@ ui:
caption: People can mention you as "@username".
msg: Username cannot be empty.
msg_range: Username up to 30 characters
character: 'Must use the character set "a-z", "0-9", " - . _"'
character: 'Must use the character set "a-z", "0-9", "- . _"'
avatar:
label: Profile Image
gravatar: Gravatar
+1 -1
View File
@@ -684,7 +684,7 @@ ui:
caption: People can mention you as "@username".
msg: Username cannot be empty.
msg_range: Username must be 2-30 characters in length.
character: 'Must use the character set "a-z", "0-9", " - . _"'
character: 'Must use the character set "a-z", "0-9", "- . _"'
avatar:
label: Profile Image
gravatar: Gravatar
+1 -1
View File
@@ -1292,7 +1292,7 @@ ui:
caption: People can mention you as "@username".
msg: Username cannot be empty.
msg_range: Username must be 2-30 characters in length.
character: 'Must use the character set "a-z", "0-9", " - . _"'
character: 'Must use the character set "a-z", "0-9", "- . _"'
avatar:
label: Profilbild
gravatar: Gravatar
+1 -1
View File
@@ -1292,7 +1292,7 @@ ui:
caption: People can mention you as "@username".
msg: Username cannot be empty.
msg_range: Username must be 2-30 characters in length.
character: 'Must use the character set "a-z", "0-9", " - . _"'
character: 'Must use the character set "a-z", "0-9", "- . _"'
avatar:
label: Profile image
gravatar: Gravatar
+1 -1
View File
@@ -1292,7 +1292,7 @@ ui:
caption: Mọi người có thể nhắc đến bạn với "@username".
msg: Tên người dùng không thể trống.
msg_range: Username must be 2-30 characters in length.
character: 'Chỉ sử dụng bộ ký tự "a-z", "0-9", " - . _"'
character: 'Chỉ sử dụng bộ ký tự "a-z", "0-9", "- . _"'
avatar:
label: Hình ảnh hồ sơ
gravatar: Gravatar
+6 -3
View File
@@ -841,6 +841,7 @@ ui:
http_50X: HTTP 错误 500
http_403: HTTP 错误 403
logout: 退出
posts: 帖子
notifications:
title: 通知
inbox: 收件箱
@@ -1214,7 +1215,7 @@ ui:
msg:
empty: 名字不能为空
range: 名称长度必须在 2 至 30 个字符之间。
character: '只能由 "a-z"、"A-Z"、"0-9"、" - . _" 组成'
character: '只能由 "a-z"、"0-9"、" - . _" 组成'
email:
label: 邮箱
msg:
@@ -1292,7 +1293,7 @@ ui:
caption: 用户可以通过 "@用户名" 来提及你。
msg: 用户名不能为空
msg_range: 显示名称长度必须为 2-30 个字符。
character: '只能由 "a-z"、"A-Z"、"0-9"、" - . _" 组成'
character: '只能由 "a-z"、"0-9"、"- . _" 组成'
avatar:
label: 头像
gravatar: Gravatar
@@ -1381,9 +1382,11 @@ ui:
search: 搜索人员
question_detail:
action: 操作
created: 创建于
Asked: 提问于
asked: 提问于
update: 修改于
Edited: 编辑于
edit: 编辑于
commented: 评论
Views: 阅读次数
@@ -1695,7 +1698,7 @@ ui:
admin_name:
label: 名字
msg: 名字不能为空。
character: '只能由 "a-z"、"A-Z"、"0-9"、" - . _" 组成'
character: '只能由 "a-z"、"0-9"、" - . _" 组成'
msg_max_length: 名称长度必须在 2 至 30 个字符之间。
admin_password:
label: 密码
+1 -1
View File
@@ -1292,7 +1292,7 @@ ui:
caption: 用戶之間可以通過 "@用戶名" 進行交互。
msg: 用戶名不能為空
msg_range: Username must be 2-30 characters in length.
character: '必須由 "a-z", "0-9", " - . _" 組成'
character: '必須由 "a-z", "0-9", "- . _" 組成'
avatar:
label: Profile image
gravatar: 頭像
+2 -2
View File
@@ -25,9 +25,9 @@ import (
"path/filepath"
"github.com/apache/answer/internal/base/data"
"github.com/apache/answer/internal/base/path"
"github.com/apache/answer/internal/base/server"
"github.com/apache/answer/internal/base/translator"
"github.com/apache/answer/internal/cli"
"github.com/apache/answer/internal/router"
"github.com/apache/answer/internal/service/service_config"
"github.com/apache/answer/pkg/writer"
@@ -98,7 +98,7 @@ func (c *AllConfig) SetEnvironmentOverrides() {
// ReadConfig read config
func ReadConfig(configFilePath string) (c *AllConfig, err error) {
if len(configFilePath) == 0 {
configFilePath = filepath.Join(cli.ConfigFileDir, cli.DefaultConfigFileName)
configFilePath = filepath.Join(path.ConfigFileDir, path.DefaultConfigFileName)
}
c = &AllConfig{}
config, err := viper.NewWithPath(configFilePath)
+53
View File
@@ -0,0 +1,53 @@
/*
* 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 path
import (
"path/filepath"
"sync"
)
const (
DefaultConfigFileName = "config.yaml"
DefaultCacheFileName = "cache.db"
DefaultReservedUsernamesConfigFileName = "reserved-usernames.json"
)
var (
ConfigFileDir = "/conf/"
UploadFilePath = "/uploads/"
I18nPath = "/i18n/"
CacheDir = "/cache/"
formatAllPathOnce sync.Once
)
func FormatAllPath(dataDirPath string) {
formatAllPathOnce.Do(func() {
ConfigFileDir = filepath.Join(dataDirPath, ConfigFileDir)
UploadFilePath = filepath.Join(dataDirPath, UploadFilePath)
I18nPath = filepath.Join(dataDirPath, I18nPath)
CacheDir = filepath.Join(dataDirPath, CacheDir)
})
}
// GetConfigFilePath get config file path
func GetConfigFilePath() string {
return filepath.Join(ConfigFileDir, DefaultConfigFileName)
}
+2
View File
@@ -47,6 +47,7 @@ const (
QuestionAlreadyDeleted = "error.question.already_deleted"
QuestionUnderReview = "error.question.under_review"
QuestionContentCannotEmpty = "error.question.content_cannot_empty"
QuestionContentLessThanMinimum = "error.question.content_less_than_minumum"
AnswerNotFound = "error.answer.not_found"
AnswerCannotDeleted = "error.answer.cannot_deleted"
AnswerCannotUpdate = "error.answer.cannot_update"
@@ -77,6 +78,7 @@ const (
TagCannotUpdate = "error.tag.cannot_update"
TagIsUsedCannotDelete = "error.tag.is_used_cannot_delete"
TagAlreadyExist = "error.tag.already_exist"
TagMinCount = "error.tag.minimum_count"
RankFailToMeetTheCondition = "error.rank.fail_to_meet_the_condition"
VoteRankFailToMeetTheCondition = "error.rank.vote_fail_to_meet_the_condition"
NoEnoughRankToOperate = "error.rank.no_enough_rank_to_operate"
+1
View File
@@ -484,6 +484,7 @@ func copyDirEntries(sourceFs fs.FS, sourceDir, targetDir string, ignoreDir ...st
// Construct the absolute path for the source file/directory
srcPath := filepath.Join(sourceDir, path)
srcPath = filepath.ToSlash(srcPath)
// Construct the absolute path for the destination file/directory
dstPath := filepath.Join(targetDir, path)
+8 -36
View File
@@ -23,45 +23,17 @@ import (
"fmt"
"os"
"path/filepath"
"sync"
"github.com/apache/answer/configs"
"github.com/apache/answer/i18n"
"github.com/apache/answer/internal/base/path"
"github.com/apache/answer/pkg/dir"
"github.com/apache/answer/pkg/writer"
)
const (
DefaultConfigFileName = "config.yaml"
DefaultCacheFileName = "cache.db"
DefaultReservedUsernamesConfigFileName = "reserved-usernames.json"
)
var (
ConfigFileDir = "/conf/"
UploadFilePath = "/uploads/"
I18nPath = "/i18n/"
CacheDir = "/cache/"
formatAllPathONCE sync.Once
)
// GetConfigFilePath get config file path
func GetConfigFilePath() string {
return filepath.Join(ConfigFileDir, DefaultConfigFileName)
}
func FormatAllPath(dataDirPath string) {
formatAllPathONCE.Do(func() {
ConfigFileDir = filepath.Join(dataDirPath, ConfigFileDir)
UploadFilePath = filepath.Join(dataDirPath, UploadFilePath)
I18nPath = filepath.Join(dataDirPath, I18nPath)
CacheDir = filepath.Join(dataDirPath, CacheDir)
})
}
// InstallAllInitialEnvironment install all initial environment
func InstallAllInitialEnvironment(dataDirPath string) {
FormatAllPath(dataDirPath)
path.FormatAllPath(dataDirPath)
installUploadDir()
InstallI18nBundle(false)
fmt.Println("install all initial environment done")
@@ -69,7 +41,7 @@ func InstallAllInitialEnvironment(dataDirPath string) {
func InstallConfigFile(configFilePath string) error {
if len(configFilePath) == 0 {
configFilePath = filepath.Join(ConfigFileDir, DefaultConfigFileName)
configFilePath = filepath.Join(path.ConfigFileDir, path.DefaultConfigFileName)
}
fmt.Println("[config-file] try to create at ", configFilePath)
@@ -79,7 +51,7 @@ func InstallConfigFile(configFilePath string) error {
return nil
}
if err := dir.CreateDirIfNotExist(ConfigFileDir); err != nil {
if err := dir.CreateDirIfNotExist(path.ConfigFileDir); err != nil {
fmt.Printf("[config-file] create directory fail %s\n", err.Error())
return fmt.Errorf("create directory fail %s", err.Error())
}
@@ -95,10 +67,10 @@ func InstallConfigFile(configFilePath string) error {
func installUploadDir() {
fmt.Println("[upload-dir] try to install...")
if err := dir.CreateDirIfNotExist(UploadFilePath); err != nil {
if err := dir.CreateDirIfNotExist(path.UploadFilePath); err != nil {
fmt.Printf("[upload-dir] install fail %s\n", err.Error())
} else {
fmt.Printf("[upload-dir] install success, upload directory is %s\n", UploadFilePath)
fmt.Printf("[upload-dir] install success, upload directory is %s\n", path.UploadFilePath)
}
}
@@ -108,7 +80,7 @@ func InstallI18nBundle(replace bool) {
if len(os.Getenv("SKIP_REPLACE_I18N")) > 0 {
replace = false
}
if err := dir.CreateDirIfNotExist(I18nPath); err != nil {
if err := dir.CreateDirIfNotExist(path.I18nPath); err != nil {
fmt.Println(err.Error())
return
}
@@ -120,7 +92,7 @@ func InstallI18nBundle(replace bool) {
}
fmt.Printf("[i18n] find i18n bundle %d\n", len(i18nList))
for _, item := range i18nList {
path := filepath.Join(I18nPath, item.Name())
path := filepath.Join(path.I18nPath, item.Name())
content, err := i18n.I18n.ReadFile(item.Name())
if err != nil {
continue
+2 -1
View File
@@ -23,6 +23,7 @@ import (
"fmt"
"github.com/apache/answer/internal/base/data"
"github.com/apache/answer/internal/base/path"
"github.com/apache/answer/internal/entity"
"github.com/apache/answer/pkg/dir"
)
@@ -32,7 +33,7 @@ func CheckConfigFile(configPath string) bool {
}
func CheckUploadDir() bool {
return dir.CheckDirExist(UploadFilePath)
return dir.CheckDirExist(path.UploadFilePath)
}
// CheckDBConnection check database whether the connection is normal
+288
View File
@@ -0,0 +1,288 @@
/*
* 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 cli
import (
"bufio"
"context"
"crypto/rand"
"fmt"
"math/big"
"os"
"runtime"
"strings"
"github.com/apache/answer/internal/base/conf"
"github.com/apache/answer/internal/base/data"
"github.com/apache/answer/internal/base/path"
"github.com/apache/answer/internal/repo/auth"
"github.com/apache/answer/internal/repo/user"
authService "github.com/apache/answer/internal/service/auth"
"github.com/apache/answer/pkg/checker"
_ "github.com/go-sql-driver/mysql"
_ "github.com/lib/pq"
"golang.org/x/crypto/bcrypt"
"golang.org/x/term"
_ "modernc.org/sqlite"
"xorm.io/xorm"
)
const (
charsetLower = "abcdefghijklmnopqrstuvwxyz"
charsetUpper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
charsetDigits = "0123456789"
charsetSpecial = "!@#$%^&*~?_-"
maxRetries = 10
defaultRandomPasswordLength = 12
)
var charset = []string{
charsetLower,
charsetUpper,
charsetDigits,
charsetSpecial,
}
type ResetPasswordOptions struct {
Email string
Password string
}
func ResetPassword(ctx context.Context, dataDirPath string, opts *ResetPasswordOptions) error {
path.FormatAllPath(dataDirPath)
config, err := conf.ReadConfig(path.GetConfigFilePath())
if err != nil {
return fmt.Errorf("read config file failed: %w", err)
}
db, err := initDatabase(config.Data.Database.Driver, config.Data.Database.Connection)
if err != nil {
return fmt.Errorf("connect database failed: %w", err)
}
defer db.Close()
cache, cacheCleanup, err := data.NewCache(config.Data.Cache)
if err != nil {
return fmt.Errorf("initialize cache failed: %w", err)
}
defer cacheCleanup()
dataData, dataCleanup, err := data.NewData(db, cache)
if err != nil {
return fmt.Errorf("initialize data layer failed: %w", err)
}
defer dataCleanup()
userRepo := user.NewUserRepo(dataData)
authRepo := auth.NewAuthRepo(dataData)
authSvc := authService.NewAuthService(authRepo)
email := strings.TrimSpace(opts.Email)
if email == "" {
reader := bufio.NewReader(os.Stdin)
fmt.Print("Please input user email: ")
emailInput, err := reader.ReadString('\n')
if err != nil {
return fmt.Errorf("read email input failed: %w", err)
}
email = strings.TrimSpace(emailInput)
}
userInfo, exist, err := userRepo.GetByEmail(ctx, email)
if err != nil {
return fmt.Errorf("query user failed: %w", err)
}
if !exist {
return fmt.Errorf("user not found: %s", email)
}
fmt.Printf("You are going to reset password for user: %s\n", email)
password := strings.TrimSpace(opts.Password)
if password != "" {
printWarning("Passing password via command line may be recorded in shell history")
if err := checker.CheckPassword(password); err != nil {
return fmt.Errorf("password validation failed: %w", err)
}
} else {
password, err = promptForPassword()
if err != nil {
return fmt.Errorf("password input failed: %w", err)
}
}
if !confirmAction(fmt.Sprintf("This will reset password for user '[%s]%s'. Continue?", userInfo.DisplayName, email)) {
fmt.Println("Operation cancelled")
return nil
}
hashPwd, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("encrypt password failed: %w", err)
}
if err = userRepo.UpdatePass(ctx, userInfo.ID, string(hashPwd)); err != nil {
return fmt.Errorf("update password failed: %w", err)
}
authSvc.RemoveUserAllTokens(ctx, userInfo.ID)
fmt.Printf("Password has been successfully updated for user: %s\n", email)
fmt.Println("All login sessions have been cleared")
return nil
}
// promptForPassword prompts for a password
func promptForPassword() (string, error) {
for {
input, err := getPasswordInput("Please input new password (empty to generate random password): ")
if err != nil {
return "", err
}
if input == "" {
password, err := generateRandomPasswordWithRetry()
if err != nil {
return "", fmt.Errorf("generate random password failed: %w", err)
}
fmt.Printf("Generated random password: %s\n", password)
fmt.Println("Please save this password in a secure location")
return password, nil
}
if err := checker.CheckPassword(input); err != nil {
fmt.Printf("Password validation failed: %v\n", err)
fmt.Println("Please try again")
continue
}
confirmPwd, err := getPasswordInput("Please confirm new password: ")
if err != nil {
return "", err
}
if input != confirmPwd {
fmt.Println("Passwords do not match, please try again")
continue
}
return input, nil
}
}
func generateRandomPasswordWithRetry() (string, error) {
var password string
var err error
for range maxRetries {
password, err = generateRandomPassword(defaultRandomPasswordLength)
if err != nil {
continue
}
if err := checker.CheckPassword(password); err == nil {
return password, nil
}
}
if err != nil {
return "", err
}
return "", fmt.Errorf("failed to generate valid password after %d retries", maxRetries)
}
func getPasswordInput(prompt string) (string, error) {
fmt.Print(prompt)
password, err := term.ReadPassword(int(os.Stdin.Fd()))
if err != nil {
return "", err
}
fmt.Println()
return string(password), nil
}
func generateRandomPassword(length int) (string, error) {
if length < len(charset) {
return "", fmt.Errorf("password length must be at least %d", len(charset))
}
bytes := make([]byte, length)
for i, charsetItem := range charset {
charIndex, err := rand.Int(rand.Reader, big.NewInt(int64(len(charsetItem))))
if err != nil {
return "", err
}
bytes[i] = charsetItem[charIndex.Int64()]
}
fullCharset := strings.Join(charset, "")
for i := len(charset); i < length; i++ {
charIndex, err := rand.Int(rand.Reader, big.NewInt(int64(len(fullCharset))))
if err != nil {
return "", err
}
bytes[i] = fullCharset[charIndex.Int64()]
}
for i := len(bytes) - 1; i > 0; i-- {
j, err := rand.Int(rand.Reader, big.NewInt(int64(i+1)))
if err != nil {
return "", err
}
bytes[i], bytes[j.Int64()] = bytes[j.Int64()], bytes[i]
}
return string(bytes), nil
}
func initDatabase(driver, connection string) (*xorm.Engine, error) {
dataConf := &data.Database{Driver: driver, Connection: connection}
if !CheckDBConnection(dataConf) {
return nil, fmt.Errorf("database connection check failed")
}
engine, err := data.NewDB(false, dataConf)
if err != nil {
return nil, err
}
return engine, nil
}
func printWarning(msg string) {
if runtime.GOOS == "windows" {
fmt.Printf("[WARNING] %s\n", msg)
} else {
fmt.Printf("\033[31m[WARNING] %s\033[0m\n", msg)
}
}
func confirmAction(prompt string) bool {
reader := bufio.NewReader(os.Stdin)
fmt.Printf("%s [y/N]: ", prompt)
response, err := reader.ReadString('\n')
if err != nil {
return false
}
response = strings.ToLower(strings.TrimSpace(response))
return response == "y" || response == "yes"
}
+5 -1
View File
@@ -20,6 +20,8 @@
package controller
import (
"net/http"
"github.com/apache/answer/internal/base/handler"
"github.com/apache/answer/internal/base/middleware"
"github.com/apache/answer/internal/base/reason"
@@ -34,7 +36,6 @@ import (
"github.com/apache/answer/pkg/uid"
"github.com/gin-gonic/gin"
"github.com/segmentfault/pacman/errors"
"net/http"
)
// CommentController comment controller
@@ -120,6 +121,9 @@ func (cc *CommentController) AddComment(ctx *gin.Context) {
return
}
req.UserAgent = ctx.GetHeader("User-Agent")
req.IP = ctx.ClientIP()
resp, err := cc.commentService.AddComment(ctx, req)
if !isAdmin || !linkUrlLimitUser {
cc.actionService.ActionRecordAdd(ctx, entity.CaptchaActionComment, req.UserID)
+1
View File
@@ -53,4 +53,5 @@ var ProviderSetController = wire.NewSet(
NewEmbedController,
NewBadgeController,
NewRenderController,
NewSidebarController,
)
@@ -0,0 +1,48 @@
/*
* 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"
"github.com/apache/answer/plugin"
"github.com/gin-gonic/gin"
)
// SidebarController is the controller for the sidebar plugin.
type SidebarController struct{}
// NewSidebarController creates a new instance of SidebarController.
func NewSidebarController() *SidebarController {
return &SidebarController{}
}
// GetSidebarConfig retrieves the sidebar configuration from the registered sidebar plugins.
func (uc *SidebarController) GetSidebarConfig(ctx *gin.Context) {
resp := &plugin.SidebarConfig{}
_ = plugin.CallSidebar(func(fn plugin.Sidebar) error {
cfg, err := fn.GetSidebarConfig()
if err != nil {
return err
}
resp = cfg
return nil
})
handler.HandleResponse(ctx, nil, resp)
}
+5
View File
@@ -87,3 +87,8 @@ func (c *Comment) SetReplyCommentID(str string) {
c.ReplyCommentID = sql.NullInt64{Valid: false}
}
}
// GetMentionUsernameList get mention username list
func (c *Comment) GetMentionUsernameList() []string {
return converter.GetMentionUsernameList(c.OriginalText)
}
+5 -4
View File
@@ -30,6 +30,7 @@ import (
"github.com/apache/answer/internal/base/conf"
"github.com/apache/answer/internal/base/data"
"github.com/apache/answer/internal/base/handler"
"github.com/apache/answer/internal/base/path"
"github.com/apache/answer/internal/base/reason"
"github.com/apache/answer/internal/base/translator"
"github.com/apache/answer/internal/cli"
@@ -62,7 +63,7 @@ func LangOptions(ctx *gin.Context) {
// @Success 200 {object} handler.RespBody{}
// @Router /installation/language/config [get]
func GetLangMapping(ctx *gin.Context) {
t, err := translator.NewTranslator(&translator.I18n{BundleDir: cli.I18nPath})
t, err := translator.NewTranslator(&translator.I18n{BundleDir: path.I18nPath})
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
@@ -186,9 +187,9 @@ func InitEnvironment(ctx *gin.Context) {
}
c.Data.Database.Driver = req.DbType
c.Data.Database.Connection = req.GetConnection()
c.Data.Cache.FilePath = filepath.Join(cli.CacheDir, cli.DefaultCacheFileName)
c.I18n.BundleDir = cli.I18nPath
c.ServiceConfig.UploadPath = cli.UploadFilePath
c.Data.Cache.FilePath = filepath.Join(path.CacheDir, path.DefaultCacheFileName)
c.I18n.BundleDir = path.I18nPath
c.ServiceConfig.UploadPath = path.UploadFilePath
if err := conf.RewriteConfig(confPath, c); err != nil {
log.Errorf("rewrite config failed %s", err)
+2 -2
View File
@@ -23,8 +23,8 @@ import (
"fmt"
"os"
"github.com/apache/answer/internal/base/path"
"github.com/apache/answer/internal/base/translator"
"github.com/apache/answer/internal/cli"
)
var (
@@ -35,7 +35,7 @@ var (
func Run(configPath string) {
confPath = configPath
// initialize translator for return internationalization error when installing.
_, err := translator.NewTranslator(&translator.I18n{BundleDir: cli.I18nPath})
_, err := translator.NewTranslator(&translator.I18n{BundleDir: path.I18nPath})
if err != nil {
panic(err)
}
+2
View File
@@ -289,7 +289,9 @@ func (m *Mentor) initSiteInfoPrivilegeRank() {
func (m *Mentor) initSiteInfoWrite() {
writeData := map[string]interface{}{
"min_content": 6,
"restrict_answer": true,
"min_tags": 1,
"required_tag": false,
"recommend_tags": []string{},
"reserved_tags": []string{},
+1
View File
@@ -103,6 +103,7 @@ var migrations = []Migration{
NewMigration("v1.4.5", "add file record", addFileRecord, true),
NewMigration("v1.5.1", "add plugin kv storage", addPluginKVStorage, true),
NewMigration("v1.6.0", "move user config to interface", moveUserConfigToInterface, true),
NewMigration("v1.7.0", "add optional tags", addOptionalTags, true),
}
func GetMigrations() []Migration {
+69
View File
@@ -0,0 +1,69 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package migrations
import (
"context"
"encoding/json"
"fmt"
"github.com/apache/answer/internal/base/constant"
"github.com/apache/answer/internal/entity"
"github.com/apache/answer/internal/schema"
"xorm.io/xorm"
)
func addOptionalTags(ctx context.Context, x *xorm.Engine) error {
writeSiteInfo := &entity.SiteInfo{
Type: constant.SiteTypeWrite,
}
exist, err := x.Context(ctx).Get(writeSiteInfo)
if err != nil {
return fmt.Errorf("get config failed: %w", err)
}
if exist {
type OldSiteWriteReq struct {
MinimumContent int `json:"min_content"`
RestrictAnswer bool `json:"restrict_answer"`
MinimumTags int `json:"min_tags"`
RequiredTag bool `json:"required_tag"`
RecommendTags []*schema.SiteWriteTag `json:"recommend_tags"`
ReservedTags []*schema.SiteWriteTag `json:"reserved_tags"`
MaxImageSize int `json:"max_image_size"`
MaxAttachmentSize int `json:"max_attachment_size"`
MaxImageMegapixel int `json:"max_image_megapixel"`
AuthorizedImageExtensions []string `json:"authorized_image_extensions"`
AuthorizedAttachmentExtensions []string `json:"authorized_attachment_extensions"`
}
content := &OldSiteWriteReq{}
_ = json.Unmarshal([]byte(writeSiteInfo.Content), content)
content.MinimumTags = 1
content.MinimumContent = 6
data, _ := json.Marshal(content)
writeSiteInfo.Content = string(data)
_, err = x.Context(ctx).ID(writeSiteInfo.ID).Cols("content").Update(writeSiteInfo)
if err != nil {
return fmt.Errorf("update site info failed: %w", err)
}
}
return nil
}
+11
View File
@@ -92,6 +92,17 @@ func (cr *commentRepo) UpdateCommentContent(
return
}
// UpdateCommentStatus update comment status
func (cr *commentRepo) UpdateCommentStatus(ctx context.Context, commentID string, status int) (err error) {
_, err = cr.data.DB.Context(ctx).ID(commentID).Update(&entity.Comment{
Status: status,
})
if err != nil {
err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
}
return
}
// GetComment get comment one
func (cr *commentRepo) GetComment(ctx context.Context, commentID string) (
comment *entity.Comment, exist bool, err error) {
@@ -22,6 +22,7 @@ package repo_test
import (
"context"
"testing"
"time"
"github.com/apache/answer/internal/entity"
"github.com/apache/answer/internal/repo/auth"
@@ -53,7 +54,7 @@ func Test_userAdminRepo_UpdateUserStatus(t *testing.T) {
assert.Equal(t, entity.UserStatusAvailable, got.Status)
err = userAdminRepo.UpdateUserStatus(context.TODO(), "1", entity.UserStatusSuspended, entity.EmailStatusAvailable,
"admin@admin.com")
"admin@admin.com", time.Now().Add(time.Minute*5))
assert.NoError(t, err)
got, exist, err = userAdminRepo.GetUserInfo(context.TODO(), "1")
@@ -62,7 +63,7 @@ func Test_userAdminRepo_UpdateUserStatus(t *testing.T) {
assert.Equal(t, entity.UserStatusSuspended, got.Status)
err = userAdminRepo.UpdateUserStatus(context.TODO(), "1", entity.UserStatusAvailable, entity.EmailStatusAvailable,
"admin@admin.com")
"admin@admin.com", time.Time{})
assert.NoError(t, err)
got, exist, err = userAdminRepo.GetUserInfo(context.TODO(), "1")
+6
View File
@@ -30,6 +30,7 @@ type PluginAPIRouter struct {
captchaController *controller.CaptchaController
embedController *controller.EmbedController
renderController *controller.RenderController
sidebarController *controller.SidebarController
}
func NewPluginAPIRouter(
@@ -38,6 +39,7 @@ func NewPluginAPIRouter(
captchaController *controller.CaptchaController,
embedController *controller.EmbedController,
renderController *controller.RenderController,
sidebarController *controller.SidebarController,
) *PluginAPIRouter {
return &PluginAPIRouter{
connectorController: connectorController,
@@ -45,6 +47,7 @@ func NewPluginAPIRouter(
captchaController: captchaController,
embedController: embedController,
renderController: renderController,
sidebarController: sidebarController,
}
}
@@ -68,6 +71,9 @@ func (pr *PluginAPIRouter) RegisterUnAuthConnectorRouter(r *gin.RouterGroup) {
r.GET("/captcha/config", pr.captchaController.GetCaptchaConfig)
r.GET("/embed/config", pr.embedController.GetEmbedConfig)
r.GET("/render/config", pr.renderController.GetRenderConfig)
// sidebar plugin
r.GET("/sidebar/config", pr.sidebarController.GetSidebarConfig)
}
func (pr *PluginAPIRouter) RegisterAuthUserConnectorRouter(r *gin.RouterGroup) {
+3
View File
@@ -51,6 +51,9 @@ type AddCommentReq struct {
CanEdit bool `json:"-"`
// whether user can delete it
CanDelete bool `json:"-"`
IP string `json:"-"`
UserAgent string `json:"-"`
}
func (req *AddCommentReq) Check() (errFields []*validator.FormErrorField, err error) {
+6 -26
View File
@@ -79,11 +79,11 @@ type QuestionAdd struct {
// question title
Title string `validate:"required,notblank,gte=6,lte=150" json:"title"`
// content
Content string `validate:"required,notblank,gte=6,lte=65535" json:"content"`
Content string `validate:"gte=0,lte=65535" json:"content"`
// html
HTML string `json:"-"`
// tags
Tags []*TagItem `validate:"required,dive" json:"tags"`
Tags []*TagItem `validate:"dive" json:"tags"`
// user id
UserID string `json:"-"`
QuestionPermission
@@ -100,12 +100,6 @@ func (req *QuestionAdd) Check() (errFields []*validator.FormErrorField, err erro
tag.ParsedText = converter.Markdown2HTML(tag.OriginalText)
}
}
if req.HTML == "" {
return append(errFields, &validator.FormErrorField{
ErrorField: "content",
ErrorMsg: reason.QuestionContentCannotEmpty,
}), errors.BadRequest(reason.QuestionContentCannotEmpty)
}
return nil, nil
}
@@ -113,13 +107,13 @@ type QuestionAddByAnswer struct {
// question title
Title string `validate:"required,notblank,gte=6,lte=150" json:"title"`
// content
Content string `validate:"required,notblank,gte=6,lte=65535" json:"content"`
Content string `validate:"gte=0,lte=65535" json:"content"`
// html
HTML string `json:"-"`
AnswerContent string `validate:"required,notblank,gte=6,lte=65535" json:"answer_content"`
AnswerHTML string `json:"-"`
// tags
Tags []*TagItem `validate:"required,dive" json:"tags"`
Tags []*TagItem `validate:"dive" json:"tags"`
// user id
UserID string `json:"-"`
MentionUsernameList []string `validate:"omitempty" json:"mention_username_list"`
@@ -138,19 +132,11 @@ func (req *QuestionAddByAnswer) Check() (errFields []*validator.FormErrorField,
tag.ParsedText = converter.Markdown2HTML(tag.OriginalText)
}
}
if req.HTML == "" {
errFields = append(errFields, &validator.FormErrorField{
ErrorField: "content",
ErrorMsg: reason.QuestionContentCannotEmpty,
})
}
if req.AnswerHTML == "" {
errFields = append(errFields, &validator.FormErrorField{
ErrorField: "answer_content",
ErrorMsg: reason.AnswerContentCannotEmpty,
})
}
if req.HTML == "" || req.AnswerHTML == "" {
return errFields, errors.BadRequest(reason.QuestionContentCannotEmpty)
}
return nil, nil
@@ -195,12 +181,12 @@ type QuestionUpdate struct {
// question title
Title string `validate:"required,notblank,gte=6,lte=150" json:"title"`
// content
Content string `validate:"required,notblank,gte=6,lte=65535" json:"content"`
Content string `validate:"gte=0,lte=65535" json:"content"`
// html
HTML string `json:"-"`
InviteUser []string `validate:"omitempty" json:"invite_user"`
// tags
Tags []*TagItem `validate:"required,dive" json:"tags"`
Tags []*TagItem `validate:"dive" json:"tags"`
// edit summary
EditSummary string `validate:"omitempty" json:"edit_summary"`
// user id
@@ -227,12 +213,6 @@ type QuestionUpdateInviteUser struct {
func (req *QuestionUpdate) Check() (errFields []*validator.FormErrorField, err error) {
req.HTML = converter.Markdown2HTML(req.Content)
if req.HTML == "" {
return append(errFields, &validator.FormErrorField{
ErrorField: "content",
ErrorMsg: reason.QuestionContentCannotEmpty,
}), errors.BadRequest(reason.QuestionContentCannotEmpty)
}
return nil, nil
}
+2
View File
@@ -75,7 +75,9 @@ type SiteBrandingReq struct {
// SiteWriteReq site write request
type SiteWriteReq struct {
MinimumContent int `validate:"omitempty,gte=0,lte=65535" json:"min_content"`
RestrictAnswer bool `validate:"omitempty" json:"restrict_answer"`
MinimumTags int `validate:"omitempty,gte=0,lte=5" json:"min_tags"`
RequiredTag bool `validate:"omitempty" json:"required_tag"`
RecommendTags []*SiteWriteTag `validate:"omitempty,dive" json:"recommend_tags"`
ReservedTags []*SiteWriteTag `validate:"omitempty,dive" json:"reserved_tags"`
+17 -3
View File
@@ -21,7 +21,10 @@ package comment
import (
"context"
"github.com/apache/answer/internal/service/event_queue"
"github.com/apache/answer/internal/service/review"
"time"
"github.com/apache/answer/internal/base/constant"
@@ -50,6 +53,7 @@ type CommentRepo interface {
AddComment(ctx context.Context, comment *entity.Comment) (err error)
RemoveComment(ctx context.Context, commentID string) (err error)
UpdateCommentContent(ctx context.Context, commentID string, original string, parsedText string) (err error)
UpdateCommentStatus(ctx context.Context, commentID string, status int) (err error)
GetComment(ctx context.Context, commentID string) (comment *entity.Comment, exist bool, err error)
GetCommentPage(ctx context.Context, commentQuery *CommentQuery) (
comments []*entity.Comment, total int64, err error)
@@ -88,6 +92,7 @@ type CommentService struct {
externalNotificationQueueService notice_queue.ExternalNotificationQueueService
activityQueueService activity_queue.ActivityQueueService
eventQueueService event_queue.EventQueueService
reviewService *review.ReviewService
}
// NewCommentService new comment service
@@ -103,6 +108,7 @@ func NewCommentService(
externalNotificationQueueService notice_queue.ExternalNotificationQueueService,
activityQueueService activity_queue.ActivityQueueService,
eventQueueService event_queue.EventQueueService,
reviewService *review.ReviewService,
) *CommentService {
return &CommentService{
commentRepo: commentRepo,
@@ -116,6 +122,7 @@ func NewCommentService(
externalNotificationQueueService: externalNotificationQueueService,
activityQueueService: activityQueueService,
eventQueueService: eventQueueService,
reviewService: reviewService,
}
}
@@ -160,14 +167,21 @@ func (cs *CommentService) AddComment(ctx context.Context, req *schema.AddComment
return nil, err
}
comment.Status = cs.reviewService.AddCommentReview(ctx, comment, req.IP, req.UserAgent)
if err := cs.commentRepo.UpdateCommentStatus(ctx, comment.ID, comment.Status); err != nil {
return nil, err
}
resp = &schema.GetCommentResp{}
resp.SetFromComment(comment)
resp.MemberActions = permission.GetCommentPermission(ctx, req.UserID, resp.UserID,
time.Now(), req.CanEdit, req.CanDelete)
commentResp, err := cs.addCommentNotification(ctx, req, resp, comment, objInfo)
if err != nil {
return commentResp, err
if comment.Status == entity.CommentStatusAvailable {
commentResp, err := cs.addCommentNotification(ctx, req, resp, comment, objInfo)
if err != nil {
return commentResp, err
}
}
// get user info
@@ -34,6 +34,7 @@ type CommentCommonRepo interface {
GetCommentWithoutStatus(ctx context.Context, commentID string) (comment *entity.Comment, exist bool, err error)
GetCommentCount(ctx context.Context) (count int64, err error)
RemoveAllUserComment(ctx context.Context, userID string) (err error)
UpdateCommentStatus(ctx context.Context, commentID string, status int) (err error)
}
// CommentCommonService user service
+73 -14
View File
@@ -229,13 +229,30 @@ func (qs *QuestionService) AddQuestionCheckTags(ctx context.Context, Tags []*ent
return []string{}, nil
}
func (qs *QuestionService) CheckAddQuestion(ctx context.Context, req *schema.QuestionAdd) (errorlist any, err error) {
if len(req.Tags) == 0 {
minimumTags, err := qs.tagCommon.GetMinimumTags(ctx)
if err != nil {
return
}
if len(req.Tags) < minimumTags {
errorlist := make([]*validator.FormErrorField, 0)
errorlist = append(errorlist, &validator.FormErrorField{
ErrorField: "tags",
ErrorMsg: translator.Tr(handler.GetLangByCtx(ctx), reason.TagNotFound),
ErrorMsg: translator.Tr(handler.GetLangByCtx(ctx), reason.TagMinCount),
})
err = errors.BadRequest(reason.RecommendTagEnter)
err = errors.BadRequest(reason.TagMinCount)
return errorlist, err
}
minimumContentLength, err := qs.questioncommon.GetMinimumContentLength(ctx)
if err != nil {
return
}
if len(req.Content) < minimumContentLength {
errorlist := make([]*validator.FormErrorField, 0)
errorlist = append(errorlist, &validator.FormErrorField{
ErrorField: "content",
ErrorMsg: translator.Tr(handler.GetLangByCtx(ctx), reason.QuestionContentLessThanMinimum),
})
err = errors.BadRequest(reason.QuestionContentLessThanMinimum)
return errorlist, err
}
recommendExist, err := qs.tagCommon.ExistRecommend(ctx, req.Tags)
@@ -284,13 +301,30 @@ 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) {
if len(req.Tags) == 0 {
minimumTags, err := qs.tagCommon.GetMinimumTags(ctx)
if err != nil {
return
}
if len(req.Tags) < minimumTags {
errorlist := make([]*validator.FormErrorField, 0)
errorlist = append(errorlist, &validator.FormErrorField{
ErrorField: "tags",
ErrorMsg: translator.Tr(handler.GetLangByCtx(ctx), reason.TagNotFound),
ErrorMsg: translator.Tr(handler.GetLangByCtx(ctx), reason.TagMinCount),
})
err = errors.BadRequest(reason.RecommendTagEnter)
err = errors.BadRequest(reason.TagMinCount)
return errorlist, err
}
minimumContentLength, err := qs.questioncommon.GetMinimumContentLength(ctx)
if err != nil {
return
}
if len(req.Content) < minimumContentLength {
errorlist := make([]*validator.FormErrorField, 0)
errorlist = append(errorlist, &validator.FormErrorField{
ErrorField: "content",
ErrorMsg: translator.Tr(handler.GetLangByCtx(ctx), reason.QuestionContentLessThanMinimum),
})
err = errors.BadRequest(reason.QuestionContentLessThanMinimum)
return errorlist, err
}
recommendExist, err := qs.tagCommon.ExistRecommend(ctx, req.Tags)
@@ -370,9 +404,9 @@ func (qs *QuestionService) AddQuestion(ctx context.Context, req *schema.Question
objectTagData.ObjectID = question.ID
objectTagData.Tags = req.Tags
objectTagData.UserID = req.UserID
err = qs.ChangeTag(ctx, &objectTagData)
errorlist, err := qs.ChangeTag(ctx, &objectTagData)
if err != nil {
return
return errorlist, err
}
_ = qs.questionRepo.UpdateSearch(ctx, question.ID)
@@ -413,8 +447,15 @@ func (qs *QuestionService) AddQuestion(ctx context.Context, req *schema.Question
})
if question.Status == entity.QuestionStatusAvailable {
qs.externalNotificationQueueService.Send(ctx,
schema.CreateNewQuestionNotificationMsg(question.ID, question.Title, question.UserID, tags))
newTags, newTagsErr := qs.tagCommon.GetTagListByNames(ctx, tagNameList)
if newTagsErr != nil {
log.Error("get question newTags error %v", newTagsErr)
qs.externalNotificationQueueService.Send(ctx,
schema.CreateNewQuestionNotificationMsg(question.ID, question.Title, question.UserID, tags))
} else {
qs.externalNotificationQueueService.Send(ctx,
schema.CreateNewQuestionNotificationMsg(question.ID, question.Title, question.UserID, newTags))
}
}
qs.eventQueueService.Send(ctx, schema.NewEvent(constant.EventQuestionCreate, req.UserID).TID(question.ID).
QID(question.ID, question.UserID))
@@ -892,6 +933,20 @@ func (qs *QuestionService) UpdateQuestion(ctx context.Context, req *schema.Quest
question.UserID = dbinfo.UserID
question.LastEditUserID = req.UserID
minimumContentLength, err := qs.questioncommon.GetMinimumContentLength(ctx)
if err != nil {
return
}
if len(req.Content) < minimumContentLength {
errorlist := make([]*validator.FormErrorField, 0)
errorlist = append(errorlist, &validator.FormErrorField{
ErrorField: "content",
ErrorMsg: translator.Tr(handler.GetLangByCtx(ctx), reason.QuestionContentLessThanMinimum),
})
err = errors.BadRequest(reason.QuestionContentLessThanMinimum)
return errorlist, err
}
oldTags, tagerr := qs.tagCommon.GetObjectEntityTag(ctx, question.ID)
if tagerr != nil {
return questionInfo, tagerr
@@ -993,9 +1048,9 @@ func (qs *QuestionService) UpdateQuestion(ctx context.Context, req *schema.Quest
objectTagData.ObjectID = question.ID
objectTagData.Tags = req.Tags
objectTagData.UserID = req.UserID
tagerr := qs.ChangeTag(ctx, &objectTagData)
errorlist, tagerr := qs.ChangeTag(ctx, &objectTagData)
if tagerr != nil {
return questionInfo, tagerr
return errorlist, tagerr
}
}
@@ -1095,8 +1150,12 @@ func (qs *QuestionService) InviteUserInfo(ctx context.Context, questionID string
return qs.questioncommon.InviteUserInfo(ctx, questionID)
}
func (qs *QuestionService) ChangeTag(ctx context.Context, objectTagData *schema.TagChange) error {
return qs.tagCommon.ObjectChangeTag(ctx, objectTagData)
func (qs *QuestionService) ChangeTag(ctx context.Context, objectTagData *schema.TagChange) (errorlist []*validator.FormErrorField, err error) {
minimumTags, err := qs.tagCommon.GetMinimumTags(ctx)
if err != nil {
return nil, err
}
return qs.tagCommon.ObjectChangeTag(ctx, objectTagData, minimumTags)
}
func (qs *QuestionService) CheckChangeReservedTag(ctx context.Context, oldobjectTagData, objectTagData []*entity.Tag) (bool, bool, []string, []string) {
+5 -1
View File
@@ -214,7 +214,11 @@ func (rs *RevisionService) revisionAuditQuestion(ctx context.Context, revisionit
objectTagData := schema.TagChange{}
objectTagData.ObjectID = question.ID
objectTagData.Tags = objectTagTags
saveerr = rs.tagCommon.ObjectChangeTag(ctx, &objectTagData)
minimumTags, err := rs.tagCommon.GetMinimumTags(ctx)
if err != nil {
return err
}
_, saveerr = rs.tagCommon.ObjectChangeTag(ctx, &objectTagData, minimumTags)
if saveerr != nil {
return saveerr
}
@@ -1,22 +1,3 @@
/*
* 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.
*/
// Code generated by MockGen. DO NOT EDIT.
// Source: ./siteinfo_service.go
//
@@ -279,5 +279,17 @@ func (ns *ExternalNotificationService) newPluginQuestionNotification(
raw.QuestionUrl = display.QuestionURL(
seoInfo.Permalink, siteInfo.SiteUrl,
msg.NewQuestionTemplateRawData.QuestionID, msg.NewQuestionTemplateRawData.QuestionTitle)
if len(msg.NewQuestionTemplateRawData.QuestionAuthorUserID) > 0 {
triggerUser, exist, err := ns.userRepo.GetByUserID(ctx, msg.NewQuestionTemplateRawData.QuestionAuthorUserID)
if err != nil {
log.Errorf("get trigger user basic info failed: %v", err)
return
}
if exist {
raw.TriggerUserID = triggerUser.ID
raw.TriggerUserDisplayName = triggerUser.DisplayName
raw.TriggerUserUrl = display.UserURL(siteInfo.SiteUrl, triggerUser.Username)
}
}
return raw
}
@@ -899,3 +899,11 @@ func (qs *QuestionCommon) tryToGetQuestionIDFromMsg(ctx context.Context, closeMs
questionID = uid.DeShortID(questionID)
return questionID
}
func (qs *QuestionCommon) GetMinimumContentLength(ctx context.Context) (int, error) {
siteInfo, err := qs.siteInfoService.GetSiteWrite(ctx)
if err != nil {
return 6, err
}
return siteInfo.MinimumContent, nil
}
+270
View File
@@ -28,6 +28,7 @@ import (
"github.com/apache/answer/internal/entity"
"github.com/apache/answer/internal/schema"
answercommon "github.com/apache/answer/internal/service/answer_common"
commentcommon "github.com/apache/answer/internal/service/comment_common"
"github.com/apache/answer/internal/service/notice_queue"
"github.com/apache/answer/internal/service/object_info"
questioncommon "github.com/apache/answer/internal/service/question_common"
@@ -68,6 +69,7 @@ type ReviewService struct {
externalNotificationQueueService notice_queue.ExternalNotificationQueueService
notificationQueueService notice_queue.NotificationQueueService
siteInfoService siteinfo_common.SiteInfoCommonService
commentCommonRepo commentcommon.CommentCommonRepo
}
// NewReviewService new review service
@@ -84,6 +86,7 @@ func NewReviewService(
questionCommon *questioncommon.QuestionCommon,
notificationQueueService notice_queue.NotificationQueueService,
siteInfoService siteinfo_common.SiteInfoCommonService,
commentCommonRepo commentcommon.CommentCommonRepo,
) *ReviewService {
return &ReviewService{
reviewRepo: reviewRepo,
@@ -98,6 +101,7 @@ func NewReviewService(
questionCommon: questionCommon,
notificationQueueService: notificationQueueService,
siteInfoService: siteInfoService,
commentCommonRepo: commentCommonRepo,
}
}
@@ -153,6 +157,30 @@ func (cs *ReviewService) AddAnswerReview(ctx context.Context,
return answerStatus
}
// AddCommentReview add review for comment if needed
func (cs *ReviewService) AddCommentReview(ctx context.Context,
comment *entity.Comment, ip, ua string) (commentStatus int) {
reviewContent := &plugin.ReviewContent{
ObjectType: constant.CommentObjectType,
Content: comment.ParsedText,
IP: ip,
UserAgent: ua,
}
reviewContent.Author = cs.getReviewContentAuthorInfo(ctx, comment.UserID)
reviewStatus := cs.callPluginToReview(ctx, comment.UserID, comment.ID, reviewContent)
switch reviewStatus {
case plugin.ReviewStatusApproved:
commentStatus = entity.CommentStatusAvailable
case plugin.ReviewStatusNeedReview:
commentStatus = entity.CommentStatusPending
case plugin.ReviewStatusDeleteDirectly:
commentStatus = entity.CommentStatusDeleted
default:
commentStatus = entity.CommentStatusAvailable
}
return commentStatus
}
// get review content author info
func (cs *ReviewService) getReviewContentAuthorInfo(ctx context.Context, userID string) (author plugin.ReviewContentAuthor) {
user, exist, err := cs.userCommon.GetUserBasicInfoByID(ctx, userID)
@@ -314,6 +342,32 @@ func (cs *ReviewService) updateObjectStatus(ctx context.Context, review *entity.
log.Errorf("update user answer count failed, err: %v", err)
}
}
case constant.CommentObjectType:
commentInfo, exist, err := cs.commentCommonRepo.GetCommentWithoutStatus(ctx, review.ObjectID)
if err != nil {
return err
}
if !exist {
return errors.BadRequest(reason.ObjectNotFound)
}
if isApprove {
commentInfo.Status = entity.CommentStatusAvailable
} else {
commentInfo.Status = entity.CommentStatusDeleted
}
if err := cs.commentCommonRepo.UpdateCommentStatus(ctx, commentInfo.ID, commentInfo.Status); err != nil {
return err
}
_, exist, err = cs.questionRepo.GetQuestion(ctx, commentInfo.QuestionID)
if err != nil {
return err
}
if !exist {
return errors.BadRequest(reason.ObjectNotFound)
}
if isApprove {
cs.notificationCommentOnTheQuestion(ctx, commentInfo)
}
}
return
}
@@ -364,6 +418,222 @@ func (cs *ReviewService) notificationAnswerTheQuestion(ctx context.Context,
cs.externalNotificationQueueService.Send(ctx, externalNotificationMsg)
}
func (cs *ReviewService) notificationCommentOnTheQuestion(ctx context.Context, comment *entity.Comment) {
objInfo, err := cs.objectInfoService.GetInfo(ctx, comment.ObjectID)
if err != nil {
log.Error(err)
return
}
if objInfo.IsDeleted() {
log.Error("object already deleted")
return
}
objInfo.ObjectID = uid.DeShortID(objInfo.ObjectID)
objInfo.QuestionID = uid.DeShortID(objInfo.QuestionID)
objInfo.AnswerID = uid.DeShortID(objInfo.AnswerID)
// The priority of the notification
// 1. reply to user
// 2. comment mention to user
// 3. answer or question was commented
alreadyNotifiedUserID := make(map[string]bool)
// get reply user info
replyUserID := comment.GetReplyUserID()
if len(replyUserID) > 0 && replyUserID != comment.UserID {
replyUser, _, err := cs.userCommon.GetUserBasicInfoByID(ctx, replyUserID)
if err != nil {
log.Error(err)
return
}
cs.notificationCommentReply(ctx, replyUser.ID, comment.ID, comment.UserID,
objInfo.QuestionID, objInfo.Title, htmltext.FetchExcerpt(comment.ParsedText, "...", 240))
alreadyNotifiedUserID[replyUser.ID] = true
return
}
mentionUsernameList := comment.GetMentionUsernameList()
if len(mentionUsernameList) > 0 {
alreadyNotifiedUserIDs := cs.notificationMention(
ctx, mentionUsernameList, comment.ID, comment.UserID, alreadyNotifiedUserID)
for _, userID := range alreadyNotifiedUserIDs {
alreadyNotifiedUserID[userID] = true
}
return
}
if objInfo.ObjectType == constant.QuestionObjectType && !alreadyNotifiedUserID[objInfo.ObjectCreatorUserID] {
cs.notificationQuestionComment(ctx, objInfo.ObjectCreatorUserID,
objInfo.QuestionID, objInfo.Title, comment.ID, comment.UserID, htmltext.FetchExcerpt(comment.ParsedText, "...", 240))
} else if objInfo.ObjectType == constant.AnswerObjectType && !alreadyNotifiedUserID[objInfo.ObjectCreatorUserID] {
cs.notificationAnswerComment(ctx, objInfo.QuestionID, objInfo.Title, objInfo.AnswerID,
objInfo.ObjectCreatorUserID, comment.ID, comment.UserID, htmltext.FetchExcerpt(comment.ParsedText, "...", 240))
}
return
}
func (cs *ReviewService) notificationCommentReply(ctx context.Context, replyUserID, commentID, commentUserID,
questionID, questionTitle, commentSummary string) {
msg := &schema.NotificationMsg{
ReceiverUserID: replyUserID,
TriggerUserID: commentUserID,
Type: schema.NotificationTypeInbox,
ObjectID: commentID,
}
msg.ObjectType = constant.CommentObjectType
msg.NotificationAction = constant.NotificationReplyToYou
cs.notificationQueueService.Send(ctx, msg)
// Send external notification.
receiverUserInfo, exist, err := cs.userRepo.GetByUserID(ctx, replyUserID)
if err != nil {
log.Error(err)
return
}
if !exist {
log.Warnf("user %s not found", replyUserID)
return
}
externalNotificationMsg := &schema.ExternalNotificationMsg{
ReceiverUserID: receiverUserInfo.ID,
ReceiverEmail: receiverUserInfo.EMail,
ReceiverLang: receiverUserInfo.Language,
}
rawData := &schema.NewCommentTemplateRawData{
QuestionTitle: questionTitle,
QuestionID: questionID,
CommentID: commentID,
CommentSummary: commentSummary,
UnsubscribeCode: token.GenerateToken(),
}
commentUser, _, _ := cs.userCommon.GetUserBasicInfoByID(ctx, commentUserID)
if commentUser != nil {
rawData.CommentUserDisplayName = commentUser.DisplayName
}
externalNotificationMsg.NewCommentTemplateRawData = rawData
cs.externalNotificationQueueService.Send(ctx, externalNotificationMsg)
}
func (cs *ReviewService) notificationMention(
ctx context.Context, mentionUsernameList []string, commentID, commentUserID string,
alreadyNotifiedUserID map[string]bool) (alreadyNotifiedUserIDs []string) {
for _, username := range mentionUsernameList {
userInfo, exist, err := cs.userCommon.GetUserBasicInfoByUserName(ctx, username)
if err != nil {
log.Error(err)
continue
}
if exist && !alreadyNotifiedUserID[userInfo.ID] {
msg := &schema.NotificationMsg{
ReceiverUserID: userInfo.ID,
TriggerUserID: commentUserID,
Type: schema.NotificationTypeInbox,
ObjectID: commentID,
}
msg.ObjectType = constant.CommentObjectType
msg.NotificationAction = constant.NotificationMentionYou
cs.notificationQueueService.Send(ctx, msg)
alreadyNotifiedUserIDs = append(alreadyNotifiedUserIDs, userInfo.ID)
}
}
return alreadyNotifiedUserIDs
}
func (cs *ReviewService) notificationQuestionComment(ctx context.Context, questionUserID,
questionID, questionTitle, commentID, commentUserID, commentSummary string) {
if questionUserID == commentUserID {
return
}
// send internal notification
msg := &schema.NotificationMsg{
ReceiverUserID: questionUserID,
TriggerUserID: commentUserID,
Type: schema.NotificationTypeInbox,
ObjectID: commentID,
}
msg.ObjectType = constant.CommentObjectType
msg.NotificationAction = constant.NotificationCommentQuestion
cs.notificationQueueService.Send(ctx, msg)
// send external notification
receiverUserInfo, exist, err := cs.userRepo.GetByUserID(ctx, questionUserID)
if err != nil {
log.Error(err)
return
}
if !exist {
log.Warnf("user %s not found", questionUserID)
return
}
externalNotificationMsg := &schema.ExternalNotificationMsg{
ReceiverUserID: receiverUserInfo.ID,
ReceiverEmail: receiverUserInfo.EMail,
ReceiverLang: receiverUserInfo.Language,
}
rawData := &schema.NewCommentTemplateRawData{
QuestionTitle: questionTitle,
QuestionID: questionID,
CommentID: commentID,
CommentSummary: commentSummary,
UnsubscribeCode: token.GenerateToken(),
}
commentUser, _, _ := cs.userCommon.GetUserBasicInfoByID(ctx, commentUserID)
if commentUser != nil {
rawData.CommentUserDisplayName = commentUser.DisplayName
}
externalNotificationMsg.NewCommentTemplateRawData = rawData
cs.externalNotificationQueueService.Send(ctx, externalNotificationMsg)
}
func (cs *ReviewService) notificationAnswerComment(ctx context.Context,
questionID, questionTitle, answerID, answerUserID, commentID, commentUserID, commentSummary string) {
if answerUserID == commentUserID {
return
}
// Send internal notification.
msg := &schema.NotificationMsg{
ReceiverUserID: answerUserID,
TriggerUserID: commentUserID,
Type: schema.NotificationTypeInbox,
ObjectID: commentID,
}
msg.ObjectType = constant.CommentObjectType
msg.NotificationAction = constant.NotificationCommentAnswer
cs.notificationQueueService.Send(ctx, msg)
// Send external notification.
receiverUserInfo, exist, err := cs.userRepo.GetByUserID(ctx, answerUserID)
if err != nil {
log.Error(err)
return
}
if !exist {
log.Warnf("user %s not found", answerUserID)
return
}
externalNotificationMsg := &schema.ExternalNotificationMsg{
ReceiverUserID: receiverUserInfo.ID,
ReceiverEmail: receiverUserInfo.EMail,
ReceiverLang: receiverUserInfo.Language,
}
rawData := &schema.NewCommentTemplateRawData{
QuestionTitle: questionTitle,
QuestionID: questionID,
AnswerID: answerID,
CommentID: commentID,
CommentSummary: commentSummary,
UnsubscribeCode: token.GenerateToken(),
}
commentUser, _, _ := cs.userCommon.GetUserBasicInfoByID(ctx, commentUserID)
if commentUser != nil {
rawData.CommentUserDisplayName = commentUser.DisplayName
}
externalNotificationMsg.NewCommentTemplateRawData = rawData
cs.externalNotificationQueueService.Send(ctx, externalNotificationMsg)
}
// GetReviewPendingCount get review pending count
func (cs *ReviewService) GetReviewPendingCount(ctx context.Context) (count int64, err error) {
return cs.reviewRepo.GetReviewCount(ctx, entity.ReviewStatusPending)
+28 -8
View File
@@ -27,7 +27,9 @@ import (
"strings"
"github.com/apache/answer/internal/base/constant"
"github.com/apache/answer/internal/base/handler"
"github.com/apache/answer/internal/base/reason"
"github.com/apache/answer/internal/base/translator"
"github.com/apache/answer/internal/base/validator"
"github.com/apache/answer/internal/entity"
"github.com/apache/answer/internal/schema"
@@ -292,6 +294,15 @@ func (ts *TagCommonService) ExistRecommend(ctx context.Context, tags []*schema.T
return false, nil
}
func (ts *TagCommonService) GetMinimumTags(ctx context.Context) (int, error) {
siteInfo, err := ts.siteInfoService.GetSiteWrite(ctx)
if err != nil {
return 1, err
}
minimumTags := siteInfo.MinimumTags
return minimumTags, nil
}
func (ts *TagCommonService) HasNewTag(ctx context.Context, tags []*schema.TagItem) (bool, error) {
tagNames := make([]string, 0)
tagMap := make(map[string]bool)
@@ -648,9 +659,18 @@ func (ts *TagCommonService) CheckChangeReservedTag(ctx context.Context, oldobjec
}
// ObjectChangeTag change object tag list
func (ts *TagCommonService) ObjectChangeTag(ctx context.Context, objectTagData *schema.TagChange) (err error) {
if len(objectTagData.Tags) == 0 {
return nil
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 {
errorlist := make([]*validator.FormErrorField, 0)
errorlist = append(errorlist, &validator.FormErrorField{
ErrorField: "tags",
ErrorMsg: translator.Tr(handler.GetLangByCtx(ctx), reason.TagMinCount),
})
err = errors.BadRequest(reason.TagMinCount)
return errorlist, err
}
thisObjTagNameList := make([]string, 0)
@@ -663,7 +683,7 @@ func (ts *TagCommonService) ObjectChangeTag(ctx context.Context, objectTagData *
// find tags name
tagListInDb, err := ts.tagCommonRepo.GetTagListByNames(ctx, thisObjTagNameList)
if err != nil {
return err
return nil, err
}
tagInDbMapping := make(map[string]*entity.Tag)
@@ -691,7 +711,7 @@ func (ts *TagCommonService) ObjectChangeTag(ctx context.Context, objectTagData *
if len(addTagList) > 0 {
err = ts.tagCommonRepo.AddTagList(ctx, addTagList)
if err != nil {
return err
return nil, err
}
for _, tag := range addTagList {
thisObjTagIDList = append(thisObjTagIDList, tag.ID)
@@ -704,7 +724,7 @@ func (ts *TagCommonService) ObjectChangeTag(ctx context.Context, objectTagData *
revisionDTO.Content = string(tagInfoJson)
revisionID, err := ts.revisionService.AddRevision(ctx, revisionDTO, true)
if err != nil {
return err
return nil, err
}
ts.activityQueueService.Send(ctx, &schema.ActivityMsg{
UserID: objectTagData.UserID,
@@ -718,9 +738,9 @@ func (ts *TagCommonService) ObjectChangeTag(ctx context.Context, objectTagData *
err = ts.CreateOrUpdateTagRelList(ctx, objectTagData.ObjectID, thisObjTagIDList)
if err != nil {
return err
return nil, err
}
return nil
return nil, nil
}
func (ts *TagCommonService) CountTagRelByTagID(ctx context.Context, tagID string) (count int64, err error) {
+2 -2
View File
@@ -26,7 +26,7 @@ import (
"sync"
"github.com/apache/answer/configs"
"github.com/apache/answer/internal/cli"
"github.com/apache/answer/internal/base/path"
"github.com/apache/answer/pkg/dir"
)
@@ -36,7 +36,7 @@ var (
)
func initReservedUsername() {
reservedUsernamesJsonFilePath := filepath.Join(cli.ConfigFileDir, cli.DefaultReservedUsernamesConfigFileName)
reservedUsernamesJsonFilePath := filepath.Join(path.ConfigFileDir, path.DefaultReservedUsernamesConfigFileName)
if dir.CheckFileExist(reservedUsernamesJsonFilePath) {
// if reserved username file exists, read it and replace configuration
reservedUsernamesJsonFile, err := os.ReadFile(reservedUsernamesJsonFilePath)
+18 -1
View File
@@ -19,8 +19,25 @@
package converter
import "github.com/segmentfault/pacman/utils"
import (
"regexp"
"github.com/segmentfault/pacman/utils"
)
func DeleteUserDisplay(userID string) string {
return utils.EnShortID(StringToInt64(userID), 100)
}
func GetMentionUsernameList(text string) []string {
re := regexp.MustCompile(`\[@([^\]]+)\]\(/users/[^\)]+\)`)
matches := re.FindAllStringSubmatch(text, -1)
var usernames []string
for _, match := range matches {
if len(match) > 1 {
usernames = append(usernames, match[1])
}
}
return usernames
}
+20 -10
View File
@@ -23,16 +23,17 @@ type ConfigType string
type InputType string
const (
ConfigTypeInput ConfigType = "input"
ConfigTypeTextarea ConfigType = "textarea"
ConfigTypeCheckbox ConfigType = "checkbox"
ConfigTypeRadio ConfigType = "radio"
ConfigTypeSelect ConfigType = "select"
ConfigTypeUpload ConfigType = "upload"
ConfigTypeTimezone ConfigType = "timezone"
ConfigTypeSwitch ConfigType = "switch"
ConfigTypeButton ConfigType = "button"
ConfigTypeLegend ConfigType = "legend"
ConfigTypeInput ConfigType = "input"
ConfigTypeTextarea ConfigType = "textarea"
ConfigTypeCheckbox ConfigType = "checkbox"
ConfigTypeRadio ConfigType = "radio"
ConfigTypeSelect ConfigType = "select"
ConfigTypeUpload ConfigType = "upload"
ConfigTypeTimezone ConfigType = "timezone"
ConfigTypeSwitch ConfigType = "switch"
ConfigTypeButton ConfigType = "button"
ConfigTypeLegend ConfigType = "legend"
ConfigTypeTagSelector ConfigType = "tag_selector"
)
const (
@@ -105,6 +106,15 @@ type OnCompleteAction struct {
RefreshFormConfig bool `json:"refresh_form_config"`
}
// TagSelectorOption represents a tag option in the tag selector config value field
type TagSelectorOption struct {
TagID string `json:"tag_id"`
SlugName string `json:"slug_name"`
DisplayName string `json:"display_name"`
Recommend bool `json:"recommend"`
Reserved bool `json:"reserved"`
}
type Config interface {
Base
+4
View File
@@ -126,6 +126,10 @@ func Register(p Base) {
if _, ok := p.(KVStorage); ok {
registerKVStorage(p.(KVStorage))
}
if _, ok := p.(Sidebar); ok {
registerSidebar(p.(Sidebar))
}
}
type Stack[T Base] struct {
+37
View File
@@ -0,0 +1,37 @@
/*
* 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 plugin
package plugin
type SidebarConfig struct {
Tags []*TagSelectorOption `json:"tags"`
LinksText string `json:"links_text"`
}
type Sidebar interface {
Base
GetSidebarConfig() (sidebarConfig *SidebarConfig, err error)
}
var (
// CallRender is a function that calls all registered parsers
CallSidebar,
registerSidebar = MakePlugin[Sidebar](false)
)
+2 -1
View File
@@ -1,3 +1,4 @@
github.com/apache/answer-plugins/connector-basic@latest
github.com/apache/answer-plugins/reviewer-basic@latest
github.com/apache/answer-plugins/captcha-basic@latest
github.com/apache/answer-plugins/captcha-basic@latest
github.com/apache/answer-plugins/quick-links@latest
+1
View File
@@ -3,3 +3,4 @@ ESLINT_NO_DEV_ERRORS=true
PUBLIC_URL=/
REACT_APP_API_URL=/
REACT_APP_BASE_URL=
REACT_APP_API_BASE_URL=
+1 -1
View File
@@ -22,7 +22,7 @@
--an-toolbar-divider: rgba(0, 0, 0, 0.1);
--an-ced4da: #ced4da;
--an-e9ecef: #e9ecef;
--an-pre: #161b22;
--an-pre: #f8f9fa;
--an-6c757d: #6c757d;
--an-212529: #212529;
--an-gray-300: var(--bs-gray-300);
+2
View File
@@ -439,6 +439,8 @@ export interface AdminSettingsLegal {
export interface AdminSettingsWrite {
restrict_answer?: boolean;
min_tags?: number;
min_content?: number;
recommend_tags?: Tag[];
required_tag?: boolean;
reserved_tags?: Tag[];
+1
View File
@@ -35,6 +35,7 @@
padding-left: 12px;
padding-right: 12px;
}
.page-main {
max-width: 100%;
}
@@ -64,7 +64,9 @@ const ActionBar = ({
}`}
onClick={onVote}>
<Icon name="hand-thumbs-up-fill" />
{voteCount > 0 && <span className="ms-2">{voteCount}</span>}
{voteCount > 0 && (
<span className="ms-2 link-secondary">{voteCount}</span>
)}
</Button>
<Button
variant="link"
+33 -12
View File
@@ -17,7 +17,7 @@
* under the License.
*/
import { useState, useEffect } from 'react';
import { FC, useState, useEffect } from 'react';
import { Button } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
@@ -50,7 +50,14 @@ import { Form, ActionBar, Reply } from './components';
import './index.scss';
const Comment = ({ objectId, mode, commentId }) => {
interface IProps {
objectId: string;
mode?: 'answer' | 'question';
commentId?: string;
children?: React.ReactNode;
}
const Comment: FC<IProps> = ({ objectId, mode, commentId, children }) => {
const pageUsers = usePageUsers();
const [pageIndex, setPageIndex] = useState(0);
const [visibleComment, setVisibleComment] = useState(false);
@@ -374,11 +381,18 @@ const Comment = ({ objectId, mode, commentId }) => {
return (
<>
<Reactions
objectId={objectId}
showAddCommentBtn={comments.length === 0}
handleClickComment={handleAddComment}
/>
<div
className={classNames(
'd-flex flex-wrap justify-content-between align-items-center',
comments.length === 0 ? '' : 'mb-3',
)}>
<Reactions
objectId={objectId}
showAddCommentBtn={comments.length === 0}
handleClickComment={handleAddComment}
/>
{children}
</div>
<div
className={classNames(
'comments-wrap',
@@ -403,11 +417,18 @@ const Comment = ({ objectId, mode, commentId }) => {
/>
) : (
<div className="d-block">
{item.reply_user_display_name && (
<Link to="." className="small me-1 text-nowrap">
@{item.reply_user_display_name}
</Link>
)}
{item.reply_user_display_name &&
(item.reply_user_status !== 'deleted' ? (
<Link
to={`/users/${item.reply_username}`}
className="small me-1 text-nowrap">
@{item.reply_user_display_name}
</Link>
) : (
<span className="small me-1 text-nowrap">
@{item.reply_user_display_name}
</span>
))}
<div
className="fmt small text-break text-wrap"
+1 -1
View File
@@ -32,7 +32,7 @@ const Index = () => {
const cc = `${fullYear} ${siteName}`;
return (
<footer className="py-3 bg-light w-100">
<footer className="py-3 w-100">
<p className="text-center mb-0 small">
{/* Link to Terms of Service with right margin */}
<Link to="/tos" className="me-3">
@@ -123,7 +123,7 @@ const Index: FC<Props> = ({ redDot, userInfo, logOut }) => {
{ucAgent?.enabled &&
(ucAgent?.agent_info?.url ||
ucAgent?.agent_info?.control_center?.length) ? (
<Dropdown align="end">
<Dropdown align="end" data-bs-theme={isDarkTheme() ? 'dark' : 'light'}>
<Dropdown.Toggle
variant="success"
id="dropdown-uca"
+1 -1
View File
@@ -57,7 +57,7 @@ const Header: FC = () => {
* Automatically append `tag` information when creating a question
*/
const tagMatch = useMatch('/tags/:slugName');
let askUrl = '/questions/ask';
let askUrl = '/questions/add';
if (tagMatch && tagMatch.params.slugName) {
askUrl = `${askUrl}?tags=${encodeURIComponent(tagMatch.params.slugName)}`;
}
+88 -45
View File
@@ -22,7 +22,7 @@ import { Button, Dropdown } from 'react-bootstrap';
import { Link, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Modal } from '@/components';
import { Icon, Modal } from '@/components';
import { useReportModal, useToast } from '@/hooks';
import { useCaptchaPlugin } from '@/utils/pluginKit';
import { QuestionOperationReq } from '@/common/interface';
@@ -40,6 +40,8 @@ import { tryNormalLogged } from '@/utils/guard';
import { floppyNavigation } from '@/utils';
import { toastStore } from '@/stores';
import '@/components/QueryGroup/index.scss';
interface IProps {
type: 'answer' | 'question';
qid: string;
@@ -334,54 +336,95 @@ const Index: FC<IProps> = ({
) || [];
return (
<div className="d-flex align-items-center">
<Share type={type} qid={qid} aid={aid} title={title} />
{firstAction?.map((item) => {
if (item.action === 'edit') {
<>
<div className="md-show align-items-center">
<Share
type={type}
qid={qid}
aid={aid}
title={title}
className="link-secondary small"
/>
{firstAction?.map((item) => {
if (item.action === 'edit') {
return (
<Link
key={item.action}
to={editUrl}
className="link-secondary p-0 small ms-3"
onClick={(evt) => handleEdit(evt, editUrl)}
style={{ lineHeight: '23px' }}>
{item.name}
</Link>
);
}
return (
<Link
<Button
key={item.action}
to={editUrl}
className="link-secondary p-0 small ms-3"
onClick={(evt) => handleEdit(evt, editUrl)}
style={{ lineHeight: '23px' }}>
variant="link"
size="sm"
className="link-secondary p-0 ms-3"
onClick={() => handleAction(item.action)}>
{item.name}
</Link>
</Button>
);
}
return (
<Button
key={item.action}
variant="link"
size="sm"
className="link-secondary p-0 ms-3"
onClick={() => handleAction(item.action)}>
{item.name}
</Button>
);
})}
{secondAction.length > 0 && (
<Dropdown className="ms-3 d-flex">
<Dropdown.Toggle
variant="link"
size="sm"
className="link-secondary p-0 no-toggle">
{t('action', { keyPrefix: 'question_detail' })}
</Dropdown.Toggle>
<Dropdown.Menu>
{secondAction.map((item) => {
return (
<Dropdown.Item
key={item.action}
onClick={() => handleAction(item.action)}>
{item.name}
</Dropdown.Item>
);
})}
</Dropdown.Menu>
</Dropdown>
)}
</div>
})}
{secondAction.length > 0 && (
<Dropdown className="ms-3 d-flex">
<Dropdown.Toggle
variant="link"
size="sm"
title={t('action', { keyPrefix: 'question_detail' })}
className="link-secondary p-0 no-toggle">
<Icon name="three-dots" />
</Dropdown.Toggle>
<Dropdown.Menu>
{secondAction.map((item) => {
return (
<Dropdown.Item
key={item.action}
onClick={() => handleAction(item.action)}>
{item.name}
</Dropdown.Item>
);
})}
</Dropdown.Menu>
</Dropdown>
)}
</div>
<div className="md-hide">
{memberActions.length > 0 && (
<Dropdown className="d-flex">
<Dropdown.Toggle
variant="link"
size="sm"
title={t('action', { keyPrefix: 'question_detail' })}
className="link-secondary no-toggle">
<Icon name="three-dots" />
</Dropdown.Toggle>
<Dropdown.Menu>
<Share
type={type}
qid={qid}
aid={aid}
title={title}
className="inherit"
mode="mobile"
/>
{[...firstAction, ...secondAction].map((item) => {
return (
<Dropdown.Item
key={item.action}
onClick={() => handleAction(item.action)}>
{item.name}
</Dropdown.Item>
);
})}
</Dropdown.Menu>
</Dropdown>
)}
</div>
</>
);
};
@@ -0,0 +1,66 @@
/*
* 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 { TagSelector } from '@/components';
import type * as Type from '@/common/interface';
interface Props {
maxTagLength?: number;
description?: string;
fieldName: string;
onChange?: (fd: Type.FormDataType) => void;
formData: Type.FormDataType;
}
const Index: FC<Props> = ({
description,
maxTagLength,
fieldName,
onChange,
formData,
}) => {
const fieldObject = formData[fieldName];
const handleChange = (data: Type.Tag[]) => {
const state = {
...formData,
[fieldName]: {
...formData[fieldName],
value: data,
isInvalid: false,
},
};
if (typeof onChange === 'function') {
onChange(state);
}
};
return (
<TagSelector
value={fieldObject?.value || []}
onChange={handleChange}
maxTagLength={maxTagLength || 0}
isInvalid={fieldObject?.isInvalid}
formText={description}
errMsg={fieldObject?.errorMsg}
/>
);
};
export default Index;
@@ -27,6 +27,7 @@ import Textarea from './Textarea';
import Input from './Input';
import Button from './Button';
import InputGroup from './InputGroup';
import TagSelector from './TagSelector';
export {
Legend,
@@ -39,4 +40,5 @@ export {
Input,
Button,
InputGroup,
TagSelector,
};
+12 -1
View File
@@ -51,6 +51,7 @@ import {
Input,
Button as SfButton,
InputGroup,
TagSelector,
} from './components';
export * from './types';
@@ -258,6 +259,7 @@ const SchemaForm: ForwardRefRenderFunction<FormRef, FormProps> = (
description,
enum: enumValues = [],
enumNames = [],
max_length = 0,
} = properties[key];
const { 'ui:widget': widget = 'input', 'ui:options': uiOpt } =
uiSchema?.[key] || {};
@@ -413,11 +415,20 @@ const SchemaForm: ForwardRefRenderFunction<FormRef, FormProps> = (
/>
</InputGroup>
) : null}
{widget === 'tag_selector' ? (
<TagSelector
maxTagLength={max_length}
fieldName={key}
onChange={onChange}
formData={formData}
description={description}
/>
) : null}
{/* Unified handling of `Feedback` and `Text` */}
<Form.Control.Feedback type="invalid">
{fieldState?.errorMsg}
</Form.Control.Feedback>
{description ? (
{description && widget !== 'tag_selector' ? (
<Form.Text dangerouslySetInnerHTML={{ __html: description }} />
) : null}
</Form.Group>

Some files were not shown because too many files have changed in this diff Show More