diff --git a/Makefile b/Makefile index 0319a0198..6e9679a89 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index a05b68f81..8d201500e 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ To learn more about the project, visit [answer.apache.org](https://answer.apache ### Running with docker ```bash -docker run -d -p 9080:80 -v answer-data:/data --name answer apache/answer:1.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). diff --git a/cmd/command.go b/cmd/command.go index 7d779893f..ca01aa80b 100644 --- a/cmd/command.go +++ b/cmd/command.go @@ -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. diff --git a/cmd/main.go b/cmd/main.go index 35256c073..f166d2309 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -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) } diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 947b6605d..de2be196a 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -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) diff --git a/docs/docs.go b/docs/docs.go index 263e6775e..ec062df29 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -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 diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 9cdf248e1..a01074d19 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -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: diff --git a/go.mod b/go.mod index 6578d5cef..b9fa70ebd 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index afe9b1ea9..725870fcc 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/i18n/af_ZA.yaml b/i18n/af_ZA.yaml index f421ba9af..0121bde1a 100644 --- a/i18n/af_ZA.yaml +++ b/i18n/af_ZA.yaml @@ -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 diff --git a/i18n/ar_SA.yaml b/i18n/ar_SA.yaml index 094a05523..25c086f11 100644 --- a/i18n/ar_SA.yaml +++ b/i18n/ar_SA.yaml @@ -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 diff --git a/i18n/az_AZ.yaml b/i18n/az_AZ.yaml index c7bfcaa8f..4249c5f03 100644 --- a/i18n/az_AZ.yaml +++ b/i18n/az_AZ.yaml @@ -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 diff --git a/i18n/bal_BA.yaml b/i18n/bal_BA.yaml index c7bfcaa8f..4249c5f03 100644 --- a/i18n/bal_BA.yaml +++ b/i18n/bal_BA.yaml @@ -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 diff --git a/i18n/ban_ID.yaml b/i18n/ban_ID.yaml index c7bfcaa8f..4249c5f03 100644 --- a/i18n/ban_ID.yaml +++ b/i18n/ban_ID.yaml @@ -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 diff --git a/i18n/bn_BD.yaml b/i18n/bn_BD.yaml index c7bfcaa8f..4249c5f03 100644 --- a/i18n/bn_BD.yaml +++ b/i18n/bn_BD.yaml @@ -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 diff --git a/i18n/bs_BA.yaml b/i18n/bs_BA.yaml index c7bfcaa8f..4249c5f03 100644 --- a/i18n/bs_BA.yaml +++ b/i18n/bs_BA.yaml @@ -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 diff --git a/i18n/ca_ES.yaml b/i18n/ca_ES.yaml index 094a05523..25c086f11 100644 --- a/i18n/ca_ES.yaml +++ b/i18n/ca_ES.yaml @@ -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 diff --git a/i18n/cs_CZ.yaml b/i18n/cs_CZ.yaml index 2b691ec58..2670ea78f 100644 --- a/i18n/cs_CZ.yaml +++ b/i18n/cs_CZ.yaml @@ -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 diff --git a/i18n/cy_GB.yaml b/i18n/cy_GB.yaml index 3df4a38ac..8c2efe121 100644 --- a/i18n/cy_GB.yaml +++ b/i18n/cy_GB.yaml @@ -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 diff --git a/i18n/da_DK.yaml b/i18n/da_DK.yaml index 2d1b7a2ea..9e5fa198c 100644 --- a/i18n/da_DK.yaml +++ b/i18n/da_DK.yaml @@ -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 diff --git a/i18n/de_DE.yaml b/i18n/de_DE.yaml index b68e874de..fca37d87a 100644 --- a/i18n/de_DE.yaml +++ b/i18n/de_DE.yaml @@ -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 diff --git a/i18n/el_GR.yaml b/i18n/el_GR.yaml index 094a05523..25c086f11 100644 --- a/i18n/el_GR.yaml +++ b/i18n/el_GR.yaml @@ -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 diff --git a/i18n/en_US.yaml b/i18n/en_US.yaml index da34fcb16..fcdf48a70 100644 --- a/i18n/en_US.yaml +++ b/i18n/en_US.yaml @@ -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 diff --git a/i18n/es_ES.yaml b/i18n/es_ES.yaml index 655846db6..885a52fd2 100644 --- a/i18n/es_ES.yaml +++ b/i18n/es_ES.yaml @@ -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 diff --git a/i18n/fa_IR.yaml b/i18n/fa_IR.yaml index c6c48472f..e82d243b8 100644 --- a/i18n/fa_IR.yaml +++ b/i18n/fa_IR.yaml @@ -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 diff --git a/i18n/fi_FI.yaml b/i18n/fi_FI.yaml index 094a05523..25c086f11 100644 --- a/i18n/fi_FI.yaml +++ b/i18n/fi_FI.yaml @@ -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 diff --git a/i18n/fr_FR.yaml b/i18n/fr_FR.yaml index 0be195741..904b9a7eb 100644 --- a/i18n/fr_FR.yaml +++ b/i18n/fr_FR.yaml @@ -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 diff --git a/i18n/he_IL.yaml b/i18n/he_IL.yaml index 094a05523..25c086f11 100644 --- a/i18n/he_IL.yaml +++ b/i18n/he_IL.yaml @@ -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 diff --git a/i18n/hi_IN.yaml b/i18n/hi_IN.yaml index c26f39921..b115df7e6 100644 --- a/i18n/hi_IN.yaml +++ b/i18n/hi_IN.yaml @@ -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 diff --git a/i18n/hu_HU.yaml b/i18n/hu_HU.yaml index 094a05523..25c086f11 100644 --- a/i18n/hu_HU.yaml +++ b/i18n/hu_HU.yaml @@ -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 diff --git a/i18n/hy_AM.yaml b/i18n/hy_AM.yaml index c7bfcaa8f..4249c5f03 100644 --- a/i18n/hy_AM.yaml +++ b/i18n/hy_AM.yaml @@ -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 diff --git a/i18n/id_ID.yaml b/i18n/id_ID.yaml index 6ff10273b..5f91a2e0e 100644 --- a/i18n/id_ID.yaml +++ b/i18n/id_ID.yaml @@ -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 diff --git a/i18n/it_IT.yaml b/i18n/it_IT.yaml index 8b8fd71c5..075bf8e72 100644 --- a/i18n/it_IT.yaml +++ b/i18n/it_IT.yaml @@ -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 diff --git a/i18n/ja_JP.yaml b/i18n/ja_JP.yaml index eb8bf19f6..67bde2abf 100644 --- a/i18n/ja_JP.yaml +++ b/i18n/ja_JP.yaml @@ -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: diff --git a/i18n/ko_KR.yaml b/i18n/ko_KR.yaml index 97c254172..1fa711fd1 100644 --- a/i18n/ko_KR.yaml +++ b/i18n/ko_KR.yaml @@ -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 diff --git a/i18n/ml_IN.yaml b/i18n/ml_IN.yaml index c42260585..f61ad62ba 100644 --- a/i18n/ml_IN.yaml +++ b/i18n/ml_IN.yaml @@ -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 diff --git a/i18n/nl_NL.yaml b/i18n/nl_NL.yaml index 094a05523..25c086f11 100644 --- a/i18n/nl_NL.yaml +++ b/i18n/nl_NL.yaml @@ -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 diff --git a/i18n/no_NO.yaml b/i18n/no_NO.yaml index a0cd01396..abe12baf4 100644 --- a/i18n/no_NO.yaml +++ b/i18n/no_NO.yaml @@ -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 diff --git a/i18n/pl_PL.yaml b/i18n/pl_PL.yaml index 21562f911..275a89db4 100644 --- a/i18n/pl_PL.yaml +++ b/i18n/pl_PL.yaml @@ -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 diff --git a/i18n/pt_BR.yaml b/i18n/pt_BR.yaml index b051fddb2..42d56aaf7 100644 --- a/i18n/pt_BR.yaml +++ b/i18n/pt_BR.yaml @@ -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 diff --git a/i18n/pt_PT.yaml b/i18n/pt_PT.yaml index ec9609c1c..c11747a04 100644 --- a/i18n/pt_PT.yaml +++ b/i18n/pt_PT.yaml @@ -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 diff --git a/i18n/ro_RO.yaml b/i18n/ro_RO.yaml index 85a9c6f6d..47f8c3aa9 100644 --- a/i18n/ro_RO.yaml +++ b/i18n/ro_RO.yaml @@ -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 diff --git a/i18n/ru_RU.yaml b/i18n/ru_RU.yaml index c71a27e37..d1763320a 100644 --- a/i18n/ru_RU.yaml +++ b/i18n/ru_RU.yaml @@ -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 diff --git a/i18n/sq_AL.yaml b/i18n/sq_AL.yaml index c7bfcaa8f..4249c5f03 100644 --- a/i18n/sq_AL.yaml +++ b/i18n/sq_AL.yaml @@ -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 diff --git a/i18n/sr_SP.yaml b/i18n/sr_SP.yaml index 094a05523..25c086f11 100644 --- a/i18n/sr_SP.yaml +++ b/i18n/sr_SP.yaml @@ -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 diff --git a/i18n/sv_SE.yaml b/i18n/sv_SE.yaml index 6c0627613..3a7794166 100644 --- a/i18n/sv_SE.yaml +++ b/i18n/sv_SE.yaml @@ -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 diff --git a/i18n/te_IN.yaml b/i18n/te_IN.yaml index 4b2629171..c61288c15 100644 --- a/i18n/te_IN.yaml +++ b/i18n/te_IN.yaml @@ -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 diff --git a/i18n/vi_VN.yaml b/i18n/vi_VN.yaml index 6bebd382f..1f6c3298d 100644 --- a/i18n/vi_VN.yaml +++ b/i18n/vi_VN.yaml @@ -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 diff --git a/i18n/zh_CN.yaml b/i18n/zh_CN.yaml index b62fd4700..2f0830145 100644 --- a/i18n/zh_CN.yaml +++ b/i18n/zh_CN.yaml @@ -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: 密码 diff --git a/i18n/zh_TW.yaml b/i18n/zh_TW.yaml index 089b64a70..c3378ea42 100644 --- a/i18n/zh_TW.yaml +++ b/i18n/zh_TW.yaml @@ -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: 頭像 diff --git a/internal/base/conf/conf.go b/internal/base/conf/conf.go index 4b71b206f..db83862b0 100644 --- a/internal/base/conf/conf.go +++ b/internal/base/conf/conf.go @@ -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) diff --git a/internal/base/path/path.go b/internal/base/path/path.go new file mode 100644 index 000000000..bcd3665be --- /dev/null +++ b/internal/base/path/path.go @@ -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) +} diff --git a/internal/base/reason/reason.go b/internal/base/reason/reason.go index 953c316ed..42e29f4c5 100644 --- a/internal/base/reason/reason.go +++ b/internal/base/reason/reason.go @@ -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" diff --git a/internal/cli/build.go b/internal/cli/build.go index 43a7aa9fe..a5a4d938e 100644 --- a/internal/cli/build.go +++ b/internal/cli/build.go @@ -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) diff --git a/internal/cli/install.go b/internal/cli/install.go index 69e673d57..650555801 100644 --- a/internal/cli/install.go +++ b/internal/cli/install.go @@ -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 diff --git a/internal/cli/install_check.go b/internal/cli/install_check.go index 75ed5d3b2..9326e069f 100644 --- a/internal/cli/install_check.go +++ b/internal/cli/install_check.go @@ -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 diff --git a/internal/cli/reset_password.go b/internal/cli/reset_password.go new file mode 100644 index 000000000..2a7d1af4c --- /dev/null +++ b/internal/cli/reset_password.go @@ -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" +} diff --git a/internal/controller/comment_controller.go b/internal/controller/comment_controller.go index 288e8aee4..5b807d935 100644 --- a/internal/controller/comment_controller.go +++ b/internal/controller/comment_controller.go @@ -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) diff --git a/internal/controller/controller.go b/internal/controller/controller.go index cbf80f7fa..b5c3f91c4 100644 --- a/internal/controller/controller.go +++ b/internal/controller/controller.go @@ -53,4 +53,5 @@ var ProviderSetController = wire.NewSet( NewEmbedController, NewBadgeController, NewRenderController, + NewSidebarController, ) diff --git a/internal/controller/plugin_sidebar_controller.go b/internal/controller/plugin_sidebar_controller.go new file mode 100644 index 000000000..871861798 --- /dev/null +++ b/internal/controller/plugin_sidebar_controller.go @@ -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) +} diff --git a/internal/entity/comment_entity.go b/internal/entity/comment_entity.go index c96f6b3c1..248016f88 100644 --- a/internal/entity/comment_entity.go +++ b/internal/entity/comment_entity.go @@ -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) +} diff --git a/internal/install/install_controller.go b/internal/install/install_controller.go index d43bbb45f..08b4c9780 100644 --- a/internal/install/install_controller.go +++ b/internal/install/install_controller.go @@ -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) diff --git a/internal/install/install_main.go b/internal/install/install_main.go index 0d65f33f0..41ccdcf40 100644 --- a/internal/install/install_main.go +++ b/internal/install/install_main.go @@ -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) } diff --git a/internal/migrations/init.go b/internal/migrations/init.go index fde27cdde..392ecb2c6 100644 --- a/internal/migrations/init.go +++ b/internal/migrations/init.go @@ -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{}, diff --git a/internal/migrations/migrations.go b/internal/migrations/migrations.go index 4b5335b06..9caa28ed1 100644 --- a/internal/migrations/migrations.go +++ b/internal/migrations/migrations.go @@ -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 { diff --git a/internal/migrations/v28.go b/internal/migrations/v28.go new file mode 100644 index 000000000..0fa8ef884 --- /dev/null +++ b/internal/migrations/v28.go @@ -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 +} diff --git a/internal/repo/comment/comment_repo.go b/internal/repo/comment/comment_repo.go index f1794824c..10b7b3048 100644 --- a/internal/repo/comment/comment_repo.go +++ b/internal/repo/comment/comment_repo.go @@ -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) { diff --git a/internal/repo/repo_test/user_backyard_repo_test.go b/internal/repo/repo_test/user_backyard_repo_test.go index e1db0d601..073a600cf 100644 --- a/internal/repo/repo_test/user_backyard_repo_test.go +++ b/internal/repo/repo_test/user_backyard_repo_test.go @@ -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") diff --git a/internal/router/plugin_api_router.go b/internal/router/plugin_api_router.go index ce5062ba5..3b74b3ddd 100644 --- a/internal/router/plugin_api_router.go +++ b/internal/router/plugin_api_router.go @@ -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) { diff --git a/internal/schema/comment_schema.go b/internal/schema/comment_schema.go index 104e3f33f..015da071d 100644 --- a/internal/schema/comment_schema.go +++ b/internal/schema/comment_schema.go @@ -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) { diff --git a/internal/schema/question_schema.go b/internal/schema/question_schema.go index b5e4baa32..84b97b830 100644 --- a/internal/schema/question_schema.go +++ b/internal/schema/question_schema.go @@ -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 } diff --git a/internal/schema/siteinfo_schema.go b/internal/schema/siteinfo_schema.go index 19f01ffdb..76f66cb07 100644 --- a/internal/schema/siteinfo_schema.go +++ b/internal/schema/siteinfo_schema.go @@ -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"` diff --git a/internal/service/comment/comment_service.go b/internal/service/comment/comment_service.go index ba9bbe1d3..3a5b3e6c9 100644 --- a/internal/service/comment/comment_service.go +++ b/internal/service/comment/comment_service.go @@ -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 diff --git a/internal/service/comment_common/comment_service.go b/internal/service/comment_common/comment_service.go index 0b6423a42..623d00819 100644 --- a/internal/service/comment_common/comment_service.go +++ b/internal/service/comment_common/comment_service.go @@ -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 diff --git a/internal/service/content/question_service.go b/internal/service/content/question_service.go index ec9ce48e9..1d1d1af39 100644 --- a/internal/service/content/question_service.go +++ b/internal/service/content/question_service.go @@ -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) { diff --git a/internal/service/content/revision_service.go b/internal/service/content/revision_service.go index effc012ba..1aed4cf82 100644 --- a/internal/service/content/revision_service.go +++ b/internal/service/content/revision_service.go @@ -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 } diff --git a/internal/service/mock/siteinfo_repo_mock.go b/internal/service/mock/siteinfo_repo_mock.go index a98ceb68c..0a1b31e84 100644 --- a/internal/service/mock/siteinfo_repo_mock.go +++ b/internal/service/mock/siteinfo_repo_mock.go @@ -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 // diff --git a/internal/service/notification/new_question_notification.go b/internal/service/notification/new_question_notification.go index 8911967a2..8a12d0b8c 100644 --- a/internal/service/notification/new_question_notification.go +++ b/internal/service/notification/new_question_notification.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 } diff --git a/internal/service/question_common/question.go b/internal/service/question_common/question.go index 923920485..feb5626ed 100644 --- a/internal/service/question_common/question.go +++ b/internal/service/question_common/question.go @@ -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 +} diff --git a/internal/service/review/review_service.go b/internal/service/review/review_service.go index 7f934f236..40089fb2d 100644 --- a/internal/service/review/review_service.go +++ b/internal/service/review/review_service.go @@ -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) diff --git a/internal/service/tag_common/tag_common.go b/internal/service/tag_common/tag_common.go index 318eac20a..fed5dfbfb 100644 --- a/internal/service/tag_common/tag_common.go +++ b/internal/service/tag_common/tag_common.go @@ -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) { diff --git a/pkg/checker/reserved_username.go b/pkg/checker/reserved_username.go index 6dfd34710..0971b5274 100644 --- a/pkg/checker/reserved_username.go +++ b/pkg/checker/reserved_username.go @@ -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) diff --git a/pkg/converter/user.go b/pkg/converter/user.go index b5c5042dc..b55b74492 100644 --- a/pkg/converter/user.go +++ b/pkg/converter/user.go @@ -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 +} diff --git a/plugin/config.go b/plugin/config.go index b03e794ee..849ad6171 100644 --- a/plugin/config.go +++ b/plugin/config.go @@ -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 diff --git a/plugin/plugin.go b/plugin/plugin.go index a9e173100..266848353 100644 --- a/plugin/plugin.go +++ b/plugin/plugin.go @@ -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 { diff --git a/plugin/sidebar.go b/plugin/sidebar.go new file mode 100644 index 000000000..690a70d93 --- /dev/null +++ b/plugin/sidebar.go @@ -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) +) diff --git a/script/plugin_list b/script/plugin_list index 8a7676276..a0d8c5791 100644 --- a/script/plugin_list +++ b/script/plugin_list @@ -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 \ No newline at end of file +github.com/apache/answer-plugins/captcha-basic@latest +github.com/apache/answer-plugins/quick-links@latest \ No newline at end of file diff --git a/ui/.env.production b/ui/.env.production index c371a163b..f86b9ccdd 100644 --- a/ui/.env.production +++ b/ui/.env.production @@ -3,3 +3,4 @@ ESLINT_NO_DEV_ERRORS=true PUBLIC_URL=/ REACT_APP_API_URL=/ REACT_APP_BASE_URL= +REACT_APP_API_BASE_URL= diff --git a/ui/src/common/color.scss b/ui/src/common/color.scss index 1ded9c42c..36c9a2c14 100644 --- a/ui/src/common/color.scss +++ b/ui/src/common/color.scss @@ -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); diff --git a/ui/src/common/interface.ts b/ui/src/common/interface.ts index 114b0e4ce..f2901908f 100644 --- a/ui/src/common/interface.ts +++ b/ui/src/common/interface.ts @@ -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[]; diff --git a/ui/src/common/sideNavLayout.scss b/ui/src/common/sideNavLayout.scss index db2fd0163..0308c167d 100644 --- a/ui/src/common/sideNavLayout.scss +++ b/ui/src/common/sideNavLayout.scss @@ -35,6 +35,7 @@ padding-left: 12px; padding-right: 12px; } + .page-main { max-width: 100%; } diff --git a/ui/src/components/Comment/components/ActionBar/index.tsx b/ui/src/components/Comment/components/ActionBar/index.tsx index c55bf40e2..692b0d208 100644 --- a/ui/src/components/Comment/components/ActionBar/index.tsx +++ b/ui/src/components/Comment/components/ActionBar/index.tsx @@ -64,7 +64,9 @@ const ActionBar = ({ }`} onClick={onVote}> - {voteCount > 0 && {voteCount}} + {voteCount > 0 && ( + {voteCount} + )} { +interface IProps { + objectId: string; + mode?: 'answer' | 'question'; + commentId?: string; + children?: React.ReactNode; +} + +const Comment: FC = ({ 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 ( <> - + + + {children} + { /> ) : ( - {item.reply_user_display_name && ( - - @{item.reply_user_display_name} - - )} + {item.reply_user_display_name && + (item.reply_user_status !== 'deleted' ? ( + + @{item.reply_user_display_name} + + ) : ( + + @{item.reply_user_display_name} + + ))} { const cc = `${fullYear} ${siteName}`; return ( - ); diff --git a/ui/src/components/UserCard/index.tsx b/ui/src/components/UserCard/index.tsx index 44bd19e0f..cc7e883c6 100644 --- a/ui/src/components/UserCard/index.tsx +++ b/ui/src/components/UserCard/index.tsx @@ -28,10 +28,12 @@ import { formatCount } from '@/utils'; interface Props { data: any; time: number; - preFix: string; + preFix?: string; isLogged: boolean; timelinePath: string; className?: string; + updateTime?: number; + updateTimePrefix?: string; } const Index: FC = ({ @@ -41,6 +43,8 @@ const Index: FC = ({ isLogged, timelinePath, className = '', + updateTime = 0, + updateTimePrefix = '', }) => { return ( @@ -81,7 +85,7 @@ const Index: FC = ({ /> > )} - + {data?.status !== 'deleted' ? ( = ({ preFix={preFix} className="link-secondary" /> + {updateTime > 0 && ( + <> + • + + > + )} ) : ( - + <> + + {updateTime > 0 && ( + <> + • + + > + )} + > ))} diff --git a/ui/src/hooks/useUserModal/index.tsx b/ui/src/hooks/useUserModal/index.tsx index 653380071..bdf1f7892 100644 --- a/ui/src/hooks/useUserModal/index.tsx +++ b/ui/src/hooks/useUserModal/index.tsx @@ -58,7 +58,7 @@ const useAddUserModal = (props: IProps = {}) => { 'ui:options': { rows: 7, placeholder: t('form.fields.users.placeholder'), - className: 'small', + className: 'small font-monospace', }, }, }; diff --git a/ui/src/index.scss b/ui/src/index.scss index fb1e06a79..92f143d5f 100644 --- a/ui/src/index.scss +++ b/ui/src/index.scss @@ -52,6 +52,10 @@ img[src=''] { visibility: hidden !important; } +.page-main { + overflow-x: auto; +} + .btn-link { text-decoration: none; } @@ -119,10 +123,12 @@ img[src=''] { display: flex; flex-direction: column; } -#root > footer { +#root footer { margin-top: auto !important; padding-bottom: constant(safe-area-inset-bottom); padding-bottom: env(safe-area-inset-bottom); + padding-left: 12px !important; + padding-right: 12px !important; } .bg-f5 { @@ -278,14 +284,21 @@ img[src=''] { color: var(--an-212529); padding: 2px 4px; border-radius: 0.25rem; + overflow-wrap: normal; + white-space: inherit; } } pre { - background-color: var(--bs-gray-100); + background-color: var(--an-pre); border-radius: 0.25rem; padding: 1rem; max-height: 38rem; white-space: pre-wrap; + overflow: auto; + code { + overflow-wrap: normal; + white-space: inherit; + } } blockquote { border-left: 0.25rem solid #ced4da; @@ -422,3 +435,7 @@ img[src=''] { display: none; line-height: 1; } + +.inherit { + color: inherit !important; +} diff --git a/ui/src/pages/Admin/Write/index.tsx b/ui/src/pages/Admin/Write/index.tsx index f7832e7fd..86d44d6d9 100644 --- a/ui/src/pages/Admin/Write/index.tsx +++ b/ui/src/pages/Admin/Write/index.tsx @@ -37,6 +37,16 @@ const initFormData = { errorMsg: '', isInvalid: false, }, + min_content: { + value: 0, + errorMsg: '', + isInvalid: false, + }, + min_tags: { + value: 0, + errorMsg: '', + isInvalid: false, + }, recommend_tags: { value: [] as Type.Tag[], errorMsg: '', @@ -133,9 +143,11 @@ const Index: FC = () => { } const reqParams: Type.AdminSettingsWrite = { recommend_tags: formData.recommend_tags.value, + min_tags: Number(formData.min_tags.value), reserved_tags: formData.reserved_tags.value, required_tag: formData.required_tag.value, restrict_answer: formData.restrict_answer.value, + min_content: Number(formData.min_content.value), max_image_size: Number(formData.max_image_size.value), max_attachment_size: Number(formData.max_attachment_size.value), max_image_megapixel: Number(formData.max_image_megapixel.value), @@ -177,6 +189,8 @@ const Index: FC = () => { if (Array.isArray(res.recommend_tags)) { formData.recommend_tags.value = res.recommend_tags; } + formData.min_content.value = res.min_content; + formData.min_tags.value = res.min_tags; formData.required_tag.value = res.required_tag; formData.restrict_answer.value = res.restrict_answer; if (Array.isArray(res.reserved_tags)) { @@ -197,10 +211,6 @@ const Index: FC = () => { initData(); }, []); - // const handleOnChange = (data) => { - // setFormData(data); - // }; - return ( <> {t('page_title')} @@ -247,7 +257,27 @@ const Index: FC = () => { errMsg={formData.recommend_tags.errorMsg} /> - + + {t('min_tags.label')} + { + handleValueChange({ + min_tags: { + value: evt.target.value, + errorMsg: '', + isInvalid: false, + }, + }); + }} + /> + {t('min_tags.text')} + + {formData.min_tags.errorMsg} + + {t('required_tag.title')} { {formData.required_tag.errorMsg} - + + {t('min_content.label')} + { + handleValueChange({ + min_content: { + value: evt.target.value, + errorMsg: '', + isInvalid: false, + }, + }); + }} + /> + {t('min_content.text')} + + {formData.min_content.errorMsg} + + {t('restrict_answer.title')} { handleTagsChange(resp); }); }; + const writeInfo = writeSettingStore((state) => state.write); const isEdit = qid !== undefined; @@ -423,6 +425,21 @@ const Ask = () => { usePageTags({ title: pageTitle, }); + + const handleContentHint = () => { + if ( + !writeInfo || + writeInfo.min_content === undefined || + !writeInfo.min_content + ) { + return t(`form.fields.body.hint.optional_body`); + } + + return t(`form.fields.body.hint.minimum_characters`, { + min_content_length: writeInfo.min_content, + }); + }; + return ( {isEdit ? t('edit_title') : t('title')} @@ -451,7 +468,6 @@ const Ask = () => { )} - {t('form.fields.title.label')} { {bool && } - {t('form.fields.body.label')} { }} ref={editorRef} /> + {handleContentHint()} {formData.content.errorMsg} - {t('form.fields.tags.label')} { errMsg={formData.tags.errorMsg} /> - {!isEdit && ( <> { )} > )} - {isEdit && ( {t('form.fields.edit_summary.label')} diff --git a/ui/src/pages/Questions/Detail/components/Answer/index.tsx b/ui/src/pages/Questions/Detail/components/Answer/index.tsx index 825f698da..957a60df0 100644 --- a/ui/src/pages/Questions/Detail/components/Answer/index.tsx +++ b/ui/src/pages/Questions/Detail/components/Answer/index.tsx @@ -20,7 +20,7 @@ import { memo, FC, useEffect, useRef } from 'react'; import { Button, Alert, Badge } from 'react-bootstrap'; import { useTranslation } from 'react-i18next'; -import { Link, useSearchParams } from 'react-router-dom'; +import { useSearchParams } from 'react-router-dom'; import { Actions, @@ -28,7 +28,6 @@ import { UserCard, Icon, Comment, - FormatTime, htmlRender, ImgViewer, } from '@/components'; @@ -110,22 +109,34 @@ const Index: FC = ({ {t('post_pending', { keyPrefix: 'messages' })} )} - - {data?.accepted === 2 && ( - - - - Best answer - + + + - )} + + {data?.accepted === 2 && ( + + + + Best answer + + + )} + - + = ({ )} - - - - - - {data.update_user_info && - data.update_user_info?.username !== data.user_info?.username ? ( - - ) : isLogged ? ( - - - - ) : ( - - )} - - - - - - + commentId={String(searchParams.get('commentId'))}> + + ); }; diff --git a/ui/src/pages/Questions/Detail/components/LinkedQuestions/index.tsx b/ui/src/pages/Questions/Detail/components/LinkedQuestions/index.tsx index 22952af08..42a0c94ee 100644 --- a/ui/src/pages/Questions/Detail/components/LinkedQuestions/index.tsx +++ b/ui/src/pages/Questions/Detail/components/LinkedQuestions/index.tsx @@ -53,7 +53,7 @@ const Index: FC = ({ id }) => { {t('title')} - + {t('more', { keyPrefix: 'btns' })} diff --git a/ui/src/pages/Questions/Detail/components/Question/index.tsx b/ui/src/pages/Questions/Detail/components/Question/index.tsx index 86c1ba83a..149cd8500 100644 --- a/ui/src/pages/Questions/Detail/components/Question/index.tsx +++ b/ui/src/pages/Questions/Detail/components/Question/index.tsx @@ -26,11 +26,10 @@ import { Tag, Actions, Operate, - UserCard, + BaseUserCard, Comment, FormatTime, htmlRender, - Icon, ImgViewer, } from '@/components'; import { useRenderHtmlPlugin } from '@/utils/pluginKit'; @@ -41,8 +40,8 @@ import { pathFactory } from '@/router/pathFactory'; interface Props { data: any; hasAnswer: boolean; - isLogged: boolean; initPage: (type: string) => void; + isLogged: boolean; } const Index: FC = ({ data, initPage, hasAnswer, isLogged }) => { @@ -91,7 +90,7 @@ const Index: FC = ({ data, initPage, hasAnswer, isLogged }) => { return ( - + = ({ data, initPage, hasAnswer, isLogged }) => { - - {data?.pin === 2 && ( - - + + + {isLogged ? ( + <> + + + + + + + + > + ) : ( + <> + - {t('pinned', { keyPrefix: 'btns' })} - + + + > )} - - {data?.view_count > 0 && ( {t('Views')} {formatCount(data.view_count)} @@ -142,19 +156,21 @@ const Index: FC = ({ data, initPage, hasAnswer, isLogged }) => { - - {data?.tags?.map((item: any) => { - return ; - })} - + + + {data?.tags?.map((item: any) => { + return ; + })} + + = ({ data, initPage, hasAnswer, isLogged }) => { }} /> - - + + = ({ data, initPage, hasAnswer, isLogged }) => { isAccepted={Boolean(data?.accepted_answer_id)} callback={initPage} /> - - - {data.update_user_info && - data.update_user_info?.username !== data.user_info?.username ? ( - - ) : isLogged ? ( - - - - ) : ( - - )} - - - - + - - ); }; diff --git a/ui/src/pages/Questions/Detail/components/Reactions/index.tsx b/ui/src/pages/Questions/Detail/components/Reactions/index.tsx index 9c747cf83..9386b94c8 100644 --- a/ui/src/pages/Questions/Detail/components/Reactions/index.tsx +++ b/ui/src/pages/Questions/Detail/components/Reactions/index.tsx @@ -21,8 +21,6 @@ import { FC, memo, useEffect, useState } from 'react'; import { Button, OverlayTrigger, Popover, Tooltip } from 'react-bootstrap'; import { useTranslation } from 'react-i18next'; -import classNames from 'classnames'; - import { Icon } from '@/components'; import { queryReactions, updateReaction } from '@/services'; import { tryNormalLogged } from '@/utils/guard'; @@ -111,10 +109,7 @@ const Index: FC = ({ ); return ( - + {showAddCommentBtn && ( = ({ id }) => { return null; } + if (!isLoading && data?.count === 0) { + return null; + } + return ( {t('title')} diff --git a/ui/src/pages/Questions/Linked/index.tsx b/ui/src/pages/Questions/Linked/index.tsx index 9a08af521..bf8465faf 100644 --- a/ui/src/pages/Questions/Linked/index.tsx +++ b/ui/src/pages/Questions/Linked/index.tsx @@ -73,6 +73,11 @@ const LinkedQuestions: FC = () => { title: t('title'), }); + console.log( + 'listData', + QUESTION_ORDER_KEYS.filter((v) => v !== 'unanswered').slice(0, 5), + ); + return ( @@ -85,7 +90,9 @@ const LinkedQuestions: FC = () => { source="linked" data={listData} order={curOrder} - orderList={QUESTION_ORDER_KEYS.slice(0, 5)} + orderList={QUESTION_ORDER_KEYS.filter( + (v) => v !== 'unanswered', + ).slice(0, 5)} isLoading={listLoading} /> diff --git a/ui/src/pages/Questions/index.tsx b/ui/src/pages/Questions/index.tsx index 2f08cbf01..7239feb66 100644 --- a/ui/src/pages/Questions/index.tsx +++ b/ui/src/pages/Questions/index.tsx @@ -69,7 +69,7 @@ const Questions: FC = () => { usePageTags({ title: pageTitle, subtitle: slogan }); return ( - + = ({ data }) => { - {data.object_type === 'question' ? 'Q' : 'A'} + {t(data.object_type, { keyPrefix: 'btns' })} diff --git a/ui/src/pages/SideNavLayout/index.tsx b/ui/src/pages/SideNavLayout/index.tsx index 90f3f3b1d..0db8593e9 100644 --- a/ui/src/pages/SideNavLayout/index.tsx +++ b/ui/src/pages/SideNavLayout/index.tsx @@ -28,11 +28,11 @@ const Index: FC = () => { return ( - + diff --git a/ui/src/pages/Tags/Detail/index.tsx b/ui/src/pages/Tags/Detail/index.tsx index 8bfd5ee8c..62ff5dd45 100644 --- a/ui/src/pages/Tags/Detail/index.tsx +++ b/ui/src/pages/Tags/Detail/index.tsx @@ -38,7 +38,7 @@ import { } from '@/services'; import QuestionList, { QUESTION_ORDER_KEYS } from '@/components/QuestionList'; import HotQuestions from '@/components/HotQuestions'; -import { guard } from '@/utils'; +import { guard, pageTitleType } from '@/utils'; import { pathFactory } from '@/router/pathFactory'; const Index: FC = () => { @@ -100,7 +100,7 @@ const Index: FC = () => { }, [tagResp, followResp]); let pageTitle = ''; if (tagInfo?.display_name) { - pageTitle = `'${tagInfo.display_name}' ${t('questions', { + pageTitle = `'${tagInfo.display_name}' ${t(pageTitleType(), { keyPrefix: 'page_title', })}`; } diff --git a/ui/src/pages/Users/Notifications/components/index.ts b/ui/src/pages/Users/Notifications/components/index.ts new file mode 100644 index 000000000..f8183485d --- /dev/null +++ b/ui/src/pages/Users/Notifications/components/index.ts @@ -0,0 +1,4 @@ +import Inbox from './Inbox'; +import Achievements from './Achievements'; + +export { Inbox, Achievements }; diff --git a/ui/src/pages/Users/Notifications/index.tsx b/ui/src/pages/Users/Notifications/index.tsx index 28ca9dd17..4b7fc9ecc 100644 --- a/ui/src/pages/Users/Notifications/index.tsx +++ b/ui/src/pages/Users/Notifications/index.tsx @@ -33,8 +33,7 @@ import { } from '@/services'; import { floppyNavigation } from '@/utils'; -import Inbox from './components/Inbox'; -import Achievements from './components/Achievements'; +import { Inbox, Achievements } from './components'; import './index.scss'; const PAGE_SIZE = 10; diff --git a/ui/src/plugins/index.ts b/ui/src/plugins/index.ts index 7cbc4cd97..3100dcf14 100644 --- a/ui/src/plugins/index.ts +++ b/ui/src/plugins/index.ts @@ -17,6 +17,4 @@ * under the License. */ -export default null; - -// export { default as Demo } from './Demo'; +export default null; \ No newline at end of file diff --git a/ui/src/router/routes.ts b/ui/src/router/routes.ts index 59907b418..3ca8431fc 100644 --- a/ui/src/router/routes.ts +++ b/ui/src/router/routes.ts @@ -75,6 +75,13 @@ const routes: RouteNode[] = [ { path: 'questions/ask', page: 'pages/Questions/Ask', + guard: () => { + return guard.askRedirect(); + }, + }, + { + path: 'questions/add', + page: 'pages/Questions/Ask', guard: () => { return guard.activated(); }, @@ -110,15 +117,19 @@ const routes: RouteNode[] = [ page: 'pages/Questions/Detail', }, { - path: 'questions/linked/:qid', + path: '/questions/linked/:qid', + page: 'pages/Questions/Linked', + guard: () => { + return guard.linkedRedirect(); + }, + }, + { + path: '/linked/:qid', page: 'pages/Questions/Linked', }, { path: '/search', page: 'pages/Search', - guard: () => { - return guard.googleSnapshotRedirect(); - }, }, // tags { @@ -136,6 +147,10 @@ const routes: RouteNode[] = [ path: 'tags/:tagName', page: 'pages/Tags/Detail', }, + { + path: 'tags/:tagName/questions', + page: 'pages/Tags/Detail', + }, { path: 'tags/:tagName/info', page: 'pages/Tags/Info', diff --git a/ui/src/stores/writeSetting.ts b/ui/src/stores/writeSetting.ts index ec0ae1ebb..9f6542d27 100644 --- a/ui/src/stores/writeSetting.ts +++ b/ui/src/stores/writeSetting.ts @@ -29,6 +29,8 @@ interface IProps { const Index = create((set) => ({ write: { restrict_answer: true, + min_tags: 1, + min_content: 6, recommend_tags: [], required_tag: false, reserved_tags: [], diff --git a/ui/src/utils/common.ts b/ui/src/utils/common.ts index 996da7f6f..4cc78ebe4 100644 --- a/ui/src/utils/common.ts +++ b/ui/src/utils/common.ts @@ -292,6 +292,17 @@ function isDarkTheme() { return htmlTag.getAttribute('data-bs-theme') === 'dark'; } +function pageTitleType() { + const { pathname } = window.location; + if (pathname.endsWith('/articles')) { + return 'articles'; + } + if (pathname.endsWith('/questions')) { + return 'questions'; + } + return 'posts'; +} + export { thousandthDivision, formatCount, @@ -310,4 +321,5 @@ export { getUaType, changeTheme, isDarkTheme, + pageTitleType, }; diff --git a/ui/src/utils/guard.ts b/ui/src/utils/guard.ts index e033f795a..24064669a 100644 --- a/ui/src/utils/guard.ts +++ b/ui/src/utils/guard.ts @@ -391,22 +391,23 @@ export const initAppSettingsStore = async () => { } }; -export const googleSnapshotRedirect = () => { - const gr: TGuardResult = { ok: true }; - const searchStr = new URLSearchParams(window.location.search)?.get('q') || ''; - if (window.location.host !== 'webcache.googleusercontent.com') { - return gr; - } - if (searchStr.indexOf('cache:') === 0 && searchStr.includes(':http')) { - const redirectUrl = `http${searchStr.split(':http')[1]}`; - const pathname = redirectUrl.replace(new URL(redirectUrl).origin, ''); +export const askRedirect = () => { + const queryString = window.location.search; - gr.ok = false; - gr.redirect = pathname || '/'; - return gr; - } + return { + ok: false, + redirect: `/questions/add${queryString}`, + }; +}; - return gr; +export const linkedRedirect = () => { + const queryString = window.location.search; + const pathname = window.location.pathname.replace('/questions', ''); + + return { + ok: false, + redirect: `${pathname}${queryString}`, + }; }; let appInitialized = false; diff --git a/ui/src/utils/pluginKit/interface.ts b/ui/src/utils/pluginKit/interface.ts index d946ed6ee..a1b641d86 100644 --- a/ui/src/utils/pluginKit/interface.ts +++ b/ui/src/utils/pluginKit/interface.ts @@ -29,6 +29,7 @@ export enum PluginType { Route = 'route', Captcha = 'captcha', Render = 'render', + Sidebar = 'sidebar', } export interface PluginInfo { diff --git a/ui/template/header.html b/ui/template/header.html index 404df75b1..d5d9a18ac 100644 --- a/ui/template/header.html +++ b/ui/template/header.html @@ -144,7 +144,7 @@ - +