diff --git a/Makefile b/Makefile
index 62981b789..d88d5085b 100644
--- a/Makefile
+++ b/Makefile
@@ -1,6 +1,6 @@
.PHONY: build clean ui
-VERSION=1.5.0
+VERSION=1.5.1
BIN=answer
DIR_SRC=./cmd/answer
DOCKER_CMD=docker
diff --git a/README.md b/README.md
index 3e1a1bcfe..8f5ae243b 100644
--- a/README.md
+++ b/README.md
@@ -23,7 +23,7 @@ To learn more about the project, visit [answer.apache.org](https://answer.apache
### Running with docker
```bash
-docker run -d -p 9080:80 -v answer-data:/data --name answer apache/answer:1.5.0
+docker run -d -p 9080:80 -v answer-data:/data --name answer apache/answer:1.5.1
```
For more information, see [Installation](https://answer.apache.org/docs/installation).
diff --git a/cmd/command.go b/cmd/command.go
index fe17713e7..7d779893f 100644
--- a/cmd/command.go
+++ b/cmd/command.go
@@ -82,7 +82,6 @@ func init() {
}
var (
- // rootCmd represents the base command when called without any subcommands
rootCmd = &cobra.Command{
Use: "answer",
Short: "Answer is a minimalist open source Q&A community.",
@@ -92,11 +91,10 @@ To run answer, use:
- 'answer run' to launch application.`,
}
- // runCmd represents the run command
runCmd = &cobra.Command{
Use: "run",
- Short: "Run the application",
- Long: `Run the application`,
+ Short: "Run Answer",
+ Long: `Start running Answer`,
Run: func(_ *cobra.Command, _ []string) {
cli.FormatAllPath(dataDirPath)
fmt.Println("config file path: ", cli.GetConfigFilePath())
@@ -105,11 +103,10 @@ To run answer, use:
},
}
- // initCmd represents the init command
initCmd = &cobra.Command{
Use: "init",
- Short: "init answer application",
- Long: `init answer application`,
+ Short: "Initialize Answer",
+ Long: `Initialize Answer with specified configuration`,
Run: func(_ *cobra.Command, _ []string) {
// check config file and database. if config file exists and database is already created, init done
cli.InstallAllInitialEnvironment(dataDirPath)
@@ -135,11 +132,10 @@ To run answer, use:
},
}
- // upgradeCmd represents the upgrade command
upgradeCmd = &cobra.Command{
Use: "upgrade",
- Short: "upgrade Answer version",
- Long: `upgrade Answer version`,
+ Short: "Upgrade Answer",
+ Long: `Upgrade Answer to the latest version`,
Run: func(_ *cobra.Command, _ []string) {
log.SetLogger(log.NewStdLogger(os.Stdout))
cli.FormatAllPath(dataDirPath)
@@ -157,11 +153,10 @@ To run answer, use:
},
}
- // dumpCmd represents the dump command
dumpCmd = &cobra.Command{
Use: "dump",
- Short: "back up data",
- Long: `back up data`,
+ Short: "Back up data",
+ Long: `Back up database into an SQL file`,
Run: func(_ *cobra.Command, _ []string) {
fmt.Println("Answer is backing up data")
cli.FormatAllPath(dataDirPath)
@@ -179,10 +174,9 @@ To run answer, use:
},
}
- // checkCmd represents the check command
checkCmd = &cobra.Command{
Use: "check",
- Short: "checking the required environment",
+ Short: "Check the required environment",
Long: `Check if the current environment meets the startup requirements`,
Run: func(_ *cobra.Command, _ []string) {
cli.FormatAllPath(dataDirPath)
@@ -214,10 +208,9 @@ To run answer, use:
},
}
- // buildCmd used to build another answer with plugins
buildCmd = &cobra.Command{
Use: "build",
- Short: "used to build answer with plugins",
+ Short: "Build Answer with plugins",
Long: `Build a new Answer with plugins that you need`,
Run: func(_ *cobra.Command, _ []string) {
fmt.Printf("try to build a new answer with plugins:\n%s\n", strings.Join(buildWithPlugins, "\n"))
@@ -235,11 +228,10 @@ To run answer, use:
},
}
- // pluginCmd prints all plugins packed in the binary
pluginCmd = &cobra.Command{
Use: "plugin",
- Short: "prints all plugins packed in the binary",
- Long: `prints all plugins packed in the binary`,
+ Short: "Print all plugins packed in the binary",
+ Long: `Print all plugins packed in the binary`,
Run: func(_ *cobra.Command, _ []string) {
_ = plugin.CallBase(func(base plugin.Base) error {
info := base.Info()
@@ -249,11 +241,10 @@ To run answer, use:
},
}
- // configCmd set some config to default value
configCmd = &cobra.Command{
Use: "config",
- Short: "set some config to default value",
- Long: `set some config to default value`,
+ Short: "Set some config to default value",
+ Long: `Set some config to default value`,
Run: func(_ *cobra.Command, _ []string) {
cli.FormatAllPath(dataDirPath)
@@ -286,10 +277,9 @@ To run answer, use:
},
}
- // i18nCmd used to merge i18n files
i18nCmd = &cobra.Command{
Use: "i18n",
- Short: "overwrite i18n files",
+ Short: "Overwrite i18n files",
Long: `Merge i18n files from plugins to original i18n files. It will overwrite the original i18n files`,
Run: func(_ *cobra.Command, _ []string) {
if err := cli.ReplaceI18nFilesLocal(i18nTargetPath); err != nil {
diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go
index 702f60df3..f98deb424 100644
--- a/cmd/wire_gen.go
+++ b/cmd/wire_gen.go
@@ -181,7 +181,9 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database,
metaCommonService := metacommon.NewMetaCommonService(metaRepo)
questionCommon := questioncommon.NewQuestionCommon(questionRepo, answerRepo, voteRepo, followRepo, tagCommonService, userCommon, collectionCommon, answerCommon, metaCommonService, configService, activityQueueService, revisionRepo, siteInfoCommonService, dataData)
eventQueueService := event_queue.NewEventQueueService()
- userService := content.NewUserService(userRepo, userActiveActivityRepo, activityRepo, emailService, authService, siteInfoCommonService, userRoleRelService, userCommon, userExternalLoginService, userNotificationConfigRepo, userNotificationConfigService, questionCommon, eventQueueService)
+ fileRecordRepo := file_record.NewFileRecordRepo(dataData)
+ fileRecordService := file_record2.NewFileRecordService(fileRecordRepo, revisionRepo, serviceConf, siteInfoCommonService, userCommon)
+ userService := content.NewUserService(userRepo, userActiveActivityRepo, activityRepo, emailService, authService, siteInfoCommonService, userRoleRelService, userCommon, userExternalLoginService, userNotificationConfigRepo, userNotificationConfigService, questionCommon, eventQueueService, fileRecordService)
captchaRepo := captcha.NewCaptchaRepo(dataData)
captchaService := action.NewCaptchaService(captchaRepo)
userController := controller.NewUserController(authService, userService, captchaService, emailService, siteInfoCommonService, userNotificationConfigService)
@@ -239,7 +241,7 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database,
reasonService := reason2.NewReasonService(reasonRepo)
reasonController := controller.NewReasonController(reasonService)
themeController := controller_admin.NewThemeController()
- siteInfoService := siteinfo.NewSiteInfoService(siteInfoRepo, siteInfoCommonService, emailService, tagCommonService, configService, questionCommon)
+ siteInfoService := siteinfo.NewSiteInfoService(siteInfoRepo, siteInfoCommonService, emailService, tagCommonService, configService, questionCommon, fileRecordService)
siteInfoController := controller_admin.NewSiteInfoController(siteInfoService)
controllerSiteInfoController := controller.NewSiteInfoController(siteInfoCommonService)
notificationCommon := notificationcommon.NewNotificationCommon(dataData, notificationRepo, userCommon, activityRepo, followRepo, objService, notificationQueueService, userExternalLoginRepo, siteInfoCommonService)
@@ -248,8 +250,6 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database,
notificationController := controller.NewNotificationController(notificationService, rankService)
dashboardService := dashboard.NewDashboardService(questionRepo, answerRepo, commentCommonRepo, voteRepo, userRepo, reportRepo, configService, siteInfoCommonService, serviceConf, reviewService, revisionRepo, dataData)
dashboardController := controller.NewDashboardController(dashboardService)
- fileRecordRepo := file_record.NewFileRecordRepo(dataData)
- fileRecordService := file_record2.NewFileRecordService(fileRecordRepo, revisionRepo, serviceConf, siteInfoCommonService)
uploaderService := uploader.NewUploaderService(serviceConf, siteInfoCommonService, fileRecordService)
uploadController := controller.NewUploadController(uploaderService)
activityActivityRepo := activity.NewActivityRepo(dataData, configService)
diff --git a/docs/docs.go b/docs/docs.go
index 4f5fe86ec..82ab3c779 100644
--- a/docs/docs.go
+++ b/docs/docs.go
@@ -8263,7 +8263,7 @@ const docTemplate = `{
"display_name": {
"type": "string",
"maxLength": 30,
- "minLength": 4
+ "minLength": 2
},
"email": {
"type": "string",
@@ -8274,7 +8274,8 @@ const docTemplate = `{
},
"username": {
"type": "string",
- "maxLength": 30
+ "maxLength": 30,
+ "minLength": 2
}
}
},
@@ -11084,7 +11085,8 @@ const docTemplate = `{
},
"display_name": {
"type": "string",
- "maxLength": 30
+ "maxLength": 30,
+ "minLength": 2
},
"location": {
"type": "string",
diff --git a/docs/swagger.json b/docs/swagger.json
index 5a48fd0de..689255a42 100644
--- a/docs/swagger.json
+++ b/docs/swagger.json
@@ -8236,7 +8236,7 @@
"display_name": {
"type": "string",
"maxLength": 30,
- "minLength": 4
+ "minLength": 2
},
"email": {
"type": "string",
@@ -8247,7 +8247,8 @@
},
"username": {
"type": "string",
- "maxLength": 30
+ "maxLength": 30,
+ "minLength": 2
}
}
},
@@ -11057,7 +11058,8 @@
},
"display_name": {
"type": "string",
- "maxLength": 30
+ "maxLength": 30,
+ "minLength": 2
},
"location": {
"type": "string",
diff --git a/docs/swagger.yaml b/docs/swagger.yaml
index c06ce9499..a399b3bfd 100644
--- a/docs/swagger.yaml
+++ b/docs/swagger.yaml
@@ -527,7 +527,7 @@ definitions:
properties:
display_name:
maxLength: 30
- minLength: 4
+ minLength: 2
type: string
email:
maxLength: 500
@@ -536,6 +536,7 @@ definitions:
type: string
username:
maxLength: 30
+ minLength: 2
type: string
required:
- display_name
@@ -2485,6 +2486,7 @@ definitions:
type: string
display_name:
maxLength: 30
+ minLength: 2
type: string
location:
maxLength: 100
diff --git a/i18n/af_ZA.yaml b/i18n/af_ZA.yaml
index 0d1cf1cf8..f421ba9af 100644
--- a/i18n/af_ZA.yaml
+++ b/i18n/af_ZA.yaml
@@ -678,12 +678,12 @@ ui:
display_name:
label: Display Name
msg: Display name cannot be empty.
- msg_range: Display name up to 30 characters.
+ msg_range: Display name must be 2-30 characters in length.
username:
label: Username
caption: People can mention you as "@username".
msg: Username cannot be empty.
- msg_range: Username up to 30 characters.
+ msg_range: Username must be 2-30 characters in length.
character: 'Must use the character set "a-z", "0-9", " - . _"'
avatar:
label: Profile Image
diff --git a/i18n/ar_SA.yaml b/i18n/ar_SA.yaml
index 1f298b937..094a05523 100644
--- a/i18n/ar_SA.yaml
+++ b/i18n/ar_SA.yaml
@@ -678,12 +678,12 @@ ui:
display_name:
label: Display Name
msg: Display name cannot be empty.
- msg_range: Display name up to 30 characters.
+ msg_range: Display name must be 2-30 characters in length.
username:
label: Username
caption: People can mention you as "@username".
msg: Username cannot be empty.
- msg_range: Username up to 30 characters.
+ msg_range: Username must be 2-30 characters in length.
character: 'Must use the character set "a-z", "0-9", " - . _"'
avatar:
label: Profile Image
diff --git a/i18n/ca_ES.yaml b/i18n/ca_ES.yaml
index 1f298b937..094a05523 100644
--- a/i18n/ca_ES.yaml
+++ b/i18n/ca_ES.yaml
@@ -678,12 +678,12 @@ ui:
display_name:
label: Display Name
msg: Display name cannot be empty.
- msg_range: Display name up to 30 characters.
+ msg_range: Display name must be 2-30 characters in length.
username:
label: Username
caption: People can mention you as "@username".
msg: Username cannot be empty.
- msg_range: Username up to 30 characters.
+ msg_range: Username must be 2-30 characters in length.
character: 'Must use the character set "a-z", "0-9", " - . _"'
avatar:
label: Profile Image
diff --git a/i18n/cs_CZ.yaml b/i18n/cs_CZ.yaml
index e7561c9e9..e7fda9afd 100644
--- a/i18n/cs_CZ.yaml
+++ b/i18n/cs_CZ.yaml
@@ -1275,12 +1275,12 @@ ui:
display_name:
label: Display name
msg: Display name cannot be empty.
- msg_range: Display name up to 30 characters.
+ msg_range: Display name must be 2-30 characters in length.
username:
label: Username
caption: People can mention you as "@username".
msg: Username cannot be empty.
- msg_range: Username up to 30 characters.
+ msg_range: Username must be 2-30 characters in length.
character: 'Must use the character set "a-z", "0-9", " - . _"'
avatar:
label: Profile image
@@ -1852,10 +1852,10 @@ ui:
fields:
display_name:
label: Display name
- msg_range: Display name up to 30 characters.
+ msg_range: Display name must be 2-30 characters in length.
username:
label: Username
- msg_range: Username up to 30 characters.
+ msg_range: Username must be 2-30 characters in length.
email:
label: Email
msg_invalid: Invalid Email Address.
diff --git a/i18n/cy_GB.yaml b/i18n/cy_GB.yaml
index 09987e25b..ed65acbb0 100644
--- a/i18n/cy_GB.yaml
+++ b/i18n/cy_GB.yaml
@@ -1275,12 +1275,12 @@ ui:
display_name:
label: Display name
msg: Display name cannot be empty.
- msg_range: Display name up to 30 characters.
+ msg_range: Display name must be 2-30 characters in length.
username:
label: Username
caption: People can mention you as "@username".
msg: Username cannot be empty.
- msg_range: Username up to 30 characters.
+ msg_range: Username must be 2-30 characters in length.
character: 'Must use the character set "a-z", "0-9", " - . _"'
avatar:
label: Profile image
@@ -1852,10 +1852,10 @@ ui:
fields:
display_name:
label: Display name
- msg_range: Display name up to 30 characters.
+ msg_range: Display name must be 2-30 characters in length.
username:
label: Username
- msg_range: Username up to 30 characters.
+ msg_range: Username must be 2-30 characters in length.
email:
label: Email
msg_invalid: Invalid Email Address.
diff --git a/i18n/el_GR.yaml b/i18n/el_GR.yaml
index 1f298b937..094a05523 100644
--- a/i18n/el_GR.yaml
+++ b/i18n/el_GR.yaml
@@ -678,12 +678,12 @@ ui:
display_name:
label: Display Name
msg: Display name cannot be empty.
- msg_range: Display name up to 30 characters.
+ msg_range: Display name must be 2-30 characters in length.
username:
label: Username
caption: People can mention you as "@username".
msg: Username cannot be empty.
- msg_range: Username up to 30 characters.
+ msg_range: Username must be 2-30 characters in length.
character: 'Must use the character set "a-z", "0-9", " - . _"'
avatar:
label: Profile Image
diff --git a/i18n/en_US.yaml b/i18n/en_US.yaml
index a22396cf9..cb05e5e13 100644
--- a/i18n/en_US.yaml
+++ b/i18n/en_US.yaml
@@ -1300,12 +1300,12 @@ ui:
display_name:
label: Display name
msg: Display name cannot be empty.
- msg_range: Display name up to 30 characters.
+ msg_range: Display name must be 2-30 characters in length.
username:
label: Username
caption: People can mention you as "@username".
msg: Username cannot be empty.
- msg_range: Username up to 30 characters.
+ msg_range: Username must be 2-30 characters in length.
character: 'Must use the character set "a-z", "0-9", " - . _"'
avatar:
label: Profile image
@@ -1787,11 +1787,11 @@ ui:
seo: SEO
customize: Customize
themes: Themes
- css_html: CSS/HTML
login: Login
privileges: Privileges
plugins: Plugins
installed_plugins: Installed Plugins
+ apperance: Appearance
website_welcome: Welcome to {{site_name}}
user_center:
login: Login
@@ -1892,10 +1892,10 @@ ui:
fields:
display_name:
label: Display name
- msg_range: Display name up to 30 characters.
+ msg_range: Display name must be 2-30 characters in length.
username:
label: Username
- msg_range: Username up to 30 characters.
+ msg_range: Username must be 2-30 characters in length.
email:
label: Email
msg_invalid: Invalid Email Address.
@@ -1927,9 +1927,9 @@ ui:
name: Name
email: Email
reputation: Reputation
- created_at: Created Time
- delete_at: Deleted Time
- suspend_at: Suspended Time
+ created_at: Created time
+ delete_at: Deleted time
+ suspend_at: Suspended time
status: Status
role: Role
action: Action
@@ -2143,7 +2143,7 @@ ui:
color_scheme:
label: Color scheme
navbar_style:
- label: Navbar style
+ label: Navbar background style
primary_color:
label: Primary color
text: Modify the colors used by your themes
diff --git a/i18n/fa_IR.yaml b/i18n/fa_IR.yaml
index 5838f4cc8..429aa8563 100644
--- a/i18n/fa_IR.yaml
+++ b/i18n/fa_IR.yaml
@@ -1852,10 +1852,10 @@ ui:
fields:
display_name:
label: Display name
- msg_range: Display name up to 30 characters.
+ msg_range: Display name must be 2-30 characters in length.
username:
label: Username
- msg_range: Username up to 30 characters.
+ msg_range: Username must be 2-30 characters in length.
email:
label: Email
msg_invalid: Invalid Email Address.
diff --git a/i18n/fi_FI.yaml b/i18n/fi_FI.yaml
index 1f298b937..094a05523 100644
--- a/i18n/fi_FI.yaml
+++ b/i18n/fi_FI.yaml
@@ -678,12 +678,12 @@ ui:
display_name:
label: Display Name
msg: Display name cannot be empty.
- msg_range: Display name up to 30 characters.
+ msg_range: Display name must be 2-30 characters in length.
username:
label: Username
caption: People can mention you as "@username".
msg: Username cannot be empty.
- msg_range: Username up to 30 characters.
+ msg_range: Username must be 2-30 characters in length.
character: 'Must use the character set "a-z", "0-9", " - . _"'
avatar:
label: Profile Image
diff --git a/i18n/he_IL.yaml b/i18n/he_IL.yaml
index 1f298b937..094a05523 100644
--- a/i18n/he_IL.yaml
+++ b/i18n/he_IL.yaml
@@ -678,12 +678,12 @@ ui:
display_name:
label: Display Name
msg: Display name cannot be empty.
- msg_range: Display name up to 30 characters.
+ msg_range: Display name must be 2-30 characters in length.
username:
label: Username
caption: People can mention you as "@username".
msg: Username cannot be empty.
- msg_range: Username up to 30 characters.
+ msg_range: Username must be 2-30 characters in length.
character: 'Must use the character set "a-z", "0-9", " - . _"'
avatar:
label: Profile Image
diff --git a/i18n/hi_IN.yaml b/i18n/hi_IN.yaml
index c2d3781d8..8dac7f47e 100644
--- a/i18n/hi_IN.yaml
+++ b/i18n/hi_IN.yaml
@@ -1275,12 +1275,12 @@ ui:
display_name:
label: Display name
msg: Display name cannot be empty.
- msg_range: Display name up to 30 characters.
+ msg_range: Display name must be 2-30 characters in length.
username:
label: Username
caption: People can mention you as "@username".
msg: Username cannot be empty.
- msg_range: Username up to 30 characters.
+ msg_range: Username must be 2-30 characters in length.
character: 'Must use the character set "a-z", "0-9", " - . _"'
avatar:
label: Profile image
@@ -1852,10 +1852,10 @@ ui:
fields:
display_name:
label: Display name
- msg_range: Display name up to 30 characters.
+ msg_range: Display name must be 2-30 characters in length.
username:
label: Username
- msg_range: Username up to 30 characters.
+ msg_range: Username must be 2-30 characters in length.
email:
label: Email
msg_invalid: Invalid Email Address.
diff --git a/i18n/hu_HU.yaml b/i18n/hu_HU.yaml
index 1f298b937..094a05523 100644
--- a/i18n/hu_HU.yaml
+++ b/i18n/hu_HU.yaml
@@ -678,12 +678,12 @@ ui:
display_name:
label: Display Name
msg: Display name cannot be empty.
- msg_range: Display name up to 30 characters.
+ msg_range: Display name must be 2-30 characters in length.
username:
label: Username
caption: People can mention you as "@username".
msg: Username cannot be empty.
- msg_range: Username up to 30 characters.
+ msg_range: Username must be 2-30 characters in length.
character: 'Must use the character set "a-z", "0-9", " - . _"'
avatar:
label: Profile Image
diff --git a/i18n/id_ID.yaml b/i18n/id_ID.yaml
index ff969bfa0..03a707c8e 100644
--- a/i18n/id_ID.yaml
+++ b/i18n/id_ID.yaml
@@ -1275,12 +1275,12 @@ ui:
display_name:
label: Display name
msg: Display name cannot be empty.
- msg_range: Display name up to 30 characters.
+ msg_range: Display name must be 2-30 characters in length.
username:
label: Username
caption: People can mention you as "@username".
msg: Username cannot be empty.
- msg_range: Username up to 30 characters.
+ msg_range: Username must be 2-30 characters in length.
character: 'Must use the character set "a-z", "0-9", " - . _"'
avatar:
label: Profile image
@@ -1852,10 +1852,10 @@ ui:
fields:
display_name:
label: Display name
- msg_range: Display name up to 30 characters.
+ msg_range: Display name must be 2-30 characters in length.
username:
label: Username
- msg_range: Username up to 30 characters.
+ msg_range: Username must be 2-30 characters in length.
email:
label: Email
msg_invalid: Invalid Email Address.
diff --git a/i18n/ko_KR.yaml b/i18n/ko_KR.yaml
index e1381004e..543ef3b7f 100644
--- a/i18n/ko_KR.yaml
+++ b/i18n/ko_KR.yaml
@@ -1852,10 +1852,10 @@ ui:
fields:
display_name:
label: Display name
- msg_range: Display name up to 30 characters.
+ msg_range: Display name must be 2-30 characters in length.
username:
label: Username
- msg_range: Username up to 30 characters.
+ msg_range: Username must be 2-30 characters in length.
email:
label: 이메일
msg_invalid: Invalid Email Address.
diff --git a/i18n/ml_IN.yaml b/i18n/ml_IN.yaml
index 9a3f74593..a1a6da696 100644
--- a/i18n/ml_IN.yaml
+++ b/i18n/ml_IN.yaml
@@ -1275,12 +1275,12 @@ ui:
display_name:
label: Display name
msg: Display name cannot be empty.
- msg_range: Display name up to 30 characters.
+ msg_range: Display name must be 2-30 characters in length.
username:
label: Username
caption: People can mention you as "@username".
msg: Username cannot be empty.
- msg_range: Username up to 30 characters.
+ msg_range: Username must be 2-30 characters in length.
character: 'Must use the character set "a-z", "0-9", " - . _"'
avatar:
label: Profile image
@@ -1852,10 +1852,10 @@ ui:
fields:
display_name:
label: Display name
- msg_range: Display name up to 30 characters.
+ msg_range: Display name must be 2-30 characters in length.
username:
label: Username
- msg_range: Username up to 30 characters.
+ msg_range: Username must be 2-30 characters in length.
email:
label: Email
msg_invalid: Invalid Email Address.
diff --git a/i18n/nl_NL.yaml b/i18n/nl_NL.yaml
index 1f298b937..094a05523 100644
--- a/i18n/nl_NL.yaml
+++ b/i18n/nl_NL.yaml
@@ -678,12 +678,12 @@ ui:
display_name:
label: Display Name
msg: Display name cannot be empty.
- msg_range: Display name up to 30 characters.
+ msg_range: Display name must be 2-30 characters in length.
username:
label: Username
caption: People can mention you as "@username".
msg: Username cannot be empty.
- msg_range: Username up to 30 characters.
+ msg_range: Username must be 2-30 characters in length.
character: 'Must use the character set "a-z", "0-9", " - . _"'
avatar:
label: Profile Image
diff --git a/i18n/no_NO.yaml b/i18n/no_NO.yaml
index 90b00b6c9..a0cd01396 100644
--- a/i18n/no_NO.yaml
+++ b/i18n/no_NO.yaml
@@ -678,12 +678,12 @@ ui:
display_name:
label: Display Name
msg: Display name cannot be empty.
- msg_range: Display name up to 30 characters.
+ msg_range: Display name must be 2-30 characters in length.
username:
label: Username
caption: People can mention you as "@username".
msg: Username cannot be empty.
- msg_range: Username up to 30 characters.
+ msg_range: Username must be 2-30 characters in length.
character: 'Must use the character set "a-z", "0-9", " - . _"'
avatar:
label: Profile Image
diff --git a/i18n/ro_RO.yaml b/i18n/ro_RO.yaml
index 80b0ebdb1..e7ba8455a 100644
--- a/i18n/ro_RO.yaml
+++ b/i18n/ro_RO.yaml
@@ -1852,10 +1852,10 @@ ui:
fields:
display_name:
label: Display name
- msg_range: Display name up to 30 characters.
+ msg_range: Display name must be 2-30 characters in length.
username:
label: Username
- msg_range: Username up to 30 characters.
+ msg_range: Username must be 2-30 characters in length.
email:
label: Email
msg_invalid: Invalid Email Address.
diff --git a/i18n/ru_RU.yaml b/i18n/ru_RU.yaml
index d2507f99d..58e80e985 100644
--- a/i18n/ru_RU.yaml
+++ b/i18n/ru_RU.yaml
@@ -1852,10 +1852,10 @@ ui:
fields:
display_name:
label: Display name
- msg_range: Display name up to 30 characters.
+ msg_range: Display name must be 2-30 characters in length.
username:
label: Username
- msg_range: Username up to 30 characters.
+ msg_range: Username must be 2-30 characters in length.
email:
label: Email
msg_invalid: Invalid Email Address.
diff --git a/i18n/sk_SK.yaml b/i18n/sk_SK.yaml
index 414e6a9dd..1de09bd70 100644
--- a/i18n/sk_SK.yaml
+++ b/i18n/sk_SK.yaml
@@ -1852,10 +1852,10 @@ ui:
fields:
display_name:
label: Display name
- msg_range: Display name up to 30 characters.
+ msg_range: Display name must be 2-30 characters in length.
username:
label: Username
- msg_range: Username up to 30 characters.
+ msg_range: Username must be 2-30 characters in length.
email:
label: Email
msg_invalid: Invalid Email Address.
diff --git a/i18n/sr_SP.yaml b/i18n/sr_SP.yaml
index 1f298b937..094a05523 100644
--- a/i18n/sr_SP.yaml
+++ b/i18n/sr_SP.yaml
@@ -678,12 +678,12 @@ ui:
display_name:
label: Display Name
msg: Display name cannot be empty.
- msg_range: Display name up to 30 characters.
+ msg_range: Display name must be 2-30 characters in length.
username:
label: Username
caption: People can mention you as "@username".
msg: Username cannot be empty.
- msg_range: Username up to 30 characters.
+ msg_range: Username must be 2-30 characters in length.
character: 'Must use the character set "a-z", "0-9", " - . _"'
avatar:
label: Profile Image
diff --git a/i18n/sv_SE.yaml b/i18n/sv_SE.yaml
index a2ecc2b19..910313f97 100644
--- a/i18n/sv_SE.yaml
+++ b/i18n/sv_SE.yaml
@@ -1275,12 +1275,12 @@ ui:
display_name:
label: Visningsnamn
msg: Display name cannot be empty.
- msg_range: Display name up to 30 characters.
+ msg_range: Display name must be 2-30 characters in length.
username:
label: Användarnamn
caption: People can mention you as "@username".
msg: Username cannot be empty.
- msg_range: Username up to 30 characters.
+ msg_range: Username must be 2-30 characters in length.
character: 'Must use the character set "a-z", "0-9", " - . _"'
avatar:
label: Profilbild
@@ -1852,10 +1852,10 @@ ui:
fields:
display_name:
label: Visningsnamn
- msg_range: Display name up to 30 characters.
+ msg_range: Display name must be 2-30 characters in length.
username:
label: Användarnamn
- msg_range: Username up to 30 characters.
+ msg_range: Username must be 2-30 characters in length.
email:
label: Email
msg_invalid: Ogiltig e-postadress.
diff --git a/i18n/te_IN.yaml b/i18n/te_IN.yaml
index 425239fc3..cc5ce3380 100644
--- a/i18n/te_IN.yaml
+++ b/i18n/te_IN.yaml
@@ -1275,12 +1275,12 @@ ui:
display_name:
label: Display name
msg: Display name cannot be empty.
- msg_range: Display name up to 30 characters.
+ msg_range: Display name must be 2-30 characters in length.
username:
label: Username
caption: People can mention you as "@username".
msg: Username cannot be empty.
- msg_range: Username up to 30 characters.
+ msg_range: Username must be 2-30 characters in length.
character: 'Must use the character set "a-z", "0-9", " - . _"'
avatar:
label: Profile image
@@ -1852,10 +1852,10 @@ ui:
fields:
display_name:
label: Display name
- msg_range: Display name up to 30 characters.
+ msg_range: Display name must be 2-30 characters in length.
username:
label: Username
- msg_range: Username up to 30 characters.
+ msg_range: Username must be 2-30 characters in length.
email:
label: Email
msg_invalid: Invalid Email Address.
diff --git a/i18n/tr_TR.yaml b/i18n/tr_TR.yaml
index f1bf034a7..0b845a0ef 100644
--- a/i18n/tr_TR.yaml
+++ b/i18n/tr_TR.yaml
@@ -1275,12 +1275,12 @@ ui:
display_name:
label: Display name
msg: Display name cannot be empty.
- msg_range: Display name up to 30 characters.
+ msg_range: Display name must be 2-30 characters in length.
username:
label: Username
caption: People can mention you as "@username".
msg: Username cannot be empty.
- msg_range: Username up to 30 characters.
+ msg_range: Username must be 2-30 characters in length.
character: 'Must use the character set "a-z", "0-9", " - . _"'
avatar:
label: Profile image
@@ -1852,10 +1852,10 @@ ui:
fields:
display_name:
label: Display name
- msg_range: Display name up to 30 characters.
+ msg_range: Display name must be 2-30 characters in length.
username:
label: Username
- msg_range: Username up to 30 characters.
+ msg_range: Username must be 2-30 characters in length.
email:
label: Email
msg_invalid: Invalid Email Address.
diff --git a/i18n/zh_CN.yaml b/i18n/zh_CN.yaml
index 64dfcf98d..f1a9f67b5 100644
--- a/i18n/zh_CN.yaml
+++ b/i18n/zh_CN.yaml
@@ -1748,11 +1748,11 @@ ui:
seo: SEO
customize: 自定义
themes: 主题
- css_html: CSS/HTML
login: 登录
privileges: 特权
plugins: 插件
installed_plugins: 已安装插件
+ apperance: 外观
website_welcome: 欢迎来到 {{site_name}}
user_center:
login: 登录
@@ -2111,16 +2111,13 @@ ui:
page_title: CSS 与 HTML
custom_css:
label: 自定义 CSS
- text: >
-
+ text: 这将作为 <link> 标签插入
head:
label: 头部
- text: >
-
+ text: 这将在 </head> 前插入
header:
label: 页眉
- text: >
-
+ text: 这将在 <body> 后插入
footer:
label: 页脚
text: 这将在 </body> 之前插入。
diff --git a/i18n/zh_TW.yaml b/i18n/zh_TW.yaml
index 72ab17795..4e02c70d4 100644
--- a/i18n/zh_TW.yaml
+++ b/i18n/zh_TW.yaml
@@ -1852,10 +1852,10 @@ ui:
fields:
display_name:
label: Display name
- msg_range: Display name up to 30 characters.
+ msg_range: Display name must be 2-30 characters in length.
username:
label: Username
- msg_range: Username up to 30 characters.
+ msg_range: Username must be 2-30 characters in length.
email:
label: Email
msg_invalid: Invalid Email Address.
diff --git a/internal/base/middleware/avatar.go b/internal/base/middleware/avatar.go
index 1d2464173..98430638b 100644
--- a/internal/base/middleware/avatar.go
+++ b/internal/base/middleware/avatar.go
@@ -21,6 +21,7 @@ package middleware
import (
"fmt"
+ "net/http"
"net/url"
"os"
"path"
@@ -62,7 +63,8 @@ func (am *AvatarMiddleware) AvatarThumb() gin.HandlerFunc {
filePath, err = am.uploaderService.AvatarThumbFile(ctx, filename, size)
if err != nil {
log.Error(err)
- ctx.Abort()
+ ctx.AbortWithStatus(http.StatusNotFound)
+ return
}
}
avatarFile, err := os.ReadFile(filePath)
diff --git a/internal/controller/user_controller.go b/internal/controller/user_controller.go
index c8ffd7fef..49b9b23c6 100644
--- a/internal/controller/user_controller.go
+++ b/internal/controller/user_controller.go
@@ -717,9 +717,12 @@ func (uc *UserController) SearchUserListByName(ctx *gin.Context) {
}
func (uc *UserController) setVisitCookies(ctx *gin.Context, visitToken string, force bool) {
- cookie, err := ctx.Cookie(constant.UserVisitCookiesCacheKey)
- if err == nil && len(cookie) > 0 && !force {
- return
+ if !force {
+ cookie, _ := ctx.Cookie(constant.UserVisitCookiesCacheKey)
+ // If the cookie is the same as the visitToken, no need to set it again
+ if cookie == visitToken {
+ return
+ }
}
general, err := uc.siteInfoCommonService.GetSiteGeneral(ctx)
if err != nil {
diff --git a/internal/controller_admin/siteinfo_controller.go b/internal/controller_admin/siteinfo_controller.go
index cd1b2ab68..8a92daba3 100644
--- a/internal/controller_admin/siteinfo_controller.go
+++ b/internal/controller_admin/siteinfo_controller.go
@@ -28,6 +28,7 @@ import (
"github.com/apache/answer/internal/schema"
"github.com/apache/answer/internal/service/siteinfo"
"github.com/gin-gonic/gin"
+ "github.com/segmentfault/pacman/log"
)
// SiteInfoController site info controller
@@ -274,8 +275,17 @@ func (sc *SiteInfoController) UpdateBranding(ctx *gin.Context) {
if handler.BindAndCheck(ctx, req) {
return
}
- err := sc.siteInfoService.SaveSiteBranding(ctx, req)
- handler.HandleResponse(ctx, err, nil)
+ currentBranding, getBrandingErr := sc.siteInfoService.GetSiteBranding(ctx)
+ if getBrandingErr == nil {
+ cleanUpErr := sc.siteInfoService.CleanUpRemovedBrandingFiles(ctx, req, currentBranding)
+ if cleanUpErr != nil {
+ log.Errorf("failed to clean up removed branding file(s): %v", cleanUpErr)
+ }
+ } else {
+ log.Errorf("failed to get current site branding: %v", getBrandingErr)
+ }
+ saveErr := sc.siteInfoService.SaveSiteBranding(ctx, req)
+ handler.HandleResponse(ctx, saveErr, nil)
}
// UpdateSiteWrite update site write info
diff --git a/internal/entity/plugin_kv_storage_entity.go b/internal/entity/plugin_kv_storage_entity.go
new file mode 100644
index 000000000..c7e6efbe3
--- /dev/null
+++ b/internal/entity/plugin_kv_storage_entity.go
@@ -0,0 +1,32 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package entity
+
+type PluginKVStorage struct {
+ ID int `xorm:"not null pk autoincr INT(11) id"`
+ PluginSlugName string `xorm:"not null VARCHAR(128) UNIQUE(uk_psg) plugin_slug_name"`
+ Group string `xorm:"not null VARCHAR(128) UNIQUE(uk_psg) 'group'"`
+ Key string `xorm:"not null VARCHAR(128) UNIQUE(uk_psg) 'key'"`
+ Value string `xorm:"not null TEXT value"`
+}
+
+func (PluginKVStorage) TableName() string {
+ return "plugin_kv_storage"
+}
diff --git a/internal/migrations/init.go b/internal/migrations/init.go
index b2c814e13..610e24d8b 100644
--- a/internal/migrations/init.go
+++ b/internal/migrations/init.go
@@ -233,7 +233,7 @@ func (m *Mentor) initSiteInfoLegalConfig() {
}
func (m *Mentor) initSiteInfoThemeConfig() {
- themeConfig := `{"theme":"default","theme_config":{"default":{"navbar_style":"colored","primary_color":"#0033ff"}}}`
+ themeConfig := `{"theme":"default","theme_config":{"default":{"navbar_style":"#0033ff","primary_color":"#0033ff"}}}`
_, m.err = m.engine.Context(m.ctx).Insert(&entity.SiteInfo{
Type: "theme",
Content: themeConfig,
diff --git a/internal/migrations/init_data.go b/internal/migrations/init_data.go
index 7322f7388..96151625d 100644
--- a/internal/migrations/init_data.go
+++ b/internal/migrations/init_data.go
@@ -74,6 +74,8 @@ var (
&entity.Badge{},
&entity.BadgeGroup{},
&entity.BadgeAward{},
+ &entity.FileRecord{},
+ &entity.PluginKVStorage{},
}
roles = []*entity.Role{
diff --git a/internal/migrations/migrations.go b/internal/migrations/migrations.go
index 57f4778e5..212bf14bb 100644
--- a/internal/migrations/migrations.go
+++ b/internal/migrations/migrations.go
@@ -101,6 +101,7 @@ var migrations = []Migration{
NewMigration("v1.4.1", "add question link", addQuestionLink, true),
NewMigration("v1.4.2", "add the number of question links", addQuestionLinkedCount, true),
NewMigration("v1.4.5", "add file record", addFileRecord, true),
+ NewMigration("v1.5.1", "add plugin kv storage", addPluginKVStorage, true),
}
func GetMigrations() []Migration {
diff --git a/internal/migrations/v26.go b/internal/migrations/v26.go
new file mode 100644
index 000000000..008a094a4
--- /dev/null
+++ b/internal/migrations/v26.go
@@ -0,0 +1,31 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package migrations
+
+import (
+ "context"
+
+ "github.com/apache/answer/internal/entity"
+ "xorm.io/xorm"
+)
+
+func addPluginKVStorage(ctx context.Context, x *xorm.Engine) error {
+ return x.Context(ctx).Sync(new(entity.PluginKVStorage))
+}
diff --git a/internal/migrations/v5.go b/internal/migrations/v5.go
index 9d422d743..91d12f159 100644
--- a/internal/migrations/v5.go
+++ b/internal/migrations/v5.go
@@ -50,7 +50,7 @@ func addThemeAndPrivateMode(ctx context.Context, x *xorm.Engine) error {
}
}
- themeConfig := `{"theme":"default","theme_config":{"default":{"navbar_style":"colored","primary_color":"#0033ff"}}}`
+ themeConfig := `{"theme":"default","theme_config":{"default":{"navbar_style":"#0033ff","primary_color":"#0033ff"}}}`
themeSiteInfo := &entity.SiteInfo{
Type: "theme",
Content: themeConfig,
diff --git a/internal/repo/file_record/file_record_repo.go b/internal/repo/file_record/file_record_repo.go
index ed081be40..ce486c7ab 100644
--- a/internal/repo/file_record/file_record_repo.go
+++ b/internal/repo/file_record/file_record_repo.go
@@ -82,3 +82,18 @@ func (fr *fileRecordRepo) UpdateFileRecord(ctx context.Context, fileRecord *enti
}
return
}
+
+// GetFileRecordByURL gets a file record by its url
+func (fr *fileRecordRepo) GetFileRecordByURL(ctx context.Context, fileURL string) (record *entity.FileRecord, err error) {
+ record = &entity.FileRecord{}
+ session := fr.data.DB.Context(ctx)
+ exists, err := session.Where("file_url = ? AND status = ?", fileURL, entity.FileRecordStatusAvailable).Get(record)
+ if err != nil {
+ err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
+ return
+ }
+ if !exists {
+ return
+ }
+ return record, nil
+}
diff --git a/internal/repo/site_info/siteinfo_repo.go b/internal/repo/site_info/siteinfo_repo.go
index 420e483f3..5f95b7486 100644
--- a/internal/repo/site_info/siteinfo_repo.go
+++ b/internal/repo/site_info/siteinfo_repo.go
@@ -101,3 +101,18 @@ func (sr *siteInfoRepo) setCache(ctx context.Context, siteType string, siteInfo
log.Error(err)
}
}
+
+func (sr *siteInfoRepo) IsBrandingFileUsed(ctx context.Context, filePath string) (bool, error) {
+ siteInfo := &entity.SiteInfo{}
+ count, err := sr.data.DB.Context(ctx).
+ Table("site_info").
+ Where(builder.Eq{"type": "branding"}).
+ And(builder.Like{"content", "%" + filePath + "%"}).
+ Count(&siteInfo)
+
+ if err != nil {
+ return false, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
+ }
+
+ return count > 0, nil
+}
diff --git a/internal/repo/user/user_repo.go b/internal/repo/user/user_repo.go
index f56e00b89..a85cd79a1 100644
--- a/internal/repo/user/user_repo.go
+++ b/internal/repo/user/user_repo.go
@@ -33,6 +33,7 @@ import (
"github.com/apache/answer/plugin"
"github.com/segmentfault/pacman/errors"
"github.com/segmentfault/pacman/log"
+ "xorm.io/builder"
"xorm.io/xorm"
)
@@ -380,3 +381,17 @@ func decorateByUserCenterUser(original *entity.User, ucUser *plugin.UserCenterBa
original.Status = int(ucUser.Status)
}
}
+
+func (ur *userRepo) IsAvatarFileUsed(ctx context.Context, filePath string) (bool, error) {
+ user := &entity.User{}
+ count, err := ur.data.DB.Context(ctx).
+ Table("user").
+ Where(builder.Like{"avatar", "%" + filePath + "%"}).
+ Count(&user)
+
+ if err != nil {
+ return false, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
+ }
+
+ return count > 0, nil
+}
diff --git a/internal/schema/backyard_user_schema.go b/internal/schema/backyard_user_schema.go
index b6e29f65d..923a20ed9 100644
--- a/internal/schema/backyard_user_schema.go
+++ b/internal/schema/backyard_user_schema.go
@@ -111,8 +111,8 @@ type UpdateUserRoleReq struct {
// EditUserProfileReq edit user profile request
type EditUserProfileReq struct {
UserID string `validate:"required" json:"user_id"`
- DisplayName string `validate:"required,gte=4,lte=30" json:"display_name"`
- Username string `validate:"omitempty,gt=3,lte=30" json:"username"`
+ DisplayName string `validate:"required,gte=2,lte=30" json:"display_name"`
+ Username string `validate:"omitempty,gte=2,lte=30" json:"username"`
Email string `validate:"required,email,gt=0,lte=500" json:"email"`
LoginUserID string `json:"-"`
IsAdmin bool `json:"-"`
diff --git a/internal/schema/user_schema.go b/internal/schema/user_schema.go
index 3abbbdb36..7c0af1dc4 100644
--- a/internal/schema/user_schema.go
+++ b/internal/schema/user_schema.go
@@ -238,7 +238,7 @@ func (u *UserModifyPasswordReq) Check() (errFields []*validator.FormErrorField,
}
type UpdateInfoRequest struct {
- DisplayName string `validate:"omitempty,gt=0,lte=30" json:"display_name"`
+ DisplayName string `validate:"omitempty,gte=2,lte=30" json:"display_name"`
Username string `validate:"omitempty,gte=2,lte=30" json:"username"`
Avatar AvatarInfo `json:"avatar"`
Bio string `validate:"omitempty,gt=0,lte=4096" json:"bio"`
diff --git a/internal/service/content/user_service.go b/internal/service/content/user_service.go
index b59790c11..ece3a86de 100644
--- a/internal/service/content/user_service.go
+++ b/internal/service/content/user_service.go
@@ -23,9 +23,10 @@ import (
"context"
"encoding/json"
"fmt"
+ "time"
+
"github.com/apache/answer/internal/service/event_queue"
"github.com/apache/answer/pkg/token"
- "time"
"github.com/apache/answer/internal/base/constant"
questioncommon "github.com/apache/answer/internal/service/question_common"
@@ -41,6 +42,7 @@ import (
"github.com/apache/answer/internal/service/activity_common"
"github.com/apache/answer/internal/service/auth"
"github.com/apache/answer/internal/service/export"
+ "github.com/apache/answer/internal/service/file_record"
"github.com/apache/answer/internal/service/role"
"github.com/apache/answer/internal/service/siteinfo_common"
usercommon "github.com/apache/answer/internal/service/user_common"
@@ -67,6 +69,7 @@ type UserService struct {
userNotificationConfigService *user_notification_config.UserNotificationConfigService
questionService *questioncommon.QuestionCommon
eventQueueService event_queue.EventQueueService
+ fileRecordService *file_record.FileRecordService
}
func NewUserService(userRepo usercommon.UserRepo,
@@ -82,6 +85,7 @@ func NewUserService(userRepo usercommon.UserRepo,
userNotificationConfigService *user_notification_config.UserNotificationConfigService,
questionService *questioncommon.QuestionCommon,
eventQueueService event_queue.EventQueueService,
+ fileRecordService *file_record.FileRecordService,
) *UserService {
return &UserService{
userCommonService: userCommonService,
@@ -97,6 +101,7 @@ func NewUserService(userRepo usercommon.UserRepo,
userNotificationConfigService: userNotificationConfigService,
questionService: questionService,
eventQueueService: eventQueueService,
+ fileRecordService: fileRecordService,
}
}
@@ -355,6 +360,9 @@ func (us *UserService) UpdateInfo(ctx context.Context, req *schema.UpdateInfoReq
}
cond := us.formatUserInfoForUpdateInfo(oldUserInfo, req, siteUsers)
+
+ us.cleanUpRemovedAvatar(ctx, oldUserInfo.Avatar, cond.Avatar)
+
err = us.userRepo.UpdateInfo(ctx, cond)
if err != nil {
return nil, err
@@ -363,6 +371,41 @@ func (us *UserService) UpdateInfo(ctx context.Context, req *schema.UpdateInfoReq
return nil, err
}
+func (us *UserService) cleanUpRemovedAvatar(
+ ctx context.Context,
+ oldAvatarJSON string,
+ newAvatarJSON string,
+) {
+ if oldAvatarJSON == newAvatarJSON {
+ return
+ }
+
+ var oldAvatar, newAvatar schema.AvatarInfo
+
+ _ = json.Unmarshal([]byte(oldAvatarJSON), &oldAvatar)
+ _ = json.Unmarshal([]byte(newAvatarJSON), &newAvatar)
+
+ if len(oldAvatar.Custom) == 0 {
+ return
+ }
+
+ // clean up if old is custom and it's either removed or replaced
+ if oldAvatar.Custom != newAvatar.Custom {
+ fileRecord, err := us.fileRecordService.GetFileRecordByURL(ctx, oldAvatar.Custom)
+ if err != nil {
+ log.Error(err)
+ return
+ }
+ if fileRecord == nil {
+ log.Warn("no file record found for old avatar url:", oldAvatar.Custom)
+ return
+ }
+ if err := us.fileRecordService.DeleteAndMoveFileRecord(ctx, fileRecord); err != nil {
+ log.Error(err)
+ }
+ }
+}
+
func (us *UserService) formatUserInfoForUpdateInfo(
oldUserInfo *entity.User, req *schema.UpdateInfoRequest, siteUsersConf *schema.SiteUsersResp) *entity.User {
avatar, _ := json.Marshal(req.Avatar)
diff --git a/internal/service/file_record/file_record_service.go b/internal/service/file_record/file_record_service.go
index abb983768..29097ba8c 100644
--- a/internal/service/file_record/file_record_service.go
+++ b/internal/service/file_record/file_record_service.go
@@ -24,6 +24,7 @@ import (
"fmt"
"os"
"path/filepath"
+ "strings"
"time"
"github.com/apache/answer/internal/base/constant"
@@ -31,6 +32,7 @@ import (
"github.com/apache/answer/internal/service/revision"
"github.com/apache/answer/internal/service/service_config"
"github.com/apache/answer/internal/service/siteinfo_common"
+ usercommon "github.com/apache/answer/internal/service/user_common"
"github.com/apache/answer/pkg/checker"
"github.com/apache/answer/pkg/dir"
"github.com/apache/answer/pkg/writer"
@@ -44,6 +46,7 @@ type FileRecordRepo interface {
GetFileRecordPage(ctx context.Context, page, pageSize int, cond *entity.FileRecord) (
fileRecordList []*entity.FileRecord, total int64, err error)
DeleteFileRecord(ctx context.Context, id int) (err error)
+ GetFileRecordByURL(ctx context.Context, fileURL string) (record *entity.FileRecord, err error)
}
// FileRecordService file record service
@@ -52,6 +55,7 @@ type FileRecordService struct {
revisionRepo revision.RevisionRepo
serviceConfig *service_config.ServiceConfig
siteInfoService siteinfo_common.SiteInfoCommonService
+ userService *usercommon.UserCommon
}
// NewFileRecordService new file record service
@@ -60,12 +64,14 @@ func NewFileRecordService(
revisionRepo revision.RevisionRepo,
serviceConfig *service_config.ServiceConfig,
siteInfoService siteinfo_common.SiteInfoCommonService,
+ userService *usercommon.UserCommon,
) *FileRecordService {
return &FileRecordService{
fileRecordRepo: fileRecordRepo,
revisionRepo: revisionRepo,
serviceConfig: serviceConfig,
siteInfoService: siteInfoService,
+ userService: userService,
}
}
@@ -104,6 +110,21 @@ func (fs *FileRecordService) CleanOrphanUploadFiles(ctx context.Context) {
if fileRecord.CreatedAt.AddDate(0, 0, 2).After(time.Now()) {
continue
}
+ if isBrandingOrAvatarFile(fileRecord.FilePath) {
+ if strings.Contains(fileRecord.FilePath, constant.BrandingSubPath+"/") {
+ if fs.siteInfoService.IsBrandingFileUsed(ctx, fileRecord.FilePath) {
+ continue
+ }
+ } else if strings.Contains(fileRecord.FilePath, constant.AvatarSubPath+"/") {
+ if fs.userService.IsAvatarFileUsed(ctx, fileRecord.FilePath) {
+ continue
+ }
+ }
+ if err := fs.DeleteAndMoveFileRecord(ctx, fileRecord); err != nil {
+ log.Error(err)
+ }
+ continue
+ }
if checker.IsNotZeroString(fileRecord.ObjectID) {
_, exist, err := fs.revisionRepo.GetLastRevisionByObjectID(ctx, fileRecord.ObjectID)
if err != nil {
@@ -129,7 +150,7 @@ func (fs *FileRecordService) CleanOrphanUploadFiles(ctx context.Context) {
}
}
// Delete and move the file record
- if err := fs.deleteAndMoveFileRecord(ctx, fileRecord); err != nil {
+ if err := fs.DeleteAndMoveFileRecord(ctx, fileRecord); err != nil {
log.Error(err)
}
}
@@ -137,6 +158,10 @@ func (fs *FileRecordService) CleanOrphanUploadFiles(ctx context.Context) {
}
}
+func isBrandingOrAvatarFile(filePath string) bool {
+ return strings.Contains(filePath, constant.BrandingSubPath+"/") || strings.Contains(filePath, constant.AvatarSubPath+"/")
+}
+
func (fs *FileRecordService) PurgeDeletedFiles(ctx context.Context) {
deletedPath := filepath.Join(fs.serviceConfig.UploadPath, constant.DeletedSubPath)
log.Infof("purge deleted files: %s", deletedPath)
@@ -152,7 +177,7 @@ func (fs *FileRecordService) PurgeDeletedFiles(ctx context.Context) {
return
}
-func (fs *FileRecordService) deleteAndMoveFileRecord(ctx context.Context, fileRecord *entity.FileRecord) error {
+func (fs *FileRecordService) DeleteAndMoveFileRecord(ctx context.Context, fileRecord *entity.FileRecord) error {
// Delete the file record
if err := fs.fileRecordRepo.DeleteFileRecord(ctx, fileRecord.ID); err != nil {
return fmt.Errorf("delete file record error: %v", err)
@@ -170,3 +195,12 @@ func (fs *FileRecordService) deleteAndMoveFileRecord(ctx context.Context, fileRe
log.Debugf("delete and move file: %s", fileRecord.FileURL)
return nil
}
+
+func (fs *FileRecordService) GetFileRecordByURL(ctx context.Context, fileURL string) (record *entity.FileRecord, err error) {
+ record, err = fs.fileRecordRepo.GetFileRecordByURL(ctx, fileURL)
+ if err != nil {
+ log.Errorf("error retrieving file record by URL: %v", err)
+ return
+ }
+ return
+}
diff --git a/internal/service/plugin_common/plugin_common_service.go b/internal/service/plugin_common/plugin_common_service.go
index c1b0ad442..d3aa839b2 100644
--- a/internal/service/plugin_common/plugin_common_service.go
+++ b/internal/service/plugin_common/plugin_common_service.go
@@ -135,6 +135,15 @@ func (ps *PluginCommonService) GetUserPluginConfig(ctx context.Context, req *sch
}
func (ps *PluginCommonService) initPluginData() {
+ _ = plugin.CallKVStorage(func(k plugin.KVStorage) error {
+ k.SetOperator(plugin.NewKVOperator(
+ ps.data.DB,
+ ps.data.Cache,
+ k.Info().SlugName,
+ ))
+ return nil
+ })
+
// init plugin status
pluginStatus, err := ps.configService.GetStringValue(context.TODO(), constant.PluginStatus)
if err != nil {
diff --git a/internal/service/siteinfo/siteinfo_service.go b/internal/service/siteinfo/siteinfo_service.go
index 5141faeee..a0f4891c4 100644
--- a/internal/service/siteinfo/siteinfo_service.go
+++ b/internal/service/siteinfo/siteinfo_service.go
@@ -22,6 +22,7 @@ package siteinfo
import (
"context"
"encoding/json"
+ errpkg "errors"
"fmt"
"strings"
@@ -33,6 +34,7 @@ import (
"github.com/apache/answer/internal/schema"
"github.com/apache/answer/internal/service/config"
"github.com/apache/answer/internal/service/export"
+ "github.com/apache/answer/internal/service/file_record"
questioncommon "github.com/apache/answer/internal/service/question_common"
"github.com/apache/answer/internal/service/siteinfo_common"
tagcommon "github.com/apache/answer/internal/service/tag_common"
@@ -49,6 +51,7 @@ type SiteInfoService struct {
tagCommonService *tagcommon.TagCommonService
configService *config.ConfigService
questioncommon *questioncommon.QuestionCommon
+ fileRecordService *file_record.FileRecordService
}
func NewSiteInfoService(
@@ -58,6 +61,7 @@ func NewSiteInfoService(
tagCommonService *tagcommon.TagCommonService,
configService *config.ConfigService,
questioncommon *questioncommon.QuestionCommon,
+ fileRecordService *file_record.FileRecordService,
) *SiteInfoService {
plugin.RegisterGetSiteURLFunc(func() string {
@@ -76,6 +80,7 @@ func NewSiteInfoService(
tagCommonService: tagCommonService,
configService: configService,
questioncommon: questioncommon,
+ fileRecordService: fileRecordService,
}
}
@@ -438,3 +443,47 @@ func (s *SiteInfoService) UpdatePrivilegesConfig(ctx context.Context, req *schem
}
return
}
+
+func (s *SiteInfoService) CleanUpRemovedBrandingFiles(
+ ctx context.Context,
+ newBranding *schema.SiteBrandingReq,
+ currentBranding *schema.SiteBrandingResp,
+) error {
+ var allErrors []error
+ currentFiles := map[string]string{
+ "logo": currentBranding.Logo,
+ "mobile_logo": currentBranding.MobileLogo,
+ "square_icon": currentBranding.SquareIcon,
+ "favicon": currentBranding.Favicon,
+ }
+
+ newFiles := map[string]string{
+ "logo": newBranding.Logo,
+ "mobile_logo": newBranding.MobileLogo,
+ "square_icon": newBranding.SquareIcon,
+ "favicon": newBranding.Favicon,
+ }
+
+ for key, currentFile := range currentFiles {
+ newFile := newFiles[key]
+ if currentFile != "" && currentFile != newFile {
+ fileRecord, err := s.fileRecordService.GetFileRecordByURL(ctx, currentFile)
+ if err != nil {
+ allErrors = append(allErrors, err)
+ continue
+ }
+ if fileRecord == nil {
+ err := errpkg.New("file record is nil for key " + key)
+ allErrors = append(allErrors, err)
+ continue
+ }
+ if err := s.fileRecordService.DeleteAndMoveFileRecord(ctx, fileRecord); err != nil {
+ allErrors = append(allErrors, err)
+ }
+ }
+ }
+ if len(allErrors) > 0 {
+ return errpkg.Join(allErrors...)
+ }
+ return nil
+}
diff --git a/internal/service/siteinfo_common/siteinfo_service.go b/internal/service/siteinfo_common/siteinfo_service.go
index f715bc9b0..0c896c2b0 100644
--- a/internal/service/siteinfo_common/siteinfo_service.go
+++ b/internal/service/siteinfo_common/siteinfo_service.go
@@ -35,6 +35,7 @@ import (
type SiteInfoRepo interface {
SaveByType(ctx context.Context, siteType string, data *entity.SiteInfo) (err error)
GetByType(ctx context.Context, siteType string) (siteInfo *entity.SiteInfo, exist bool, err error)
+ IsBrandingFileUsed(ctx context.Context, filePath string) (bool, error)
}
// siteInfoCommonService site info common service
@@ -56,6 +57,7 @@ type SiteInfoCommonService interface {
GetSiteTheme(ctx context.Context) (resp *schema.SiteThemeResp, err error)
GetSiteSeo(ctx context.Context) (resp *schema.SiteSeoResp, err error)
GetSiteInfoByType(ctx context.Context, siteType string, resp interface{}) (err error)
+ IsBrandingFileUsed(ctx context.Context, filePath string) bool
}
// NewSiteInfoCommonService new site info common service
@@ -233,3 +235,13 @@ func (s *siteInfoCommonService) GetSiteInfoByType(ctx context.Context, siteType
_ = json.Unmarshal([]byte(siteInfo.Content), resp)
return nil
}
+
+func (s *siteInfoCommonService) IsBrandingFileUsed(ctx context.Context, filePath string) bool {
+ used, err := s.siteInfoRepo.IsBrandingFileUsed(ctx, filePath)
+ if err != nil {
+ log.Errorf("error checking if branding file is used: %v", err)
+ // will try again with the next clean up
+ return true
+ }
+ return used
+}
diff --git a/internal/service/uploader/upload.go b/internal/service/uploader/upload.go
index d5cc2dfbe..2ae5369df 100644
--- a/internal/service/uploader/upload.go
+++ b/internal/service/uploader/upload.go
@@ -127,7 +127,13 @@ func (us *uploaderService) UploadAvatarFile(ctx *gin.Context, userID string) (ur
newFilename := fmt.Sprintf("%s%s", uid.IDStr12(), fileExt)
avatarFilePath := path.Join(constant.AvatarSubPath, newFilename)
- return us.uploadImageFile(ctx, fileHeader, avatarFilePath)
+ url, err = us.uploadImageFile(ctx, fileHeader, avatarFilePath)
+ if err != nil {
+ return "", err
+ }
+ us.fileRecordService.AddFileRecord(ctx, userID, avatarFilePath, url, string(plugin.UserAvatar))
+ return url, nil
+
}
func (us *uploaderService) AvatarThumbFile(ctx *gin.Context, fileName string, size int) (url string, err error) {
@@ -149,7 +155,7 @@ func (us *uploaderService) AvatarThumbFile(ctx *gin.Context, fileName string, si
filePath := fmt.Sprintf("%s/%s/%s", us.serviceConfig.UploadPath, constant.AvatarSubPath, fileName)
avatarFile, err = os.ReadFile(filePath)
if err != nil {
- return "", errors.InternalServer(reason.UnknownError).WithError(err).WithStack()
+ return "", errors.NotFound(reason.UnknownError).WithError(err)
}
reader := bytes.NewReader(avatarFile)
img, err := imaging.Decode(reader)
@@ -282,7 +288,13 @@ func (us *uploaderService) UploadBrandingFile(ctx *gin.Context, userID string) (
newFilename := fmt.Sprintf("%s%s", uid.IDStr12(), fileExt)
avatarFilePath := path.Join(constant.BrandingSubPath, newFilename)
- return us.uploadImageFile(ctx, fileHeader, avatarFilePath)
+ url, err = us.uploadImageFile(ctx, fileHeader, avatarFilePath)
+ if err != nil {
+ return "", err
+ }
+ us.fileRecordService.AddFileRecord(ctx, userID, avatarFilePath, url, string(plugin.AdminBranding))
+ return url, nil
+
}
func (us *uploaderService) uploadImageFile(ctx *gin.Context, file *multipart.FileHeader, fileSubPath string) (
diff --git a/internal/service/user_common/user.go b/internal/service/user_common/user.go
index 7f50eafe5..3df99261d 100644
--- a/internal/service/user_common/user.go
+++ b/internal/service/user_common/user.go
@@ -60,6 +60,7 @@ type UserRepo interface {
GetByEmail(ctx context.Context, email string) (userInfo *entity.User, exist bool, err error)
GetUserCount(ctx context.Context) (count int64, err error)
SearchUserListByName(ctx context.Context, name string, limit int, onlyStaff bool) (userList []*entity.User, err error)
+ IsAvatarFileUsed(ctx context.Context, filePath string) (bool, error)
}
// UserCommon user service
@@ -245,3 +246,13 @@ func (us *UserCommon) CacheLoginUserInfo(ctx context.Context, userID string, use
}
return accessToken, userCacheInfo, nil
}
+
+func (us *UserCommon) IsAvatarFileUsed(ctx context.Context, filePath string) bool {
+ used, err := us.userRepo.IsAvatarFileUsed(ctx, filePath)
+ if err != nil {
+ log.Errorf("error checking if branding file is used: %v", err)
+ // will try again with the next clean up
+ return true
+ }
+ return used
+}
diff --git a/plugin/kv_storage.go b/plugin/kv_storage.go
new file mode 100644
index 000000000..d1ed3eaa6
--- /dev/null
+++ b/plugin/kv_storage.go
@@ -0,0 +1,336 @@
+/*
+ * 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
+
+import (
+ "context"
+ "fmt"
+ "math/rand/v2"
+ "time"
+
+ "github.com/apache/answer/internal/entity"
+ "github.com/segmentfault/pacman/cache"
+ "github.com/segmentfault/pacman/log"
+ "xorm.io/builder"
+ "xorm.io/xorm"
+)
+
+// Error variables for KV storage operations
+var (
+ // ErrKVKeyNotFound is returned when the requested key does not exist in the KV storage
+ ErrKVKeyNotFound = fmt.Errorf("key not found in KV storage")
+ // ErrKVGroupEmpty is returned when a required group name is empty
+ ErrKVGroupEmpty = fmt.Errorf("group name is empty")
+ // ErrKVKeyEmpty is returned when a required key name is empty
+ ErrKVKeyEmpty = fmt.Errorf("key name is empty")
+ // ErrKVKeyAndGroupEmpty is returned when both key and group names are empty
+ ErrKVKeyAndGroupEmpty = fmt.Errorf("both key and group are empty")
+ // ErrKVTransactionFailed is returned when a KV storage transaction operation fails
+ ErrKVTransactionFailed = fmt.Errorf("KV storage transaction failed")
+)
+
+// KVParams is the parameters for KV storage operations
+type KVParams struct {
+ Group string
+ Key string
+ Value string
+ Page int
+ PageSize int
+}
+
+// KVOperator provides methods to interact with the key-value storage system for plugins
+type KVOperator struct {
+ data *Data
+ session *xorm.Session
+ pluginSlugName string
+ cacheTTL time.Duration
+}
+
+// KVStorageOption defines a function type that configures a KVOperator
+type KVStorageOption func(*KVOperator)
+
+// WithCacheTTL is the option to set the cache TTL; the default value is 30 minutes.
+// If ttl is less than 0, the cache will not be used
+func WithCacheTTL(ttl time.Duration) KVStorageOption {
+ return func(kv *KVOperator) {
+ kv.cacheTTL = ttl
+ }
+}
+
+// Option is used to set the options for the KV storage
+func (kv *KVOperator) Option(opts ...KVStorageOption) {
+ for _, opt := range opts {
+ opt(kv)
+ }
+}
+
+func (kv *KVOperator) getSession(ctx context.Context) (*xorm.Session, func()) {
+ session := kv.session
+ cleanup := func() {}
+ if session == nil {
+ session = kv.data.DB.NewSession().Context(ctx)
+ cleanup = func() {
+ if session != nil {
+ session.Close()
+ }
+ }
+ }
+ return session, cleanup
+}
+
+func (kv *KVOperator) getCacheKey(params KVParams) string {
+ return fmt.Sprintf("plugin_kv_storage:%s:group:%s:key:%s", kv.pluginSlugName, params.Group, params.Key)
+}
+
+func (kv *KVOperator) setCache(ctx context.Context, params KVParams) {
+ if kv.cacheTTL < 0 {
+ return
+ }
+
+ ttl := kv.cacheTTL
+ if ttl > 10 {
+ ttl += time.Duration(float64(ttl) * 0.1 * (1 - rand.Float64()))
+ }
+
+ cacheKey := kv.getCacheKey(params)
+ if err := kv.data.Cache.SetString(ctx, cacheKey, params.Value, ttl); err != nil {
+ log.Warnf("cache set failed: %v, key: %s", err, cacheKey)
+ }
+}
+
+func (kv *KVOperator) getCache(ctx context.Context, params KVParams) (string, bool, error) {
+ if kv.cacheTTL < 0 {
+ return "", false, nil
+ }
+
+ cacheKey := kv.getCacheKey(params)
+ return kv.data.Cache.GetString(ctx, cacheKey)
+}
+
+func (kv *KVOperator) cleanCache(ctx context.Context, params KVParams) {
+ if kv.cacheTTL < 0 {
+ return
+ }
+
+ if err := kv.data.Cache.Del(ctx, kv.getCacheKey(params)); err != nil {
+ log.Warnf("Failed to delete cache for key %s: %v", params.Key, err)
+ }
+}
+
+// Get retrieves a value from KV storage by group and key.
+// Returns the value as a string or an error if the key is not found.
+func (kv *KVOperator) Get(ctx context.Context, params KVParams) (string, error) {
+ if params.Key == "" {
+ return "", ErrKVKeyEmpty
+ }
+
+ if value, exist, err := kv.getCache(ctx, params); err == nil && exist {
+ return value, nil
+ }
+
+ // query
+ data := entity.PluginKVStorage{}
+ query, cleanup := kv.getSession(ctx)
+ defer cleanup()
+
+ query.Where(builder.Eq{
+ "plugin_slug_name": kv.pluginSlugName,
+ "`group`": params.Group,
+ "`key`": params.Key,
+ })
+
+ has, err := query.Get(&data)
+ if err != nil {
+ return "", err
+ }
+ if !has {
+ return "", ErrKVKeyNotFound
+ }
+
+ params.Value = data.Value
+ kv.setCache(ctx, params)
+
+ return data.Value, nil
+}
+
+// Set stores a value in KV storage with the specified group and key.
+// Updates the value if it already exists.
+func (kv *KVOperator) Set(ctx context.Context, params KVParams) error {
+ if params.Key == "" {
+ return ErrKVKeyEmpty
+ }
+
+ query, cleanup := kv.getSession(ctx)
+ defer cleanup()
+
+ data := &entity.PluginKVStorage{
+ PluginSlugName: kv.pluginSlugName,
+ Group: params.Group,
+ Key: params.Key,
+ Value: params.Value,
+ }
+
+ kv.cleanCache(ctx, params)
+
+ affected, err := query.Where(builder.Eq{
+ "plugin_slug_name": kv.pluginSlugName,
+ "`group`": params.Group,
+ "`key`": params.Key,
+ }).Cols("value").Update(data)
+ if err != nil {
+ return err
+ }
+
+ if affected == 0 {
+ _, err = query.Insert(data)
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// Del removes values from KV storage by group and/or key.
+// If both group and key are provided, only that specific entry is deleted.
+// If only group is provided, all entries in that group are deleted.
+// At least one of group or key must be provided.
+func (kv *KVOperator) Del(ctx context.Context, params KVParams) error {
+ if params.Key == "" && params.Group == "" {
+ return ErrKVKeyAndGroupEmpty
+ }
+
+ kv.cleanCache(ctx, params)
+
+ session, cleanup := kv.getSession(ctx)
+ defer cleanup()
+
+ session.Where(builder.Eq{
+ "plugin_slug_name": kv.pluginSlugName,
+ })
+ if params.Group != "" {
+ session.Where(builder.Eq{"`group`": params.Group})
+ }
+ if params.Key != "" {
+ session.Where(builder.Eq{"`key`": params.Key})
+ }
+
+ _, err := session.Delete(&entity.PluginKVStorage{})
+ return err
+}
+
+// GetByGroup retrieves all key-value pairs for a specific group with pagination support.
+// Returns a map of keys to values or an error if the group is empty or not found.
+func (kv *KVOperator) GetByGroup(ctx context.Context, params KVParams) (map[string]string, error) {
+ if params.Group == "" {
+ return nil, ErrKVGroupEmpty
+ }
+
+ if params.Page < 1 {
+ params.Page = 1
+ }
+ if params.PageSize < 1 {
+ params.PageSize = 10
+ }
+
+ query, cleanup := kv.getSession(ctx)
+ defer cleanup()
+
+ var items []entity.PluginKVStorage
+ err := query.Where(builder.Eq{"plugin_slug_name": kv.pluginSlugName, "`group`": params.Group}).
+ Limit(params.PageSize, (params.Page-1)*params.PageSize).
+ OrderBy("id ASC").
+ Find(&items)
+ if err != nil {
+ return nil, err
+ }
+
+ result := make(map[string]string, len(items))
+ for _, item := range items {
+ result[item.Key] = item.Value
+ }
+
+ return result, nil
+}
+
+// Tx executes a function within a transaction context. If the KVOperator already has a session,
+// it will use that session. Otherwise, it creates a new transaction session.
+// The transaction will be committed if the function returns nil, or rolled back if it returns an error.
+func (kv *KVOperator) Tx(ctx context.Context, fn func(ctx context.Context, kv *KVOperator) error) error {
+ var (
+ txKv = kv
+ shouldCommit bool
+ )
+
+ if kv.session == nil {
+ session := kv.data.DB.NewSession().Context(ctx)
+ if err := session.Begin(); err != nil {
+ session.Close()
+ return fmt.Errorf("%w: begin transaction failed: %v", ErrKVTransactionFailed, err)
+ }
+
+ defer func() {
+ if !shouldCommit {
+ if rollbackErr := session.Rollback(); rollbackErr != nil {
+ log.Errorf("rollback failed: %v", rollbackErr)
+ }
+ }
+ session.Close()
+ }()
+
+ txKv = &KVOperator{
+ session: session,
+ data: kv.data,
+ pluginSlugName: kv.pluginSlugName,
+ }
+ shouldCommit = true
+ }
+
+ if err := fn(ctx, txKv); err != nil {
+ return fmt.Errorf("%w: %v", ErrKVTransactionFailed, err)
+ }
+
+ if shouldCommit {
+ if err := txKv.session.Commit(); err != nil {
+ return fmt.Errorf("%w: commit failed: %v", ErrKVTransactionFailed, err)
+ }
+ }
+ return nil
+}
+
+// KVStorage defines the interface for plugins that need data storage capabilities
+type KVStorage interface {
+ Info() Info
+ SetOperator(operator *KVOperator)
+}
+
+var (
+ CallKVStorage,
+ registerKVStorage = MakePlugin[KVStorage](true)
+)
+
+// NewKVOperator creates a new KV storage operator with the specified database engine, cache and plugin name.
+// It returns a KVOperator instance that can be used to interact with the plugin's storage.
+func NewKVOperator(db *xorm.Engine, cache cache.Cache, pluginSlugName string) *KVOperator {
+ return &KVOperator{
+ data: &Data{DB: db, Cache: cache},
+ pluginSlugName: pluginSlugName,
+ cacheTTL: 30 * time.Minute,
+ }
+}
diff --git a/plugin/plugin.go b/plugin/plugin.go
index 36087c547..a9e173100 100644
--- a/plugin/plugin.go
+++ b/plugin/plugin.go
@@ -23,13 +23,21 @@ import (
"encoding/json"
"sync"
+ "github.com/segmentfault/pacman/cache"
"github.com/segmentfault/pacman/i18n"
+ "xorm.io/xorm"
"github.com/apache/answer/internal/base/handler"
"github.com/apache/answer/internal/base/translator"
"github.com/gin-gonic/gin"
)
+// Data is defined here to avoid circular dependency with internal/base/data
+type Data struct {
+ DB *xorm.Engine
+ Cache cache.Cache
+}
+
// GinContext is a wrapper of gin.Context
// We export it to make it easy to use in plugins
type GinContext = gin.Context
@@ -114,6 +122,10 @@ func Register(p Base) {
if _, ok := p.(Importer); ok {
registerImporter(p.(Importer))
}
+
+ if _, ok := p.(KVStorage); ok {
+ registerKVStorage(p.(KVStorage))
+ }
}
type Stack[T Base] struct {
diff --git a/plugin/plugin_test/kv_storage_test.go b/plugin/plugin_test/kv_storage_test.go
new file mode 100644
index 000000000..0dcd5b46a
--- /dev/null
+++ b/plugin/plugin_test/kv_storage_test.go
@@ -0,0 +1,412 @@
+/*
+ * 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_test
+
+import (
+ "context"
+ "fmt"
+ "math/rand"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/apache/answer/plugin"
+ "github.com/segmentfault/pacman/log"
+ _ "modernc.org/sqlite"
+)
+
+var (
+ testPlugin *TestKVStoragePlugin
+)
+
+// Helper functions for testing
+func mustSet(t *testing.T, kv *plugin.KVOperator, ctx context.Context, group, key, value string) {
+ if err := kv.Set(ctx, plugin.KVParams{Group: group, Key: key, Value: value}); err != nil {
+ t.Fatalf("Failed to set %s/%s: %v", group, key, err)
+ }
+}
+
+func mustGet(t *testing.T, kv *plugin.KVOperator, ctx context.Context, group, key, expected string) {
+ val, err := kv.Get(ctx, plugin.KVParams{Group: group, Key: key})
+ if err != nil {
+ t.Fatalf("Failed to get %s/%s: %v", group, key, err)
+ }
+ if val != expected {
+ t.Errorf("Expected '%s' for %s/%s, got '%s'", expected, group, key, val)
+ }
+}
+
+func mustDel(t *testing.T, kv *plugin.KVOperator, ctx context.Context, group, key string) {
+ if err := kv.Del(ctx, plugin.KVParams{Group: group, Key: key}); err != nil {
+ t.Fatalf("Failed to delete %s/%s: %v", group, key, err)
+ }
+}
+
+func assertNotFound(t *testing.T, kv *plugin.KVOperator, ctx context.Context, group, key string) {
+ val, err := kv.Get(ctx, plugin.KVParams{Group: group, Key: key})
+ if err != plugin.ErrKVKeyNotFound {
+ t.Errorf("Expected ErrKVKeyNotFound for %s/%s, got: %v", group, key, err)
+ }
+ if val != "" {
+ t.Errorf("Expected empty value for %s/%s, got: '%s'", group, key, val)
+ }
+}
+
+func assertError(t *testing.T, err error, expected error, msg string) {
+ if err != expected {
+ t.Errorf("%s: expected %v, got %v", msg, expected, err)
+ }
+}
+
+// TestKVStoragePlugin implements KVStorage interface for testing
+type TestKVStoragePlugin struct {
+ operator *plugin.KVOperator
+}
+
+// Info returns plugin information
+func (p *TestKVStoragePlugin) Info() plugin.Info {
+ return plugin.Info{
+ Name: plugin.MakeTranslator("test_kv_storage_name"),
+ SlugName: "test_kv_storage",
+ Description: plugin.MakeTranslator("test_kv_storage_desc"),
+ Author: "Answer Team",
+ Version: "1.0.0",
+ Link: "https://github.com/apache/answer",
+ }
+}
+
+// SetOperator sets KV operator
+func (p *TestKVStoragePlugin) SetOperator(operator *plugin.KVOperator) {
+ p.operator = operator
+}
+
+// setupTestEnvironment sets up test environment
+func setupTestEnvironment() {
+ // Initialize only once
+ if testPlugin != nil {
+ return
+ }
+
+ // Create and register test plugin
+ testPlugin = &TestKVStoragePlugin{}
+ plugin.Register(testPlugin)
+
+ // Enable plugin
+ plugin.StatusManager.Enable("test_kv_storage", true)
+
+ // Initialize plugin data, refer to plugin_common_service.go implementation
+ _ = plugin.CallKVStorage(func(k plugin.KVStorage) error {
+ k.SetOperator(plugin.NewKVOperator(
+ testDataSource.DB,
+ testDataSource.Cache,
+ k.Info().SlugName,
+ ))
+ return nil
+ })
+}
+
+// Test basic operations including CRUD and edge cases
+func TestBasicOperations(t *testing.T) {
+ setupTestEnvironment()
+ kv := testPlugin.operator
+ ctx := context.Background()
+
+ t.Run("BasicCRUD", func(t *testing.T) {
+ // Set/Get
+ mustSet(t, kv, ctx, "group1", "key1", "value1")
+ mustGet(t, kv, ctx, "group1", "key1", "value1")
+
+ // Update
+ mustSet(t, kv, ctx, "group1", "key1", "new_value")
+ mustGet(t, kv, ctx, "group1", "key1", "new_value")
+
+ // Delete
+ mustDel(t, kv, ctx, "group1", "key1")
+ assertNotFound(t, kv, ctx, "group1", "key1")
+
+ // Group operation
+ mustSet(t, kv, ctx, "group1", "key2", "value2")
+ mustSet(t, kv, ctx, "group1", "key3", "value3")
+ groupData, err := kv.GetByGroup(ctx, plugin.KVParams{Group: "group1", Page: 1, PageSize: 10})
+ if err != nil {
+ t.Fatalf("Failed to get group data: %v", err)
+ }
+
+ // the groupData should only have key2 and key3 because key1 is deleted
+ if len(groupData) != 2 {
+ t.Errorf("Expected 2 items, got %d", len(groupData))
+ }
+ if groupData["key2"] != "value2" || groupData["key3"] != "value3" {
+ t.Errorf("Unexpected group data: %v", groupData)
+ }
+ })
+
+ t.Run("EdgeCases", func(t *testing.T) {
+ // Empty key
+ err := kv.Set(ctx, plugin.KVParams{Group: "group", Key: "", Value: "value"})
+ assertError(t, err, plugin.ErrKVKeyEmpty, "Empty key test")
+
+ // Empty group query
+ _, err = kv.GetByGroup(ctx, plugin.KVParams{Group: "", Page: 1, PageSize: 10})
+ assertError(t, err, plugin.ErrKVGroupEmpty, "Empty group test")
+
+ // Non-existent key
+ assertNotFound(t, kv, ctx, "non_exist_group", "non_exist_key")
+
+ // Cache penetration protection
+ key := fmt.Sprintf("non_exist_key_%d", time.Now().UnixNano())
+ assertNotFound(t, kv, ctx, "cache_penetration", key)
+ })
+
+ t.Run("CacheConsistency", func(t *testing.T) {
+ mustSet(t, kv, ctx, "cache_group", "cache_key", "cache_value")
+ mustGet(t, kv, ctx, "cache_group", "cache_key", "cache_value")
+
+ // Update and verify immediate consistency
+ mustSet(t, kv, ctx, "cache_group", "cache_key", "updated_value")
+ mustGet(t, kv, ctx, "cache_group", "cache_key", "updated_value")
+ })
+}
+
+// Test transactions including rollback and nested transactions
+func TestTransactions(t *testing.T) {
+ setupTestEnvironment()
+ kv := testPlugin.operator
+ ctx := context.Background()
+
+ t.Run("SuccessfulTransaction", func(t *testing.T) {
+ err := kv.Tx(ctx, func(ctx context.Context, txKv *plugin.KVOperator) error {
+ if err := txKv.Set(ctx, plugin.KVParams{Group: "tx_group", Key: "tx_key1", Value: "tx_value1"}); err != nil {
+ return err
+ }
+ if err := txKv.Set(ctx, plugin.KVParams{Group: "tx_group", Key: "tx_key2", Value: "tx_value2"}); err != nil {
+ return err
+ }
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("Successful transaction failed: %v", err)
+ }
+
+ mustGet(t, kv, ctx, "tx_group", "tx_key1", "tx_value1")
+ mustGet(t, kv, ctx, "tx_group", "tx_key2", "tx_value2")
+ })
+
+ t.Run("TransactionRollback", func(t *testing.T) {
+ err := kv.Tx(ctx, func(ctx context.Context, txKv *plugin.KVOperator) error {
+ if err := txKv.Set(ctx, plugin.KVParams{Group: "tx_group", Key: "tx_key3", Value: "tx_value3"}); err != nil {
+ return err
+ }
+ return fmt.Errorf("mock error")
+ })
+ if err == nil {
+ t.Error("Expected transaction to fail but it succeeded")
+ }
+
+ assertNotFound(t, kv, ctx, "tx_group", "tx_key3")
+ })
+
+ t.Run("NestedTransactions", func(t *testing.T) {
+ err := kv.Tx(ctx, func(ctx context.Context, txKv *plugin.KVOperator) error {
+ if err := txKv.Set(ctx, plugin.KVParams{Group: "nested", Key: "key1", Value: "value1"}); err != nil {
+ return err
+ }
+
+ return txKv.Tx(ctx, func(ctx context.Context, nestedKv *plugin.KVOperator) error {
+ if err := nestedKv.Set(ctx, plugin.KVParams{Group: "nested", Key: "key2", Value: "value2"}); err != nil {
+ return err
+ }
+ return fmt.Errorf("mock nested error")
+ })
+ })
+ if err == nil {
+ t.Error("Expected nested transaction to fail but it succeeded")
+ }
+
+ // Verify outer transaction also rolled back
+ assertNotFound(t, kv, ctx, "nested", "key1")
+ assertNotFound(t, kv, ctx, "nested", "key2")
+ })
+}
+
+// Test pagination in GetByGroup
+func TestPagination(t *testing.T) {
+ setupTestEnvironment()
+ kv := testPlugin.operator
+ ctx := context.Background()
+ totalItems := 25
+
+ for i := range totalItems {
+ mustSet(t, kv, ctx, "pagination", fmt.Sprintf("key%d", i), fmt.Sprintf("value%d", i))
+ }
+
+ // Test pagination
+ page1, err := kv.GetByGroup(ctx, plugin.KVParams{Group: "pagination", Page: 1, PageSize: 10})
+ if err != nil {
+ t.Fatalf("Failed to get page 1: %v", err)
+ }
+ if len(page1) != 10 {
+ t.Errorf("Page 1: expected 10 items, got %d", len(page1))
+ }
+
+ page2, err := kv.GetByGroup(ctx, plugin.KVParams{Group: "pagination", Page: 2, PageSize: 10})
+ if err != nil {
+ t.Fatalf("Failed to get page 2: %v", err)
+ }
+ if len(page2) != 10 {
+ t.Errorf("Page 2: expected 10 items, got %d", len(page2))
+ }
+
+ page3, err := kv.GetByGroup(ctx, plugin.KVParams{Group: "pagination", Page: 3, PageSize: 10})
+ if err != nil {
+ t.Fatalf("Failed to get page 3: %v", err)
+ }
+ if len(page3) != 5 {
+ t.Errorf("Page 3: expected 5 items, got %d", len(page3))
+ }
+
+ // Verify different keys on different pages
+ for i := range 10 {
+ key := fmt.Sprintf("key%d", i)
+ if _, ok := page1[key]; !ok {
+ t.Errorf("Pagination test failed, key %s should be on page 1", key)
+ }
+ }
+ for i := range 10 {
+ key := fmt.Sprintf("key%d", i+10)
+ if _, ok := page2[key]; !ok {
+ t.Errorf("Pagination test failed, key %s should be on page 2", key)
+ }
+ }
+}
+
+// Test concurrent operations and performance
+func TestConcurrency(t *testing.T) {
+ setupTestEnvironment()
+ kv := testPlugin.operator
+ ctx := context.Background()
+
+ t.Run("BasicConcurrency", func(t *testing.T) {
+ parallel := 10
+ var wg sync.WaitGroup
+ wg.Add(parallel)
+
+ for i := range parallel {
+ go func(index int) {
+ defer wg.Done()
+ time.Sleep(time.Duration(rand.Intn(200)) * time.Millisecond)
+ mustSet(t, kv, ctx, "concurrent", fmt.Sprintf("key%d", index), "value")
+ }(i)
+ }
+ wg.Wait()
+
+ // Verify results
+ wg.Add(parallel)
+ for i := range parallel {
+ go func(index int) {
+ defer wg.Done()
+ time.Sleep(time.Duration(rand.Intn(200)) * time.Millisecond)
+ mustGet(t, kv, ctx, "concurrent", fmt.Sprintf("key%d", index), "value")
+ }(i)
+ }
+ wg.Wait()
+ })
+
+ t.Run("StressTest", func(t *testing.T) {
+ if testing.Short() {
+ t.Skip("Skipping stress test in short mode")
+ }
+
+ totalOps := 1000
+ workerCount := 20
+ prefix := "stress_test"
+ opsPerWorker := totalOps / workerCount
+
+ log.Info("Starting KV storage stress test...")
+ startTime := time.Now()
+
+ // Concurrent write test
+ var wg sync.WaitGroup
+ errorCount := int64(0)
+
+ for w := range workerCount {
+ wg.Add(1)
+ go func(workerID int) {
+ defer wg.Done()
+ startIdx := workerID * opsPerWorker
+
+ for i := range opsPerWorker {
+ i := startIdx + i
+ err := kv.Set(ctx, plugin.KVParams{
+ Group: prefix,
+ Key: fmt.Sprintf("key%d", i),
+ Value: fmt.Sprintf("value%d", i),
+ })
+ if err != nil {
+ log.Warnf("Write error: %v", err)
+ errorCount++
+ }
+ }
+ }(w)
+ }
+ wg.Wait()
+
+ writeTime := time.Since(startTime)
+
+ // Verify data integrity
+ groupData, err := kv.GetByGroup(ctx, plugin.KVParams{Group: prefix, Page: 1, PageSize: totalOps})
+ if err != nil {
+ t.Fatalf("Failed to verify data: %v", err)
+ }
+ if len(groupData) != totalOps {
+ t.Errorf("Data loss: expected %d items, got %d", totalOps, len(groupData))
+ }
+
+ // Concurrent read test
+ startTime = time.Now()
+ readErrors := int64(0)
+
+ wg.Add(workerCount)
+ for range workerCount {
+ go func() {
+ defer wg.Done()
+ for range opsPerWorker {
+ keyIdx := rand.Intn(totalOps)
+ key := fmt.Sprintf("key%d", keyIdx)
+ expected := fmt.Sprintf("value%d", keyIdx)
+
+ val, err := kv.Get(ctx, plugin.KVParams{Group: prefix, Key: key})
+ if err != nil {
+ readErrors++
+ } else if val != expected {
+ t.Errorf("Data inconsistency: key=%s, expected=%s, got=%s", key, expected, val)
+ }
+ }
+ }()
+ }
+ wg.Wait()
+
+ readTime := time.Since(startTime)
+
+ log.Infof("Stress test completed:")
+ log.Infof(" Write: %d ops in %v (%.1f ops/sec), %d errors", totalOps, writeTime, float64(totalOps)/writeTime.Seconds(), errorCount)
+ log.Infof(" Read: %d ops in %v (%.1f ops/sec), %d errors", totalOps, readTime, float64(totalOps)/readTime.Seconds(), readErrors)
+ })
+}
diff --git a/plugin/plugin_test/plugin_main_test.go b/plugin/plugin_test/plugin_main_test.go
new file mode 100644
index 000000000..add11460b
--- /dev/null
+++ b/plugin/plugin_test/plugin_main_test.go
@@ -0,0 +1,207 @@
+/*
+ * 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_test
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/apache/answer/internal/base/data"
+ "github.com/apache/answer/internal/migrations"
+ "github.com/ory/dockertest/v3"
+ "github.com/ory/dockertest/v3/docker"
+ "github.com/segmentfault/pacman/cache"
+ "github.com/segmentfault/pacman/log"
+ "xorm.io/xorm"
+ "xorm.io/xorm/schemas"
+)
+
+var (
+ mysqlDBSetting = TestDBSetting{
+ Driver: string(schemas.MYSQL),
+ ImageName: "mariadb",
+ ImageVersion: "10.4.7",
+ ENV: []string{"MYSQL_ROOT_PASSWORD=root", "MYSQL_DATABASE=answer", "MYSQL_ROOT_HOST=%"},
+ PortID: "3306/tcp",
+ Connection: "root:root@(localhost:%s)/answer?parseTime=true", // port is not fixed, it will be got by port id
+ }
+ postgresDBSetting = TestDBSetting{
+ Driver: string(schemas.POSTGRES),
+ ImageName: "postgres",
+ ImageVersion: "14",
+ ENV: []string{"POSTGRES_USER=root", "POSTGRES_PASSWORD=root", "POSTGRES_DB=answer", "LISTEN_ADDRESSES='*'"},
+ PortID: "5432/tcp",
+ Connection: "host=localhost port=%s user=root password=root dbname=answer sslmode=disable",
+ }
+ sqlite3DBSetting = TestDBSetting{
+ Driver: string(schemas.SQLITE),
+ Connection: filepath.Join(os.TempDir(), "answer-test-data.db"),
+ }
+ dbSettingMapping = map[string]TestDBSetting{
+ mysqlDBSetting.Driver: mysqlDBSetting,
+ sqlite3DBSetting.Driver: sqlite3DBSetting,
+ postgresDBSetting.Driver: postgresDBSetting,
+ }
+ // after all test down will execute tearDown function to clean-up
+ tearDown func()
+ // testDataSource used for repo testing
+ testDataSource *data.Data
+ testCache cache.Cache
+)
+
+func TestMain(t *testing.M) {
+ dbSetting, ok := dbSettingMapping[os.Getenv("TEST_DB_DRIVER")]
+ if !ok {
+ // Use sqlite3 to test.
+ dbSetting = dbSettingMapping[string(schemas.SQLITE)]
+ }
+ if dbSetting.Driver == string(schemas.SQLITE) {
+ os.RemoveAll(dbSetting.Connection)
+ }
+
+ defer func() {
+ if tearDown != nil {
+ tearDown()
+ }
+ }()
+ if err := initTestDataSource(dbSetting); err != nil {
+ panic(err)
+ }
+ log.Info("init test database successfully")
+
+ if ret := t.Run(); ret != 0 {
+ os.Exit(ret)
+ }
+}
+
+type TestDBSetting struct {
+ Driver string
+ ImageName string
+ ImageVersion string
+ ENV []string
+ PortID string
+ Connection string
+}
+
+func initTestDataSource(dbSetting TestDBSetting) error {
+ connection, imageCleanUp, err := initDatabaseImage(dbSetting)
+ if err != nil {
+ return err
+ }
+ dbSetting.Connection = connection
+
+ dbEngine, err := initDatabase(dbSetting)
+ if err != nil {
+ return err
+ }
+
+ newCache, err := initCache()
+ if err != nil {
+ return err
+ }
+
+ newData, dbCleanUp, err := data.NewData(dbEngine, newCache)
+ if err != nil {
+ return err
+ }
+ testDataSource = newData
+ testCache = newCache
+
+ tearDown = func() {
+ dbCleanUp()
+ log.Info("cleanup test database successfully")
+ imageCleanUp()
+ log.Info("cleanup test database image successfully")
+ }
+ return nil
+}
+
+func initDatabaseImage(dbSetting TestDBSetting) (connection string, cleanup func(), err error) {
+ // sqlite3 don't need to set up image
+ if dbSetting.Driver == string(schemas.SQLITE) {
+ return dbSetting.Connection, func() {
+ log.Info("remove database", dbSetting.Connection)
+ err = os.Remove(dbSetting.Connection)
+ if err != nil {
+ log.Error(err)
+ }
+ }, nil
+ }
+ pool, err := dockertest.NewPool("")
+ pool.MaxWait = time.Minute * 5
+ if err != nil {
+ return "", nil, fmt.Errorf("could not connect to docker: %s", err)
+ }
+
+ //resource, err := pool.Run(dbSetting.ImageName, dbSetting.ImageVersion, dbSetting.ENV)
+ resource, err := pool.RunWithOptions(&dockertest.RunOptions{
+ Repository: dbSetting.ImageName,
+ Tag: dbSetting.ImageVersion,
+ Env: dbSetting.ENV,
+ }, func(config *docker.HostConfig) {
+ config.AutoRemove = true
+ config.RestartPolicy = docker.RestartPolicy{Name: "no"}
+ })
+ if err != nil {
+ return "", nil, fmt.Errorf("could not pull resource: %s", err)
+ }
+
+ connection = fmt.Sprintf(dbSetting.Connection, resource.GetPort(dbSetting.PortID))
+ if err := pool.Retry(func() error {
+ db, err := sql.Open(dbSetting.Driver, connection)
+ if err != nil {
+ return err
+ }
+ return db.Ping()
+ }); err != nil {
+ return "", nil, fmt.Errorf("could not connect to database: %s", err)
+ }
+ return connection, func() { _ = pool.Purge(resource) }, nil
+}
+
+func initDatabase(dbSetting TestDBSetting) (dbEngine *xorm.Engine, err error) {
+ dataConf := &data.Database{Driver: dbSetting.Driver, Connection: dbSetting.Connection}
+ dbEngine, err = data.NewDB(true, dataConf)
+ if err != nil {
+ return nil, fmt.Errorf("connection to database failed: %s", err)
+ }
+ if err := migrations.NewMentor(context.TODO(), dbEngine, &migrations.InitNeedUserInputData{
+ Language: "en_US",
+ SiteName: "ANSWER",
+ SiteURL: "http://127.0.0.1:8080/",
+ ContactEmail: "answer@answer.com",
+ AdminName: "admin",
+ AdminPassword: "admin",
+ AdminEmail: "answer@answer.com",
+ }).InitDB(); err != nil {
+ return nil, fmt.Errorf("migrations init database failed: %s", err)
+ }
+ return dbEngine, nil
+}
+
+func initCache() (newCache cache.Cache, err error) {
+ newCache, _, err = data.NewCache(&data.CacheConf{})
+ return newCache, err
+}
diff --git a/ui/src/common/constants.ts b/ui/src/common/constants.ts
index 855d1a244..163d4989a 100644
--- a/ui/src/common/constants.ts
+++ b/ui/src/common/constants.ts
@@ -103,16 +103,16 @@ export const ADMIN_NAV_MENUS = [
icon: 'award-fill',
},
{
- name: 'customize',
+ name: 'apperance',
icon: 'palette-fill',
children: [
{
name: 'themes',
},
{
- name: 'css_html',
- path: 'css-html',
+ name: 'customize',
},
+ { name: 'branding' },
],
},
{
@@ -121,7 +121,6 @@ export const ADMIN_NAV_MENUS = [
children: [
{ name: 'general' },
{ name: 'interface' },
- { name: 'branding' },
{ name: 'smtp' },
{ name: 'legal' },
{ name: 'write' },
diff --git a/ui/src/components/Editor/ToolBars/image.tsx b/ui/src/components/Editor/ToolBars/image.tsx
index 322da08d7..c9950b069 100644
--- a/ui/src/components/Editor/ToolBars/image.tsx
+++ b/ui/src/components/Editor/ToolBars/image.tsx
@@ -228,30 +228,68 @@ const Image = ({ editorInstance }) => {
return;
}
event.preventDefault();
-
- let innerText = '';
- const allPTag = new DOMParser()
- .parseFromString(
- htmlStr.replace(
- /]*)>/gi,
- `
\n\n
`, - ), - 'text/html', - ) - .querySelectorAll('body p'); - - allPTag.forEach((p, index) => { - const text = p.textContent || ''; - if (text !== '') { - if (index === allPTag.length - 1) { - innerText += `${p.textContent}`; + const parser = new DOMParser(); + const doc = parser.parseFromString(htmlStr, 'text/html'); + const { body } = doc; + + let markdownText = ''; + + function traverse(node) { + if (node.nodeType === Node.TEXT_NODE) { + // text node + markdownText += node.textContent; + } else if (node.nodeType === Node.ELEMENT_NODE) { + // element node + const tagName = node.tagName.toLowerCase(); + + if (tagName === 'img') { + // img node + const src = node.getAttribute('src'); + const alt = node.getAttribute('alt') || t('image.text'); + markdownText += ``; + } else if (tagName === 'br') { + // br node + markdownText += '\n'; } else { - innerText += `${p.textContent}${text.endsWith('\n') ? '' : '\n\n'}`; + for (let i = 0; i < node.childNodes.length; i += 1) { + traverse(node.childNodes[i]); + } + } + + const blockLevelElements = [ + 'p', + 'div', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'ul', + 'ol', + 'li', + 'blockquote', + 'pre', + 'table', + 'thead', + 'tbody', + 'tr', + 'th', + 'td', + ]; + if (blockLevelElements.includes(tagName)) { + markdownText += '\n\n'; } } + } + + traverse(body); + + markdownText = markdownText.replace(/[\n\s]+/g, (match) => { + return match.length > 1 ? '\n\n' : match; }); - editor.replaceSelection(innerText); + editor.replaceSelection(markdownText); }; const handleClick = () => { if (!link.value) { diff --git a/ui/src/components/Editor/Viewer.tsx b/ui/src/components/Editor/Viewer.tsx index 3f41831dc..58187a034 100644 --- a/ui/src/components/Editor/Viewer.tsx +++ b/ui/src/components/Editor/Viewer.tsx @@ -78,7 +78,7 @@ const Index = ({ value }, ref) => {